feat(balance): add timeseries aggregator helpers + tests

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) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-04-25 16:06:23 -04:00
parent 80c0a97841
commit 396310aa74
2 changed files with 397 additions and 0 deletions

View file

@ -26,6 +26,10 @@ import {
validateLineKindInvariants, validateLineKindInvariants,
PRICED_VALUE_TOLERANCE, PRICED_VALUE_TOLERANCE,
BalanceServiceError, BalanceServiceError,
getSnapshotTotalsByDate,
getSnapshotTotalsByCategoryAndDate,
getAccountsLatestSnapshot,
getAccountsPeriodAnchor,
} from "./balance.service"; } from "./balance.service";
const mockSelect = vi.fn(); const mockSelect = vi.fn();
@ -801,3 +805,172 @@ describe("upsertSnapshotLines — priced kind", () => {
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]); 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/);
});
});

View file

@ -731,3 +731,227 @@ export async function getPreviousSnapshot(
); );
return rows[0] ?? null; 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<SnapshotTotalPoint[]> {
const { clause, params } = buildDateRangeClause(range, "s");
const db = await getDb();
return db.select<SnapshotTotalPoint[]>(
`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<string, number>;
}
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<SnapshotCategoryBreakdownPoint[]> {
const { clause, params } = buildDateRangeClause(range, "s");
const db = await getDb();
const rows = await db.select<RawCategoryBreakdownRow[]>(
`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<AccountLatestSnapshot[]>(
`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<AccountPeriodAnchor[]> {
// 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<AccountPeriodAnchor[]>(
`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
);
}