Merge pull request 'feat(balance): chart vehicle/class toggle + collapsible returns' (#208) from issue-204-chart-vehicle-toggle-collapsible-returns into main

This commit is contained in:
maximus 2026-06-02 01:08:40 +00:00
commit d3b8ad6266
8 changed files with 439 additions and 86 deletions

View file

@ -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<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,
// 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 [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,6 +281,26 @@ export default function BalanceAccountsTable({
}
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">
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30">
@ -247,6 +317,8 @@ export default function BalanceAccountsTable({
<th className="text-right px-4 py-3 font-medium">
{t("balance.overview.periodDelta")}
</th>
{showReturns && (
<>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
{t("balance.accountsTable.return3m")}
</th>
@ -259,6 +331,8 @@ export default function BalanceAccountsTable({
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
{t("balance.accountsTable.unadjusted")}
</th>
</>
)}
<th className="text-right px-4 py-3 font-medium w-12">
{t("balance.account.fields.actions")}
</th>
@ -309,6 +383,8 @@ export default function BalanceAccountsTable({
"—"
)}
</td>
{showReturns && (
<>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["3M"]
? "…"
@ -329,6 +405,8 @@ export default function BalanceAccountsTable({
? "…"
: renderUnadjustedCell(accReturns["1A"])}
</td>
</>
)}
<td className="px-4 py-3 text-right relative">
<button
type="button"
@ -385,5 +463,6 @@ export default function BalanceAccountsTable({
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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<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
* vertical `<ReferenceLine>` 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, <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(
() =>
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<string>();
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<string, string | number> = {
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({
<Tooltip
formatter={(value: number | undefined, name) => [
cadFormatter.format(value ?? 0),
categoryLabels[String(name)] ?? String(name),
activeLabels[String(name)] ?? String(name),
]}
labelFormatter={(label) => formatDate(String(label))}
contentStyle={tooltipContentStyle}
/>
<Legend
formatter={(value) => categoryLabels[String(value)] ?? String(value)}
formatter={(value) => activeLabels[String(value)] ?? String(value)}
/>
{categoryKeys.map((key, idx) => (
<Area

View file

@ -1,23 +1,29 @@
// useBalanceOverview — scoped useReducer hook backing BalancePage.
//
// Domain coverage (per spec-plan-bilan.md v2 / Issue #141):
// - Time-series for the evolution chart (totals + per-category breakdown)
// Domain coverage (per spec-plan-bilan.md v2 / Issue #141, extended in #204):
// - Time-series for the evolution chart (totals + per-category + per-vehicle)
// - Per-account latest snapshot value + period-anchor value (for Δ%)
// - Period selector (3M / 6M / 1A / 3A / Tout)
// - 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
// (Modified Dietz). The accounts table reserves columns for the return
// metrics with TODO comments.
// `chartMode` (line/stacked) and `groupAxis` (class/vehicle) are two ORTHOGONAL
// dimensions: `groupAxis` only changes which breakdown feeds the stacked chart;
// 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 {
getSnapshotTotalsByDate,
getSnapshotTotalsByCategoryAndDate,
getSnapshotTotalsByVehicleAndDate,
getAccountsLatestSnapshot,
getAccountsPeriodAnchor,
type SnapshotTotalPoint,
type SnapshotCategoryBreakdownPoint,
type SnapshotVehicleBreakdownPoint,
type AccountLatestSnapshot,
type AccountPeriodAnchor,
type SnapshotDateRange,
@ -25,12 +31,17 @@ import {
export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all";
export type BalanceChartMode = "line" | "stacked";
/** Stacked-chart grouping axis: by asset class (category) or fiscal envelope. */
export type BalanceGroupAxis = "class" | "vehicle";
interface State {
period: BalancePeriod;
chartMode: BalanceChartMode;
/** Orthogonal to `chartMode`; only meaningful in stacked mode. Default 'class'. */
groupAxis: BalanceGroupAxis;
evolutionTotals: SnapshotTotalPoint[];
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
accountsLatest: AccountLatestSnapshot[];
accountsPeriodAnchor: AccountPeriodAnchor[];
isLoading: boolean;
@ -40,12 +51,14 @@ interface State {
type Action =
| { type: "SET_PERIOD"; payload: BalancePeriod }
| { type: "SET_CHART_MODE"; payload: BalanceChartMode }
| { type: "SET_GROUP_AXIS"; payload: BalanceGroupAxis }
| { type: "LOAD_START" }
| {
type: "LOAD_SUCCESS";
payload: {
evolutionTotals: SnapshotTotalPoint[];
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
accountsLatest: AccountLatestSnapshot[];
accountsPeriodAnchor: AccountPeriodAnchor[];
};
@ -56,8 +69,10 @@ function initialState(): State {
return {
period: "1A",
chartMode: "line",
groupAxis: "class",
evolutionTotals: [],
evolutionByCategory: [],
evolutionByVehicle: [],
accountsLatest: [],
accountsPeriodAnchor: [],
isLoading: false,
@ -71,6 +86,8 @@ function reducer(state: State, action: Action): State {
return { ...state, period: action.payload };
case "SET_CHART_MODE":
return { ...state, chartMode: action.payload };
case "SET_GROUP_AXIS":
return { ...state, groupAxis: action.payload };
case "LOAD_START":
return { ...state, isLoading: true, error: null };
case "LOAD_SUCCESS":
@ -117,6 +134,7 @@ export interface UseBalanceOverviewResult {
state: State;
setPeriod: (period: BalancePeriod) => void;
setChartMode: (mode: BalanceChartMode) => void;
setGroupAxis: (axis: BalanceGroupAxis) => void;
reload: () => Promise<void>;
}
@ -127,10 +145,12 @@ 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([
// 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),
]);
@ -139,6 +159,7 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
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 };
}

View file

@ -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",

View file

@ -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",

View file

@ -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<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) => {
try {
await archiveBalanceAccount(accountId);
@ -223,6 +241,37 @@ export default function BalancePage() {
))}
</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 */}
<div
role="group"
@ -246,12 +295,16 @@ export default function BalancePage() {
))}
</div>
</div>
</div>
<BalanceEvolutionChart
mode={state.chartMode}
groupAxis={state.groupAxis}
totals={state.evolutionTotals}
byCategory={state.evolutionByCategory}
byVehicle={state.evolutionByVehicle}
categoryLabels={categoryLabels}
vehicleLabels={vehicleLabels}
transferMarkers={allTransferMarkers}
/>

View file

@ -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([]);

View file

@ -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<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). */
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