// 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; } 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 }; }