// BalanceEvolutionChart — line / stacked-area chart of net worth over time. // // Issue #141 (Bilan #3). Reuses the established Recharts patterns from the // reports/* charts (see decisions-log #141 — native SVG was reconsidered; // Recharts is the single chart pattern in this codebase). Two modes: // - 'line' : a single LineChart of `SUM(value)` per snapshot date. // - 'stacked' : an AreaChart with one Area per category (stackId='all'). // // Tooltip shows per-category breakdown in stacked mode and just the total in // line mode. import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ReferenceLine, } from "recharts"; import type { SnapshotTotalPoint, SnapshotCategoryBreakdownPoint, } from "../../services/balance.service"; import type { BalanceChartMode } from "../../hooks/useBalanceOverview"; import type { BalanceAccountTransferWithTransaction } from "../../shared/types"; // Stable palette for the stacked-by-category areas. Indexed deterministically // by category sort order so the colour assignment stays consistent across // renders and period changes. Reused from the reports CategoryBarChart palette. const CATEGORY_PALETTE = [ "#3b82f6", // blue "#10b981", // emerald "#f59e0b", // amber "#8b5cf6", // violet "#ef4444", // red "#06b6d4", // cyan "#ec4899", // pink "#84cc16", // lime "#f97316", // orange "#6366f1", // indigo ]; export interface BalanceEvolutionChartProps { mode: BalanceChartMode; totals: SnapshotTotalPoint[]; byCategory: SnapshotCategoryBreakdownPoint[]; /** Map category_key → translated label so the legend reads naturally. */ categoryLabels?: Record; /** * Issue #142 — every linked transfer in the visible range. Rendered as * vertical `` markers on the X axis: green for `in` * (capital added), red for `out` (capital removed). The label tooltip * shows the underlying transaction date + description. */ transferMarkers?: BalanceAccountTransferWithTransaction[]; } export default function BalanceEvolutionChart({ mode, totals, byCategory, categoryLabels = {}, transferMarkers = [], }: BalanceEvolutionChartProps) { const { t, i18n } = useTranslation(); const cadFormatter = useMemo( () => new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, }), [i18n.language] ); const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA"; const formatDate = (iso: string) => new Date(iso).toLocaleDateString(dateLocale, { year: "numeric", month: "short", day: "numeric", }); // --- Line-mode dataset --- const lineData = useMemo( () => totals.map((p) => ({ snapshot_date: p.snapshot_date, total: p.total, })), [totals] ); // --- Stacked-area dataset --- // We transpose the per-snapshot bucket into one row per snapshot_date with // one column per category_key. Categories absent at a snapshot date are // emitted as 0 so Recharts renders a continuous stack. const { stackedData, categoryKeys } = useMemo(() => { const keys = new Set(); for (const point of byCategory) { for (const k of Object.keys(point.byCategory)) keys.add(k); } const orderedKeys = Array.from(keys).sort(); const data = byCategory.map((point) => { const row: Record = { snapshot_date: point.snapshot_date, }; for (const k of orderedKeys) { row[k] = point.byCategory[k] ?? 0; } return row; }); return { stackedData: data, categoryKeys: orderedKeys }; }, [byCategory]); const isEmpty = mode === "line" ? lineData.length === 0 : stackedData.length === 0; // Filter transfer markers to dates that are actually rendered on the X // axis (categorical scale ignores unknown ticks). We don't aggregate or // dedupe — the user can have several transfers on the same day across // accounts; ReferenceLine tolerates duplicates fine. const xAxisDates = useMemo(() => { const dates = new Set(); if (mode === "line") { for (const p of lineData) dates.add(p.snapshot_date); } else { for (const p of stackedData) dates.add(p.snapshot_date as string); } return dates; }, [mode, lineData, stackedData]); const renderableMarkers = useMemo( () => transferMarkers .filter((m) => xAxisDates.has(m.transaction_date)) // Sort so 'in' (green) draws before 'out' (red) for stable z-order. .sort((a, b) => a.direction === b.direction ? 0 : a.direction === "in" ? -1 : 1 ), [transferMarkers, xAxisDates] ); if (isEmpty) { return (

{t("balance.chart.empty")}

); } const tooltipContentStyle = { backgroundColor: "var(--card)", border: "1px solid var(--border)", borderRadius: "0.5rem", color: "var(--foreground)", }; return (
{mode === "line" ? ( formatDate(s)} /> cadFormatter.format(v)} width={88} /> cadFormatter.format(value ?? 0) } labelFormatter={(label) => formatDate(String(label))} contentStyle={tooltipContentStyle} /> {renderableMarkers.map((m) => ( ))} ) : ( formatDate(s)} /> cadFormatter.format(v)} width={88} /> [ cadFormatter.format(value ?? 0), categoryLabels[String(name)] ?? String(name), ]} labelFormatter={(label) => formatDate(String(label))} contentStyle={tooltipContentStyle} /> categoryLabels[String(value)] ?? String(value)} /> {categoryKeys.map((key, idx) => ( ))} {renderableMarkers.map((m) => ( ))} )}
); }