Compare commits

..

2 commits

Author SHA1 Message Date
d3b8ad6266 Merge pull request 'feat(balance): chart vehicle/class toggle + collapsible returns' (#208) from issue-204-chart-vehicle-toggle-collapsible-returns into main 2026-06-02 01:08:40 +00:00
le king fu
0104e9223a feat(balance): chart vehicle/class toggle + collapsible returns (#204)
All checks were successful
PR Check / rust (pull_request) Successful in 22m13s
PR Check / frontend (pull_request) Successful in 2m21s
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) <noreply@anthropic.com>
2026-06-01 21:05:00 -04:00
8 changed files with 439 additions and 86 deletions

View file

@ -20,15 +20,33 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { import type {
AccountLatestSnapshot, AccountLatestSnapshot,
AccountPeriodAnchor, AccountPeriodAnchor,
} from "../../services/balance.service"; } from "../../services/balance.service";
import { computeAccountReturn } from "../../services/balance.service"; import { computeAccountReturn } from "../../services/balance.service";
import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
import type { AccountReturn } from "../../shared/types"; import type { AccountReturn } from "../../shared/types";
import { renderCategoryLabelFromAccount } from "../../utils/renderCategoryLabel"; 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) => const cadFormatter = (locale: string) =>
new Intl.NumberFormat(locale, { new Intl.NumberFormat(locale, {
style: "currency", style: "currency",
@ -98,8 +116,40 @@ export default function BalanceAccountsTable({
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null); const [openMenuFor, setOpenMenuFor] = useState<number | null>(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, // 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<ReturnsByAccount>({}); const [returns, setReturns] = useState<ReturnsByAccount>({});
const [returnsLoading, setReturnsLoading] = useState(false); const [returnsLoading, setReturnsLoading] = useState(false);
@ -120,7 +170,7 @@ export default function BalanceAccountsTable({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function loadReturns() { async function loadReturns() {
if (accounts.length === 0) { if (!showReturns || accounts.length === 0) {
setReturns({}); setReturns({});
return; return;
} }
@ -155,7 +205,7 @@ export default function BalanceAccountsTable({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [accounts, horizons]); }, [accounts, horizons, showReturns]);
if (accounts.length === 0) { if (accounts.length === 0) {
return ( return (
@ -231,6 +281,26 @@ export default function BalanceAccountsTable({
} }
return ( return (
<div className="space-y-2">
<div className="flex justify-end">
<button
type="button"
onClick={toggleReturns}
aria-expanded={showReturns}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
>
{showReturns ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
{t(
showReturns
? "balance.accountsTable.toggleReturns.hide"
: "balance.accountsTable.toggleReturns.show"
)}
</button>
</div>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30"> <thead className="bg-[var(--muted)]/30">
@ -247,6 +317,8 @@ export default function BalanceAccountsTable({
<th className="text-right px-4 py-3 font-medium"> <th className="text-right px-4 py-3 font-medium">
{t("balance.overview.periodDelta")} {t("balance.overview.periodDelta")}
</th> </th>
{showReturns && (
<>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}> <th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
{t("balance.accountsTable.return3m")} {t("balance.accountsTable.return3m")}
</th> </th>
@ -259,6 +331,8 @@ export default function BalanceAccountsTable({
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}> <th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
{t("balance.accountsTable.unadjusted")} {t("balance.accountsTable.unadjusted")}
</th> </th>
</>
)}
<th className="text-right px-4 py-3 font-medium w-12"> <th className="text-right px-4 py-3 font-medium w-12">
{t("balance.account.fields.actions")} {t("balance.account.fields.actions")}
</th> </th>
@ -309,6 +383,8 @@ export default function BalanceAccountsTable({
"—" "—"
)} )}
</td> </td>
{showReturns && (
<>
<td className="px-4 py-3 text-right tabular-nums"> <td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["3M"] {returnsLoading && !accReturns["3M"]
? "…" ? "…"
@ -329,6 +405,8 @@ export default function BalanceAccountsTable({
? "…" ? "…"
: renderUnadjustedCell(accReturns["1A"])} : renderUnadjustedCell(accReturns["1A"])}
</td> </td>
</>
)}
<td className="px-4 py-3 text-right relative"> <td className="px-4 py-3 text-right relative">
<button <button
type="button" type="button"
@ -385,5 +463,6 @@ export default function BalanceAccountsTable({
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
); );
} }

View file

@ -27,8 +27,12 @@ import {
import type { import type {
SnapshotTotalPoint, SnapshotTotalPoint,
SnapshotCategoryBreakdownPoint, SnapshotCategoryBreakdownPoint,
SnapshotVehicleBreakdownPoint,
} from "../../services/balance.service"; } from "../../services/balance.service";
import type { BalanceChartMode } from "../../hooks/useBalanceOverview"; import type {
BalanceChartMode,
BalanceGroupAxis,
} from "../../hooks/useBalanceOverview";
import type { BalanceAccountTransferWithTransaction } from "../../shared/types"; import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
// Stable palette for the stacked-by-category areas. Indexed deterministically // Stable palette for the stacked-by-category areas. Indexed deterministically
@ -49,10 +53,20 @@ const CATEGORY_PALETTE = [
export interface BalanceEvolutionChartProps { export interface BalanceEvolutionChartProps {
mode: BalanceChartMode; 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[]; totals: SnapshotTotalPoint[];
byCategory: SnapshotCategoryBreakdownPoint[]; byCategory: SnapshotCategoryBreakdownPoint[];
/** Per-vehicle breakdown for the `groupAxis === 'vehicle'` stacked variant. */
byVehicle?: SnapshotVehicleBreakdownPoint[];
/** Map category_key → translated label so the legend reads naturally. */ /** Map category_key → translated label so the legend reads naturally. */
categoryLabels?: Record<string, string>; categoryLabels?: Record<string, string>;
/** Map vehicle_key (incl. 'none') → translated label for the vehicle axis. */
vehicleLabels?: Record<string, string>;
/** /**
* Issue #142 every linked transfer in the visible range. Rendered as * Issue #142 every linked transfer in the visible range. Rendered as
* vertical `<ReferenceLine>` markers on the X axis: green for `in` * vertical `<ReferenceLine>` markers on the X axis: green for `in`
@ -64,13 +78,37 @@ export interface BalanceEvolutionChartProps {
export default function BalanceEvolutionChart({ export default function BalanceEvolutionChart({
mode, mode,
groupAxis = "class",
totals, totals,
byCategory, byCategory,
byVehicle = [],
categoryLabels = {}, categoryLabels = {},
vehicleLabels = {},
transferMarkers = [], transferMarkers = [],
}: BalanceEvolutionChartProps) { }: BalanceEvolutionChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
// The stacked chart is driven by whichever axis is active. Both breakdowns
// share the `{ snapshot_date, <map> }` 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( const cadFormatter = useMemo(
() => () =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", { new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
@ -101,25 +139,26 @@ export default function BalanceEvolutionChart({
// --- Stacked-area dataset --- // --- Stacked-area dataset ---
// We transpose the per-snapshot bucket into one row per snapshot_date with // 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 // one column per series key (asset class OR fiscal envelope, per groupAxis).
// emitted as 0 so Recharts renders a continuous stack. // Keys absent at a snapshot date are emitted as 0 so Recharts renders a
// continuous stack.
const { stackedData, categoryKeys } = useMemo(() => { const { stackedData, categoryKeys } = useMemo(() => {
const keys = new Set<string>(); const keys = new Set<string>();
for (const point of byCategory) { for (const point of stackedSource) {
for (const k of Object.keys(point.byCategory)) keys.add(k); for (const k of Object.keys(point.series)) keys.add(k);
} }
const orderedKeys = Array.from(keys).sort(); const orderedKeys = Array.from(keys).sort();
const data = byCategory.map((point) => { const data = stackedSource.map((point) => {
const row: Record<string, string | number> = { const row: Record<string, string | number> = {
snapshot_date: point.snapshot_date, snapshot_date: point.snapshot_date,
}; };
for (const k of orderedKeys) { for (const k of orderedKeys) {
row[k] = point.byCategory[k] ?? 0; row[k] = point.series[k] ?? 0;
} }
return row; return row;
}); });
return { stackedData: data, categoryKeys: orderedKeys }; return { stackedData: data, categoryKeys: orderedKeys };
}, [byCategory]); }, [stackedSource]);
const isEmpty = const isEmpty =
mode === "line" ? lineData.length === 0 : stackedData.length === 0; mode === "line" ? lineData.length === 0 : stackedData.length === 0;
@ -247,13 +286,13 @@ export default function BalanceEvolutionChart({
<Tooltip <Tooltip
formatter={(value: number | undefined, name) => [ formatter={(value: number | undefined, name) => [
cadFormatter.format(value ?? 0), cadFormatter.format(value ?? 0),
categoryLabels[String(name)] ?? String(name), activeLabels[String(name)] ?? String(name),
]} ]}
labelFormatter={(label) => formatDate(String(label))} labelFormatter={(label) => formatDate(String(label))}
contentStyle={tooltipContentStyle} contentStyle={tooltipContentStyle}
/> />
<Legend <Legend
formatter={(value) => categoryLabels[String(value)] ?? String(value)} formatter={(value) => activeLabels[String(value)] ?? String(value)}
/> />
{categoryKeys.map((key, idx) => ( {categoryKeys.map((key, idx) => (
<Area <Area

View file

@ -1,23 +1,29 @@
// useBalanceOverview — scoped useReducer hook backing BalancePage. // useBalanceOverview — scoped useReducer hook backing BalancePage.
// //
// Domain coverage (per spec-plan-bilan.md v2 / Issue #141): // Domain coverage (per spec-plan-bilan.md v2 / Issue #141, extended in #204):
// - Time-series for the evolution chart (totals + per-category breakdown) // - Time-series for the evolution chart (totals + per-category + per-vehicle)
// - Per-account latest snapshot value + period-anchor value (for Δ%) // - Per-account latest snapshot value + period-anchor value (for Δ%)
// - Period selector (3M / 6M / 1A / 3A / Tout) // - Period selector (3M / 6M / 1A / 3A / Tout)
// - Chart mode toggle (line / stacked-area) // - Chart mode toggle (line / stacked-area)
// - Group-axis toggle for the stacked mode (asset class / fiscal envelope)
// //
// Returns are intentionally OUT of scope here — they ship in Issue #142 // `chartMode` (line/stacked) and `groupAxis` (class/vehicle) are two ORTHOGONAL
// (Modified Dietz). The accounts table reserves columns for the return // dimensions: `groupAxis` only changes which breakdown feeds the stacked chart;
// metrics with TODO comments. // it has no effect in line mode. They are kept as independent state to avoid
// conflating "how to draw" with "what to group by" (Issue #204).
//
// Returns (Modified Dietz) are loaded by the accounts table itself, not here.
import { useReducer, useEffect, useCallback } from "react"; import { useReducer, useEffect, useCallback } from "react";
import { import {
getSnapshotTotalsByDate, getSnapshotTotalsByDate,
getSnapshotTotalsByCategoryAndDate, getSnapshotTotalsByCategoryAndDate,
getSnapshotTotalsByVehicleAndDate,
getAccountsLatestSnapshot, getAccountsLatestSnapshot,
getAccountsPeriodAnchor, getAccountsPeriodAnchor,
type SnapshotTotalPoint, type SnapshotTotalPoint,
type SnapshotCategoryBreakdownPoint, type SnapshotCategoryBreakdownPoint,
type SnapshotVehicleBreakdownPoint,
type AccountLatestSnapshot, type AccountLatestSnapshot,
type AccountPeriodAnchor, type AccountPeriodAnchor,
type SnapshotDateRange, type SnapshotDateRange,
@ -25,12 +31,17 @@ import {
export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all"; export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all";
export type BalanceChartMode = "line" | "stacked"; export type BalanceChartMode = "line" | "stacked";
/** Stacked-chart grouping axis: by asset class (category) or fiscal envelope. */
export type BalanceGroupAxis = "class" | "vehicle";
interface State { interface State {
period: BalancePeriod; period: BalancePeriod;
chartMode: BalanceChartMode; chartMode: BalanceChartMode;
/** Orthogonal to `chartMode`; only meaningful in stacked mode. Default 'class'. */
groupAxis: BalanceGroupAxis;
evolutionTotals: SnapshotTotalPoint[]; evolutionTotals: SnapshotTotalPoint[];
evolutionByCategory: SnapshotCategoryBreakdownPoint[]; evolutionByCategory: SnapshotCategoryBreakdownPoint[];
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
accountsLatest: AccountLatestSnapshot[]; accountsLatest: AccountLatestSnapshot[];
accountsPeriodAnchor: AccountPeriodAnchor[]; accountsPeriodAnchor: AccountPeriodAnchor[];
isLoading: boolean; isLoading: boolean;
@ -40,12 +51,14 @@ interface State {
type Action = type Action =
| { type: "SET_PERIOD"; payload: BalancePeriod } | { type: "SET_PERIOD"; payload: BalancePeriod }
| { type: "SET_CHART_MODE"; payload: BalanceChartMode } | { type: "SET_CHART_MODE"; payload: BalanceChartMode }
| { type: "SET_GROUP_AXIS"; payload: BalanceGroupAxis }
| { type: "LOAD_START" } | { type: "LOAD_START" }
| { | {
type: "LOAD_SUCCESS"; type: "LOAD_SUCCESS";
payload: { payload: {
evolutionTotals: SnapshotTotalPoint[]; evolutionTotals: SnapshotTotalPoint[];
evolutionByCategory: SnapshotCategoryBreakdownPoint[]; evolutionByCategory: SnapshotCategoryBreakdownPoint[];
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
accountsLatest: AccountLatestSnapshot[]; accountsLatest: AccountLatestSnapshot[];
accountsPeriodAnchor: AccountPeriodAnchor[]; accountsPeriodAnchor: AccountPeriodAnchor[];
}; };
@ -56,8 +69,10 @@ function initialState(): State {
return { return {
period: "1A", period: "1A",
chartMode: "line", chartMode: "line",
groupAxis: "class",
evolutionTotals: [], evolutionTotals: [],
evolutionByCategory: [], evolutionByCategory: [],
evolutionByVehicle: [],
accountsLatest: [], accountsLatest: [],
accountsPeriodAnchor: [], accountsPeriodAnchor: [],
isLoading: false, isLoading: false,
@ -71,6 +86,8 @@ function reducer(state: State, action: Action): State {
return { ...state, period: action.payload }; return { ...state, period: action.payload };
case "SET_CHART_MODE": case "SET_CHART_MODE":
return { ...state, chartMode: action.payload }; return { ...state, chartMode: action.payload };
case "SET_GROUP_AXIS":
return { ...state, groupAxis: action.payload };
case "LOAD_START": case "LOAD_START":
return { ...state, isLoading: true, error: null }; return { ...state, isLoading: true, error: null };
case "LOAD_SUCCESS": case "LOAD_SUCCESS":
@ -117,6 +134,7 @@ export interface UseBalanceOverviewResult {
state: State; state: State;
setPeriod: (period: BalancePeriod) => void; setPeriod: (period: BalancePeriod) => void;
setChartMode: (mode: BalanceChartMode) => void; setChartMode: (mode: BalanceChartMode) => void;
setGroupAxis: (axis: BalanceGroupAxis) => void;
reload: () => Promise<void>; reload: () => Promise<void>;
} }
@ -127,10 +145,12 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
dispatch({ type: "LOAD_START" }); dispatch({ type: "LOAD_START" });
try { try {
const range = computeBalanceDateRange(period); const range = computeBalanceDateRange(period);
// Parallel fetches — no inter-dependency between the four queries. // Parallel fetches — no inter-dependency between the queries.
const [totals, byCategory, latest, anchors] = await Promise.all([ const [totals, byCategory, byVehicle, latest, anchors] =
await Promise.all([
getSnapshotTotalsByDate(range), getSnapshotTotalsByDate(range),
getSnapshotTotalsByCategoryAndDate(range), getSnapshotTotalsByCategoryAndDate(range),
getSnapshotTotalsByVehicleAndDate(range),
getAccountsLatestSnapshot(), getAccountsLatestSnapshot(),
getAccountsPeriodAnchor(range), getAccountsPeriodAnchor(range),
]); ]);
@ -139,6 +159,7 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
payload: { payload: {
evolutionTotals: totals, evolutionTotals: totals,
evolutionByCategory: byCategory, evolutionByCategory: byCategory,
evolutionByVehicle: byVehicle,
accountsLatest: latest, accountsLatest: latest,
accountsPeriodAnchor: anchors, accountsPeriodAnchor: anchors,
}, },
@ -162,7 +183,11 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
dispatch({ type: "SET_CHART_MODE", payload: mode }); 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]); const reload = useCallback(() => load(state.period), [load, state.period]);
return { state, setPeriod, setChartMode, reload }; return { state, setPeriod, setChartMode, setGroupAxis, reload };
} }

View file

@ -1563,10 +1563,15 @@
"chart": { "chart": {
"empty": "No snapshot for this period.", "empty": "No snapshot for this period.",
"modeLegend": "Chart display mode", "modeLegend": "Chart display mode",
"axisLegend": "Stacked chart grouping axis",
"totalSeriesLabel": "Total", "totalSeriesLabel": "Total",
"mode": { "mode": {
"line": "Line", "line": "Line",
"stacked": "Stacked by type" "stacked": "Stacked by type"
},
"axis": {
"byAssetClass": "By asset class",
"byVehicle": "By envelope"
} }
}, },
"onboarding": { "onboarding": {
@ -1788,7 +1793,14 @@
"sinceCreation": "Since inception", "sinceCreation": "Since inception",
"sinceCreationTooltip": "Modified Dietz return since the first snapshot.", "sinceCreationTooltip": "Modified Dietz return since the first snapshot.",
"unadjusted": "Unadjusted", "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": { "transfers": {
"linkAction": "Link transfers", "linkAction": "Link transfers",

View file

@ -1563,10 +1563,15 @@
"chart": { "chart": {
"empty": "Aucun snapshot pour cette période.", "empty": "Aucun snapshot pour cette période.",
"modeLegend": "Mode d'affichage du graphique", "modeLegend": "Mode d'affichage du graphique",
"axisLegend": "Axe de regroupement du graphique empilé",
"totalSeriesLabel": "Total", "totalSeriesLabel": "Total",
"mode": { "mode": {
"line": "Ligne", "line": "Ligne",
"stacked": "Empilé par type" "stacked": "Empilé par type"
},
"axis": {
"byAssetClass": "Par classe d'actif",
"byVehicle": "Par enveloppe"
} }
}, },
"onboarding": { "onboarding": {
@ -1788,7 +1793,14 @@
"sinceCreation": "Depuis création", "sinceCreation": "Depuis création",
"sinceCreationTooltip": "Rendement Modified Dietz depuis le premier snapshot.", "sinceCreationTooltip": "Rendement Modified Dietz depuis le premier snapshot.",
"unadjusted": "Non ajusté", "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": { "transfers": {
"linkAction": "Lier transferts", "linkAction": "Lier transferts",

View file

@ -18,7 +18,10 @@ import {
useBalanceOverview, useBalanceOverview,
type BalancePeriod, type BalancePeriod,
type BalanceChartMode, type BalanceChartMode,
type BalanceGroupAxis,
} from "../hooks/useBalanceOverview"; } from "../hooks/useBalanceOverview";
import { BALANCE_VEHICLE_TYPES } from "../shared/types";
import { VEHICLE_NONE_BUCKET } from "../services/balance.service";
import { import {
archiveBalanceAccount, archiveBalanceAccount,
listAccountTransfers, listAccountTransfers,
@ -41,7 +44,8 @@ const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
export default function BalancePage() { export default function BalancePage() {
const { t } = useTranslation(); 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 // Issue #142 — link-transfers modal state. Categories list is loaded once
// on mount (used by the modal's filter dropdown). // on mount (used by the modal's filter dropdown).
@ -150,6 +154,20 @@ export default function BalancePage() {
return m; return m;
}, [state.accountsLatest, t]); }, [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<string, string> = {
[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) => { const handleArchiveAccount = async (accountId: number) => {
try { try {
await archiveBalanceAccount(accountId); await archiveBalanceAccount(accountId);
@ -223,6 +241,37 @@ export default function BalancePage() {
))} ))}
</div> </div>
<div className="flex flex-wrap items-center gap-3">
{/* Stacked-mode group-axis sub-toggle (asset class / envelope).
Only meaningful while the stacked mode is active. */}
{state.chartMode === "stacked" && (
<div
role="group"
aria-label={t("balance.chart.axisLegend")}
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
>
{(["class", "vehicle"] as BalanceGroupAxis[]).map((axis) => (
<button
key={axis}
type="button"
onClick={() => setGroupAxis(axis)}
className={`px-3 py-1.5 text-sm font-medium ${
state.groupAxis === axis
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
}`}
aria-pressed={state.groupAxis === axis}
>
{t(
axis === "class"
? "balance.chart.axis.byAssetClass"
: "balance.chart.axis.byVehicle"
)}
</button>
))}
</div>
)}
{/* Chart mode toggle */} {/* Chart mode toggle */}
<div <div
role="group" role="group"
@ -246,12 +295,16 @@ export default function BalancePage() {
))} ))}
</div> </div>
</div> </div>
</div>
<BalanceEvolutionChart <BalanceEvolutionChart
mode={state.chartMode} mode={state.chartMode}
groupAxis={state.groupAxis}
totals={state.evolutionTotals} totals={state.evolutionTotals}
byCategory={state.evolutionByCategory} byCategory={state.evolutionByCategory}
byVehicle={state.evolutionByVehicle}
categoryLabels={categoryLabels} categoryLabels={categoryLabels}
vehicleLabels={vehicleLabels}
transferMarkers={allTransferMarkers} transferMarkers={allTransferMarkers}
/> />

View file

@ -40,6 +40,7 @@ import {
BalanceServiceError, BalanceServiceError,
getSnapshotTotalsByDate, getSnapshotTotalsByDate,
getSnapshotTotalsByCategoryAndDate, getSnapshotTotalsByCategoryAndDate,
getSnapshotTotalsByVehicleAndDate,
getAccountsLatestSnapshot, getAccountsLatestSnapshot,
getAccountsPeriodAnchor, getAccountsPeriodAnchor,
computeAccountReturn, computeAccountReturn,
@ -693,6 +694,13 @@ describe("balance accounts — vehicle_type (#202)", () => {
const sql = mockSelect.mock.calls[0][0] as string; const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("c.custom_label AS category_custom_label"); 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", () => { 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", () => { describe("getAccountsLatestSnapshot", () => {
it("returns [] when there are no active accounts", async () => { it("returns [] when there are no active accounts", async () => {
mockSelect.mockResolvedValueOnce([]); mockSelect.mockResolvedValueOnce([]);

View file

@ -1326,6 +1326,65 @@ export async function getSnapshotTotalsByCategoryAndDate(
return out; 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<string, number>;
}
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<SnapshotVehicleBreakdownPoint[]> {
const { clause, params } = buildDateRangeClause(range, "s");
const db = await getDb();
const rows = await db.select<RawVehicleBreakdownRow[]>(
`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). */ /** Latest-snapshot value per active account (Issue #141). */
export interface AccountLatestSnapshot { export interface AccountLatestSnapshot {
account_id: number; account_id: number;
@ -1337,6 +1396,11 @@ export interface AccountLatestSnapshot {
category_kind: BalanceCategoryKind; category_kind: BalanceCategoryKind;
/** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */ /** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */
category_custom_label?: string | null; 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. */ /** Date of the snapshot whose value is reported, or null if no snapshot exists. */
latest_snapshot_date: string | null; latest_snapshot_date: string | null;
/** Value at that snapshot, or null if the account has no snapshot lines. */ /** 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.i18n_key AS category_i18n_key,
c.kind AS category_kind, c.kind AS category_kind,
c.custom_label AS category_custom_label, c.custom_label AS category_custom_label,
a.vehicle_type AS vehicle_type,
(SELECT s.snapshot_date (SELECT s.snapshot_date
FROM balance_snapshot_lines l FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id JOIN balance_snapshots s ON s.id = l.snapshot_id