Simpl-Resultat/src/hooks/useBalanceOverview.ts
le king fu 0104e9223a
All checks were successful
PR Check / rust (pull_request) Successful in 22m13s
PR Check / frontend (pull_request) Successful in 2m21s
feat(balance): chart vehicle/class toggle + collapsible returns (#204)
Issue 3 of overnight-2026-06-01-bilan-axe-vehicule. Builds the tracking UI
on top of the merged data layer (#202) and input UI (#203).

- Service: getSnapshotTotalsByVehicleAndDate(range) mirrors the by-category
  aggregation with GROUP BY COALESCE(a.vehicle_type, 'none') so null-envelope
  accounts land in a single 'none' bucket (never a SQL NULL key). Add
  vehicle_type to getAccountsLatestSnapshot SELECT + type.
- useBalanceOverview: new groupAxis ('class'|'vehicle') state ORTHOGONAL to
  chartMode; loads byVehicle alongside byCategory. Default groupAxis='class'.
- BalanceEvolutionChart + BalancePage: stacked-mode sub-toggle 'Par classe
  d'actif' (default) / 'Par enveloppe'. Vehicle legend reuses the #203
  vehicleType.* labels; the 'none' bucket uses balance.vehicle.none.
- BalanceAccountsTable: 4 return columns collapsed by default with a toggle,
  persisted across sessions via userPreferenceService key balance_show_returns.
- i18n FR/EN: balance.chart.axis.{byAssetClass,byVehicle}, balance.vehicle.none,
  balance.accountsTable.toggleReturns.{show,hide} (+ axisLegend aria label).

Tests: npm run build green (0 type errors); vitest 3314 passed. Added 5
service tests for the 'none' bucket + mixed envelopes + date range.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:05:00 -04:00

193 lines
6.6 KiB
TypeScript

// useBalanceOverview — scoped useReducer hook backing BalancePage.
//
// 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)
//
// `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,
} from "../services/balance.service";
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;
error: string | null;
}
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[];
};
}
| { type: "LOAD_ERROR"; payload: string };
function initialState(): State {
return {
period: "1A",
chartMode: "line",
groupAxis: "class",
evolutionTotals: [],
evolutionByCategory: [],
evolutionByVehicle: [],
accountsLatest: [],
accountsPeriodAnchor: [],
isLoading: false,
error: null,
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_PERIOD":
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":
return {
...state,
...action.payload,
isLoading: false,
error: null,
};
case "LOAD_ERROR":
return { ...state, isLoading: false, error: action.payload };
default:
return state;
}
}
/**
* Pure helper: turn a `BalancePeriod` into a `SnapshotDateRange` anchored on
* the supplied `today` (defaults to now). Exported so the unit tests can
* exercise the date math without mocking time.
*
* Period anchor decision (decisions-log #141): we anchor on `today`, not on
* the latest snapshot. Aggregators read snapshot rows so the answer is
* identical either way, but anchoring on today keeps the chart's right edge
* stable as the user enters new snapshots — intuitive UX.
*/
export function computeBalanceDateRange(
period: BalancePeriod,
today: Date = new Date()
): SnapshotDateRange {
if (period === "all") return {};
const days =
period === "3M" ? 90 : period === "6M" ? 180 : period === "1A" ? 365 : 1095;
const from = new Date(today);
from.setDate(from.getDate() - days);
// Local-civil `YYYY-MM-DD` (matches normalizeSnapshotDate's expectations).
const yyyy = from.getFullYear();
const mm = String(from.getMonth() + 1).padStart(2, "0");
const dd = String(from.getDate()).padStart(2, "0");
return { from: `${yyyy}-${mm}-${dd}` };
}
export interface UseBalanceOverviewResult {
state: State;
setPeriod: (period: BalancePeriod) => void;
setChartMode: (mode: BalanceChartMode) => void;
setGroupAxis: (axis: BalanceGroupAxis) => void;
reload: () => Promise<void>;
}
export function useBalanceOverview(): UseBalanceOverviewResult {
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const load = useCallback(async (period: BalancePeriod) => {
dispatch({ type: "LOAD_START" });
try {
const range = computeBalanceDateRange(period);
// Parallel fetches — no inter-dependency between the queries.
const [totals, byCategory, byVehicle, latest, anchors] =
await Promise.all([
getSnapshotTotalsByDate(range),
getSnapshotTotalsByCategoryAndDate(range),
getSnapshotTotalsByVehicleAndDate(range),
getAccountsLatestSnapshot(),
getAccountsPeriodAnchor(range),
]);
dispatch({
type: "LOAD_SUCCESS",
payload: {
evolutionTotals: totals,
evolutionByCategory: byCategory,
evolutionByVehicle: byVehicle,
accountsLatest: latest,
accountsPeriodAnchor: anchors,
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
dispatch({ type: "LOAD_ERROR", payload: message });
}
}, []);
// Reload whenever the period changes (and on mount).
useEffect(() => {
void load(state.period);
}, [state.period, load]);
const setPeriod = useCallback((period: BalancePeriod) => {
dispatch({ type: "SET_PERIOD", payload: period });
}, []);
const setChartMode = useCallback((mode: BalanceChartMode) => {
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, setGroupAxis, reload };
}