From 396310aa742c781b05721495e538c0cfac28e5db Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:06:23 -0400 Subject: [PATCH 1/5] feat(balance): add timeseries aggregator helpers + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four service helpers used by the upcoming `/balance` overview: - getSnapshotTotalsByDate(range?) — SUM(value) GROUP BY snapshot_date with an optional inclusive [from, to] range. LEFT JOIN preserves empty snapshots as zero rows so the chart shows continuity. - getSnapshotTotalsByCategoryAndDate(range?) — same aggregation broken down by balance_categories.key, returned as one row per snapshot date with a `byCategory` map. Powers the stacked-area variant. - getAccountsLatestSnapshot() — one row per active account with the value of its most-recent snapshot line (NULL when none exists). Filters archived accounts via WHERE is_active = 1 AND archived_at IS NULL, matches the listBalanceAccounts default. - getAccountsPeriodAnchor(range) — earliest snapshot_date >= from per account, with the value at that date — the anchor used to compute the per-account Δ% column on the accounts table. Tests cover empty DB, single/multi snapshot, archived exclusion via SQL inspection, date-range params (from-only, both bounds, open). Refs: #141 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/balance.service.test.ts | 173 +++++++++++++++++++++ src/services/balance.service.ts | 224 +++++++++++++++++++++++++++ 2 files changed, 397 insertions(+) diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index 980415e..1510e75 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -26,6 +26,10 @@ import { validateLineKindInvariants, PRICED_VALUE_TOLERANCE, BalanceServiceError, + getSnapshotTotalsByDate, + getSnapshotTotalsByCategoryAndDate, + getAccountsLatestSnapshot, + getAccountsPeriodAnchor, } from "./balance.service"; const mockSelect = vi.fn(); @@ -801,3 +805,172 @@ describe("upsertSnapshotLines — priced kind", () => { expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]); }); }); + +// ----------------------------------------------------------------------------- +// Time-series aggregators (Issue #141 / Bilan #3) +// ----------------------------------------------------------------------------- + +describe("getSnapshotTotalsByDate", () => { + it("returns an empty array on an empty DB", async () => { + mockSelect.mockResolvedValueOnce([]); + expect(await getSnapshotTotalsByDate()).toEqual([]); + }); + + it("aggregates SUM(value) and orders ASC by snapshot_date", async () => { + mockSelect.mockResolvedValueOnce([ + { snapshot_date: "2026-01-31", total: 1000 }, + { snapshot_date: "2026-02-28", total: 1100 }, + { snapshot_date: "2026-03-31", total: 1250 }, + ]); + const out = await getSnapshotTotalsByDate(); + expect(out).toEqual([ + { snapshot_date: "2026-01-31", total: 1000 }, + { snapshot_date: "2026-02-28", total: 1100 }, + { snapshot_date: "2026-03-31", total: 1250 }, + ]); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("FROM balance_snapshots"); + expect(sql).toContain("LEFT JOIN balance_snapshot_lines"); + expect(sql).toContain("GROUP BY s.snapshot_date"); + expect(sql).toContain("ORDER BY s.snapshot_date ASC"); + // Empty range → no WHERE clause + no params + expect(sql).not.toContain("WHERE"); + expect(mockSelect.mock.calls[0][1]).toEqual([]); + }); + + it("applies an inclusive [from, to] date range filter", async () => { + mockSelect.mockResolvedValueOnce([]); + await getSnapshotTotalsByDate({ from: "2026-01-01", to: "2026-03-31" }); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("WHERE"); + expect(sql).toContain("s.snapshot_date >="); + expect(sql).toContain("s.snapshot_date <="); + expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-03-31"]); + }); + + it("supports an open-ended `from` only", async () => { + mockSelect.mockResolvedValueOnce([]); + await getSnapshotTotalsByDate({ from: "2026-01-01" }); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("s.snapshot_date >="); + expect(sql).not.toContain("s.snapshot_date <="); + expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]); + }); +}); + +describe("getSnapshotTotalsByCategoryAndDate", () => { + it("returns [] on empty DB", async () => { + mockSelect.mockResolvedValueOnce([]); + expect(await getSnapshotTotalsByCategoryAndDate()).toEqual([]); + }); + + it("buckets multiple category rows under the same snapshot_date", async () => { + mockSelect.mockResolvedValueOnce([ + { snapshot_date: "2026-01-31", category_key: "cash", total: 500 }, + { snapshot_date: "2026-01-31", category_key: "tfsa", total: 1500 }, + { snapshot_date: "2026-02-28", category_key: "cash", total: 700 }, + { snapshot_date: "2026-02-28", category_key: "tfsa", total: 1700 }, + ]); + const out = await getSnapshotTotalsByCategoryAndDate(); + expect(out).toEqual([ + { + snapshot_date: "2026-01-31", + byCategory: { cash: 500, tfsa: 1500 }, + }, + { + snapshot_date: "2026-02-28", + byCategory: { cash: 700, tfsa: 1700 }, + }, + ]); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("INNER JOIN balance_snapshot_lines"); + expect(sql).toContain("INNER JOIN balance_accounts"); + expect(sql).toContain("INNER JOIN balance_categories"); + expect(sql).toContain("GROUP BY s.snapshot_date, c.key"); + }); + + it("applies date range params when supplied", async () => { + mockSelect.mockResolvedValueOnce([]); + await getSnapshotTotalsByCategoryAndDate({ + 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", () => { + it("returns [] when there are no active accounts", async () => { + mockSelect.mockResolvedValueOnce([]); + expect(await getAccountsLatestSnapshot()).toEqual([]); + }); + + it("returns one row per active account joined with category metadata", async () => { + mockSelect.mockResolvedValueOnce([ + { + account_id: 1, + account_name: "BMO chequing", + symbol: null, + balance_category_id: 10, + category_key: "cash", + category_i18n_key: "balance.category.cash", + category_kind: "simple", + latest_snapshot_date: "2026-03-31", + latest_value: 1234.56, + }, + { + account_id: 2, + account_name: "Wealthsimple TFSA", + symbol: null, + balance_category_id: 11, + category_key: "tfsa", + category_i18n_key: "balance.category.tfsa", + category_kind: "simple", + latest_snapshot_date: null, + latest_value: null, + }, + ]); + const out = await getAccountsLatestSnapshot(); + expect(out).toHaveLength(2); + expect(out[0].latest_value).toBe(1234.56); + expect(out[1].latest_value).toBeNull(); + const sql = mockSelect.mock.calls[0][0] as string; + // Filter: only active, non-archived accounts. + expect(sql).toContain("a.is_active = 1"); + expect(sql).toContain("a.archived_at IS NULL"); + // LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface. + expect(sql).toContain("ORDER BY s.snapshot_date DESC"); + expect(sql).toContain("LIMIT 1"); + }); +}); + +describe("getAccountsPeriodAnchor", () => { + it("queries with a from-only filter", async () => { + mockSelect.mockResolvedValueOnce([ + { account_id: 1, anchor_snapshot_date: "2026-01-31", anchor_value: 1000 }, + ]); + const rows = await getAccountsPeriodAnchor({ from: "2026-01-01" }); + expect(rows).toHaveLength(1); + expect(rows[0].anchor_value).toBe(1000); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("MIN(s.snapshot_date)"); + expect(sql).toContain("GROUP BY l.account_id"); + expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]); + }); + + it("queries with both from and to", async () => { + mockSelect.mockResolvedValueOnce([]); + await getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" }); + expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]); + }); + + it("works with an empty range (open-ended)", async () => { + mockSelect.mockResolvedValueOnce([]); + await getAccountsPeriodAnchor({}); + const sql = mockSelect.mock.calls[0][0] as string; + // No WHERE clause when neither bound is set. + expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/); + }); +}); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index a3c0b63..f3244fd 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -731,3 +731,227 @@ export async function getPreviousSnapshot( ); return rows[0] ?? null; } + +// ----------------------------------------------------------------------------- +// Time-series aggregators (Issue #141 / Bilan #3) — used by BalancePage. +// ----------------------------------------------------------------------------- + +/** + * Optional [from, to] range filter expressed in ISO `YYYY-MM-DD` format. + * Both endpoints are inclusive. `from` and `to` may each be omitted to leave + * that side unbounded. + */ +export interface SnapshotDateRange { + from?: string; + to?: string; +} + +/** Aggregated total at a given snapshot date. */ +export interface SnapshotTotalPoint { + snapshot_date: string; + total: number; +} + +function buildDateRangeClause( + range: SnapshotDateRange | undefined, + baseAlias: string +): { clause: string; params: unknown[] } { + if (!range || (!range.from && !range.to)) { + return { clause: "", params: [] }; + } + const parts: string[] = []; + const params: unknown[] = []; + if (range.from) { + const from = normalizeSnapshotDate(range.from); + parts.push(`${baseAlias}.snapshot_date >= $${params.length + 1}`); + params.push(from); + } + if (range.to) { + const to = normalizeSnapshotDate(range.to); + parts.push(`${baseAlias}.snapshot_date <= $${params.length + 1}`); + params.push(to); + } + return { clause: `WHERE ${parts.join(" AND ")}`, params }; +} + +/** + * Returns the aggregated total value of every snapshot, sorted by date ASC. + * Used by the line variant of the evolution chart on `/balance`. + * + * The aggregation is `SUM(value) GROUP BY snapshot_date` — every account + * contributing to the snapshot is summed in. Snapshots with no lines + * collapse to a `total = 0` row (preserved so the chart shows continuity). + */ +export async function getSnapshotTotalsByDate( + range?: SnapshotDateRange +): Promise { + const { clause, params } = buildDateRangeClause(range, "s"); + const db = await getDb(); + return db.select( + `SELECT s.snapshot_date AS snapshot_date, + COALESCE(SUM(l.value), 0) AS total + FROM balance_snapshots s + LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id + ${clause} + GROUP BY s.snapshot_date + ORDER BY s.snapshot_date ASC`, + params + ); +} + +/** Per-snapshot breakdown by category. */ +export interface SnapshotCategoryBreakdownPoint { + snapshot_date: string; + byCategory: Record; +} + +interface RawCategoryBreakdownRow { + snapshot_date: string; + category_key: string; + total: number; +} + +/** + * Returns per-snapshot totals broken down by `balance_categories.key`, + * sorted by date ASC. Used by the stacked-area variant of the evolution + * chart. Categories with no value at a given date are omitted from the + * `byCategory` map (chart consumers should treat absent keys as zero). + * + * Lines whose joined account points to no category are skipped — that + * shouldn't happen given FK RESTRICT but the JOIN is defensive. + */ +export async function getSnapshotTotalsByCategoryAndDate( + range?: SnapshotDateRange +): Promise { + const { clause, params } = buildDateRangeClause(range, "s"); + const db = await getDb(); + const rows = await db.select( + `SELECT s.snapshot_date AS snapshot_date, + c.key AS category_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 + INNER JOIN balance_categories c ON c.id = a.balance_category_id + ${clause} + GROUP BY s.snapshot_date, c.key + ORDER BY s.snapshot_date ASC, c.key ASC`, + params + ); + // Bucket rows by snapshot_date — many rows per date, one per category. + const out: SnapshotCategoryBreakdownPoint[] = []; + let current: SnapshotCategoryBreakdownPoint | null = null; + for (const r of rows) { + if (!current || current.snapshot_date !== r.snapshot_date) { + current = { snapshot_date: r.snapshot_date, byCategory: {} }; + out.push(current); + } + current.byCategory[r.category_key] = r.total; + } + return out; +} + +/** Latest-snapshot value per active account (Issue #141). */ +export interface AccountLatestSnapshot { + account_id: number; + account_name: string; + symbol: string | null; + balance_category_id: number; + category_key: string; + category_i18n_key: string; + category_kind: BalanceCategoryKind; + /** Date of the snapshot whose value is reported, or null if no snapshot exists. */ + latest_snapshot_date: string | null; + /** Value at that snapshot, or null if the account has no snapshot lines. */ + latest_value: number | null; +} + +/** + * Returns one row per active (non-archived) account with the value of its + * most-recent snapshot line. Accounts with no snapshot rows yet still + * appear, with `latest_value = null`. Used by the accounts table on + * `/balance` (#141) and as a building block for the period Δ% column. + * + * Implementation: a correlated subquery picks the line with the largest + * `s.snapshot_date` for each account — SQLite handles this fine on the + * indexed `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`. + */ +export async function getAccountsLatestSnapshot(): Promise< + AccountLatestSnapshot[] +> { + const db = await getDb(); + return db.select( + `SELECT a.id AS account_id, + a.name AS account_name, + a.symbol AS symbol, + a.balance_category_id AS balance_category_id, + c.key AS category_key, + c.i18n_key AS category_i18n_key, + c.kind AS category_kind, + (SELECT s.snapshot_date + FROM balance_snapshot_lines l + JOIN balance_snapshots s ON s.id = l.snapshot_id + WHERE l.account_id = a.id + ORDER BY s.snapshot_date DESC + LIMIT 1) AS latest_snapshot_date, + (SELECT l.value + FROM balance_snapshot_lines l + JOIN balance_snapshots s ON s.id = l.snapshot_id + WHERE l.account_id = a.id + ORDER BY s.snapshot_date DESC + LIMIT 1) AS latest_value + FROM balance_accounts a + INNER JOIN balance_categories c ON c.id = a.balance_category_id + WHERE a.is_active = 1 AND a.archived_at IS NULL + ORDER BY c.sort_order, a.name` + ); +} + +/** + * Returns the value at the earliest snapshot for each account whose + * `snapshot_date` is `>= range.from` (and `<= range.to` when set), so the + * accounts table can compute a per-account Δ% over the selected period. + * + * Returns one row per account with a snapshot in range. Accounts without + * any snapshot in the period are omitted — callers default their Δ% to + * `null` (rendered as "—"). + */ +export interface AccountPeriodAnchor { + account_id: number; + anchor_snapshot_date: string; + anchor_value: number; +} + +export async function getAccountsPeriodAnchor( + range: SnapshotDateRange +): Promise { + // For each account, find the earliest snapshot_date >= range.from (and + // <= range.to when set), then read that line's value. + const params: unknown[] = []; + const conditions: string[] = []; + if (range.from) { + conditions.push(`s.snapshot_date >= $${params.length + 1}`); + params.push(normalizeSnapshotDate(range.from)); + } + if (range.to) { + conditions.push(`s.snapshot_date <= $${params.length + 1}`); + params.push(normalizeSnapshotDate(range.to)); + } + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const db = await getDb(); + return db.select( + `SELECT l.account_id AS account_id, + MIN(s.snapshot_date) AS anchor_snapshot_date, + (SELECT l2.value + FROM balance_snapshot_lines l2 + JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id + WHERE l2.account_id = l.account_id + AND s2.snapshot_date = MIN(s.snapshot_date) + LIMIT 1) AS anchor_value + FROM balance_snapshot_lines l + JOIN balance_snapshots s ON s.id = l.snapshot_id + ${where} + GROUP BY l.account_id`, + params + ); +} -- 2.45.2 From 202b008bc9ed41eb4a59ee0771ccbb5ec8dbf1ff Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:06:38 -0400 Subject: [PATCH 2/5] feat(balance): add useBalanceOverview hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped useReducer hook backing BalancePage. Tracks: - period (3M / 6M / 1A / 3A / all) — defaults to 1A - chartMode (line / stacked) — defaults to line - evolutionTotals + evolutionByCategory + accountsLatest + accountsPeriodAnchor (parallel-fetched on mount and on every period change via Promise.all) - isLoading + error Exposes computeBalanceDateRange(period, today) as a pure helper so the date math is unit-testable without mocking time. Anchors on `today` rather than the latest snapshot — keeps the chart's right edge stable as the user enters new snapshots. Refs: #141 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hooks/useBalanceOverview.test.ts | 39 +++++++ src/hooks/useBalanceOverview.ts | 168 +++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/hooks/useBalanceOverview.test.ts create mode 100644 src/hooks/useBalanceOverview.ts 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 }; +} -- 2.45.2 From ffefa90fd0d20c60329e71b718ef5d66d83c14f4 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:07:04 -0400 Subject: [PATCH 3/5] feat(balance): add BalancePage with chart + accounts table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new components composed under a new BalancePage at /balance: - BalanceOverviewCard — latest aggregate net worth, Δ% vs the previous chronological snapshot (rendered as "—" when only one snapshot exists), 60-day staleness warning, and a "+ Nouveau snapshot" CTA pointing at /balance/snapshot. - BalanceEvolutionChart — Recharts-based line / stacked-area toggle. Line mode plots SUM(value) per snapshot_date with a single primary-coloured stroke. Stacked mode transposes the byCategory series into one Area per category_key with a fixed 10-color palette indexed deterministically. Tooltip formats CAD via Intl.NumberFormat. - BalanceAccountsTable — one row per active account with name, category label, latest value, and Δ% over the active period (latest_value vs the period anchor). Returns columns (3M / 1Y / since-creation / unadjusted) reserved for #142 with a TODO marker. Action menu includes a disabled "Detail" placeholder + functional "Archive" wired through reload(). BalancePage composes the three with an inline period selector (3M / 6M / 1A / 3A / Tout) and chart-mode toggle, both styled as segmented controls. State flows through useBalanceOverview. Route /balance registered before /balance/accounts in App.tsx. Refs: #141 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 2 + .../balance/BalanceAccountsTable.tsx | 170 ++++++++++++++ .../balance/BalanceEvolutionChart.tsx | 218 ++++++++++++++++++ .../balance/BalanceOverviewCard.tsx | 128 ++++++++++ src/pages/BalancePage.tsx | 141 +++++++++++ 5 files changed, 659 insertions(+) create mode 100644 src/components/balance/BalanceAccountsTable.tsx create mode 100644 src/components/balance/BalanceEvolutionChart.tsx create mode 100644 src/components/balance/BalanceOverviewCard.tsx create mode 100644 src/pages/BalancePage.tsx diff --git a/src/App.tsx b/src/App.tsx index db4b1f8..e987ff7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCartesPage from "./pages/ReportsCartesPage"; import SettingsPage from "./pages/SettingsPage"; import AccountsPage from "./pages/AccountsPage"; +import BalancePage from "./pages/BalancePage"; import SnapshotEditPage from "./pages/SnapshotEditPage"; import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage"; import CategoriesMigrationPage from "./pages/CategoriesMigrationPage"; @@ -116,6 +117,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> + new Intl.NumberFormat(locale, { + style: "currency", + currency: "CAD", + maximumFractionDigits: 2, + }); + +interface BalanceAccountsTableProps { + accounts: AccountLatestSnapshot[]; + periodAnchor: AccountPeriodAnchor[]; + onArchiveAccount?: (account: AccountLatestSnapshot) => void; +} + +export default function BalanceAccountsTable({ + accounts, + periodAnchor, + onArchiveAccount, +}: BalanceAccountsTableProps) { + const { t, i18n } = useTranslation(); + const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA"); + + /** account_id → period anchor (start-of-period value). */ + const anchorMap = useMemo(() => { + const m = new Map(); + for (const a of periodAnchor) m.set(a.account_id, a); + return m; + }, [periodAnchor]); + + const [openMenuFor, setOpenMenuFor] = useState(null); + + if (accounts.length === 0) { + return ( +
+ {t("balance.overview.noAccounts")} +
+ ); + } + + return ( +
+ + + + + + + + {/* TODO Issue #142: 3M / 1A / depuis-création / non-ajusté columns */} + + + + + {accounts.map((acc) => { + const anchor = anchorMap.get(acc.account_id); + const deltaPct = + acc.latest_value !== null && anchor && anchor.anchor_value !== 0 + ? ((acc.latest_value - anchor.anchor_value) / + Math.abs(anchor.anchor_value)) * + 100 + : null; + return ( + + + + + + + + ); + })} + +
+ {t("balance.account.fields.name")} + + {t("balance.account.fields.category")} + + {t("balance.overview.latestValue")} + + {t("balance.overview.periodDelta")} + + {t("balance.account.fields.actions")} +
+ {acc.account_name} + {acc.symbol ? ( + + ({acc.symbol}) + + ) : null} + + {t(acc.category_i18n_key, { defaultValue: acc.category_key })} + + {acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"} + + {deltaPct !== null ? ( + = 0 + ? "text-[var(--positive)]" + : "text-[var(--negative)]" + } + > + {deltaPct >= 0 ? "+" : ""} + {deltaPct.toFixed(2)}% + + ) : ( + "—" + )} + + + {openMenuFor === acc.account_id && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/src/components/balance/BalanceEvolutionChart.tsx b/src/components/balance/BalanceEvolutionChart.tsx new file mode 100644 index 0000000..f9908eb --- /dev/null +++ b/src/components/balance/BalanceEvolutionChart.tsx @@ -0,0 +1,218 @@ +// BalanceEvolutionChart — line / stacked-area chart of net worth over time. +// +// Issue #141 (Bilan #3). Reuses the established Recharts patterns from the +// reports/* charts (see decisions-log #141 — native SVG was reconsidered; +// Recharts is the single chart pattern in this codebase). Two modes: +// - 'line' : a single LineChart of `SUM(value)` per snapshot date. +// - 'stacked' : an AreaChart with one Area per category (stackId='all'). +// +// Tooltip shows per-category breakdown in stacked mode and just the total in +// line mode. + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + LineChart, + Line, + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import type { + SnapshotTotalPoint, + SnapshotCategoryBreakdownPoint, +} from "../../services/balance.service"; +import type { BalanceChartMode } from "../../hooks/useBalanceOverview"; + +// Stable palette for the stacked-by-category areas. Indexed deterministically +// by category sort order so the colour assignment stays consistent across +// renders and period changes. Reused from the reports CategoryBarChart palette. +const CATEGORY_PALETTE = [ + "#3b82f6", // blue + "#10b981", // emerald + "#f59e0b", // amber + "#8b5cf6", // violet + "#ef4444", // red + "#06b6d4", // cyan + "#ec4899", // pink + "#84cc16", // lime + "#f97316", // orange + "#6366f1", // indigo +]; + +export interface BalanceEvolutionChartProps { + mode: BalanceChartMode; + totals: SnapshotTotalPoint[]; + byCategory: SnapshotCategoryBreakdownPoint[]; + /** Map category_key → translated label so the legend reads naturally. */ + categoryLabels?: Record; +} + +export default function BalanceEvolutionChart({ + mode, + totals, + byCategory, + categoryLabels = {}, +}: BalanceEvolutionChartProps) { + const { t, i18n } = useTranslation(); + + const cadFormatter = useMemo( + () => + new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", { + style: "currency", + currency: "CAD", + maximumFractionDigits: 0, + }), + [i18n.language] + ); + + const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA"; + const formatDate = (iso: string) => + new Date(iso).toLocaleDateString(dateLocale, { + year: "numeric", + month: "short", + day: "numeric", + }); + + // --- Line-mode dataset --- + const lineData = useMemo( + () => + totals.map((p) => ({ + snapshot_date: p.snapshot_date, + total: p.total, + })), + [totals] + ); + + // --- Stacked-area dataset --- + // 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 + // emitted as 0 so Recharts renders a continuous stack. + const { stackedData, categoryKeys } = useMemo(() => { + const keys = new Set(); + for (const point of byCategory) { + for (const k of Object.keys(point.byCategory)) keys.add(k); + } + const orderedKeys = Array.from(keys).sort(); + const data = byCategory.map((point) => { + const row: Record = { + snapshot_date: point.snapshot_date, + }; + for (const k of orderedKeys) { + row[k] = point.byCategory[k] ?? 0; + } + return row; + }); + return { stackedData: data, categoryKeys: orderedKeys }; + }, [byCategory]); + + const isEmpty = + mode === "line" ? lineData.length === 0 : stackedData.length === 0; + + if (isEmpty) { + return ( +
+

+ {t("balance.chart.empty")} +

+
+ ); + } + + const tooltipContentStyle = { + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "0.5rem", + color: "var(--foreground)", + }; + + return ( +
+ + {mode === "line" ? ( + + + formatDate(s)} + /> + cadFormatter.format(v)} + width={88} + /> + + cadFormatter.format(value ?? 0) + } + labelFormatter={(label) => formatDate(String(label))} + contentStyle={tooltipContentStyle} + /> + + + ) : ( + + + formatDate(s)} + /> + cadFormatter.format(v)} + width={88} + /> + [ + cadFormatter.format(value ?? 0), + categoryLabels[String(name)] ?? String(name), + ]} + labelFormatter={(label) => formatDate(String(label))} + contentStyle={tooltipContentStyle} + /> + categoryLabels[String(value)] ?? String(value)} + /> + {categoryKeys.map((key, idx) => ( + + ))} + + )} + +
+ ); +} diff --git a/src/components/balance/BalanceOverviewCard.tsx b/src/components/balance/BalanceOverviewCard.tsx new file mode 100644 index 0000000..2ee60ad --- /dev/null +++ b/src/components/balance/BalanceOverviewCard.tsx @@ -0,0 +1,128 @@ +// BalanceOverviewCard — top summary tile of /balance. +// +// Issue #141 (Bilan #3). Displays: +// - The latest aggregate snapshot total (sum across all accounts on the +// most recent snapshot date). +// - Δ% versus the previous chronological snapshot (null when only one +// snapshot exists; rendered as "—"). +// - A staleness warning when the latest snapshot is older than 60 days. +// - "+ Nouveau snapshot" CTA → `/balance/snapshot`. + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react"; +import { Link } from "react-router-dom"; +import type { SnapshotTotalPoint } from "../../services/balance.service"; + +const STALENESS_DAYS = 60; +const cadFormatter = (value: number) => + new Intl.NumberFormat("en-CA", { + style: "currency", + currency: "CAD", + maximumFractionDigits: 2, + }).format(value); + +interface BalanceOverviewCardProps { + /** The full evolution series for the active period (latest at the end). */ + totals: SnapshotTotalPoint[]; +} + +export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) { + const { t, i18n } = useTranslation(); + + const summary = useMemo(() => { + if (totals.length === 0) { + return null; + } + const last = totals[totals.length - 1]; + const prev = totals.length >= 2 ? totals[totals.length - 2] : null; + const deltaPct = + prev && prev.total !== 0 + ? ((last.total - prev.total) / Math.abs(prev.total)) * 100 + : null; + const ageMs = Date.now() - new Date(last.snapshot_date).getTime(); + const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24)); + return { + latest: last, + deltaPct, + isStale: ageDays > STALENESS_DAYS, + ageDays, + }; + }, [totals]); + + const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA"; + const formatDate = (iso: string) => + new Date(iso).toLocaleDateString(dateLocale, { + year: "numeric", + month: "long", + day: "numeric", + }); + + return ( +
+
+
+

