From ffefa90fd0d20c60329e71b718ef5d66d83c14f4 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:07:04 -0400 Subject: [PATCH] feat(balance): add BalancePage with chart + accounts table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new components composed under a new BalancePage at /balance: - BalanceOverviewCard — latest aggregate net worth, Δ% vs the previous chronological snapshot (rendered as "—" when only one snapshot exists), 60-day staleness warning, and a "+ Nouveau snapshot" CTA pointing at /balance/snapshot. - BalanceEvolutionChart — Recharts-based line / stacked-area toggle. Line mode plots SUM(value) per snapshot_date with a single primary-coloured stroke. Stacked mode transposes the byCategory series into one Area per category_key with a fixed 10-color palette indexed deterministically. Tooltip formats CAD via Intl.NumberFormat. - BalanceAccountsTable — one row per active account with name, category label, latest value, and Δ% over the active period (latest_value vs the period anchor). Returns columns (3M / 1Y / since-creation / unadjusted) reserved for #142 with a TODO marker. Action menu includes a disabled "Detail" placeholder + functional "Archive" wired through reload(). BalancePage composes the three with an inline period selector (3M / 6M / 1A / 3A / Tout) and chart-mode toggle, both styled as segmented controls. State flows through useBalanceOverview. Route /balance registered before /balance/accounts in App.tsx. Refs: #141 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 2 + .../balance/BalanceAccountsTable.tsx | 170 ++++++++++++++ .../balance/BalanceEvolutionChart.tsx | 218 ++++++++++++++++++ .../balance/BalanceOverviewCard.tsx | 128 ++++++++++ src/pages/BalancePage.tsx | 141 +++++++++++ 5 files changed, 659 insertions(+) create mode 100644 src/components/balance/BalanceAccountsTable.tsx create mode 100644 src/components/balance/BalanceEvolutionChart.tsx create mode 100644 src/components/balance/BalanceOverviewCard.tsx create mode 100644 src/pages/BalancePage.tsx diff --git a/src/App.tsx b/src/App.tsx index db4b1f8..e987ff7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCartesPage from "./pages/ReportsCartesPage"; import SettingsPage from "./pages/SettingsPage"; import AccountsPage from "./pages/AccountsPage"; +import BalancePage from "./pages/BalancePage"; import SnapshotEditPage from "./pages/SnapshotEditPage"; import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage"; import CategoriesMigrationPage from "./pages/CategoriesMigrationPage"; @@ -116,6 +117,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> + new Intl.NumberFormat(locale, { + style: "currency", + currency: "CAD", + maximumFractionDigits: 2, + }); + +interface BalanceAccountsTableProps { + accounts: AccountLatestSnapshot[]; + periodAnchor: AccountPeriodAnchor[]; + onArchiveAccount?: (account: AccountLatestSnapshot) => void; +} + +export default function BalanceAccountsTable({ + accounts, + periodAnchor, + onArchiveAccount, +}: BalanceAccountsTableProps) { + const { t, i18n } = useTranslation(); + const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA"); + + /** account_id → period anchor (start-of-period value). */ + const anchorMap = useMemo(() => { + const m = new Map(); + for (const a of periodAnchor) m.set(a.account_id, a); + return m; + }, [periodAnchor]); + + const [openMenuFor, setOpenMenuFor] = useState(null); + + if (accounts.length === 0) { + return ( +
+ {t("balance.overview.noAccounts")} +
+ ); + } + + return ( +
+ + + + + + + + {/* TODO Issue #142: 3M / 1A / depuis-création / non-ajusté columns */} + + + + + {accounts.map((acc) => { + const anchor = anchorMap.get(acc.account_id); + const deltaPct = + acc.latest_value !== null && anchor && anchor.anchor_value !== 0 + ? ((acc.latest_value - anchor.anchor_value) / + Math.abs(anchor.anchor_value)) * + 100 + : null; + return ( + + + + + + + + ); + })} + +
+ {t("balance.account.fields.name")} + + {t("balance.account.fields.category")} + + {t("balance.overview.latestValue")} + + {t("balance.overview.periodDelta")} + + {t("balance.account.fields.actions")} +
+ {acc.account_name} + {acc.symbol ? ( + + ({acc.symbol}) + + ) : null} + + {t(acc.category_i18n_key, { defaultValue: acc.category_key })} + + {acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"} + + {deltaPct !== null ? ( + = 0 + ? "text-[var(--positive)]" + : "text-[var(--negative)]" + } + > + {deltaPct >= 0 ? "+" : ""} + {deltaPct.toFixed(2)}% + + ) : ( + "—" + )} + + + {openMenuFor === acc.account_id && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/components/balance/BalanceEvolutionChart.tsx b/src/components/balance/BalanceEvolutionChart.tsx new file mode 100644 index 0000000..f9908eb --- /dev/null +++ b/src/components/balance/BalanceEvolutionChart.tsx @@ -0,0 +1,218 @@ +// 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, +} from "recharts"; +import type { + SnapshotTotalPoint, + SnapshotCategoryBreakdownPoint, +} from "../../services/balance.service"; +import type { BalanceChartMode } from "../../hooks/useBalanceOverview"; + +// 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; +} + +export default function BalanceEvolutionChart({ + mode, + totals, + byCategory, + categoryLabels = {}, +}: 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; + + 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} + /> + + + ) : ( + + + 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) => ( + + ))} + + )} + +
+ ); +} diff --git a/src/components/balance/BalanceOverviewCard.tsx b/src/components/balance/BalanceOverviewCard.tsx new file mode 100644 index 0000000..2ee60ad --- /dev/null +++ b/src/components/balance/BalanceOverviewCard.tsx @@ -0,0 +1,128 @@ +// BalanceOverviewCard — top summary tile of /balance. +// +// Issue #141 (Bilan #3). Displays: +// - The latest aggregate snapshot total (sum across all accounts on the +// most recent snapshot date). +// - Δ% versus the previous chronological snapshot (null when only one +// snapshot exists; rendered as "—"). +// - A staleness warning when the latest snapshot is older than 60 days. +// - "+ Nouveau snapshot" CTA → `/balance/snapshot`. + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react"; +import { Link } from "react-router-dom"; +import type { SnapshotTotalPoint } from "../../services/balance.service"; + +const STALENESS_DAYS = 60; +const cadFormatter = (value: number) => + new Intl.NumberFormat("en-CA", { + style: "currency", + currency: "CAD", + maximumFractionDigits: 2, + }).format(value); + +interface BalanceOverviewCardProps { + /** The full evolution series for the active period (latest at the end). */ + totals: SnapshotTotalPoint[]; +} + +export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) { + const { t, i18n } = useTranslation(); + + const summary = useMemo(() => { + if (totals.length === 0) { + return null; + } + const last = totals[totals.length - 1]; + const prev = totals.length >= 2 ? totals[totals.length - 2] : null; + const deltaPct = + prev && prev.total !== 0 + ? ((last.total - prev.total) / Math.abs(prev.total)) * 100 + : null; + const ageMs = Date.now() - new Date(last.snapshot_date).getTime(); + const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24)); + return { + latest: last, + deltaPct, + isStale: ageDays > STALENESS_DAYS, + ageDays, + }; + }, [totals]); + + const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA"; + const formatDate = (iso: string) => + new Date(iso).toLocaleDateString(dateLocale, { + year: "numeric", + month: "long", + day: "numeric", + }); + + return ( +
+
+
+

