feat(balance): /balance page + evolution chart + sidebar (#141) #150
2 changed files with 207 additions and 0 deletions
39
src/hooks/useBalanceOverview.test.ts
Normal file
39
src/hooks/useBalanceOverview.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
168
src/hooks/useBalanceOverview.ts
Normal file
168
src/hooks/useBalanceOverview.ts
Normal file
|
|
@ -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<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 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue