test(analytics): guard the three unguarded Priority 0 behaviours (#155)
docs/ui-information-architecture-review.md claimed Priority 0 was guarded by analytics-sql.test.ts. That file only ever covered the three SQL *fragments* (EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, INVESTMENT_SIGNED). Items 2, 3 and 6 of the Priority 0 list had nothing, and all three fail silently — no error, no empty screen, just a number that quietly disagrees with the one beside it. New: src/__tests__/integration/analytics-priority0.test.ts, 18 tests. - **Item 2, pace/headline parity.** /api/analytics/daily summed over a month must equal /api/analytics/monthly's headline to the cent, per category, and across every month in the window. Cents rather than exact equality is the assertion because the two routes group at different grains (month+category vs month+day+category) and both carry 4dp — the displayed grain is what has to agree, which is precisely the bug (the chart ending the month a few cents off the headline above it). Fixtures are only shapes where a naive gross debit sum disagrees: a 50% split, a refund, fee and interest rows, an itemised loan repayment, a reconciled source row, family-tagged spend, transfers/investment/income, and a row someone else paid that I hold a split on. A fourth test asserts the naive sum does NOT match — without it, parity between two queries identical by construction would pass on fixtures too plain to discriminate. - **Item 3, fees date range.** Default 12 months with the window reported (from=2025-09-01, not 08-31 — toDateStr, not toISOString); the older tail excluded; months=0 all-time with a null range rather than a period claim the number does not make; a narrower months=3; the transaction-level fee lines bounded by the same window as the statement totals, since a bounded total beside an unbounded list is the same defect wearing the fix; and a nonsense months param clamped rather than producing an Invalid Date window. - **Item 6, partial-month comparisons.** The in-progress month never enters a baseline; a partial month is compared through the same day of each prior month; a complete month keeps full totals; top movers truncate both sides; year boundary and leap February. Item 6 was untestable by construction, which is why it had no test: the rules were closures in budget/page.tsx reading `new Date()` inline. Extracted to src/lib/partial-month.ts with an injectable clock, and budget/page.tsx now imports them. The extraction also de-duplicates the baseline filter, which was written out identically in two places. The file freezes the clock (vi.useFakeTimers, toFake: ["Date"] only — pg needs real timers for connection handling). Without it the fixtures drift out of the routes' rolling windows over time and the assertions go vacuous, which is the same class of silent failure the file exists to catch. Mutation-checked, all three: breaking daily's spend semantics fails 3 of the item-2 tests (the placebo correctly still passes); forcing the fees window to all-time fails 5; letting the current month into the baseline fails 1; and dropping the elapsed-day truncation fails 3.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<Response>;
|
||||
let dailyGET: (req: NextRequest) => Promise<Response>;
|
||||
let feesGET: (req: NextRequest) => Promise<Response>;
|
||||
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<string, unknown>;
|
||||
|
||||
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<number> {
|
||||
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<string, Record<number, number>>, 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<string, { spent: number }>)["2026-06"].spent;
|
||||
const pace = sumDaily(daily.daily as Record<string, Record<number, number>>, "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<string, Record<string, Record<number, number>>>)["2026-06"];
|
||||
const rows = monthly.rows as { category: string; spent: Record<string, number> }[];
|
||||
|
||||
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<string, { spent: number }>;
|
||||
const dailyMap = daily.daily as Record<string, Record<number, number>>;
|
||||
|
||||
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<string, { spent: number }>)["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<string, Record<number, number>>;
|
||||
}
|
||||
|
||||
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<string, { spent: number }> = {
|
||||
"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<string, { spent: number }> = { "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<string, { spent: number }> = {
|
||||
"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<string, { spent: number }> = { "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<string, Record<string, Record<number, number>>>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
+25
-42
@@ -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<number, number> | 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 =
|
||||
|
||||
@@ -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<number, number> | 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<string, { spent: number }>,
|
||||
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<string, Record<number, number>> | undefined;
|
||||
totals: Record<string, { spent: number }>;
|
||||
}
|
||||
): 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;
|
||||
}
|
||||
Reference in New Issue
Block a user