+ {t("balance.overview.latestTotal")} +

+ {summary ? ( + <> +

+ {cadFormatter(summary.latest.total)} +

+

+ {t("balance.overview.asOf", { + date: formatDate(summary.latest.snapshot_date), + })} +

+ + ) : ( +

+ {t("balance.overview.noSnapshots")} +

+ )} +
+ +
+ {summary && summary.deltaPct !== null && ( +
= 0 + ? "text-[var(--positive)]" + : "text-[var(--negative)]" + }`} + > + {summary.deltaPct >= 0 ? ( + + ) : ( + + )} + {summary.deltaPct >= 0 ? "+" : ""} + {summary.deltaPct.toFixed(2)}% + + {t("balance.overview.vsPrevious")} + +
+ )} + + + + {t("balance.overview.newSnapshot")} + +
+
+ + {summary?.isStale && ( +
+ + + {t("balance.overview.staleWarning", { days: summary.ageDays })} + +
+ )} +
+ ); +} diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx new file mode 100644 index 0000000..85b5772 --- /dev/null +++ b/src/pages/BalancePage.tsx @@ -0,0 +1,141 @@ +// BalancePage — overview of net worth at `/balance`. +// +// Issue #141 (Bilan #3). Composes: +// - BalanceOverviewCard (latest total + Δ% + staleness warning + new-snapshot CTA) +// - Period selector (3M / 6M / 1A / 3A / Tout) +// - Chart-mode toggle (Line / Stacked-by-category) +// - BalanceEvolutionChart +// - BalanceAccountsTable (one row per active account with latest value + Δ%) +// +// All data flows through `useBalanceOverview` (scoped useReducer). Returns +// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves +// columns with a TODO comment. + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Wallet } from "lucide-react"; +import { + useBalanceOverview, + type BalancePeriod, + type BalanceChartMode, +} from "../hooks/useBalanceOverview"; +import { archiveBalanceAccount } from "../services/balance.service"; +import BalanceOverviewCard from "../components/balance/BalanceOverviewCard"; +import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; +import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; + +const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"]; + +export default function BalancePage() { + const { t } = useTranslation(); + const { state, setPeriod, setChartMode, reload } = useBalanceOverview(); + + // Build a category_key → translated label map from the accounts payload — + // the byCategory series is keyed by `key`, not by id, and the same + // taxonomy is already loaded with `accountsLatest` joins. + const categoryLabels = useMemo(() => { + const m: Record = {}; + for (const a of state.accountsLatest) { + if (!m[a.category_key]) { + m[a.category_key] = t(a.category_i18n_key, { + defaultValue: a.category_key, + }); + } + } + return m; + }, [state.accountsLatest, t]); + + const handleArchiveAccount = async (accountId: number) => { + try { + await archiveBalanceAccount(accountId); + await reload(); + } catch { + // Reload swallows; the row simply stays. UX feedback can be added later. + } + }; + + return ( +
+
+ +

{t("balance.overview.title")}

+
+ + {state.error && ( +
+ {state.error} +
+ )} + +
+ + +
+ {/* Period selector */} +
+ {PERIOD_OPTIONS.map((p) => ( + + ))} +
+ + {/* Chart mode toggle */} +
+ {(["line", "stacked"] as BalanceChartMode[]).map((mode) => ( + + ))} +
+
+ + + +
+

+ {t("balance.overview.accountsTitle")} +

+ handleArchiveAccount(acc.account_id)} + /> +
+
+
+ ); +}