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:
commit
d3b8ad6266
8 changed files with 439 additions and 86 deletions
|
|
@ -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,9 +281,29 @@ export default function BalanceAccountsTable({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
|
<div className="space-y-2">
|
||||||
<table className="w-full text-sm">
|
<div className="flex justify-end">
|
||||||
<thead className="bg-[var(--muted)]/30">
|
<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">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3 font-medium">
|
<th className="text-left px-4 py-3 font-medium">
|
||||||
{t("balance.account.fields.name")}
|
{t("balance.account.fields.name")}
|
||||||
|
|
@ -247,18 +317,22 @@ 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>
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
|
{showReturns && (
|
||||||
{t("balance.accountsTable.return3m")}
|
<>
|
||||||
</th>
|
<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.return1yTooltip")}>
|
{t("balance.accountsTable.return3m")}
|
||||||
{t("balance.accountsTable.return1y")}
|
</th>
|
||||||
</th>
|
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return1yTooltip")}>
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
|
{t("balance.accountsTable.return1y")}
|
||||||
{t("balance.accountsTable.sinceCreation")}
|
</th>
|
||||||
</th>
|
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
|
{t("balance.accountsTable.sinceCreation")}
|
||||||
{t("balance.accountsTable.unadjusted")}
|
</th>
|
||||||
</th>
|
<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">
|
<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,26 +383,30 @@ export default function BalanceAccountsTable({
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
{showReturns && (
|
||||||
{returnsLoading && !accReturns["3M"]
|
<>
|
||||||
? "…"
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
: renderReturnCell(accReturns["3M"])}
|
{returnsLoading && !accReturns["3M"]
|
||||||
</td>
|
? "…"
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
: renderReturnCell(accReturns["3M"])}
|
||||||
{returnsLoading && !accReturns["1A"]
|
</td>
|
||||||
? "…"
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
: renderReturnCell(accReturns["1A"])}
|
{returnsLoading && !accReturns["1A"]
|
||||||
</td>
|
? "…"
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
: renderReturnCell(accReturns["1A"])}
|
||||||
{returnsLoading && !accReturns["since"]
|
</td>
|
||||||
? "…"
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
: renderReturnCell(accReturns["since"])}
|
{returnsLoading && !accReturns["since"]
|
||||||
</td>
|
? "…"
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
: renderReturnCell(accReturns["since"])}
|
||||||
{returnsLoading && !accReturns["1A"]
|
</td>
|
||||||
? "…"
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
: renderUnadjustedCell(accReturns["1A"])}
|
{returnsLoading && !accReturns["1A"]
|
||||||
</td>
|
? "…"
|
||||||
|
: renderUnadjustedCell(accReturns["1A"])}
|
||||||
|
</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"
|
||||||
|
|
@ -383,7 +461,8 @@ export default function BalanceAccountsTable({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,18 +145,21 @@ 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] =
|
||||||
getSnapshotTotalsByDate(range),
|
await Promise.all([
|
||||||
getSnapshotTotalsByCategoryAndDate(range),
|
getSnapshotTotalsByDate(range),
|
||||||
getAccountsLatestSnapshot(),
|
getSnapshotTotalsByCategoryAndDate(range),
|
||||||
getAccountsPeriodAnchor(range),
|
getSnapshotTotalsByVehicleAndDate(range),
|
||||||
]);
|
getAccountsLatestSnapshot(),
|
||||||
|
getAccountsPeriodAnchor(range),
|
||||||
|
]);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "LOAD_SUCCESS",
|
type: "LOAD_SUCCESS",
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,35 +241,70 @@ export default function BalancePage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart mode toggle */}
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div
|
{/* Stacked-mode group-axis sub-toggle (asset class / envelope).
|
||||||
role="group"
|
Only meaningful while the stacked mode is active. */}
|
||||||
aria-label={t("balance.chart.modeLegend")}
|
{state.chartMode === "stacked" && (
|
||||||
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
<div
|
||||||
>
|
role="group"
|
||||||
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
|
aria-label={t("balance.chart.axisLegend")}
|
||||||
<button
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setChartMode(mode)}
|
|
||||||
className={`px-3 py-1.5 text-sm font-medium ${
|
|
||||||
state.chartMode === mode
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
|
||||||
}`}
|
|
||||||
aria-pressed={state.chartMode === mode}
|
|
||||||
>
|
>
|
||||||
{t(`balance.chart.mode.${mode}`)}
|
{(["class", "vehicle"] as BalanceGroupAxis[]).map((axis) => (
|
||||||
</button>
|
<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"
|
||||||
|
aria-label={t("balance.chart.modeLegend")}
|
||||||
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setChartMode(mode)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium ${
|
||||||
|
state.chartMode === mode
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
aria-pressed={state.chartMode === mode}
|
||||||
|
>
|
||||||
|
{t(`balance.chart.mode.${mode}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue