From 0104e9223a8e6dcd09d70adf2b8b2f101086845d Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 1 Jun 2026 21:05:00 -0400 Subject: [PATCH] feat(balance): chart vehicle/class toggle + collapsible returns (#204) Issue 3 of overnight-2026-06-01-bilan-axe-vehicule. Builds the tracking UI on top of the merged data layer (#202) and input UI (#203). - Service: getSnapshotTotalsByVehicleAndDate(range) mirrors the by-category aggregation with GROUP BY COALESCE(a.vehicle_type, 'none') so null-envelope accounts land in a single 'none' bucket (never a SQL NULL key). Add vehicle_type to getAccountsLatestSnapshot SELECT + type. - useBalanceOverview: new groupAxis ('class'|'vehicle') state ORTHOGONAL to chartMode; loads byVehicle alongside byCategory. Default groupAxis='class'. - BalanceEvolutionChart + BalancePage: stacked-mode sub-toggle 'Par classe d'actif' (default) / 'Par enveloppe'. Vehicle legend reuses the #203 vehicleType.* labels; the 'none' bucket uses balance.vehicle.none. - BalanceAccountsTable: 4 return columns collapsed by default with a toggle, persisted across sessions via userPreferenceService key balance_show_returns. - i18n FR/EN: balance.chart.axis.{byAssetClass,byVehicle}, balance.vehicle.none, balance.accountsTable.toggleReturns.{show,hide} (+ axisLegend aria label). Tests: npm run build green (0 type errors); vitest 3314 passed. Added 5 service tests for the 'none' bucket + mixed envelopes + date range. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../balance/BalanceAccountsTable.tsx | 159 +++++++++++++----- .../balance/BalanceEvolutionChart.tsx | 59 +++++-- src/hooks/useBalanceOverview.ts | 51 ++++-- src/i18n/locales/en.json | 14 +- src/i18n/locales/fr.json | 14 +- src/pages/BalancePage.tsx | 95 ++++++++--- src/services/balance.service.test.ts | 68 ++++++++ src/services/balance.service.ts | 65 +++++++ 8 files changed, 439 insertions(+), 86 deletions(-) diff --git a/src/components/balance/BalanceAccountsTable.tsx b/src/components/balance/BalanceAccountsTable.tsx index 70addf1..4991b1a 100644 --- a/src/components/balance/BalanceAccountsTable.tsx +++ b/src/components/balance/BalanceAccountsTable.tsx @@ -20,15 +20,33 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react"; +import { + Archive, + MoreVertical, + Link as LinkIcon, + AlertTriangle, + ChevronDown, + ChevronRight, +} from "lucide-react"; import type { AccountLatestSnapshot, AccountPeriodAnchor, } from "../../services/balance.service"; import { computeAccountReturn } from "../../services/balance.service"; +import { + getPreference, + setPreference, +} from "../../services/userPreferenceService"; import type { AccountReturn } from "../../shared/types"; import { renderCategoryLabelFromAccount } from "../../utils/renderCategoryLabel"; +/** + * Preference key persisting whether the 4 Modified-Dietz return columns are + * expanded. Absent/anything-but-"1" → collapsed (the spec default). Stored via + * `userPreferenceService` so the choice survives across sessions (Issue #204). + */ +const SHOW_RETURNS_PREF_KEY = "balance_show_returns"; + const cadFormatter = (locale: string) => new Intl.NumberFormat(locale, { style: "currency", @@ -98,8 +116,40 @@ export default function BalanceAccountsTable({ const [openMenuFor, setOpenMenuFor] = useState(null); + // Progressive disclosure of the 4 return columns (Issue #204). Collapsed by + // default; the persisted choice is read once on mount. We start `false` so + // the columns never flash open before the preference resolves. + const [showReturns, setShowReturns] = useState(false); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const stored = await getPreference(SHOW_RETURNS_PREF_KEY); + if (!cancelled && stored === "1") setShowReturns(true); + } catch { + // Pref read failure: keep the collapsed default. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const toggleReturns = () => { + setShowReturns((prev) => { + const next = !prev; + // Best-effort persist; a write failure just means the next session + // falls back to the collapsed default. + void setPreference(SHOW_RETURNS_PREF_KEY, next ? "1" : "0").catch( + () => {} + ); + return next; + }); + }; + // Returns cache. Cleared whenever the account list changes (new accounts, - // archive, etc.). Loaded lazily after mount. + // archive, etc.). Loaded lazily after mount — only while the columns are + // shown, so a collapsed table never runs the Modified-Dietz computation. const [returns, setReturns] = useState({}); const [returnsLoading, setReturnsLoading] = useState(false); @@ -120,7 +170,7 @@ export default function BalanceAccountsTable({ useEffect(() => { let cancelled = false; async function loadReturns() { - if (accounts.length === 0) { + if (!showReturns || accounts.length === 0) { setReturns({}); return; } @@ -155,7 +205,7 @@ export default function BalanceAccountsTable({ return () => { cancelled = true; }; - }, [accounts, horizons]); + }, [accounts, horizons, showReturns]); if (accounts.length === 0) { return ( @@ -231,9 +281,29 @@ export default function BalanceAccountsTable({ } return ( -
- - +
+
+ +
+
+
+ - - - - + {showReturns && ( + <> + + + + + + )} @@ -309,26 +383,30 @@ export default function BalanceAccountsTable({ "—" )} - - - - + {showReturns && ( + <> + + + + + + )}
{t("balance.account.fields.name")} @@ -247,18 +317,22 @@ export default function BalanceAccountsTable({ {t("balance.overview.periodDelta")} - {t("balance.accountsTable.return3m")} - - {t("balance.accountsTable.return1y")} - - {t("balance.accountsTable.sinceCreation")} - - {t("balance.accountsTable.unadjusted")} - + {t("balance.accountsTable.return3m")} + + {t("balance.accountsTable.return1y")} + + {t("balance.accountsTable.sinceCreation")} + + {t("balance.accountsTable.unadjusted")} + {t("balance.account.fields.actions")} - {returnsLoading && !accReturns["3M"] - ? "…" - : renderReturnCell(accReturns["3M"])} - - {returnsLoading && !accReturns["1A"] - ? "…" - : renderReturnCell(accReturns["1A"])} - - {returnsLoading && !accReturns["since"] - ? "…" - : renderReturnCell(accReturns["since"])} - - {returnsLoading && !accReturns["1A"] - ? "…" - : renderUnadjustedCell(accReturns["1A"])} - + {returnsLoading && !accReturns["3M"] + ? "…" + : renderReturnCell(accReturns["3M"])} + + {returnsLoading && !accReturns["1A"] + ? "…" + : renderReturnCell(accReturns["1A"])} + + {returnsLoading && !accReturns["since"] + ? "…" + : renderReturnCell(accReturns["since"])} + + {returnsLoading && !accReturns["1A"] + ? "…" + : renderUnadjustedCell(accReturns["1A"])} +
+ +
); } diff --git a/src/components/balance/BalanceEvolutionChart.tsx b/src/components/balance/BalanceEvolutionChart.tsx index 0be8774..30a3485 100644 --- a/src/components/balance/BalanceEvolutionChart.tsx +++ b/src/components/balance/BalanceEvolutionChart.tsx @@ -27,8 +27,12 @@ import { import type { SnapshotTotalPoint, SnapshotCategoryBreakdownPoint, + SnapshotVehicleBreakdownPoint, } from "../../services/balance.service"; -import type { BalanceChartMode } from "../../hooks/useBalanceOverview"; +import type { + BalanceChartMode, + BalanceGroupAxis, +} from "../../hooks/useBalanceOverview"; import type { BalanceAccountTransferWithTransaction } from "../../shared/types"; // Stable palette for the stacked-by-category areas. Indexed deterministically @@ -49,10 +53,20 @@ const CATEGORY_PALETTE = [ export interface BalanceEvolutionChartProps { mode: BalanceChartMode; + /** + * Stacked-mode grouping axis (Issue #204). `'class'` stacks by asset class + * (the `byCategory` series), `'vehicle'` stacks by fiscal envelope (the + * `byVehicle` series). Ignored in line mode. Defaults to `'class'`. + */ + groupAxis?: BalanceGroupAxis; totals: SnapshotTotalPoint[]; byCategory: SnapshotCategoryBreakdownPoint[]; + /** Per-vehicle breakdown for the `groupAxis === 'vehicle'` stacked variant. */ + byVehicle?: SnapshotVehicleBreakdownPoint[]; /** Map category_key → translated label so the legend reads naturally. */ categoryLabels?: Record; + /** Map vehicle_key (incl. 'none') → translated label for the vehicle axis. */ + vehicleLabels?: Record; /** * Issue #142 — every linked transfer in the visible range. Rendered as * vertical `` markers on the X axis: green for `in` @@ -64,13 +78,37 @@ export interface BalanceEvolutionChartProps { export default function BalanceEvolutionChart({ mode, + groupAxis = "class", totals, byCategory, + byVehicle = [], categoryLabels = {}, + vehicleLabels = {}, transferMarkers = [], }: BalanceEvolutionChartProps) { const { t, i18n } = useTranslation(); + // The stacked chart is driven by whichever axis is active. Both breakdowns + // share the `{ snapshot_date, }` shape, so we normalize to a common + // `{ snapshot_date, series }` form here and feed a single rendering path. + const stackedSource = useMemo( + () => + groupAxis === "vehicle" + ? byVehicle.map((p) => ({ + snapshot_date: p.snapshot_date, + series: p.byVehicle, + })) + : byCategory.map((p) => ({ + snapshot_date: p.snapshot_date, + series: p.byCategory, + })), + [groupAxis, byCategory, byVehicle] + ); + + // Label map for the active axis (legend + tooltip). Falls back to the raw + // key when no translation is provided. + const activeLabels = groupAxis === "vehicle" ? vehicleLabels : categoryLabels; + const cadFormatter = useMemo( () => new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", { @@ -101,25 +139,26 @@ export default function BalanceEvolutionChart({ // --- 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. + // one column per series key (asset class OR fiscal envelope, per groupAxis). + // Keys 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); + for (const point of stackedSource) { + for (const k of Object.keys(point.series)) keys.add(k); } const orderedKeys = Array.from(keys).sort(); - const data = byCategory.map((point) => { + const data = stackedSource.map((point) => { const row: Record = { snapshot_date: point.snapshot_date, }; for (const k of orderedKeys) { - row[k] = point.byCategory[k] ?? 0; + row[k] = point.series[k] ?? 0; } return row; }); return { stackedData: data, categoryKeys: orderedKeys }; - }, [byCategory]); + }, [stackedSource]); const isEmpty = mode === "line" ? lineData.length === 0 : stackedData.length === 0; @@ -247,13 +286,13 @@ export default function BalanceEvolutionChart({ [ cadFormatter.format(value ?? 0), - categoryLabels[String(name)] ?? String(name), + activeLabels[String(name)] ?? String(name), ]} labelFormatter={(label) => formatDate(String(label))} contentStyle={tooltipContentStyle} /> categoryLabels[String(value)] ?? String(value)} + formatter={(value) => activeLabels[String(value)] ?? String(value)} /> {categoryKeys.map((key, idx) => ( void; setChartMode: (mode: BalanceChartMode) => void; + setGroupAxis: (axis: BalanceGroupAxis) => void; reload: () => Promise; } @@ -127,18 +145,21 @@ export function useBalanceOverview(): UseBalanceOverviewResult { dispatch({ type: "LOAD_START" }); try { const range = computeBalanceDateRange(period); - // Parallel fetches — no inter-dependency between the four queries. - const [totals, byCategory, latest, anchors] = await Promise.all([ - getSnapshotTotalsByDate(range), - getSnapshotTotalsByCategoryAndDate(range), - getAccountsLatestSnapshot(), - getAccountsPeriodAnchor(range), - ]); + // Parallel fetches — no inter-dependency between the queries. + const [totals, byCategory, byVehicle, latest, anchors] = + await Promise.all([ + getSnapshotTotalsByDate(range), + getSnapshotTotalsByCategoryAndDate(range), + getSnapshotTotalsByVehicleAndDate(range), + getAccountsLatestSnapshot(), + getAccountsPeriodAnchor(range), + ]); dispatch({ type: "LOAD_SUCCESS", payload: { evolutionTotals: totals, evolutionByCategory: byCategory, + evolutionByVehicle: byVehicle, accountsLatest: latest, accountsPeriodAnchor: anchors, }, @@ -162,7 +183,11 @@ export function useBalanceOverview(): UseBalanceOverviewResult { dispatch({ type: "SET_CHART_MODE", payload: mode }); }, []); + const setGroupAxis = useCallback((axis: BalanceGroupAxis) => { + dispatch({ type: "SET_GROUP_AXIS", payload: axis }); + }, []); + const reload = useCallback(() => load(state.period), [load, state.period]); - return { state, setPeriod, setChartMode, reload }; + return { state, setPeriod, setChartMode, setGroupAxis, reload }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ac32acd..38bcd95 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1563,10 +1563,15 @@ "chart": { "empty": "No snapshot for this period.", "modeLegend": "Chart display mode", + "axisLegend": "Stacked chart grouping axis", "totalSeriesLabel": "Total", "mode": { "line": "Line", "stacked": "Stacked by type" + }, + "axis": { + "byAssetClass": "By asset class", + "byVehicle": "By envelope" } }, "onboarding": { @@ -1788,7 +1793,14 @@ "sinceCreation": "Since inception", "sinceCreationTooltip": "Modified Dietz return since the first snapshot.", "unadjusted": "Unadjusted", - "unadjustedTooltip": "Simple return (V_end − V_start) / V_start, with no contribution weighting." + "unadjustedTooltip": "Simple return (V_end − V_start) / V_start, with no contribution weighting.", + "toggleReturns": { + "show": "Show returns", + "hide": "Hide returns" + } + }, + "vehicle": { + "none": "No envelope" }, "transfers": { "linkAction": "Link transfers", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4599fa4..9014661 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1563,10 +1563,15 @@ "chart": { "empty": "Aucun snapshot pour cette période.", "modeLegend": "Mode d'affichage du graphique", + "axisLegend": "Axe de regroupement du graphique empilé", "totalSeriesLabel": "Total", "mode": { "line": "Ligne", "stacked": "Empilé par type" + }, + "axis": { + "byAssetClass": "Par classe d'actif", + "byVehicle": "Par enveloppe" } }, "onboarding": { @@ -1788,7 +1793,14 @@ "sinceCreation": "Depuis création", "sinceCreationTooltip": "Rendement Modified Dietz depuis le premier snapshot.", "unadjusted": "Non ajusté", - "unadjustedTooltip": "Rendement simple (V_fin − V_début) / V_début, sans pondération des apports." + "unadjustedTooltip": "Rendement simple (V_fin − V_début) / V_début, sans pondération des apports.", + "toggleReturns": { + "show": "Afficher les rendements", + "hide": "Masquer les rendements" + } + }, + "vehicle": { + "none": "Sans enveloppe" }, "transfers": { "linkAction": "Lier transferts", diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx index fb109e7..ba3e085 100644 --- a/src/pages/BalancePage.tsx +++ b/src/pages/BalancePage.tsx @@ -18,7 +18,10 @@ import { useBalanceOverview, type BalancePeriod, type BalanceChartMode, + type BalanceGroupAxis, } from "../hooks/useBalanceOverview"; +import { BALANCE_VEHICLE_TYPES } from "../shared/types"; +import { VEHICLE_NONE_BUCKET } from "../services/balance.service"; import { archiveBalanceAccount, listAccountTransfers, @@ -41,7 +44,8 @@ const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"]; export default function BalancePage() { const { t } = useTranslation(); - const { state, setPeriod, setChartMode, reload } = useBalanceOverview(); + const { state, setPeriod, setChartMode, setGroupAxis, reload } = + useBalanceOverview(); // Issue #142 — link-transfers modal state. Categories list is loaded once // on mount (used by the modal's filter dropdown). @@ -150,6 +154,20 @@ export default function BalancePage() { return m; }, [state.accountsLatest, t]); + // Map vehicle_key → translated label for the "par enveloppe" stacked axis. + // Reuses the #203 account-form labels (`balance.account.form.vehicleType.*`) + // so the legend never duplicates strings; the NULL-envelope bucket uses the + // dedicated `balance.vehicle.none` key. + const vehicleLabels = useMemo(() => { + const m: Record = { + [VEHICLE_NONE_BUCKET]: t("balance.vehicle.none"), + }; + for (const v of BALANCE_VEHICLE_TYPES) { + m[v] = t(`balance.account.form.vehicleType.${v}`); + } + return m; + }, [t]); + const handleArchiveAccount = async (accountId: number) => { try { await archiveBalanceAccount(accountId); @@ -223,35 +241,70 @@ export default function BalancePage() { ))} - {/* Chart mode toggle */} -
- {(["line", "stacked"] as BalanceChartMode[]).map((mode) => ( - - ))} + {(["class", "vehicle"] as BalanceGroupAxis[]).map((axis) => ( + + ))} +
+ )} + + {/* Chart mode toggle */} +
+ {(["line", "stacked"] as BalanceChartMode[]).map((mode) => ( + + ))} +
diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index e4a0e9b..ecefc19 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -40,6 +40,7 @@ import { BalanceServiceError, getSnapshotTotalsByDate, getSnapshotTotalsByCategoryAndDate, + getSnapshotTotalsByVehicleAndDate, getAccountsLatestSnapshot, getAccountsPeriodAnchor, computeAccountReturn, @@ -693,6 +694,13 @@ describe("balance accounts — vehicle_type (#202)", () => { const sql = mockSelect.mock.calls[0][0] as string; expect(sql).toContain("c.custom_label AS category_custom_label"); }); + + it("getAccountsLatestSnapshot threads vehicle_type (#204)", async () => { + mockSelect.mockResolvedValueOnce([]); + await getAccountsLatestSnapshot(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("a.vehicle_type AS vehicle_type"); + }); }); describe("updateBalanceAccount", () => { @@ -1561,6 +1569,66 @@ describe("getSnapshotTotalsByCategoryAndDate", () => { }); }); +describe("getSnapshotTotalsByVehicleAndDate (#204)", () => { + it("returns [] on empty DB", async () => { + mockSelect.mockResolvedValueOnce([]); + expect(await getSnapshotTotalsByVehicleAndDate()).toEqual([]); + }); + + it("COALESCEs NULL envelopes into a single 'none' bucket", async () => { + mockSelect.mockResolvedValueOnce([]); + await getSnapshotTotalsByVehicleAndDate(); + const sql = mockSelect.mock.calls[0][0] as string; + // HIGH caveat: the GROUP BY must coalesce NULL → 'none' so null-envelope + // accounts never produce a SQL NULL key. + expect(sql).toContain("COALESCE(a.vehicle_type, 'none')"); + expect(sql).toContain("GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none')"); + expect(sql).toContain("INNER JOIN balance_accounts"); + }); + + it("buckets a mix of envelopes plus the 'none' bucket per snapshot_date", async () => { + mockSelect.mockResolvedValueOnce([ + { snapshot_date: "2026-01-31", vehicle_key: "none", total: 500 }, + { snapshot_date: "2026-01-31", vehicle_key: "tfsa", total: 1500 }, + { snapshot_date: "2026-01-31", vehicle_key: "rrsp", total: 3000 }, + { snapshot_date: "2026-02-28", vehicle_key: "none", total: 700 }, + { snapshot_date: "2026-02-28", vehicle_key: "tfsa", total: 1700 }, + ]); + const out = await getSnapshotTotalsByVehicleAndDate(); + expect(out).toEqual([ + { + snapshot_date: "2026-01-31", + byVehicle: { none: 500, tfsa: 1500, rrsp: 3000 }, + }, + { + snapshot_date: "2026-02-28", + byVehicle: { none: 700, tfsa: 1700 }, + }, + ]); + }); + + it("handles a snapshot composed solely of null-envelope accounts", async () => { + mockSelect.mockResolvedValueOnce([ + { snapshot_date: "2026-03-31", vehicle_key: "none", total: 4200 }, + ]); + const out = await getSnapshotTotalsByVehicleAndDate(); + expect(out).toEqual([ + { snapshot_date: "2026-03-31", byVehicle: { none: 4200 } }, + ]); + }); + + it("applies date range params when supplied", async () => { + mockSelect.mockResolvedValueOnce([]); + await getSnapshotTotalsByVehicleAndDate({ + from: "2026-01-01", + to: "2026-12-31", + }); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("WHERE"); + expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]); + }); +}); + describe("getAccountsLatestSnapshot", () => { it("returns [] when there are no active accounts", async () => { mockSelect.mockResolvedValueOnce([]); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index ecab6fa..6688254 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -1326,6 +1326,65 @@ export async function getSnapshotTotalsByCategoryAndDate( return out; } +/** Sentinel bucket key for accounts with no fiscal envelope (NULL vehicle_type). */ +export const VEHICLE_NONE_BUCKET = "none"; + +/** Per-snapshot breakdown by fiscal envelope (`vehicle_type`). */ +export interface SnapshotVehicleBreakdownPoint { + snapshot_date: string; + /** Keyed by vehicle_type code, with the `'none'` bucket for NULL envelopes. */ + byVehicle: Record; +} + +interface RawVehicleBreakdownRow { + snapshot_date: string; + vehicle_key: string; + total: number; +} + +/** + * Returns per-snapshot totals broken down by `balance_accounts.vehicle_type`, + * sorted by date ASC. Mirror of `getSnapshotTotalsByCategoryAndDate` for the + * "par enveloppe" axis of the stacked-area chart (Issue #204 / Étape 1). + * + * ⚠️ `vehicle_type` is NULLABLE — accounts with no envelope are grouped under + * a single `'none'` bucket via `COALESCE(a.vehicle_type, 'none')`, never a SQL + * NULL key (which would collapse to a `null` object key on the JS side). The + * `'none'` bucket is labelled with `balance.vehicle.none` in the UI. + * + * Vehicles with no value at a given date are omitted from the `byVehicle` map + * (chart consumers treat absent keys as zero). + */ +export async function getSnapshotTotalsByVehicleAndDate( + range?: SnapshotDateRange +): Promise { + const { clause, params } = buildDateRangeClause(range, "s"); + const db = await getDb(); + const rows = await db.select( + `SELECT s.snapshot_date AS snapshot_date, + COALESCE(a.vehicle_type, 'none') AS vehicle_key, + COALESCE(SUM(l.value), 0) AS total + FROM balance_snapshots s + INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id + INNER JOIN balance_accounts a ON a.id = l.account_id + ${clause} + GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none') + ORDER BY s.snapshot_date ASC, vehicle_key ASC`, + params + ); + // Bucket rows by snapshot_date — many rows per date, one per vehicle. + const out: SnapshotVehicleBreakdownPoint[] = []; + let current: SnapshotVehicleBreakdownPoint | null = null; + for (const r of rows) { + if (!current || current.snapshot_date !== r.snapshot_date) { + current = { snapshot_date: r.snapshot_date, byVehicle: {} }; + out.push(current); + } + current.byVehicle[r.vehicle_key] = r.total; + } + return out; +} + /** Latest-snapshot value per active account (Issue #141). */ export interface AccountLatestSnapshot { account_id: number; @@ -1337,6 +1396,11 @@ export interface AccountLatestSnapshot { category_kind: BalanceCategoryKind; /** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */ category_custom_label?: string | null; + /** + * Fiscal envelope of the account (`vehicle_type`), or NULL when none. + * Surfaced for the "par enveloppe" axis groupings (Issue #204). + */ + vehicle_type?: BalanceVehicleType | null; /** Date of the snapshot whose value is reported, or null if no snapshot exists. */ latest_snapshot_date: string | null; /** Value at that snapshot, or null if the account has no snapshot lines. */ @@ -1366,6 +1430,7 @@ export async function getAccountsLatestSnapshot(): Promise< c.i18n_key AS category_i18n_key, c.kind AS category_kind, c.custom_label AS category_custom_label, + a.vehicle_type AS vehicle_type, (SELECT s.snapshot_date FROM balance_snapshot_lines l JOIN balance_snapshots s ON s.id = l.snapshot_id