+ {t("balance.overview.latestTotal")} +

+ {summary ? ( + <> +

+ {cadFormatter(summary.latest.total)} +

+

+ {t("balance.overview.asOf", { + date: formatDate(summary.latest.snapshot_date), + })} +

+ + ) : ( +

+ {t("balance.overview.noSnapshots")} +

+ )} +
+ +
+ {summary && summary.deltaPct !== null && ( +
= 0 + ? "text-[var(--positive)]" + : "text-[var(--negative)]" + }`} + > + {summary.deltaPct >= 0 ? ( + + ) : ( + + )} + {summary.deltaPct >= 0 ? "+" : ""} + {summary.deltaPct.toFixed(2)}% + + {t("balance.overview.vsPrevious")} + +
+ )} + + + + {t("balance.overview.newSnapshot")} + +
+
+ + {summary?.isStale && ( +
+ + + {t("balance.overview.staleWarning", { days: summary.ageDays })} + +
+ )} +
+ ); +} diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx new file mode 100644 index 0000000..85b5772 --- /dev/null +++ b/src/pages/BalancePage.tsx @@ -0,0 +1,141 @@ +// BalancePage — overview of net worth at `/balance`. +// +// Issue #141 (Bilan #3). Composes: +// - BalanceOverviewCard (latest total + Δ% + staleness warning + new-snapshot CTA) +// - Period selector (3M / 6M / 1A / 3A / Tout) +// - Chart-mode toggle (Line / Stacked-by-category) +// - BalanceEvolutionChart +// - BalanceAccountsTable (one row per active account with latest value + Δ%) +// +// All data flows through `useBalanceOverview` (scoped useReducer). Returns +// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves +// columns with a TODO comment. + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Wallet } from "lucide-react"; +import { + useBalanceOverview, + type BalancePeriod, + type BalanceChartMode, +} from "../hooks/useBalanceOverview"; +import { archiveBalanceAccount } from "../services/balance.service"; +import BalanceOverviewCard from "../components/balance/BalanceOverviewCard"; +import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; +import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; + +const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"]; + +export default function BalancePage() { + const { t } = useTranslation(); + const { state, setPeriod, setChartMode, reload } = useBalanceOverview(); + + // Build a category_key → translated label map from the accounts payload — + // the byCategory series is keyed by `key`, not by id, and the same + // taxonomy is already loaded with `accountsLatest` joins. + const categoryLabels = useMemo(() => { + const m: Record = {}; + for (const a of state.accountsLatest) { + if (!m[a.category_key]) { + m[a.category_key] = t(a.category_i18n_key, { + defaultValue: a.category_key, + }); + } + } + return m; + }, [state.accountsLatest, t]); + + const handleArchiveAccount = async (accountId: number) => { + try { + await archiveBalanceAccount(accountId); + await reload(); + } catch { + // Reload swallows; the row simply stays. UX feedback can be added later. + } + }; + + return ( +
+
+ +

{t("balance.overview.title")}

+
+ + {state.error && ( +
+ {state.error} +
+ )} + +
+ + +
+ {/* Period selector */} +
+ {PERIOD_OPTIONS.map((p) => ( + + ))} +
+ + {/* Chart mode toggle */} +
+ {(["line", "stacked"] as BalanceChartMode[]).map((mode) => ( + + ))} +
+
+ + + +
+

+ {t("balance.overview.accountsTitle")} +

+ handleArchiveAccount(acc.account_id)} + /> +
+
+
+ ); +} -- 2.45.2 From 83ac484a222fe9b966c47ffa874d6eb1c7007052 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:07:13 -0400 Subject: [PATCH 4/5] feat(balance): add sidebar Bilan entry Insert a new "Bilan" / "Balance sheet" entry in NAV_ITEMS pointing at /balance with the Wallet lucide-react icon. Position: between Reports and Settings, matching the autopilot prompt instruction and the spec-plan-bilan v2 ordering. Sidebar.tsx imports Wallet and registers it in iconMap. Refs: #141 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/layout/Sidebar.tsx | 2 ++ src/shared/constants/index.ts | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index a402de0..6dc19a5 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { SlidersHorizontal, PiggyBank, BarChart3, + Wallet, Settings, Languages, Moon, @@ -25,6 +26,7 @@ const iconMap: Record> = { SlidersHorizontal, PiggyBank, BarChart3, + Wallet, Settings, }; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index c770dbf..9c9e612 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -46,6 +46,12 @@ export const NAV_ITEMS: NavItem[] = [ icon: "BarChart3", labelKey: "nav.reports", }, + { + key: "balance", + path: "/balance", + icon: "Wallet", + labelKey: "nav.balance", + }, { key: "settings", path: "/settings", -- 2.45.2 From 1e261ae2ea1c16e230f62856009e2d9b6561b9d8 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:08:10 -0400 Subject: [PATCH 5/5] feat(balance): i18n + CHANGELOG for /balance page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FR + EN translations under: - nav.balance — sidebar label - balance.overview.* — page title, latest total, Δ% vs previous, staleness warning, new-snapshot CTA, accounts table headers, empty/no-snapshot states - balance.period.* — 3M / 6M / 1A / 3A / all selector labels - balance.chart.* — empty state, mode legend, line / stacked toggle labels - balance.sidebar — entry label (mirrors nav.balance) CHANGELOG entry under [Unreleased] / Added documenting the new page, period selector, evolution chart modes, accounts table, sidebar entry, the four service helpers, and the new hook. Refs: #141 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + src/i18n/locales/en.json | 34 ++++++++++++++++++++++++++++++++++ src/i18n/locales/fr.json | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 0b62a25..13722f4 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -3,6 +3,7 @@ ## [Non publié] ### Ajouté +- **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141) - **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur − quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140) - **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146) - **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e52f51..4b969f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141) - **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value − quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140) - **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146) - **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 52ddd3c..60913a5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -15,6 +15,7 @@ "adjustments": "Adjustments", "budget": "Budget", "reports": "Reports", + "balance": "Balance sheet", "settings": "Settings" }, "dashboard": { @@ -1452,6 +1453,39 @@ } }, "balance": { + "overview": { + "title": "Balance sheet", + "latestTotal": "Current net worth", + "asOf": "as of {{date}}", + "noSnapshots": "No snapshot yet. Create one to start tracking your balance over time.", + "vsPrevious": "vs previous", + "newSnapshot": "New snapshot", + "staleWarning": "The latest snapshot is more than {{days}} days old. Consider updating it to keep your balance accurate.", + "latestValue": "Latest value", + "periodDelta": "Δ% over period", + "noAccounts": "No active accounts. Create a balance account to get started.", + "accountsTitle": "Accounts", + "detailAction": "Details", + "detailComingSoon": "Available in a future release." + }, + "period": { + "legend": "Analysis period", + "3M": "3 months", + "6M": "6 months", + "1A": "1 year", + "3A": "3 years", + "all": "All" + }, + "chart": { + "empty": "No snapshot for this period.", + "modeLegend": "Chart display mode", + "totalSeriesLabel": "Total", + "mode": { + "line": "Line", + "stacked": "Stacked by category" + } + }, + "sidebar": "Balance sheet", "accountsPage": { "title": "Balance accounts", "tabs": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3b4004d..b011d76 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -15,6 +15,7 @@ "adjustments": "Ajustements", "budget": "Budget", "reports": "Rapports", + "balance": "Bilan", "settings": "Paramètres" }, "dashboard": { @@ -1452,6 +1453,39 @@ } }, "balance": { + "overview": { + "title": "Bilan", + "latestTotal": "Valeur nette actuelle", + "asOf": "au {{date}}", + "noSnapshots": "Aucun snapshot pour l'instant. Créez-en un pour suivre l'évolution de votre bilan.", + "vsPrevious": "vs précédent", + "newSnapshot": "Nouveau snapshot", + "staleWarning": "Le dernier snapshot date de plus de {{days}} jours. Pensez à le mettre à jour pour suivre fidèlement l'évolution de votre bilan.", + "latestValue": "Dernière valeur", + "periodDelta": "Δ% sur la période", + "noAccounts": "Aucun compte actif. Commencez par créer un compte de bilan.", + "accountsTitle": "Comptes", + "detailAction": "Détail", + "detailComingSoon": "Disponible dans une prochaine version." + }, + "period": { + "legend": "Période d'analyse", + "3M": "3 mois", + "6M": "6 mois", + "1A": "1 an", + "3A": "3 ans", + "all": "Tout" + }, + "chart": { + "empty": "Aucun snapshot pour cette période.", + "modeLegend": "Mode d'affichage du graphique", + "totalSeriesLabel": "Total", + "mode": { + "line": "Ligne", + "stacked": "Empilé par catégorie" + } + }, + "sidebar": "Bilan", "accountsPage": { "title": "Comptes du bilan", "tabs": { -- 2.45.2