From 396310aa742c781b05721495e538c0cfac28e5db Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:06:23 -0400 Subject: [PATCH] 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 + ); +}