From 02efc75542727a6c23002a0be8c52e1641d3cd1f Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 19 Apr 2026 07:23:49 -0400 Subject: [PATCH 1/2] feat(reports/trends): add stacked-area chart option for category view (#105) Adds a segmented chart-type toggle to the /reports/trends "By category" sub-view that switches between the existing stacked bars (default, unchanged) and a new Recharts AreaChart with stackId="1" showing total composition over time. Both modes share the same category palette and SVG grayscale patterns so the visual signature is preserved. - CategoryOverTimeChart gains a chartType: 'line' | 'area' prop (default 'line' for backward compatibility with the dashboard usage). - New TrendsChartTypeToggle component, persisted in localStorage under "reports-trends-category-charttype". - Toggle only renders in the "By category" sub-view with chart view mode selected; hidden otherwise. - i18n keys reports.trends.chartLine / chartArea / chartTypeAria in FR and EN. - CHANGELOG entries in both languages. --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + .../reports/CategoryOverTimeChart.tsx | 178 +++++++++++------- .../reports/TrendsChartTypeToggle.test.ts | 50 +++++ .../reports/TrendsChartTypeToggle.tsx | 60 ++++++ src/i18n/locales/en.json | 5 +- src/i18n/locales/fr.json | 5 +- src/pages/ReportsTrendsPage.tsx | 14 ++ 8 files changed, 245 insertions(+), 69 deletions(-) create mode 100644 src/components/reports/TrendsChartTypeToggle.test.ts create mode 100644 src/components/reports/TrendsChartTypeToggle.tsx diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index a07c8e4..1b4f42f 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -4,6 +4,7 @@ ### Ajouté - **Rapport Cartes** : info-bulle d'aide sur le KPI taux d'épargne expliquant la formule — `(revenus − dépenses) ÷ revenus × 100`, calculée sur le mois de référence (#101) +- **Rapport Tendances — Par catégorie** (`/reports/trends`) : nouveau toggle segmenté pour basculer le graphique d'évolution par catégorie entre les barres empilées (par défaut, inchangé) et une vue surface empilée Recharts (``) qui montre la composition totale dans le temps. Les deux modes partagent la même palette de catégories et les mêmes patterns SVG en niveaux de gris. Le type choisi est mémorisé dans `localStorage` (`reports-trends-category-charttype`) (#105) ### Modifié - **Rapport Zoom catégorie** (`/reports/category`) : le sélecteur de catégorie est désormais un combobox saisissable et filtrable avec recherche insensible aux accents, navigation clavier (↑/↓/Entrée/Échap) et indentation hiérarchique, en remplacement du `` (#103) diff --git a/src/components/reports/CategoryOverTimeChart.tsx b/src/components/reports/CategoryOverTimeChart.tsx index a8c8523..184b464 100644 --- a/src/components/reports/CategoryOverTimeChart.tsx +++ b/src/components/reports/CategoryOverTimeChart.tsx @@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next"; import { BarChart, Bar, + AreaChart, + Area, XAxis, YAxis, Tooltip, @@ -25,6 +27,8 @@ function formatMonth(month: string): string { return date.toLocaleDateString("default", { month: "short", year: "2-digit" }); } +export type CategoryOverTimeChartType = "line" | "area"; + interface CategoryOverTimeChartProps { data: CategoryOverTimeData; hiddenCategories: Set; @@ -32,6 +36,13 @@ interface CategoryOverTimeChartProps { onShowAll: () => void; onViewDetails: (item: CategoryBreakdownItem) => void; showAmounts?: boolean; + /** + * Visual rendering mode. `line` (default) keeps the legacy stacked bars — + * preserved for backward compatibility. `area` stacks Recharts layers + * (stackId="1") showing total composition over time. Both modes share the + * same palette and SVG grayscale patterns (existing signature visual). + */ + chartType?: CategoryOverTimeChartType; } export default function CategoryOverTimeChart({ @@ -41,6 +52,7 @@ export default function CategoryOverTimeChart({ onShowAll, onViewDetails, showAmounts, + chartType = "line", }: CategoryOverTimeChartProps) { const { t } = useTranslation(); const hoveredRef = useRef(null); @@ -68,6 +80,58 @@ export default function CategoryOverTimeChart({ ); } + // Shared chart configuration used by both Bar and Area variants. + const patternPrefix = "cat-time"; + const patternDefs = ( + ({ color: c.color, index: c.index }))} + /> + ); + const commonAxes = ( + <> + + + cadFormatter(v)} + tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} + stroke="var(--border)" + width={80} + /> + { + if (hoveredCategory && name !== hoveredCategory) return [null, null]; + return [cadFormatter(Number(value) || 0), String(name)]; + }} + labelFormatter={(label) => formatMonth(String(label))} + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "8px", + color: "var(--foreground)", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + }} + wrapperStyle={{ zIndex: 50 }} + labelStyle={{ color: "var(--foreground)" }} + itemStyle={{ color: "var(--foreground)" }} + filterNull + /> + { + if (e && e.dataKey) setHoveredCategory(String(e.dataKey)); + }} + onMouseLeave={() => setHoveredCategory(null)} + wrapperStyle={{ cursor: "pointer" }} + formatter={(value) => {value}} + /> + + ); + return (
{hiddenCategories.size > 0 && ( @@ -94,73 +158,53 @@ export default function CategoryOverTimeChart({
- - ({ color: c.color, index: c.index }))} - /> - - - cadFormatter(v)} - tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} - stroke="var(--border)" - width={80} - /> - { - if (hoveredCategory && name !== hoveredCategory) return [null, null]; - return [cadFormatter(Number(value) || 0), String(name)]; - }} - labelFormatter={(label) => formatMonth(String(label))} - contentStyle={{ - backgroundColor: "var(--card)", - border: "1px solid var(--border)", - borderRadius: "8px", - color: "var(--foreground)", - boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - }} - wrapperStyle={{ zIndex: 50 }} - labelStyle={{ color: "var(--foreground)" }} - itemStyle={{ color: "var(--foreground)" }} - filterNull - /> - { - if (e && e.dataKey) setHoveredCategory(String(e.dataKey)); - }} - onMouseLeave={() => setHoveredCategory(null)} - wrapperStyle={{ cursor: "pointer" }} - formatter={(value) => {value}} - /> - {categoryEntries.map((c) => ( - { hoveredRef.current = c.name; setHoveredCategory(c.name); }} - onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }} - cursor="context-menu" - style={{ transition: "fill-opacity 150ms" }} - > - {showAmounts && ( - Number(v) ? cadFormatter(Number(v)) : ""} - style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }} - /> - )} - - ))} - + {chartType === "area" ? ( + + {patternDefs} + {commonAxes} + {categoryEntries.map((c) => ( + { hoveredRef.current = c.name; setHoveredCategory(c.name); }} + onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }} + style={{ transition: "fill-opacity 150ms", cursor: "context-menu" }} + /> + ))} + + ) : ( + + {patternDefs} + {commonAxes} + {categoryEntries.map((c) => ( + { hoveredRef.current = c.name; setHoveredCategory(c.name); }} + onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }} + cursor="context-menu" + style={{ transition: "fill-opacity 150ms" }} + > + {showAmounts && ( + Number(v) ? cadFormatter(Number(v)) : ""} + style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }} + /> + )} + + ))} + + )}
diff --git a/src/components/reports/TrendsChartTypeToggle.test.ts b/src/components/reports/TrendsChartTypeToggle.test.ts new file mode 100644 index 0000000..810570d --- /dev/null +++ b/src/components/reports/TrendsChartTypeToggle.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { readTrendsChartType, TRENDS_CHART_TYPE_STORAGE_KEY } from "./TrendsChartTypeToggle"; + +describe("readTrendsChartType", () => { + const store = new Map(); + const mockLocalStorage = { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value); + }), + removeItem: vi.fn((key: string) => { + store.delete(key); + }), + clear: vi.fn(() => store.clear()), + key: vi.fn(), + length: 0, + }; + + beforeEach(() => { + store.clear(); + vi.stubGlobal("localStorage", mockLocalStorage); + }); + + it("returns 'line' fallback when key is missing", () => { + expect(readTrendsChartType()).toBe("line"); + }); + + it("returns 'line' when stored value is 'line'", () => { + store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "line"); + expect(readTrendsChartType()).toBe("line"); + }); + + it("returns 'area' when stored value is 'area'", () => { + store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "area"); + expect(readTrendsChartType()).toBe("area"); + }); + + it("ignores invalid stored values and returns fallback", () => { + store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "bogus"); + expect(readTrendsChartType(TRENDS_CHART_TYPE_STORAGE_KEY, "area")).toBe("area"); + }); + + it("respects custom fallback when provided", () => { + expect(readTrendsChartType(TRENDS_CHART_TYPE_STORAGE_KEY, "area")).toBe("area"); + }); + + it("uses the expected storage key", () => { + expect(TRENDS_CHART_TYPE_STORAGE_KEY).toBe("reports-trends-category-charttype"); + }); +}); diff --git a/src/components/reports/TrendsChartTypeToggle.tsx b/src/components/reports/TrendsChartTypeToggle.tsx new file mode 100644 index 0000000..1229214 --- /dev/null +++ b/src/components/reports/TrendsChartTypeToggle.tsx @@ -0,0 +1,60 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { LineChart as LineIcon, AreaChart as AreaIcon } from "lucide-react"; +import type { CategoryOverTimeChartType } from "./CategoryOverTimeChart"; + +export const TRENDS_CHART_TYPE_STORAGE_KEY = "reports-trends-category-charttype"; + +export interface TrendsChartTypeToggleProps { + value: CategoryOverTimeChartType; + onChange: (value: CategoryOverTimeChartType) => void; + /** localStorage key used to persist the preference. */ + storageKey?: string; +} + +export function readTrendsChartType( + storageKey: string = TRENDS_CHART_TYPE_STORAGE_KEY, + fallback: CategoryOverTimeChartType = "line", +): CategoryOverTimeChartType { + if (typeof localStorage === "undefined") return fallback; + const saved = localStorage.getItem(storageKey); + return saved === "line" || saved === "area" ? saved : fallback; +} + +export default function TrendsChartTypeToggle({ + value, + onChange, + storageKey = TRENDS_CHART_TYPE_STORAGE_KEY, +}: TrendsChartTypeToggleProps) { + const { t } = useTranslation(); + + useEffect(() => { + if (storageKey) localStorage.setItem(storageKey, value); + }, [value, storageKey]); + + const options: { type: CategoryOverTimeChartType; icon: React.ReactNode; label: string }[] = [ + { type: "line", icon: , label: t("reports.trends.chartLine") }, + { type: "area", icon: , label: t("reports.trends.chartArea") }, + ]; + + return ( +
+ {options.map(({ type, icon, label }) => ( + + ))} +
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b14fe59..bda1312 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -360,7 +360,10 @@ "overTime": "Category Over Time", "trends": { "subviewGlobal": "Global flow", - "subviewByCategory": "By category" + "subviewByCategory": "By category", + "chartLine": "Lines", + "chartArea": "Stacked area", + "chartTypeAria": "Chart type" }, "budgetVsActual": "Budget vs Actual", "subtotalsOnTop": "Subtotals on top", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 122aeff..7dcc25a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -360,7 +360,10 @@ "overTime": "Catégories dans le temps", "trends": { "subviewGlobal": "Flux global", - "subviewByCategory": "Par catégorie" + "subviewByCategory": "Par catégorie", + "chartLine": "Lignes", + "chartArea": "Surface empilée", + "chartTypeAria": "Type de graphique" }, "budgetVsActual": "Budget vs Réel", "subtotalsOnTop": "Sous-totaux en haut", diff --git a/src/pages/ReportsTrendsPage.tsx b/src/pages/ReportsTrendsPage.tsx index 65b3b9b..f4e61ef 100644 --- a/src/pages/ReportsTrendsPage.tsx +++ b/src/pages/ReportsTrendsPage.tsx @@ -6,8 +6,13 @@ import PeriodSelector from "../components/dashboard/PeriodSelector"; import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart"; import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable"; import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart"; +import type { CategoryOverTimeChartType } from "../components/reports/CategoryOverTimeChart"; import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable"; import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle"; +import TrendsChartTypeToggle, { + readTrendsChartType, + TRENDS_CHART_TYPE_STORAGE_KEY, +} from "../components/reports/TrendsChartTypeToggle"; import { useTrends } from "../hooks/useTrends"; import { useReportsPeriod } from "../hooks/useReportsPeriod"; import type { CategoryBreakdownItem } from "../shared/types"; @@ -19,6 +24,7 @@ export default function ReportsTrendsPage() { const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends(); const [viewMode, setViewMode] = useState(() => readViewMode(STORAGE_KEY)); + const [chartType, setChartType] = useState(() => readTrendsChartType()); const [hiddenCategories, setHiddenCategories] = useState>(new Set()); const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; @@ -84,6 +90,13 @@ export default function ReportsTrendsPage() { {t("reports.trends.subviewByCategory")}
+ {subView === "byCategory" && viewMode === "chart" && ( + + )} @@ -107,6 +120,7 @@ export default function ReportsTrendsPage() { onToggleHidden={toggleHidden} onShowAll={showAll} onViewDetails={noOpDetails} + chartType={chartType} /> ) : ( From 94104c422308bdfe7e76616e156bd033578a477b Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 19 Apr 2026 07:26:22 -0400 Subject: [PATCH 2/2] refactor(reports/trends): rename chart type from 'line' to 'bar' to match actual rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy chart was a stacked BarChart, not a LineChart — the initial 'line' naming was misleading. Rename internal type, i18n key (chartLine -> chartBar, Lignes -> Barres, Lines -> Bars) and icon. Legacy 'line' in localStorage is migrated to 'bar' on read. --- src/components/reports/CategoryOverTimeChart.tsx | 12 ++++++------ .../reports/TrendsChartTypeToggle.test.ts | 13 +++++++++---- src/components/reports/TrendsChartTypeToggle.tsx | 10 ++++++---- src/i18n/locales/en.json | 2 +- src/i18n/locales/fr.json | 2 +- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/components/reports/CategoryOverTimeChart.tsx b/src/components/reports/CategoryOverTimeChart.tsx index 184b464..7941c53 100644 --- a/src/components/reports/CategoryOverTimeChart.tsx +++ b/src/components/reports/CategoryOverTimeChart.tsx @@ -27,7 +27,7 @@ function formatMonth(month: string): string { return date.toLocaleDateString("default", { month: "short", year: "2-digit" }); } -export type CategoryOverTimeChartType = "line" | "area"; +export type CategoryOverTimeChartType = "bar" | "area"; interface CategoryOverTimeChartProps { data: CategoryOverTimeData; @@ -37,10 +37,10 @@ interface CategoryOverTimeChartProps { onViewDetails: (item: CategoryBreakdownItem) => void; showAmounts?: boolean; /** - * Visual rendering mode. `line` (default) keeps the legacy stacked bars — - * preserved for backward compatibility. `area` stacks Recharts layers - * (stackId="1") showing total composition over time. Both modes share the - * same palette and SVG grayscale patterns (existing signature visual). + * Visual rendering mode. `bar` (default) keeps the legacy stacked-bar + * composition. `area` stacks Recharts layers (stackId="1") for a + * smoother flow view. Both modes share the same palette and SVG grayscale + * patterns (existing signature visual). */ chartType?: CategoryOverTimeChartType; } @@ -52,7 +52,7 @@ export default function CategoryOverTimeChart({ onShowAll, onViewDetails, showAmounts, - chartType = "line", + chartType = "bar", }: CategoryOverTimeChartProps) { const { t } = useTranslation(); const hoveredRef = useRef(null); diff --git a/src/components/reports/TrendsChartTypeToggle.test.ts b/src/components/reports/TrendsChartTypeToggle.test.ts index 810570d..7584a09 100644 --- a/src/components/reports/TrendsChartTypeToggle.test.ts +++ b/src/components/reports/TrendsChartTypeToggle.test.ts @@ -21,13 +21,18 @@ describe("readTrendsChartType", () => { vi.stubGlobal("localStorage", mockLocalStorage); }); - it("returns 'line' fallback when key is missing", () => { - expect(readTrendsChartType()).toBe("line"); + it("returns 'bar' fallback when key is missing", () => { + expect(readTrendsChartType()).toBe("bar"); }); - it("returns 'line' when stored value is 'line'", () => { + it("returns 'bar' when stored value is 'bar'", () => { + store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "bar"); + expect(readTrendsChartType()).toBe("bar"); + }); + + it("migrates legacy 'line' stored value to 'bar'", () => { store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "line"); - expect(readTrendsChartType()).toBe("line"); + expect(readTrendsChartType()).toBe("bar"); }); it("returns 'area' when stored value is 'area'", () => { diff --git a/src/components/reports/TrendsChartTypeToggle.tsx b/src/components/reports/TrendsChartTypeToggle.tsx index 1229214..a1c54b0 100644 --- a/src/components/reports/TrendsChartTypeToggle.tsx +++ b/src/components/reports/TrendsChartTypeToggle.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { LineChart as LineIcon, AreaChart as AreaIcon } from "lucide-react"; +import { BarChart3 as BarIcon, AreaChart as AreaIcon } from "lucide-react"; import type { CategoryOverTimeChartType } from "./CategoryOverTimeChart"; export const TRENDS_CHART_TYPE_STORAGE_KEY = "reports-trends-category-charttype"; @@ -14,11 +14,13 @@ export interface TrendsChartTypeToggleProps { export function readTrendsChartType( storageKey: string = TRENDS_CHART_TYPE_STORAGE_KEY, - fallback: CategoryOverTimeChartType = "line", + fallback: CategoryOverTimeChartType = "bar", ): CategoryOverTimeChartType { if (typeof localStorage === "undefined") return fallback; const saved = localStorage.getItem(storageKey); - return saved === "line" || saved === "area" ? saved : fallback; + // Back-compat: "line" was the historical key for the bar chart. + if (saved === "line") return "bar"; + return saved === "bar" || saved === "area" ? saved : fallback; } export default function TrendsChartTypeToggle({ @@ -33,7 +35,7 @@ export default function TrendsChartTypeToggle({ }, [value, storageKey]); const options: { type: CategoryOverTimeChartType; icon: React.ReactNode; label: string }[] = [ - { type: "line", icon: , label: t("reports.trends.chartLine") }, + { type: "bar", icon: , label: t("reports.trends.chartBar") }, { type: "area", icon: , label: t("reports.trends.chartArea") }, ]; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bda1312..afe5f30 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -361,7 +361,7 @@ "trends": { "subviewGlobal": "Global flow", "subviewByCategory": "By category", - "chartLine": "Lines", + "chartBar": "Bars", "chartArea": "Stacked area", "chartTypeAria": "Chart type" }, diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 7dcc25a..a64e1d2 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -361,7 +361,7 @@ "trends": { "subviewGlobal": "Flux global", "subviewByCategory": "Par catégorie", - "chartLine": "Lignes", + "chartBar": "Barres", "chartArea": "Surface empilée", "chartTypeAria": "Type de graphique" },