Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b958d05287 | ||
|
|
eaf5d5ac66 | ||
|
|
fc9ffaba25 |
+12
-4
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Executable
+66
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 &bull; alone rather than turning it into a bullet", () => {
|
it("leaves a literal &bull; alone rather than turning it into a bullet", () => {
|
||||||
|
|||||||
+25
-42
@@ -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 =
|
||||||
|
|||||||
@@ -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'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
@@ -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
|
||||||
|
|||||||
@@ -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 "{tag.name}" 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.
|
||||||
|
|||||||
@@ -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">"{conditionValue}"</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
|
||||||
|
// computed before the query resolves, so it starts empty.
|
||||||
|
//
|
||||||
|
// Adjusted during render rather than in an effect, which is also strictly
|
||||||
|
// 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) {
|
if (participantId === "" && others.length > 0) {
|
||||||
setParticipantId(others[0].id);
|
setParticipantId(others[0].id);
|
||||||
}
|
}
|
||||||
}, [others]);
|
|
||||||
// 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"
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
// discard edits in progress if either query refetched while the modal was
|
||||||
|
// open. Keying on the inputs that actually define the seed fixes both.
|
||||||
|
const seedKey =
|
||||||
|
!participants || participants.length === 0 || !currentUser
|
||||||
|
? null
|
||||||
|
: `${isBulk ? "bulk" : singleId}:${currentUser.id}:${
|
||||||
|
existingSplits ? JSON.stringify(existingSplits) : "none"
|
||||||
|
}`;
|
||||||
|
const [seededFrom, setSeededFrom] = useState<string | null>(null);
|
||||||
|
if (seedKey !== null && seedKey !== seededFrom) {
|
||||||
|
setSeededFrom(seedKey);
|
||||||
setSplitsState(
|
setSplitsState(
|
||||||
existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
|
!isBulk && existingSplits && existingSplits.length > 0
|
||||||
|
? existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
|
||||||
participant_id: s.participant_id,
|
participant_id: s.participant_id,
|
||||||
share_percent: Number(s.share_percent),
|
share_percent: Number(s.share_percent),
|
||||||
}))
|
}))
|
||||||
|
: [{ participant_id: currentUser!.id, share_percent: 100 }]
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
|
|
||||||
}
|
}
|
||||||
}, [existingSplits, participants, isBulk, currentUser]);
|
|
||||||
|
|
||||||
const total = splits.reduce((sum, s) => sum + s.share_percent, 0);
|
const total = splits.reduce((sum, s) => sum + s.share_percent, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -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