diff --git a/src/hooks/useBalanceOverview.test.ts b/src/hooks/useBalanceOverview.test.ts new file mode 100644 index 0000000..0ad85f7 --- /dev/null +++ b/src/hooks/useBalanceOverview.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { computeBalanceDateRange } from "./useBalanceOverview"; + +const FIXED_TODAY = new Date(2026, 3, 25); // local 2026-04-25 + +describe("computeBalanceDateRange", () => { + it("returns an empty range for 'all'", () => { + expect(computeBalanceDateRange("all", FIXED_TODAY)).toEqual({}); + }); + + it("subtracts 90 days for 3M and emits a from-only range", () => { + const r = computeBalanceDateRange("3M", FIXED_TODAY); + expect(r.to).toBeUndefined(); + expect(r.from).toBe("2026-01-25"); + }); + + it("subtracts 180 days for 6M", () => { + const r = computeBalanceDateRange("6M", FIXED_TODAY); + expect(r.from).toBe("2025-10-27"); + }); + + it("subtracts 365 days for 1A", () => { + const r = computeBalanceDateRange("1A", FIXED_TODAY); + expect(r.from).toBe("2025-04-25"); + }); + + it("subtracts 1095 days for 3A", () => { + const r = computeBalanceDateRange("3A", FIXED_TODAY); + expect(r.from).toBe("2023-04-26"); + }); + + it("emits ISO-8601 zero-padded month/day", () => { + // 2026-01-05 → 3M → 2025-10-07; both fields zero-padded. + const today = new Date(2026, 0, 5); + const r = computeBalanceDateRange("3M", today); + expect(r.from).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(r.from).toBe("2025-10-07"); + }); +}); diff --git a/src/hooks/useBalanceOverview.ts b/src/hooks/useBalanceOverview.ts new file mode 100644 index 0000000..faf5d23 --- /dev/null +++ b/src/hooks/useBalanceOverview.ts @@ -0,0 +1,168 @@ +// 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) +// - Per-account latest snapshot value + period-anchor value (for Δ%) +// - Period selector (3M / 6M / 1A / 3A / Tout) +// - Chart mode toggle (line / stacked-area) +// +// 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. + +import { useReducer, useEffect, useCallback } from "react"; +import { + getSnapshotTotalsByDate, + getSnapshotTotalsByCategoryAndDate, + getAccountsLatestSnapshot, + getAccountsPeriodAnchor, + type SnapshotTotalPoint, + type SnapshotCategoryBreakdownPoint, + type AccountLatestSnapshot, + type AccountPeriodAnchor, + type SnapshotDateRange, +} from "../services/balance.service"; + +export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all"; +export type BalanceChartMode = "line" | "stacked"; + +interface State { + period: BalancePeriod; + chartMode: BalanceChartMode; + evolutionTotals: SnapshotTotalPoint[]; + evolutionByCategory: SnapshotCategoryBreakdownPoint[]; + accountsLatest: AccountLatestSnapshot[]; + accountsPeriodAnchor: AccountPeriodAnchor[]; + isLoading: boolean; + error: string | null; +} + +type Action = + | { type: "SET_PERIOD"; payload: BalancePeriod } + | { type: "SET_CHART_MODE"; payload: BalanceChartMode } + | { type: "LOAD_START" } + | { + type: "LOAD_SUCCESS"; + payload: { + evolutionTotals: SnapshotTotalPoint[]; + evolutionByCategory: SnapshotCategoryBreakdownPoint[]; + accountsLatest: AccountLatestSnapshot[]; + accountsPeriodAnchor: AccountPeriodAnchor[]; + }; + } + | { type: "LOAD_ERROR"; payload: string }; + +function initialState(): State { + return { + period: "1A", + chartMode: "line", + evolutionTotals: [], + evolutionByCategory: [], + 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 "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; + 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 four queries. + const [totals, byCategory, latest, anchors] = await Promise.all([ + getSnapshotTotalsByDate(range), + getSnapshotTotalsByCategoryAndDate(range), + getAccountsLatestSnapshot(), + getAccountsPeriodAnchor(range), + ]); + dispatch({ + type: "LOAD_SUCCESS", + payload: { + evolutionTotals: totals, + evolutionByCategory: byCategory, + 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 reload = useCallback(() => load(state.period), [load, state.period]); + + return { state, setPeriod, setChartMode, reload }; +}