3 Commits
Author SHA1 Message Date
siddharthd b958d05287 test(db): resolve the integration DSN at run time instead of storing an IP (#161)
ci / lint-test (push) Successful in 1m5s
setup-test-db.sh wrote .env.test once with the Postgres container's bridge IP
baked in, and Docker reassigns those on recreation. The file rotted silently:
its stored 172.22.0.18 belongs to **gitea** as of today, and an earlier drift
had the suite pointing its TRUNCATE at **postgres-pantry** — which it would
have wiped had the credentials happened to match.

Root-cause fix: stop persisting a host at all.

- scripts/resolve-test-db.sh re-derives the DSN from the live container and
  rewrites .env.test. Resolution order is TEST_DATABASE_URL, then the
  container's published host port, then the bridge IP. `docker port
  postgres-personal` reports 127.0.0.1:5432 since the 2026-08-17 PG17
  migration, so loopback is what gets used here — and a loopback address
  cannot be reassigned to another container, which is the whole point.
  (Note this supersedes the smarthome CLAUDE.md's "postgres-personal publishes
  no host port"; that is now stale.) The bridge-IP fallback is kept for a host
  where the port is not published, and the script never prints the password.
- Wired as `pretest:integration`, so npm runs it before every integration run.
  .env.test becomes a generated artifact rather than stored configuration.
- setup-test-db.sh now only creates the database and copies the schema, then
  delegates to the resolver.
- helpers.ts::assertTestDatabase stays and its message now points at the
  resolver rather than at `docker inspect`. It still earns its place: running
  vitest directly bypasses the pretest hook, and a stale .env.test would then
  still be read.

Docs: CLAUDE.md gains a Testing section carrying this, the prisma-generate
trap for a fresh checkout or worktree, and the frozen-clock rule for tests
whose subject is defined relative to "now". AGENTS.md updated for the same, and
for lint now being blocking.
2026-08-21 00:52:54 +10:00
siddharthd eaf5d5ac66 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.
2026-08-21 00:52:36 +10:00
siddharthd fc9ffaba25 chore(ci): clear the lint debt and make lint blocking (#79)
CI ran lint with continue-on-error because 23 errors predated it. All are
fixed, so the flag is gone and a typecheck step is added beside it — nothing
else in CI compiles the test files, so a type error in a fixture could only
ever be found on a developer's machine.

Real fixes, no blanket disables and no suppressions:

- react-hooks/set-state-in-effect (6): each was "reset or seed state when an
  input changes", so each becomes a state adjustment during render behind a
  previous-value guard — the documented React pattern for it. Two were also
  latent bugs. transactions/page.tsx depended on `others`, rebuilt by .filter()
  every render, so the effect already ran every render, one commit late.
  shared/page.tsx depended on the `tagIds` array identity, so the row window
  was reset more often than "changing any filter" claims — including after
  "Show more", the one interaction it must not undo; the key now joins by
  value. csv-import-modal reads localStorage once at mount, which is a lazy
  useState initializer, not an effect (SSR-guarded, though the modal is
  mounted from client state and never prerenders).
- react-hooks/static-components (3): SortIcon was declared inside SharedPage,
  making it a new component type on every render. Hoisted to module scope with
  the sort state passed as props.
- @typescript-eslint/no-explicit-any (9): the dynamically imported route
  handlers in two integration tests. Typed as their real signatures with one
  `as unknown as NextRequest` cast at the fixture boundary, so the call sites
  and response bodies get checked instead of opted out.
- react/no-unescaped-entities (5): exact-equivalent entities (', "),
  so no rendered text changes.

Also fixes the one pre-existing tsc error (unit/order-ingestion.test.ts:511):
`options` is optional, and `?? 0` keeps the assertion failing on an absent
list rather than silencing it with a non-null bang.

9 warnings remain (no-unused-vars, exhaustive-deps). eslint exits 0 on
warnings, so lint blocks on errors only — deliberately, since none of the
remaining warnings should stop a push.
2026-08-21 00:52:14 +10:00
22 changed files with 1044 additions and 165 deletions
+12 -4
View File
@@ -22,10 +22,18 @@ jobs:
# the normal colour. # the normal colour.
- name: Generate Prisma client - name: Generate Prisma client
run: npx prisma generate run: npx prisma generate
# Advisory until the pre-existing lint debt is cleared (2026-07-19: # Blocking since #79. It was advisory from 2026-07-19 while 23 errors sat
# ~20 errors across budget/insights/shared pages) — then make blocking. # across the pages and the integration tests; those are now fixed, so a
- name: Lint (advisory) # new one must not be able to slip in behind a green tick. Warnings are
# still allowed (eslint exits 0 on them) — there are 9 left, all
# `no-unused-vars` or `exhaustive-deps` advisories, none of which should
# stop a push.
- name: Lint
run: npm run lint run: npm run lint
continue-on-error: true # Typecheck is separate from `next build`: nothing else in CI compiles the
# test files, so a type error in a fixture could only ever be found on a
# developer's machine.
- name: Typecheck
run: npx tsc --noEmit
- name: Unit tests - name: Unit tests
run: npm test run: npm test
+5 -4
View File
@@ -13,10 +13,11 @@ Keep data flow consistent: API routes call query functions in `src/lib/queries.t
- `npm ci` installs the locked dependency set (Node 22 is used in CI). - `npm ci` installs the locked dependency set (Node 22 is used in CI).
- `npm run dev` starts the local Next.js development server. - `npm run dev` starts the local Next.js development server.
- `npm run build` creates a production build; `npm start` serves it. - `npm run build` creates a production build; `npm start` serves it.
- `npm run lint` runs the Next.js ESLint configuration. Existing lint debt makes CI lint advisory, but new code should pass. - `npm run lint` runs the Next.js ESLint configuration. **Blocking in CI since #79** — the pre-existing debt is cleared, so it must stay at zero errors (warnings are still allowed).
- `npx tsc --noEmit` typechecks, including the test files. Also blocking in CI.
- `npm test` runs fast unit tests. - `npm test` runs fast unit tests.
- `npm run test:setup` prepares the PostgreSQL test database using `.env.test`. - `npm run test:setup` creates/refreshes the `personal_test` database (run it once, and after any migration).
- `npm run test:integration` runs database-backed tests. - `npm run test:integration` runs database-backed tests. It re-resolves the DSN from the live container first (`scripts/resolve-test-db.sh`), so `.env.test` must never carry a hand-written host — see `CLAUDE.md` → Testing.
- `npm run test:all` runs both test suites. - `npm run test:all` runs both test suites.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
@@ -33,4 +34,4 @@ Add schema changes as the next numbered `prisma/migrations/NNNN_description/migr
## Commits & Pull Requests ## Commits & Pull Requests
History follows concise Conventional Commit-style subjects such as `feat(rules): preview rule changes`, `fix(trips): ...`, and `docs: ...`. Keep commits focused. Pull requests should explain behavior and data-model impact, link related issues, list validation commands, and include screenshots for UI changes. Ensure unit tests and the production build pass; call out any known lint warnings or migration steps. History follows concise Conventional Commit-style subjects such as `feat(rules): preview rule changes`, `fix(trips): ...`, and `docs: ...`. Keep commits focused. Pull requests should explain behavior and data-model impact, link related issues, list validation commands, and include screenshots for UI changes. Ensure lint, typecheck, unit tests and the production build pass; call out any migration steps.
+43
View File
@@ -59,6 +59,49 @@ docker exec postgres-personal psql -U personal -d personal
docker logs finance -f docker logs finance -f
``` ```
## Testing
```bash
npm test # unit — pure logic, no database
npm run test:integration # database-backed; resolves the DSN itself (see below)
npm run test:all # both
npm run test:setup # once, and after any migration: creates/refreshes personal_test
npm run lint # blocking in CI since #79 — 0 errors, warnings allowed
npx tsc --noEmit # blocking in CI; nothing else compiles the test files
```
**The integration DSN is resolved at run time — never hand-write a host into
`.env.test`.** `scripts/resolve-test-db.sh` runs automatically as
`pretest:integration` and rewrites that file from the live container before
every run. It prefers the container's **published host port** (`docker port
postgres-personal` → `127.0.0.1:5432`, published since the 2026-08-17 PG17
migration) and falls back to the bridge IP only where that is absent. Set
`TEST_DATABASE_URL` to override it entirely.
This replaces the old "refresh the container IP" instruction, which described
the defect rather than a workaround (#161). `setup-test-db.sh` used to bake the
bridge IP into `.env.test` once, and Docker reassigns those on recreation: by
2026-08-21 the stored `172.22.0.18` belonged to **gitea**, and an earlier drift
had pointed the suite at **postgres-pantry** — whose database it would have
TRUNCATEd had the credentials happened to match. A loopback address cannot be
reassigned to another container, which is why the published port wins.
**Two guards, and both matter.** `helpers.ts::assertTestDatabase` refuses to
truncate anything whose `current_database()` is not `personal_test`, and the
suite runs `fileParallelism: false` because every file shares that one database
and `resetDB()` TRUNCATEs it.
**A fresh checkout or worktree needs `npx prisma generate` first.** The client is
generated into the gitignored `src/generated/prisma`, so without it every test
that reaches `src/lib/db.ts` fails on a missing package — which reads as a code
error rather than a setup one. CI does this explicitly for the same reason.
**Freeze the clock in any test whose subject is defined relative to "now".**
`analytics-priority0.test.ts` uses `vi.useFakeTimers({ toFake: ["Date"] })` —
only `Date`, because `pg` needs real timers for connection handling. Without it
the fixtures drift out of the routes' rolling windows over time and the
assertions quietly stop testing anything.
## Architecture ## Architecture
### Key Files ### Key Files
+17 -7
View File
@@ -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 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. headline. Both now carry 4dp and round once, at display.
Partially guarded by `src/__tests__/integration/analytics-sql.test.ts`, which Guarded in two files. `src/__tests__/integration/analytics-sql.test.ts` covers
covers the SQL fragments — `EXCLUDE_RECONCILED_SOURCE`, `NATIVE_CURRENCY` and the SQL fragments — `EXCLUDE_RECONCILED_SOURCE`, `NATIVE_CURRENCY` and
`INVESTMENT_SIGNED` — and nothing else. **Items 2, 3 and 6 have no test.** Those `INVESTMENT_SIGNED`. Since 2026-08-21 `analytics-priority0.test.ts` covers items
are the three where a regression is silent rather than loud: the pace chart would 2, 3 and 6 (#155): the three where a regression is silent rather than loud — the
simply go back to disagreeing with the headline printed directly above it, the pace chart simply going back to disagreeing with the headline printed directly
fees figure back to growing forever, and a partial month back to being measured above it, the fees figure back to growing forever, and a partial month back to
against complete ones — all without anything failing. Tracked as #155. 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) ### Landed after this doc, in the same family (2026-08-02)
+1
View File
@@ -10,6 +10,7 @@
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"test:watch": "vitest --config vitest.config.ts", "test:watch": "vitest --config vitest.config.ts",
"test:setup": "bash scripts/setup-test-db.sh", "test:setup": "bash scripts/setup-test-db.sh",
"pretest:integration": "bash scripts/resolve-test-db.sh",
"test:integration": "vitest run --config vitest.integration.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts",
"test:all": "npm test && npm run test:integration" "test:all": "npm test && npm run test:integration"
}, },
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# Resolve the integration-test DSN at run time and (re)write .env.test.
#
# Why this exists (#161): .env.test used to be written ONCE by
# setup-test-db.sh with the Postgres container's bridge IP baked in. Container
# IPs are reassigned on recreation, so the file rotted silently — by
# 2026-08-21 its 172.22.0.18 belonged to *gitea*, and an earlier drift had the
# suite pointing its TRUNCATE at postgres-pantry. It only failed safe because
# the credentials happened not to match.
#
# The fix is to stop persisting a host at all: this runs as `pretest:integration`,
# so the DSN is re-derived from the live container immediately before every run.
#
# Resolution order:
# 1. An explicit TEST_DATABASE_URL in the environment wins (CI, remote DB).
# 2. The container's published host port (`docker port`) — postgres-personal
# publishes 127.0.0.1:5432 since the 2026-08-17 PG17 migration. Preferred
# because a loopback address cannot be reassigned to another container.
# 3. The container's bridge IP, for a host where the port is not published.
#
# helpers.ts::assertTestDatabase is the backstop: it refuses to TRUNCATE
# anything whose current_database() is not `personal_test`.
set -euo pipefail
PG_CONTAINER=${PG_CONTAINER:-postgres-personal}
PG_USER=${PG_USER:-personal}
PG_PASS=${PG_PASS:-personalpassword123}
TEST_DB=${TEST_DB:-personal_test}
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env.test"
if [ -n "${TEST_DATABASE_URL:-}" ]; then
DSN="$TEST_DATABASE_URL"
SOURCE="TEST_DATABASE_URL"
else
if ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
echo "ERROR: container '$PG_CONTAINER' not found. Start it, or set TEST_DATABASE_URL." >&2
exit 1
fi
# `docker port` prints e.g. "127.0.0.1:5432" (possibly several lines).
PUBLISHED=$(docker port "$PG_CONTAINER" 5432/tcp 2>/dev/null | head -1 || true)
if [ -n "$PUBLISHED" ]; then
HOSTPORT="$PUBLISHED"
SOURCE="published port"
else
IP=$(docker inspect "$PG_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' 2>/dev/null \
| tr ' ' '\n' | grep -v '^$' | head -1)
if [ -z "$IP" ]; then
echo "ERROR: '$PG_CONTAINER' has neither a published 5432 nor a bridge IP." >&2
exit 1
fi
HOSTPORT="$IP:5432"
SOURCE="bridge IP"
fi
DSN="postgresql://$PG_USER:$PG_PASS@$HOSTPORT/$TEST_DB"
fi
printf 'DATABASE_URL=%s\n' "$DSN" > "$ENV_FILE"
# Never print the password.
echo "resolve-test-db: ${DSN%%://*}://***@${DSN#*@} (via $SOURCE)"
+14 -18
View File
@@ -1,25 +1,24 @@
#!/bin/bash #!/bin/bash
# Creates the personal_test database and writes .env.test # Creates the personal_test database and copies the production schema into it.
# Run once before integration tests: npm run test:setup # Run once (and after any migration) before integration tests: npm run test:setup
#
# It no longer writes a host into .env.test. That was the #161 defect: the file
# was written once with the container's bridge IP and then rotted as the IP was
# reassigned. scripts/resolve-test-db.sh now re-derives the DSN before every
# integration run (wired as `pretest:integration`), and this script just calls it
# at the end so a fresh setup leaves a usable .env.test behind.
set -e set -e
PG_CONTAINER=postgres-personal PG_CONTAINER=${PG_CONTAINER:-postgres-personal}
PG_USER=personal PG_USER=${PG_USER:-personal}
PG_PASS=personalpassword123 TEST_DB=${TEST_DB:-personal_test}
TEST_DB=personal_test
# Discover the container's bridge IP (accessible from the host on Linux) if ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
PG_HOST=$(docker inspect "$PG_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null | head -1)
if [ -z "$PG_HOST" ]; then
echo "ERROR: Could not find container '$PG_CONTAINER'" echo "ERROR: Could not find container '$PG_CONTAINER'"
exit 1 exit 1
fi fi
echo "Postgres container at $PG_HOST:5432"
# Create test database (ignore error if it already exists) # Create test database (ignore error if it already exists)
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \ docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \
-c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true -c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true
@@ -31,9 +30,6 @@ docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" --schema-only "$PG_USER" \
echo "Schema ready." echo "Schema ready."
# Write .env.test bash "$(dirname "$0")/resolve-test-db.sh"
cat > "$(dirname "$0")/../.env.test" << EOF
DATABASE_URL=postgresql://$PG_USER:$PG_PASS@$PG_HOST:5432/$TEST_DB
EOF
echo ".env.test written — integration tests can now run with: npm run test:integration" echo "Integration tests can now run with: npm run test:integration"
@@ -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);
});
});
+14 -8
View File
@@ -30,12 +30,17 @@ export function mockDbWithPool(p: Pool) {
/** /**
* Refuse to truncate anything that is not the test database. * Refuse to truncate anything that is not the test database.
* *
* `DATABASE_URL` in `.env.test` names the Postgres container by IP, and * `.env.test` used to name the Postgres container by bridge IP, and Docker
* container IPs move on recreation: 172.22.0.47 stopped being * reassigns those on recreation: 172.22.0.47 stopped being `postgres-personal`
* `postgres-personal` and became `postgres-pantry`, so the suite spent a while * and became `postgres-pantry`, so the suite spent a while pointing its
* pointing its TRUNCATE at another app's database. It only failed safe because * TRUNCATE at another app's database. It only failed safe because the
* the credentials happened not to match — had they matched, this would have * credentials happened not to match — had they matched, this would have wiped
* wiped pantry-app. * pantry-app.
*
* `scripts/resolve-test-db.sh` (wired as `pretest:integration`) now re-derives
* the DSN from the live container before every run, preferring the published
* loopback port. This check stays anyway: running vitest directly bypasses the
* pretest hook, and a stale `.env.test` would still be read.
* *
* Checked once per process, before the first truncate. * Checked once per process, before the first truncate.
*/ */
@@ -47,8 +52,9 @@ async function assertTestDatabase(pool: Pool) {
if (db !== "personal_test") { if (db !== "personal_test") {
throw new Error( throw new Error(
`Refusing to truncate: connected to "${db}", expected "personal_test". ` + `Refusing to truncate: connected to "${db}", expected "personal_test". ` +
`Check DATABASE_URL in .env.test — the Postgres container IP may have changed ` + `Re-resolve the DSN with \`bash scripts/resolve-test-db.sh\` (or just run ` +
`(docker inspect postgres-personal --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}').` `\`npm run test:integration\`, which does it for you), and run ` +
`\`npm run test:setup\` if personal_test does not exist yet.`
); );
} }
targetVerified = true; targetVerified = true;
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeAll } from "vitest"; import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { resolve } from "path"; import { resolve } from "path";
import type { NextRequest } from "next/server";
import { queryRaw } from "../../lib/db"; import { queryRaw } from "../../lib/db";
/** /**
@@ -13,13 +14,22 @@ const dir = resolve(__dirname, "../fixtures/orders/real");
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8"); const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
const TOKEN = "test-ingest-token"; const TOKEN = "test-ingest-token";
let POST: any;
// The handler is imported dynamically (after the env var is set), so its type
// is declared here rather than inferred. `unknown` for the request is
// deliberate: these fixtures are the two properties the route actually reads,
// not a whole NextRequest, and widening to `any` would stop the compiler
// checking the call sites too.
let POST: (req: NextRequest) => Promise<Response>;
// One cast, at the boundary, instead of `any` everywhere downstream: the
// fixture is the two properties the route actually reads, and everything past
// this line stays type-checked.
const req = (body: unknown, token: string | null = TOKEN) => const req = (body: unknown, token: string | null = TOKEN) =>
({ ({
headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) }, headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
json: async () => body, json: async () => body,
}) as any; }) as unknown as NextRequest;
beforeAll(async () => { beforeAll(async () => {
process.env.ORDER_INGEST_TOKEN = TOKEN; process.env.ORDER_INGEST_TOKEN = TOKEN;
+62 -35
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import type { NextRequest } from "next/server";
import { createPool, mockDbWithPool, resetDB } from "./helpers"; import { createPool, mockDbWithPool, resetDB } from "./helpers";
/** /**
@@ -12,15 +13,41 @@ import { createPool, mockDbWithPool, resetDB } from "./helpers";
const pool = createPool(); const pool = createPool();
mockDbWithPool(pool); mockDbWithPool(pool);
let GET: any; /** What the review route returns, to the extent these tests read it. */
let PUT: any; type ItemVerdict = { item: string; verdict: string };
type Review = {
participant_id: number;
rating: string | null;
note: string | null;
order_again: boolean | null;
item_verdicts: ItemVerdict[];
};
type ReviewBody = {
reviews: Review[];
splits: { participant_id: number; share_percent: number }[];
merchant: {
warn: boolean;
note: string | null;
counts: Record<string, number>;
history: { transaction_id: number }[];
items: { item: string; loved: number; never: number }[];
} | null;
};
type Ctx = { params: Promise<{ id: string }> };
// Declared rather than inferred because the route is imported dynamically,
// after vi.doMock. `unknown` for the request keeps the fixture honest — it is
// the two properties the handler reads, not a NextRequest.
let GET: (req: NextRequest, ctx: Ctx) => Promise<Response>;
let PUT: (req: NextRequest, ctx: Ctx) => Promise<Response>;
let ownerId: number; let ownerId: number;
let otherId: number; let otherId: number;
let txnId: number; let txnId: number;
const req = (body?: unknown) => const req = (body?: unknown) =>
({ headers: { get: () => null }, json: async () => body }) as any; ({ headers: { get: () => null }, json: async () => body }) as unknown as NextRequest;
const params = (id: number) => ({ params: Promise.resolve({ id: String(id) }) }); const params = (id: number): Ctx => ({ params: Promise.resolve({ id: String(id) }) });
const bodyOf = async (res: Response): Promise<ReviewBody> => (await res.json()) as ReviewBody;
beforeAll(async () => { beforeAll(async () => {
vi.doMock("@/lib/auth", () => ({ vi.doMock("@/lib/auth", () => ({
@@ -59,25 +86,25 @@ describe("order verdicts — one per person", () => {
it("keeps both people's verdicts on the same order", async () => { it("keeps both people's verdicts on the same order", async () => {
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId)); await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
const res = await PUT(req({ participant_id: otherId, rating: "never" }), params(txnId)); const res = await PUT(req({ participant_id: otherId, rating: "never" }), params(txnId));
const body = await res.json(); const body = await bodyOf(res);
// The bug this guards: a UNIQUE on transaction_id alone made the second // The bug this guards: a UNIQUE on transaction_id alone made the second
// save overwrite the first, and the disagreement is the useful part. // save overwrite the first, and the disagreement is the useful part.
expect(body.reviews).toHaveLength(2); expect(body.reviews).toHaveLength(2);
expect(body.reviews.find((r: any) => r.participant_id === ownerId).rating).toBe("loved"); expect(body.reviews.find((r) => r.participant_id === ownerId)?.rating).toBe("loved");
expect(body.reviews.find((r: any) => r.participant_id === otherId).rating).toBe("never"); expect(body.reviews.find((r) => r.participant_id === otherId)?.rating).toBe("never");
}); });
it("defaults the verdict to the signed-in user, not the owner", async () => { it("defaults the verdict to the signed-in user, not the owner", async () => {
const res = await PUT(req({ rating: "ok" }), params(txnId)); const res = await PUT(req({ rating: "ok" }), params(txnId));
const body = await res.json(); const body = await bodyOf(res);
expect(body.reviews[0].participant_id).toBe(ownerId); expect(body.reviews[0].participant_id).toBe(ownerId);
}); });
it("revising a verdict updates rather than duplicating", async () => { it("revising a verdict updates rather than duplicating", async () => {
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId)); await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
const res = await PUT(req({ participant_id: ownerId, rating: "never" }), params(txnId)); const res = await PUT(req({ participant_id: ownerId, rating: "never" }), params(txnId));
const body = await res.json(); const body = await bodyOf(res);
expect(body.reviews).toHaveLength(1); expect(body.reviews).toHaveLength(1);
expect(body.reviews[0].rating).toBe("never"); expect(body.reviews[0].rating).toBe("never");
}); });
@@ -88,12 +115,12 @@ describe("order verdicts — one per person", () => {
}); });
it("derives order_again from the rating", async () => { it("derives order_again from the rating", async () => {
let body = await (await PUT(req({ rating: "never" }), params(txnId))).json(); let body = await bodyOf(await PUT(req({ rating: "never" }), params(txnId)));
expect(body.reviews[0].order_again).toBe(false); expect(body.reviews[0].order_again).toBe(false);
body = await (await PUT(req({ rating: "ok" }), params(txnId))).json(); body = await bodyOf(await PUT(req({ rating: "ok" }), params(txnId)));
expect(body.reviews[0].order_again).toBe(true); expect(body.reviews[0].order_again).toBe(true);
// "bad" is also a no — you would not choose it again. // "bad" is also a no — you would not choose it again.
body = await (await PUT(req({ rating: "bad" }), params(txnId))).json(); body = await bodyOf(await PUT(req({ rating: "bad" }), params(txnId)));
expect(body.reviews[0].order_again).toBe(false); expect(body.reviews[0].order_again).toBe(false);
}); });
@@ -104,13 +131,13 @@ describe("order verdicts — one per person", () => {
const older = await seedOrder("Thai Palace", "2026-06-01"); const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "bad" }), params(older)); await PUT(req({ participant_id: ownerId, rating: "bad" }), params(older));
let body = await (await GET(req(), params(txnId))).json(); let body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant.counts.bad).toBe(1); expect(body.merchant!.counts.bad).toBe(1);
expect(body.merchant.warn).toBe(false); expect(body.merchant!.warn).toBe(false);
await PUT(req({ participant_id: ownerId, rating: "never" }), params(older)); await PUT(req({ participant_id: ownerId, rating: "never" }), params(older));
body = await (await GET(req(), params(txnId))).json(); body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant.warn).toBe(true); expect(body.merchant!.warn).toBe(true);
}); });
}); });
@@ -125,7 +152,7 @@ describe("item verdicts", () => {
); );
// No item_verdicts key at all — the shape a note-only form sends. // No item_verdicts key at all — the shape a note-only form sends.
const res = await PUT(req({ rating: "liked", note: "slow delivery" }), params(txnId)); const res = await PUT(req({ rating: "liked", note: "slow delivery" }), params(txnId));
const body = await res.json(); const body = await bodyOf(res);
expect(body.reviews[0].item_verdicts).toEqual([ expect(body.reviews[0].item_verdicts).toEqual([
{ item: "Pad Thai", verdict: "loved" }, { item: "Pad Thai", verdict: "loved" },
]); ]);
@@ -138,7 +165,7 @@ describe("item verdicts", () => {
params(txnId) params(txnId)
); );
const res = await PUT(req({ rating: "liked", item_verdicts: [] }), params(txnId)); const res = await PUT(req({ rating: "liked", item_verdicts: [] }), params(txnId));
const body = await res.json(); const body = await bodyOf(res);
expect(body.reviews[0].item_verdicts).toEqual([]); expect(body.reviews[0].item_verdicts).toEqual([]);
}); });
@@ -154,7 +181,7 @@ describe("item verdicts", () => {
}), }),
params(txnId) params(txnId)
); );
const body = await res.json(); const body = await bodyOf(res);
expect(body.reviews[0].rating).toBe("ok"); expect(body.reviews[0].rating).toBe("ok");
expect(body.reviews[0].item_verdicts).toEqual([ expect(body.reviews[0].item_verdicts).toEqual([
{ item: "Pad Thai", verdict: "loved" }, { item: "Pad Thai", verdict: "loved" },
@@ -167,10 +194,10 @@ describe("merchant history", () => {
const older = await seedOrder("Thai Palace", "2026-06-01"); const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never", note: "cold" }), params(older)); await PUT(req({ participant_id: ownerId, rating: "never", note: "cold" }), params(older));
const body = await (await GET(req(), params(txnId))).json(); const body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant.warn).toBe(true); expect(body.merchant!.warn).toBe(true);
expect(body.merchant.counts.never).toBe(1); expect(body.merchant!.counts.never).toBe(1);
expect(body.merchant.history.map((h: any) => h.transaction_id)).toEqual([older]); expect(body.merchant!.history.map((h) => h.transaction_id)).toEqual([older]);
}); });
it("treats the same restaurant as one merchant across platforms", async () => { it("treats the same restaurant as one merchant across platforms", async () => {
@@ -181,17 +208,17 @@ describe("merchant history", () => {
const shouty = await seedOrder("THAI PALACE", "2026-06-01"); const shouty = await seedOrder("THAI PALACE", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never" }), params(shouty)); await PUT(req({ participant_id: ownerId, rating: "never" }), params(shouty));
const body = await (await GET(req(), params(txnId))).json(); const body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant.warn).toBe(true); expect(body.merchant!.warn).toBe(true);
expect(body.merchant.history.map((h: any) => h.transaction_id)).toContain(shouty); expect(body.merchant!.history.map((h) => h.transaction_id)).toContain(shouty);
}); });
it("does not carry a verdict across different merchants", async () => { it("does not carry a verdict across different merchants", async () => {
const other = await seedOrder("Pizza Place", "2026-06-01"); const other = await seedOrder("Pizza Place", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never" }), params(other)); await PUT(req({ participant_id: ownerId, rating: "never" }), params(other));
const body = await (await GET(req(), params(txnId))).json(); const body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant.warn).toBe(false); expect(body.merchant!.warn).toBe(false);
}); });
it("pools item opinions across the merchant's orders", async () => { it("pools item opinions across the merchant's orders", async () => {
@@ -206,10 +233,10 @@ describe("merchant history", () => {
params(older2) params(older2)
); );
const body = await (await GET(req(), params(txnId))).json(); const body = await bodyOf(await GET(req(), params(txnId)));
// Case-folded: the same dish comes back capitalised differently between // Case-folded: the same dish comes back capitalised differently between
// receipts, and two entries for one dish is not a track record. // receipts, and two entries for one dish is not a track record.
expect(body.merchant.items).toEqual([{ item: "Pad Thai", loved: 2, never: 0 }]); expect(body.merchant!.items).toEqual([{ item: "Pad Thai", loved: 2, never: 0 }]);
}); });
it("keeps item opinions from reviews that have no overall rating", async () => { it("keeps item opinions from reviews that have no overall rating", async () => {
@@ -218,14 +245,14 @@ describe("merchant history", () => {
req({ item_verdicts: [{ item: "Satay", verdict: "never" }] }), req({ item_verdicts: [{ item: "Satay", verdict: "never" }] }),
params(older) params(older)
); );
const body = await (await GET(req(), params(txnId))).json(); const body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant.items).toEqual([{ item: "Satay", loved: 0, never: 1 }]); expect(body.merchant!.items).toEqual([{ item: "Satay", loved: 0, never: 1 }]);
}); });
}); });
describe("share state", () => { describe("share state", () => {
it("reports splits so the panel can show shared vs just me", async () => { it("reports splits so the panel can show shared vs just me", async () => {
let body = await (await GET(req(), params(txnId))).json(); let body = await bodyOf(await GET(req(), params(txnId)));
expect(body.splits).toEqual([]); expect(body.splits).toEqual([]);
await pool.query( await pool.query(
@@ -233,7 +260,7 @@ describe("share state", () => {
VALUES ($1, $2, 50)`, VALUES ($1, $2, 50)`,
[txnId, otherId] [txnId, otherId]
); );
body = await (await GET(req(), params(txnId))).json(); body = await bodyOf(await GET(req(), params(txnId)));
expect(body.splits).toHaveLength(1); expect(body.splits).toHaveLength(1);
expect(body.splits[0].participant_id).toBe(otherId); expect(body.splits[0].participant_id).toBe(otherId);
}); });
+3 -1
View File
@@ -508,7 +508,9 @@ describe("HTML entities in line items", () => {
// The ampersand decodes, and the bullet becomes a boundary, not text. // The ampersand decodes, and the bullet becomes a boundary, not text.
expect(item!.description).toContain("Sweet & Sour Crunch"); expect(item!.description).toContain("Sweet & Sour Crunch");
expect(item!.description).not.toContain("•"); expect(item!.description).not.toContain("•");
expect(item!.options.length).toBeGreaterThan(0); // `options` is optional on the parsed item; `?? 0` keeps the assertion honest
// (an absent list fails) instead of silencing the check with a non-null bang.
expect(item!.options?.length ?? 0).toBeGreaterThan(0);
}); });
it("leaves a literal &amp;bull; alone rather than turning it into a bullet", () => { it("leaves a literal &amp;bull; alone rather than turning it into a bullet", () => {
+25 -42
View File
@@ -19,16 +19,20 @@ import { useQueryClient } from "@tanstack/react-query";
import { useMonthlyAnalytics, useDailySpend, useTransactions, useUpdateTransaction } from "@/lib/hooks"; import { useMonthlyAnalytics, useDailySpend, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { formatCategory, CATEGORIES } from "@/lib/categories"; import { formatCategory, CATEGORIES } from "@/lib/categories";
import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors"; 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 { function formatMonth(m: string): string {
const [year, month] = m.split("-"); const [year, month] = m.split("-");
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "long", year: "numeric" }); 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("-"); const [year, month] = m.split("-");
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" }); 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 fmt(n: number): string { return `$${Math.round(n).toLocaleString()}`; }
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; } 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)}`; } 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. // 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. // Comparing 27 days of July against a full June always flattered July.
const compareDay = elapsedDays(selectedMonth); const { compareDay, isPartial: selectedIsPartial } = comparisonBasis(selectedMonth);
const selectedIsPartial = selectedMonth === currentMonthStr();
// Category rows for selected month // Category rows for selected month
const categoryRows = useMemo(() => { const categoryRows = useMemo(() => {
@@ -299,8 +282,10 @@ export default function AnalyticsPage() {
// Only complete months with data form the baseline. A month still in // 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 // 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. // typical curve down by however much of it has not happened yet.
const priorMonths = (analytics?.months ?? []).filter( const priorMonths = baselineMonths(
(m) => m !== selectedMonth && m !== currentMonthStr() && (analytics?.totals[m]?.spent || 0) > 0 analytics?.months ?? [],
selectedMonth,
analytics?.totals ?? {}
); );
let cum = 0; let cum = 0;
@@ -342,18 +327,16 @@ export default function AnalyticsPage() {
// baseline, because most of it has not happened. And when the month in // 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 // 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. // the same slice of each prior month, not against their full totals.
const otherMonths = months.filter( const otherMonths = baselineMonths(months, selectedMonth, analytics.totals);
(m) => m !== selectedMonth && m !== currentMonthStr() && (analytics.totals[m]?.spent || 0) > 0
);
const comparableSpend = selectedIsPartial const comparableSpend = selectedIsPartial
? spendThrough(dailyData?.daily?.[selectedMonth], compareDay) ? spendThrough(dailyData?.daily?.[selectedMonth], compareDay)
: totals.spent; : totals.spent;
const avgSpend = otherMonths.length const avgSpend = baselineAverage(otherMonths, {
? otherMonths.reduce( isPartial: selectedIsPartial,
(s, m) => s + (selectedIsPartial ? spendThrough(dailyData?.daily?.[m], compareDay) : analytics.totals[m]?.spent || 0), compareDay,
0 daily: dailyData?.daily,
) / otherMonths.length totals: analytics.totals,
: 0; });
const avgDeltaPct = avgSpend > 0 ? Math.round(((comparableSpend - avgSpend) / avgSpend) * 100) : 0; const avgDeltaPct = avgSpend > 0 ? Math.round(((comparableSpend - avgSpend) / avgSpend) * 100) : 0;
const throughQualifier = selectedIsPartial ? ` through day ${compareDay}` : ""; const throughQualifier = selectedIsPartial ? ` through day ${compareDay}` : "";
const heroSentence = const heroSentence =
+1 -1
View File
@@ -141,7 +141,7 @@ export default function ReconcilePage() {
<div className="border border-zinc-800 rounded-xl overflow-hidden"> <div className="border border-zinc-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800"> <div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800">
<p className="text-sm font-medium text-zinc-400">No statement matches found ({noMatches.length})</p> <p className="text-sm font-medium text-zinc-400">No statement matches found ({noMatches.length})</p>
<p className="text-xs text-zinc-600 mt-0.5">These may not have hit a statement yet, or the statement hasn't been imported.</p> <p className="text-xs text-zinc-600 mt-0.5">These may not have hit a statement yet, or the statement hasn&apos;t been imported.</p>
</div> </div>
<div className="divide-y divide-zinc-800"> <div className="divide-y divide-zinc-800">
{noMatches.map((tx) => ( {noMatches.map((tx) => (
+26 -9
View File
@@ -432,6 +432,19 @@ const SharedTxRow = memo(function SharedTxRow({
// ── Main page ───────────────────────────────────────────────────────────────── // ── Main page ─────────────────────────────────────────────────────────────────
type SortCol = "transaction_date" | "created_at" | "amount"; type SortCol = "transaction_date" | "created_at" | "amount";
/**
* Sort direction marker for a column header.
*
* Declared at module scope and given the sort state as props, rather than
* closing over it inside SharedPage: a component defined during render is a
* new type on every render, so React unmounts and remounts it each time
* instead of updating it.
*/
function SortIcon({ col, sortCol, sortDir }: { col: SortCol; sortCol: SortCol; sortDir: "asc" | "desc" }) {
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
}
export default function SharedPage() { export default function SharedPage() {
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const [participantId, setParticipantId] = useState<number | undefined>(undefined); const [participantId, setParticipantId] = useState<number | undefined>(undefined);
@@ -478,10 +491,6 @@ export default function SharedPage() {
else { setSortCol(col); setSortDir("desc"); } else { setSortCol(col); setSortDir("desc"); }
} }
function SortIcon({ col }: { col: SortCol }) {
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
}
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds); const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
const { data: allTags = [] } = useTags(); const { data: allTags = [] } = useTags();
const { data: me } = useCurrentUser(); const { data: me } = useCurrentUser();
@@ -504,9 +513,17 @@ export default function SharedPage() {
// table was what made expanding a receipt freeze the browser. "Show more" // table was what made expanding a receipt freeze the browser. "Show more"
// extends the window; changing any filter resets it. // extends the window; changing any filter resets it.
const [visibleCount, setVisibleCount] = useState(100); const [visibleCount, setVisibleCount] = useState(100);
useEffect(() => { // Reset during render behind a previous-value guard rather than in an effect.
// Note the key joins `tagIds` by value: as a raw dependency it was a fresh
// array on many renders, so the window was being reset more often than
// "changing any filter" claims — including after "Show more", which is the
// one interaction this must not undo.
const windowKey = `${search}|${sortCol}|${sortDir}|${tagIds.join(",")}|${participantId ?? ""}`;
const [lastWindowKey, setLastWindowKey] = useState(windowKey);
if (windowKey !== lastWindowKey) {
setLastWindowKey(windowKey);
setVisibleCount(100); setVisibleCount(100);
}, [search, sortCol, sortDir, tagIds, participantId]); }
const visible = (transactions as SharedTransactionRow[]).slice(0, visibleCount); const visible = (transactions as SharedTransactionRow[]).slice(0, visibleCount);
// Receipt expansion, same pattern as the transactions page. The shared // Receipt expansion, same pattern as the transactions page. The shared
@@ -667,13 +684,13 @@ export default function SharedPage() {
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap" className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
onClick={() => toggleSort("transaction_date")} onClick={() => toggleSort("transaction_date")}
> >
Date <SortIcon col="transaction_date" /> Date <SortIcon col="transaction_date" sortCol={sortCol} sortDir={sortDir} />
</th> </th>
<th <th
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap" className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
onClick={() => toggleSort("created_at")} onClick={() => toggleSort("created_at")}
> >
Imported <SortIcon col="created_at" /> Imported <SortIcon col="created_at" sortCol={sortCol} sortDir={sortDir} />
</th> </th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th> <th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th> <th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
@@ -681,7 +698,7 @@ export default function SharedPage() {
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white" className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
onClick={() => toggleSort("amount")} onClick={() => toggleSort("amount")}
> >
Amount <SortIcon col="amount" /> Amount <SortIcon col="amount" sortCol={sortCol} sortDir={sortDir} />
</th> </th>
{/* Paid by sits next to Splits deliberately: together they are the {/* Paid by sits next to Splits deliberately: together they are the
two halves of the question this page exists to answer — whose two halves of the question this page exists to answer — whose
+1 -1
View File
@@ -59,7 +59,7 @@ function ConvertModal({
<div className="px-6 pt-5 pb-4 border-b border-zinc-800"> <div className="px-6 pt-5 pb-4 border-b border-zinc-800">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} /> <span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
<h3 className="font-semibold text-sm text-zinc-300">Convert "{tag.name}" to Trip</h3> <h3 className="font-semibold text-sm text-zinc-300">Convert &quot;{tag.name}&quot; to Trip</h3>
</div> </div>
<p className="text-xs text-zinc-500 mt-1"> <p className="text-xs text-zinc-500 mt-1">
Creates a trip with the same name and color, and assigns all {tag.transaction_count} tagged transaction{tag.transaction_count !== 1 ? "s" : ""} to it. Creates a trip with the same name and color, and assigns all {tag.transaction_count} tagged transaction{tag.transaction_count !== 1 ? "s" : ""} to it.
+12 -6
View File
@@ -148,7 +148,7 @@ function SaveAsRulePrompt({
Automatically apply this {field === "category" ? "category" : "merchant name"} to future matching transactions. Automatically apply this {field === "category" ? "category" : "merchant name"} to future matching transactions.
</p> </p>
<div className="bg-zinc-900 rounded-lg px-3 py-2 text-xs text-zinc-300 mb-3 space-y-1"> <div className="bg-zinc-900 rounded-lg px-3 py-2 text-xs text-zinc-300 mb-3 space-y-1">
<p><span className="text-zinc-500">If</span> {conditionField === "merchant_normalized" ? "merchant" : "description"} {conditionOperator} <span className="text-white">"{conditionValue}"</span></p> <p><span className="text-zinc-500">If</span> {conditionField === "merchant_normalized" ? "merchant" : "description"} {conditionOperator} <span className="text-white">&quot;{conditionValue}&quot;</span></p>
<p> <p>
<span className="text-zinc-500">Then</span>{" "} <span className="text-zinc-500">Then</span>{" "}
{field === "category" {field === "category"
@@ -250,11 +250,17 @@ function MarkAsPaymentModal({
const [participantId, setParticipantId] = useState<number | "">(others[0]?.id ?? ""); const [participantId, setParticipantId] = useState<number | "">(others[0]?.id ?? "");
useEffect(() => { // Seed the picker once the participant list arrives — the initial state is
if (participantId === "" && others.length > 0) { // computed before the query resolves, so it starts empty.
setParticipantId(others[0].id); //
} // Adjusted during render rather than in an effect, which is also strictly
}, [others]); // more correct: `others` is rebuilt by .filter() on every render, so as a
// dependency it never compared equal and the effect ran every render anyway
// — just one commit later, painting an empty select first. The condition
// clears itself on the first non-empty list, so this cannot loop.
if (participantId === "" && others.length > 0) {
setParticipantId(others[0].id);
}
// For credits/refunds the default direction is "they paid me" // For credits/refunds the default direction is "they paid me"
const [direction, setDirection] = useState<"received" | "sent">( const [direction, setDirection] = useState<"received" | "sent">(
SPEND_TYPES.has(transaction.transaction_type) ? "sent" : "received" SPEND_TYPES.has(transaction.transaction_type) ? "sent" : "received"
+13 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState, useRef } from "react";
import { CATEGORIES, formatCategory } from "@/lib/categories"; import { CATEGORIES, formatCategory } from "@/lib/categories";
import { useImportCSV, useStatementCoverage } from "@/lib/hooks"; import { useImportCSV, useStatementCoverage } from "@/lib/hooks";
import { import {
@@ -54,13 +54,23 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
const [savePreset, setSavePreset] = useState(false); const [savePreset, setSavePreset] = useState(false);
const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]); const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [presets, setPresets] = useState<BankPreset[]>([]); // Read once, at mount, from localStorage. A lazy initializer rather than an
// effect: it runs exactly once per mount — the same semantics as
// `useEffect(…, [])` — but before the first paint, so the preset dropdown
// does not appear empty and then populate. Nothing in this component writes
// `presets` afterwards, so there is no state to keep in sync.
//
// The `typeof window` guard is for safety only: this modal is mounted from
// client state and so is never server-rendered. If that ever changes, it
// degrades to an empty list instead of throwing on `localStorage`.
const [presets] = useState<BankPreset[]>(() =>
typeof window === "undefined" ? [] : loadBankPresets()
);
const [insertedCount, setInsertedCount] = useState(0); const [insertedCount, setInsertedCount] = useState(0);
const [coveredRows, setCoveredRows] = useState<ParsedTransaction[]>([]); const [coveredRows, setCoveredRows] = useState<ParsedTransaction[]>([]);
const [showCovered, setShowCovered] = useState(false); const [showCovered, setShowCovered] = useState(false);
const coverage = useStatementCoverage(); const coverage = useStatementCoverage();
useEffect(() => { setPresets(loadBankPresets()); }, []);
function handleFile(file: File) { function handleFile(file: File) {
setError(""); setError("");
+12 -4
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useRuleMatches, useApplyRuleToSelection, type RuleMatchChange } from "@/lib/hooks"; import { useRuleMatches, useApplyRuleToSelection, type RuleMatchChange } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories"; import { formatCategory } from "@/lib/categories";
@@ -39,9 +39,17 @@ export function RulePreviewModal({
// Default to everything the rule would change — the common case is "yes, all // Default to everything the rule would change — the common case is "yes, all
// of these", with deselection as the exception. // of these", with deselection as the exception.
useEffect(() => { //
if (data?.transactions) setSelected(new Set(data.transactions.map((t) => t.id))); // Adjusted during render behind a previous-value guard rather than in an
}, [data]); // effect. In an effect the modal painted once with nothing ticked and then
// again with everything ticked; here the seeded selection is part of the
// first commit. The guard is on `data` identity, so a user deselecting a row
// is not undone on the next render — only a genuinely new preview reseeds.
const [seededFrom, setSeededFrom] = useState<typeof data>(undefined);
if (data !== seededFrom) {
setSeededFrom(data);
setSelected(new Set(data?.transactions?.map((t) => t.id) ?? []));
}
const rows = data?.transactions ?? []; const rows = data?.transactions ?? [];
const allSelected = rows.length > 0 && selected.size === rows.length; const allSelected = rows.length > 0 && selected.size === rows.length;
+12 -3
View File
@@ -82,10 +82,19 @@ export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Close drawer on route change // Close the drawer on route change.
useEffect(() => { //
// Adjusting state during render behind a previous-value guard — the
// documented React pattern for "reset state when a prop changes" — rather
// than in an effect: React re-runs this component immediately and never
// commits the intermediate DOM, so there is no cascading render. Putting it
// in each Link's onClick is the other candidate fix and is worse here: it
// misses browser back/forward, where the route changes with no click.
const [lastPathname, setLastPathname] = useState(pathname);
if (pathname !== lastPathname) {
setLastPathname(pathname);
setOpen(false); setOpen(false);
}, [pathname]); }
// Prevent body scroll when drawer is open // Prevent body scroll when drawer is open
useEffect(() => { useEffect(() => {
+27 -17
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState } from "react";
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule, useCurrentUser } from "@/lib/hooks"; import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule, useCurrentUser } from "@/lib/hooks";
interface Split { interface Split {
@@ -33,22 +33,32 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
const [saveAsRule, setSaveAsRule] = useState(false); const [saveAsRule, setSaveAsRule] = useState(false);
const [ruleSaved, setRuleSaved] = useState(false); const [ruleSaved, setRuleSaved] = useState(false);
// Initialise: bulk always defaults to 100% Me; single loads existing splits // Initialise: bulk always defaults to 100% Me; single loads existing splits.
useEffect(() => { //
if (!participants || participants.length === 0 || !currentUser) return; // Seeded during render behind a previous-value guard rather than in an
if (isBulk) { // effect. Both queries resolve after the first paint, so as an effect this
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]); // rendered an empty split table and then replaced it — and, worse, it re-ran
} else if (existingSplits && existingSplits.length > 0) { // on every identity change of `participants`/`existingSplits`, which would
setSplitsState( // discard edits in progress if either query refetched while the modal was
existingSplits.map((s: { participant_id: number; share_percent: number }) => ({ // open. Keying on the inputs that actually define the seed fixes both.
participant_id: s.participant_id, const seedKey =
share_percent: Number(s.share_percent), !participants || participants.length === 0 || !currentUser
})) ? null
); : `${isBulk ? "bulk" : singleId}:${currentUser.id}:${
} else { existingSplits ? JSON.stringify(existingSplits) : "none"
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]); }`;
} const [seededFrom, setSeededFrom] = useState<string | null>(null);
}, [existingSplits, participants, isBulk, currentUser]); if (seedKey !== null && seedKey !== seededFrom) {
setSeededFrom(seedKey);
setSplitsState(
!isBulk && existingSplits && existingSplits.length > 0
? existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
participant_id: s.participant_id,
share_percent: Number(s.share_percent),
}))
: [{ participant_id: currentUser!.id, share_percent: 100 }]
);
}
const total = splits.reduce((sum, s) => sum + s.share_percent, 0); const total = splits.reduce((sum, s) => sum + s.share_percent, 0);
+135
View File
@@ -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;
}