Six metric-integrity defects from the UI/IA review, plus two found while
verifying the review's own claims against the code.
The reconciled-row exclusion existed only in queries.ts. Every analytics
route counted the superseded manual rows as spend — 48 rows, $4,474.79 of
double count, invisible precisely because the transaction list looked
right. It is now one fragment both sides import.
The spend-pace chart computed its own totals in the browser: gross
amounts, debits only, no personal share, no refunds, fees, interest or
itemised loan repayments. On live data it ended July at $4,747.31 under a
headline reading $3,597.10 — and its own baseline line was drawn from the
split-adjusted monthly totals, so the two series in one chart disagreed
with each other. Both now come from /api/analytics/daily, built from the
same fragments as the headline.
Fees aggregated every statement ever imported with no date filter, under
a heading with no period, so a lifetime figure read as a current one and
grew forever. Now bounded, labelled, and selectable.
Comparisons no longer measure a month in progress against complete ones:
the in-progress month is out of every baseline, and a selected current
month is compared through the same day.
Two the review did not catch:
- Every analytics window was a day early. toISOString() on a
local-midnight Date converts backwards through UTC. Surfaced only once
fees started reporting the range it had used.
- /monthly rounded per category, /daily per category-day, so the pace
chart ended a few cents off the headline above it.
Shared currency needed amending rather than applying. Reading s.currency
would have labelled every order row AUD, since an order receipt has no
statement and carries its own currency — the opposite convention from a
foreign charge on an AUD statement, where amount IS AUD. NATIVE_CURRENCY's
COALESCE order keeps the two apart. Balances also now count rows whose AUD
value is genuinely unknown instead of netting a foreign figure against AUD
ones. Latent today: no foreign transaction is currently split.
Tag-filtered balance cards no longer claim "owes you". With a filter on,
payments are deliberately not subtracted, so the figure is a split total
and settling against it would record a payment for a debt that never was.
Split-coverage warnings deliberately omitted (user decision).
The split-adjusted spend expression assumed a transaction with no split row for
me was entirely mine. That is wrong when a transaction is allocated fully to
someone else: I paid, they owe all of it, and there is no row for me to match.
Both branches of the CASE missed and the ELSE charged me the full amount.
24 transactions were affected, all travel bookings between 2026-01-09 and
2026-06-26, overstating my spend by $8,579.07 across every analytics view.
Adds myShare/mySplitOf to analytics-sql.ts, which fall back to
100 - (sum of everyone else's shares) instead of 100, and applies them to all
five analytics routes. Centralised for the same reason as EXCLUDE_NON_SPEND:
the expression was duplicated five times and had already drifted.
Regression from the analytics scoping work. Chain:
1. Three transactions have a NULL category. The old WHERE clause
`COALESCE(o.category_override, t.category) NOT IN ('transfers','investment')`
evaluated to NULL for those rows, so they were silently dropped — which is
the bug EXCLUDE_NON_SPEND fixed by defaulting to 'other'.
2. Including them exposed that the SELECT still used the non-null-safe
COALESCE, so MODE() returned NULL for the affected merchant
("Shared expenses carryover (SplitMyExpenses)", a manual transaction that
the LEFT JOIN fix also newly included).
3. formatCategory(null) threw "Cannot read properties of null (reading
'split')" and took down the whole page render.
Fixed at both layers:
- Both merchants routes now use the EFFECTIVE_CATEGORY fragment, so the API
cannot emit a null category.
- formatCategory tolerates null/undefined and returns "Uncategorised". An
uncategorised transaction should never be able to crash a page.
Verified against the live DB: 200 merchants, zero null categories, and
/merchants /transactions /insights /budget /statements all render 200.
Groundwork for importing bank and loan statements alongside credit cards.
statement_type was whatever free text Gemini put in account_type ('Credit Card',
'credit card', 'credit_card', 'Business Card', 'ACCESS ADVANTAGE',
'multi-currency account'). The UI coped only by doing .includes("card"), which
breaks as soon as bank and loan statements arrive.
- Migration 0013 adds normalize_statement_type() + a BEFORE INSERT/UPDATE
trigger and a CHECK constraint over credit_card|transaction|savings|loan|
offset|investment|other. The trigger means the N8N workflow keeps working
unchanged while it still sends free text. Raw value stays in account_type.
Backfilled 99 existing rows.
- src/lib/statement-types.ts mirrors the vocabulary for the UI; statements page
now filters by the real types and headlines balance vs amount due per type.
Analytics were scoped with INNER JOIN statements + s.owner_id, which silently
dropped all 180 manual/CSV transactions (statement_id IS NULL) from every
report. Switched all six routes to LEFT JOIN + COALESCE(t.owner_id, s.owner_id)
via shared fragments in src/lib/analytics-sql.ts, so the transfers/investment
exclusion that stops card-payment double counting stays consistent. Also
extended that exclusion to trip analytics, which had none.
Drive-by: /api/analytics/subscriptions was returning 500 on an unserialisable
BigInt from COUNT(*) + 1.
Verified against the live DB: monthly spend picks up the previously invisible
manual transactions (Apr 9,849.91 -> 14,286.70) and all four analytics
endpoints return 200.
- Monthly spend chart with category breakdown drill-down
- Merchant frequency and spend analytics with per-merchant history
- Subscription detection and recurring charge tracking
- Fee and interest analytics endpoint
- Expanded category list with formatCategory display helper
- Merchant totals now show net spend (gross debits minus refunds)
- Refund count and amount shown in profile drawer and table
- Scatter plot Y-axis uses net_spend, X-axis uses debit_count
- Per-merchant transaction history includes refunds (shown as negative)
- Monthly trend chart reflects net spend per month