diff --git a/docs/ui-information-architecture-review.md b/docs/ui-information-architecture-review.md index 8f9c1a1..3756c5d 100644 --- a/docs/ui-information-architecture-review.md +++ b/docs/ui-information-architecture-review.md @@ -47,13 +47,23 @@ Also fixed while in here, both found by checking rather than by proposal: category-day, so the pace chart ended the month a few cents off its own headline. Both now carry 4dp and round once, at display. -Partially guarded by `src/__tests__/integration/analytics-sql.test.ts`, which -covers the SQL fragments — `EXCLUDE_RECONCILED_SOURCE`, `NATIVE_CURRENCY` and -`INVESTMENT_SIGNED` — and nothing else. **Items 2, 3 and 6 have no test.** Those -are the three where a regression is silent rather than loud: the pace chart would -simply go back to disagreeing with the headline printed directly above it, the -fees figure back to growing forever, and a partial month back to being measured -against complete ones — all without anything failing. Tracked as #155. +Guarded in two files. `src/__tests__/integration/analytics-sql.test.ts` covers +the SQL fragments — `EXCLUDE_RECONCILED_SOURCE`, `NATIVE_CURRENCY` and +`INVESTMENT_SIGNED`. Since 2026-08-21 `analytics-priority0.test.ts` covers items +2, 3 and 6 (#155): the three where a regression is silent rather than loud — the +pace chart simply going back to disagreeing with the headline printed directly +above it, the fees figure back to growing forever, and a partial month back to +being measured against complete ones, none of which fails anything. + +Two things were needed to make item 6 testable at all, and both are the point +rather than incidental. The rules lived as closures inside `budget/page.tsx` +reading `new Date()` inline, so they now live in `src/lib/partial-month.ts` with +an injectable clock; and the tests freeze that clock, because a fixture defined +against the real "now" drifts out of the routes' rolling windows and the +assertion goes vacuous — the same class of silent failure, one level up. Item +2's suite carries a placebo assertion for the same reason: parity between two +queries that are identical by construction proves nothing unless some fixture +can actually tell the old and new spend definitions apart. ### Landed after this doc, in the same family (2026-08-02) diff --git a/src/__tests__/integration/analytics-priority0.test.ts b/src/__tests__/integration/analytics-priority0.test.ts new file mode 100644 index 0000000..1a0d0b8 --- /dev/null +++ b/src/__tests__/integration/analytics-priority0.test.ts @@ -0,0 +1,531 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest"; +import type { NextRequest } from "next/server"; +import { createPool, mockDbWithPool, resetDB } from "./helpers"; +import { + comparisonBasis, + baselineMonths, + baselineAverage, + spendThrough, + prevMonth, +} from "../../lib/partial-month"; + +/** + * The three Priority 0 behaviours that shipped without a test (#155). + * + * `docs/ui-information-architecture-review.md` claimed Priority 0 was guarded; + * `analytics-sql.test.ts` only ever covered the three SQL *fragments* + * (EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, INVESTMENT_SIGNED). Items 2, 3 + * and 6 of that list had nothing: + * + * 2. the spend-pace series must use the same spend semantics as the headline + * printed directly above it; + * 3. fees and interest must be bounded by an explicit, reported period; + * 6. a month in progress must never be compared against complete ones. + * + * All three fail *silently* when they regress — no error, no empty screen, just + * a number that quietly disagrees with the one beside it. That is precisely the + * class the review flagged and the reason these are worth the fixture cost. + * + * The clock is frozen for every test in this file. Two of the three behaviours + * are defined relative to "now" (which month is in progress, how far back the + * default fees window reaches), so against the real clock the fixtures would + * drift out of the routes' windows and the assertions would go vacuous — which + * is the failure mode this file exists to prevent, one level up. Only `Date` is + * faked: `pg` uses real timers for its connection handling. + */ + +const pool = createPool(); +mockDbWithPool(pool); + +/** Frozen wall clock: the 15th, so the current month is genuinely half-done. */ +const NOW = new Date(2026, 7, 15, 10, 0, 0); // 2026-08-15, local time +const CURRENT_MONTH = "2026-08"; +const ELAPSED = 15; + +let monthlyGET: (req: NextRequest) => Promise; +let dailyGET: (req: NextRequest) => Promise; +let feesGET: (req: NextRequest) => Promise; +let ownerId: number; + +// One cast at the boundary: these are the two properties the analytics routes +// read (`url` for the query string, `headers` for the auth header, which is +// mocked out anyway), not a whole NextRequest. +const req = (path: string) => + ({ url: `http://localhost${path}`, headers: { get: () => null } }) as unknown as NextRequest; + +const json = async (res: Response) => (await res.json()) as Record; + +beforeAll(async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + + vi.doMock("@/lib/auth", () => ({ + getCurrentUser: async () => ({ id: ownerId, name: "Owner", email: "o@x" }), + })); + ({ GET: monthlyGET } = await import("../../app/api/analytics/monthly/route")); + ({ GET: dailyGET } = await import("../../app/api/analytics/daily/route")); + ({ GET: feesGET } = await import("../../app/api/analytics/fees/route")); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +beforeEach(async () => { + await resetDB(pool); + const a = await pool.query(`INSERT INTO participants (name) VALUES ('Owner') RETURNING id`); + const b = await pool.query(`INSERT INTO participants (name) VALUES ('Other') RETURNING id`); + ownerId = a.rows[0].id; + otherId = b.rows[0].id; +}); + +let otherId: number; + +/** A manual transaction owned by `owner`. Returns its id. */ +async function txn(over: { + date: string; + amount: number; + type?: string; + category?: string; + owner?: number; + description?: string; + interest_amount?: number; + reconciled_with_id?: number; +}): Promise { + const r = await pool.query( + `INSERT INTO transactions + (owner_id, statement_id, transaction_date, description, amount, + transaction_type, category, interest_amount, reconciled_with_id, row_index) + VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, 0) RETURNING id`, + [ + over.owner ?? ownerId, + over.date, + over.description ?? `fixture ${over.date} ${over.amount}`, + over.amount, + over.type ?? "debit", + over.category ?? "groceries", + over.interest_amount ?? null, + over.reconciled_with_id ?? null, + ] + ); + return r.rows[0].id as number; +} + +async function split(txnId: number, participantId: number, pct: number) { + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) + VALUES ($1, $2, $3)`, + [txnId, participantId, pct] + ); +} + +async function tag(txnId: number, name: string) { + const t = await pool.query( + `INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, + [name] + ); + await pool.query( + `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2)`, + [txnId, t.rows[0].id] + ); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Item 2 — the pace chart and the headline must agree +// ─────────────────────────────────────────────────────────────────────────── + +/** + * The pace chart used to sum gross `amount_aud ?? amount` over + * `transaction_type = 'debit'` in the browser. Measured on live data it ended + * July at $4,747.31 against a headline of $3,597.10 — a 32% overstatement of + * the number printed directly above it, with both labelled "spend". + * + * `/api/analytics/daily` fixed that by building the series from the same + * fragments as `/monthly`. Nothing has been asserting it since, and the two + * routes are separate files: the drift they were merged to prevent can + * reappear the moment one is edited alone. + * + * Every fixture below is a shape where the naive gross-debit sum and the real + * spend definition disagree. The last test proves that — without it, parity + * could hold simply because the fixtures were too plain to discriminate, and + * the test would pass against a route that had regressed. + */ +describe("Priority 0 item 2 — spend pace matches the headline", () => { + /** Everything the two routes are supposed to treat identically. */ + async function seedDivergentMonth(month: string) { + const d = (day: number) => `${month}-${String(day).padStart(2, "0")}`; + + // Plain debit — both definitions agree on this one. + await txn({ date: d(2), amount: 100, category: "groceries" }); + + // Half mine. Gross says 200; my share is 100. + const shared = await txn({ date: d(3), amount: 200, category: "groceries" }); + await split(shared, ownerId, 50); + await split(shared, otherId, 50); + + // A refund. Ignored entirely by a debit-only sum; must cancel spend here. + await txn({ date: d(5), amount: 40, type: "refund", category: "groceries" }); + + // Fee and interest rows: spend, but not type 'debit'. + await txn({ date: d(6), amount: 15, type: "fee", category: "fees" }); + await txn({ date: d(7), amount: 25, type: "interest", category: "fees" }); + + // An itemised loan repayment: type 'payment', so a debit-only sum skips it, + // but its interest portion is real spend and its principal is not. + await txn({ + date: d(8), + amount: 1200, + type: "payment", + category: "loan", + interest_amount: 300, + }); + + // The manual row a statement line superseded — double count if counted. + const survivor = await txn({ date: d(9), amount: 60, category: "dining" }); + await txn({ date: d(9), amount: 60, category: "dining", reconciled_with_id: survivor }); + + // Categories that are not spend at all. + await txn({ date: d(10), amount: 5000, category: "transfers" }); + await txn({ date: d(11), amount: 2000, category: "investment" }); + await txn({ date: d(12), amount: 3000, type: "credit", category: "income" }); + + // A `family`-tagged row: excluded from budget spend by EXCLUDE_NON_BUDGET_TAGS. + const family = await txn({ date: d(13), amount: 500, category: "groceries" }); + await tag(family, "family"); + + // Someone else's row I hold a split on — mine only to the extent of the split. + const theirs = await txn({ date: d(14), amount: 80, category: "dining", owner: otherId }); + await split(theirs, ownerId, 25); + await split(theirs, otherId, 75); + } + + /** Sum the sparse daily map for one month. */ + const sumDaily = (daily: Record>, month: string) => + Object.values(daily[month] ?? {}).reduce((s, v) => s + v, 0); + + it("the daily series sums to the monthly headline, to the cent", async () => { + await seedDivergentMonth("2026-06"); + + const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6"))); + const daily = await json(await dailyGET(req("/api/analytics/daily?months=6"))); + + const headline = (monthly.totals as Record)["2026-06"].spent; + const pace = sumDaily(daily.daily as Record>, "2026-06"); + + // Cents, not exact float equality: the two routes group at different grains + // (month+category vs month+day+category) and both carry 4dp, so they can + // differ below a cent by construction. The displayed grain is what has to + // agree — that IS the bug, the chart ending the month a few cents off the + // headline it sits under. + expect(Math.round(pace * 100) / 100).toBe(headline); + }); + + it("agrees per category, not just in total", async () => { + // A total can match while two categories are wrong in opposite directions, + // and the category drill-down is a real surface on this page. + await seedDivergentMonth("2026-06"); + + const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6"))); + const daily = await json(await dailyGET(req("/api/analytics/daily?months=6"))); + + const byCategory = (daily.byCategory as Record>>)["2026-06"]; + const rows = monthly.rows as { category: string; spent: Record }[]; + + expect(Object.keys(byCategory).sort()).toEqual( + rows.filter((r) => r.spent["2026-06"] !== undefined).map((r) => r.category).sort() + ); + + for (const r of rows) { + const expected = r.spent["2026-06"]; + if (expected === undefined) continue; + const got = Object.values(byCategory[r.category] ?? {}).reduce((s, v) => s + v, 0); + expect([r.category, Math.round(got * 100) / 100]).toEqual([ + r.category, + Math.round(expected * 100) / 100, + ]); + } + }); + + it("holds across every month in the window, not only the seeded one", async () => { + await seedDivergentMonth("2026-05"); + await seedDivergentMonth("2026-06"); + await seedDivergentMonth("2026-07"); + + const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6"))); + const daily = await json(await dailyGET(req("/api/analytics/daily?months=6"))); + const totals = monthly.totals as Record; + const dailyMap = daily.daily as Record>; + + for (const m of monthly.months as string[]) { + expect([m, Math.round(sumDaily(dailyMap, m) * 100) / 100]).toEqual([m, totals[m].spent]); + } + }); + + it("the fixtures actually discriminate — a gross debit sum does NOT agree", async () => { + // The placebo. Parity between two identical-by-construction queries is easy + // to assert and worthless if no fixture can tell the definitions apart. + // This pins the gap the old client-side series had. + await seedDivergentMonth("2026-06"); + + const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6"))); + const headline = (monthly.totals as Record)["2026-06"].spent; + + const naive = await pool.query<{ total: string }>( + `SELECT COALESCE(SUM(COALESCE(amount_aud, amount)), 0)::numeric(14,2) AS total + FROM transactions + WHERE transaction_type = 'debit' + AND transaction_date >= '2026-06-01' AND transaction_date < '2026-07-01'` + ); + + expect(Number(naive.rows[0].total)).toBeGreaterThan(headline); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Item 3 — fees and interest are bounded by an explicit, reported period +// ─────────────────────────────────────────────────────────────────────────── + +/** + * `/api/analytics/fees` used to aggregate every statement ever imported with no + * date filter, and the UI printed the result with no period label — so a + * lifetime-to-date total read as a current-period one and grew forever. It was + * overstating the last 12 months by roughly $2,700 of fees. + * + * The regression is silent in the worst way: the number is always plausible and + * always too big, and it gets worse the longer the app is used. + */ +describe("Priority 0 item 3 — fees and interest carry a date range", () => { + /** A statement dated by the period it covers, carrying fees and interest. */ + async function statement(billingEnd: string, fees: number, interest: number, bank = "Fixture Bank") { + const r = await pool.query( + `INSERT INTO statements + (filename, bank_name, account_number, billing_end_date, currency, + fees_charged, interest_charged, owner_id) + VALUES ($1, $2, '0000', $3, 'AUD', $4, $5, $6) RETURNING id`, + [`fees-${billingEnd}-${bank}.pdf`, bank, billingEnd, fees, interest, ownerId] + ); + return r.rows[0].id as number; + } + + beforeEach(async () => { + // Inside the default 12-month window (opens 2025-09-01 from a 2026-08-15 now). + await statement("2026-07-31", 10, 5); + await statement("2025-10-31", 20, 7); + // Outside it — the tail that used to be silently included. + await statement("2024-06-30", 1000, 500); + await statement("2019-01-31", 2500, 900); + }); + + it("defaults to the last 12 months and says so", async () => { + const body = await json(await feesGET(req("/api/analytics/fees"))); + const period = body.period as { months: number; from: string; to: string; all_time: boolean }; + + expect(period.months).toBe(12); + expect(period.all_time).toBe(false); + // The window opens on the 1st of the month 11 months back, and toDateStr() + // must not shift it a day earlier — `toISOString()` on a local-midnight + // Date does exactly that in any timezone east of Greenwich, which is how + // every analytics window used to be a day early. + expect(period.from).toBe("2025-09-01"); + expect(period.to).toBe("2026-09-01"); + + expect(body.total_fees).toBe(30); + expect(body.total_interest).toBe(12); + }); + + it("excludes the older tail — the $2,700-of-fees overstatement", async () => { + const body = await json(await feesGET(req("/api/analytics/fees"))); + // 3530 of fees exist; only 30 are in the last 12 months. + expect(body.total_fees).toBe(30); + expect(body.total_fees).not.toBe(3530); + }); + + it("months=0 asks for all time deliberately, and is labelled as such", async () => { + const body = await json(await feesGET(req("/api/analytics/fees?months=0"))); + const period = body.period as { months: number; from: null; to: null; all_time: boolean }; + + expect(period.all_time).toBe(true); + // A null range is the honest answer for an unbounded total; a from/to here + // would be a period claim the number does not actually make. + expect(period.from).toBeNull(); + expect(period.to).toBeNull(); + + expect(body.total_fees).toBe(3530); + expect(body.total_interest).toBe(1412); + }); + + it("honours a narrower explicit window", async () => { + const body = await json(await feesGET(req("/api/analytics/fees?months=3"))); + const period = body.period as { months: number; from: string }; + expect(period.months).toBe(3); + expect(period.from).toBe("2026-06-01"); + // Only the 2026-07-31 statement falls inside three months. + expect(body.total_fees).toBe(10); + expect(body.total_interest).toBe(5); + }); + + it("bounds the transaction-level fee lines by the same window", async () => { + // Two halves of the same answer, and only the statement half was ever + // date-filtered in the original. A bounded total beside an unbounded list + // is the same defect wearing the fix. + await txn({ date: "2026-07-10", amount: 12, type: "fee", category: "fees" }); + await txn({ date: "2020-02-10", amount: 99, type: "fee", category: "fees" }); + + const bounded = await json(await feesGET(req("/api/analytics/fees"))); + const all = await json(await feesGET(req("/api/analytics/fees?months=0"))); + + expect((bounded.transactions as unknown[]).length).toBe(1); + expect((all.transactions as unknown[]).length).toBe(2); + }); + + it("clamps a nonsense months parameter instead of unbounding the total", async () => { + // A NaN reaching `months` would make `new Date(y, NaN, 1)` an Invalid Date + // and the window meaningless. Falling back to 12 keeps the default claim. + const body = await json(await feesGET(req("/api/analytics/fees?months=banana"))); + expect((body.period as { months: number }).months).toBe(12); + expect(body.total_fees).toBe(30); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Item 6 — a month in progress is never compared against complete ones +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Two rules, both silent when broken (see src/lib/partial-month.ts): + * + * 1. the in-progress month never enters a baseline; + * 2. when it is the month selected, both sides of every comparison are + * truncated to the same day. + * + * These are driven off real `/api/analytics/daily` output rather than a + * hand-built map, because rule 2 depends on that route's day-of-month + * granularity — a route returning month totals only would make the rule + * unimplementable while every pure-function test still passed. + */ +describe("Priority 0 item 6 — partial-month comparisons", () => { + /** `perDay` dollars on each of days 1..n of `month`. */ + async function evenMonth(month: string, perDay: number, days: number) { + for (let d = 1; d <= days; d++) { + await txn({ date: `${month}-${String(d).padStart(2, "0")}`, amount: perDay, category: "groceries" }); + } + } + + async function dailyMap() { + const body = await json(await dailyGET(req("/api/analytics/daily?months=12"))); + return body.daily as Record>; + } + + it("recognises the in-progress month and the day to compare through", () => { + expect(comparisonBasis(CURRENT_MONTH, NOW)).toEqual({ isPartial: true, compareDay: ELAPSED }); + // A complete month compares through its last day, so the same code path + // serves both cases and `spendThrough` becomes a no-op. + expect(comparisonBasis("2026-06", NOW)).toEqual({ isPartial: false, compareDay: 30 }); + expect(comparisonBasis("2026-02", NOW)).toEqual({ isPartial: false, compareDay: 28 }); + }); + + it("keeps the in-progress month out of the baseline", () => { + const totals: Record = { + "2026-05": { spent: 100 }, + "2026-06": { spent: 100 }, + "2026-07": { spent: 100 }, + [CURRENT_MONTH]: { spent: 40 }, // half a month of spend + }; + const months = ["2026-05", "2026-06", "2026-07", CURRENT_MONTH]; + + // Baseline for a complete month: every other complete month with data. + expect(baselineMonths(months, "2026-07", totals, NOW)).toEqual(["2026-05", "2026-06"]); + // Baseline for the month in progress: itself excluded twice over. + expect(baselineMonths(months, CURRENT_MONTH, totals, NOW)).toEqual([ + "2026-05", + "2026-06", + "2026-07", + ]); + // The partial month is never in it. Averaging its 40 in would drag a + // baseline of 100 down to 85 and make an ordinary month look extravagant. + expect(baselineMonths(months, "2026-07", totals, NOW)).not.toContain(CURRENT_MONTH); + }); + + it("drops a month with no data rather than averaging a zero into the baseline", () => { + const totals: Record = { "2026-05": { spent: 100 }, "2026-06": { spent: 0 } }; + expect(baselineMonths(["2026-05", "2026-06"], "2026-07", totals, NOW)).toEqual(["2026-05"]); + }); + + it("compares the month in progress through the same day of each prior month", async () => { + // $10/day everywhere. Prior months ran the full month; the current one has + // only reached the 15th, so it holds $150 against their $300-odd. + await evenMonth("2026-06", 10, 30); + await evenMonth("2026-07", 10, 31); + await evenMonth(CURRENT_MONTH, 10, ELAPSED); + + const daily = await dailyMap(); + const totals: Record = { + "2026-06": { spent: 300 }, + "2026-07": { spent: 310 }, + [CURRENT_MONTH]: { spent: 150 }, + }; + const months = ["2026-06", "2026-07", CURRENT_MONTH]; + const { isPartial, compareDay } = comparisonBasis(CURRENT_MONTH, NOW); + const baseline = baselineMonths(months, CURRENT_MONTH, totals, NOW); + + const mine = spendThrough(daily[CURRENT_MONTH], compareDay); + const avg = baselineAverage(baseline, { isPartial, compareDay, daily, totals }); + + // Truncated on both sides, this month is exactly typical. + expect(mine).toBe(150); + expect(avg).toBe(150); + expect(Math.round(((mine - avg) / avg) * 100)).toBe(0); + + // Untruncated — the bug — it reads as 51% below average on identical + // spending, purely because half the month has not happened yet. + const naiveAvg = baseline.reduce((s, m) => s + totals[m].spent, 0) / baseline.length; + expect(Math.round(((mine - naiveAvg) / naiveAvg) * 100)).toBe(-51); + }); + + it("leaves a complete month compared on full totals", async () => { + await evenMonth("2026-06", 10, 30); + await evenMonth("2026-07", 20, 31); + + const daily = await dailyMap(); + const totals: Record = { "2026-06": { spent: 300 }, "2026-07": { spent: 620 } }; + const months = ["2026-06", "2026-07"]; + const { isPartial, compareDay } = comparisonBasis("2026-07", NOW); + const baseline = baselineMonths(months, "2026-07", totals, NOW); + + expect(isPartial).toBe(false); + // Truncation must not quietly apply to a finished month: June's full 30 + // days are the right comparison for July's full 31. + expect(baselineAverage(baseline, { isPartial, compareDay, daily, totals })).toBe(300); + }); + + it("truncates the top-movers comparison on both sides", async () => { + // The movers row asks "biggest category moves vs last month". With one side + // full and the other half-done, every category reads as a large decrease. + await evenMonth("2026-07", 10, 31); // $310 in July + await evenMonth(CURRENT_MONTH, 10, ELAPSED); // $150 so far in August + + const body = await json(await dailyGET(req("/api/analytics/daily?months=12"))); + const byCategory = body.byCategory as Record>>; + const { compareDay } = comparisonBasis(CURRENT_MONTH, NOW); + const pm = prevMonth(CURRENT_MONTH); + expect(pm).toBe("2026-07"); + + const now = spendThrough(byCategory[CURRENT_MONTH]?.groceries, compareDay); + const before = spendThrough(byCategory[pm]?.groceries, compareDay); + expect(now - before).toBe(0); + + // Against July's full month it would report a $160 fall that never happened. + const fullBefore = Object.values(byCategory[pm].groceries).reduce((s, v) => s + v, 0); + expect(now - fullBefore).toBe(-160); + }); + + it("crosses a year boundary correctly", () => { + expect(prevMonth("2026-01")).toBe("2025-12"); + expect(comparisonBasis("2025-12", NOW)).toEqual({ isPartial: false, compareDay: 31 }); + }); + + it("handles February in a leap year", () => { + // 2028 is a leap year; a hardcoded 28 would silently drop the 29th. + expect(comparisonBasis("2028-02", new Date(2028, 5, 1)).compareDay).toBe(29); + }); +}); diff --git a/src/app/budget/page.tsx b/src/app/budget/page.tsx index 4acdb95..b10f2d9 100644 --- a/src/app/budget/page.tsx +++ b/src/app/budget/page.tsx @@ -19,16 +19,20 @@ import { useQueryClient } from "@tanstack/react-query"; import { useMonthlyAnalytics, useDailySpend, useTransactions, useUpdateTransaction } from "@/lib/hooks"; import { formatCategory, CATEGORIES } from "@/lib/categories"; import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors"; +// The partial-month comparison rules live in their own module so they can be +// tested. They used to be closures here reading `new Date()` inline, which is +// why Priority 0 item 6 had no test (#155). See src/lib/partial-month.ts. +import { + currentMonthStr, + prevMonth, + daysInMonthOf, + elapsedDays, + spendThrough, + comparisonBasis, + baselineMonths, + baselineAverage, +} from "@/lib/partial-month"; -function currentMonthStr(): string { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; -} -function prevMonth(m: string): string { - const [year, month] = m.split("-").map(Number); - const d = new Date(year, month - 2, 1); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; -} function formatMonth(m: string): string { const [year, month] = m.split("-"); return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "long", year: "numeric" }); @@ -37,26 +41,6 @@ function formatShortMonth(m: string): string { const [year, month] = m.split("-"); return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" }); } -function daysInMonthOf(m: string): number { - const [year, month] = m.split("-").map(Number); - return new Date(year, month, 0).getDate(); -} -/** - * How much of `month` has actually happened. A month in progress is only - * complete up to today; every earlier month is complete. - */ -function elapsedDays(m: string): number { - return m === currentMonthStr() ? new Date().getDate() : daysInMonthOf(m); -} -/** Spend in `month` from day 1 through `throughDay` inclusive. */ -function spendThrough(days: Record | undefined, throughDay: number): number { - if (!days) return 0; - let sum = 0; - for (const [d, v] of Object.entries(days)) { - if (Number(d) <= throughDay) sum += v; - } - return sum; -} function fmt(n: number): string { return `$${Math.round(n).toLocaleString()}`; } function fmtExact(n: number): string { return `$${n.toFixed(2)}`; } function fmtSigned(n: number): string { return `${n >= 0 ? "+" : "−"}$${Math.abs(n) >= 100 ? Math.round(Math.abs(n)).toLocaleString() : Math.abs(n).toFixed(0)}`; } @@ -221,8 +205,7 @@ export default function AnalyticsPage() { // A month in progress is only comparable to prior months through the same day. // Comparing 27 days of July against a full June always flattered July. - const compareDay = elapsedDays(selectedMonth); - const selectedIsPartial = selectedMonth === currentMonthStr(); + const { compareDay, isPartial: selectedIsPartial } = comparisonBasis(selectedMonth); // Category rows for selected month const categoryRows = useMemo(() => { @@ -299,8 +282,10 @@ export default function AnalyticsPage() { // Only complete months with data form the baseline. A month still in // progress has no spend recorded past today, so including it would pull the // typical curve down by however much of it has not happened yet. - const priorMonths = (analytics?.months ?? []).filter( - (m) => m !== selectedMonth && m !== currentMonthStr() && (analytics?.totals[m]?.spent || 0) > 0 + const priorMonths = baselineMonths( + analytics?.months ?? [], + selectedMonth, + analytics?.totals ?? {} ); let cum = 0; @@ -342,18 +327,16 @@ export default function AnalyticsPage() { // baseline, because most of it has not happened. And when the month in // progress is the one selected, its running total has to be measured against // the same slice of each prior month, not against their full totals. - const otherMonths = months.filter( - (m) => m !== selectedMonth && m !== currentMonthStr() && (analytics.totals[m]?.spent || 0) > 0 - ); + const otherMonths = baselineMonths(months, selectedMonth, analytics.totals); const comparableSpend = selectedIsPartial ? spendThrough(dailyData?.daily?.[selectedMonth], compareDay) : totals.spent; - const avgSpend = otherMonths.length - ? otherMonths.reduce( - (s, m) => s + (selectedIsPartial ? spendThrough(dailyData?.daily?.[m], compareDay) : analytics.totals[m]?.spent || 0), - 0 - ) / otherMonths.length - : 0; + const avgSpend = baselineAverage(otherMonths, { + isPartial: selectedIsPartial, + compareDay, + daily: dailyData?.daily, + totals: analytics.totals, + }); const avgDeltaPct = avgSpend > 0 ? Math.round(((comparableSpend - avgSpend) / avgSpend) * 100) : 0; const throughQualifier = selectedIsPartial ? ` through day ${compareDay}` : ""; const heroSentence = diff --git a/src/lib/partial-month.ts b/src/lib/partial-month.ts new file mode 100644 index 0000000..f616fec --- /dev/null +++ b/src/lib/partial-month.ts @@ -0,0 +1,135 @@ +/** + * Partial-month comparison rules (Priority 0, item 6). + * + * A month in progress is not comparable to a complete one, and getting that + * wrong is silent: nothing fails, the current month simply looks thrifty + * because most of it has not happened yet. Two distinct rules follow from it, + * and both are applied in three places on the analytics page: + * + * 1. **The in-progress month is never part of a baseline.** Averaging it in + * drags the "typical" figure down by however much of the month is still in + * the future. + * 2. **When the in-progress month is the one being looked at, every month it + * is compared against is truncated to the same day.** Its running total is + * measured against the same slice of each prior month, not their full + * totals. + * + * This module exists so those rules can be tested. They previously lived as + * closures inside `src/app/budget/page.tsx` reading `new Date()` directly, so + * "is this month in progress" depended on the wall clock at run time and no + * test could assert either rule without waiting for the calendar. Every + * function here takes `now` explicitly, defaulting to the real clock, which is + * the whole reason the behaviour is now guardable. + * + * Pair with `/api/analytics/daily`: rule 2 needs day-of-month granularity, + * which is why that route exists. + */ + +/** `YYYY-MM` for a Date, in local time. */ +export function monthStrOf(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +/** The month currently in progress. */ +export function currentMonthStr(now: Date = new Date()): string { + return monthStrOf(now); +} + +/** The calendar month before `m`, as `YYYY-MM`. */ +export function prevMonth(m: string): string { + const [year, month] = m.split("-").map(Number); + return monthStrOf(new Date(year, month - 2, 1)); +} + +/** Total days in month `m`. */ +export function daysInMonthOf(m: string): number { + const [year, month] = m.split("-").map(Number); + return new Date(year, month, 0).getDate(); +} + +/** + * How much of `m` has actually happened. A month in progress is complete only + * up to today; every earlier month is complete. + */ +export function elapsedDays(m: string, now: Date = new Date()): number { + return m === currentMonthStr(now) ? now.getDate() : daysInMonthOf(m); +} + +/** Spend in a month from day 1 through `throughDay` inclusive. */ +export function spendThrough( + days: Record | undefined, + throughDay: number +): number { + if (!days) return 0; + let sum = 0; + for (const [d, v] of Object.entries(days)) { + if (Number(d) <= throughDay) sum += v; + } + return sum; +} + +/** + * Whether `selectedMonth` is still in progress, and the day both sides of a + * comparison must be truncated to. + * + * For a complete month the day is its last, which makes `spendThrough` a no-op + * — so callers can use the same code path either way. + */ +export function comparisonBasis( + selectedMonth: string, + now: Date = new Date() +): { isPartial: boolean; compareDay: number } { + return { + isPartial: selectedMonth === currentMonthStr(now), + compareDay: elapsedDays(selectedMonth, now), + }; +} + +/** + * The months that may form a baseline for `selectedMonth`. + * + * Excludes the selected month itself, the month in progress (rule 1), and any + * month with no spend recorded — an empty month is absence of data, not a + * thrifty month, and averaging zeroes in understates the baseline. + */ +export function baselineMonths( + months: readonly string[], + selectedMonth: string, + totals: Record, + now: Date = new Date() +): string[] { + const current = currentMonthStr(now); + return months.filter( + (m) => m !== selectedMonth && m !== current && (totals[m]?.spent || 0) > 0 + ); +} + +/** + * Mean spend across `baseline`, truncated to `compareDay` when the selected + * month is partial. + * + * `daily` is the sparse `{ [month]: { [day]: amount } }` map from + * `/api/analytics/daily`; `totals` supplies whole-month figures for the + * complete-month case. + */ +export function baselineAverage( + baseline: readonly string[], + { + isPartial, + compareDay, + daily, + totals, + }: { + isPartial: boolean; + compareDay: number; + daily: Record> | undefined; + totals: Record; + } +): number { + if (!baseline.length) return 0; + const sum = baseline.reduce( + (s, m) => s + (isPartial ? spendThrough(daily?.[m], compareDay) : totals[m]?.spent || 0), + 0 + ); + return sum / baseline.length; +}