Compare commits
2 commits
6cf0c8850e
...
d3b8ad6266
| Author | SHA1 | Date | |
|---|---|---|---|
| d3b8ad6266 | |||
|
|
0104e9223a |
8 changed files with 439 additions and 86 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue