← Back to projects

Nine sessions chasing three banks, then a deliberate rollback

This app parses a bank statement PDF and turns it into a spend breakdown by day, week, or month — the granularity the bank's own app doesn't offer. It started ANZ-only, then spent nine sessions attempting to support three different banks' PDF formats at once. Because it did not succeed, it was a learning point in giving up to pivot for better features in coming future.

9Sessions on the multi-bank attempt
1Deliberate rollback to ANZ-only
173Tests green — on synthetic data

Passing tests didn't mean the feature actually worked

Supporting CBA and NAB statements alongside ANZ meant handling three different PDF layouts — different headers, different column positions, different ways of splitting a transaction description across lines. Progress looked real: bugs got diagnosed, fixes shipped, and the multi-bank test suite eventually went fully green, 17 out of 17.

All of that was against hand-written synthetic test data shaped like a real statement — not an actual CBA or NAB PDF. When a real one was finally run through the parser, both banks came back worse than before the fixes, not better. The tests had been quietly validating a guess about what those PDFs looked like, not the PDFs themselves.

Show the technical version

Two parsing paths existed: a text-extraction path (pdf-parse) and a layout-aware path using x/y coordinates (pdf2json). The layout-aware path was the one wired into the live API. Real-PDF UAT on 2026-05-19 regressed both CBA and NAB — orphan rows were being dropped and opening-balance blocks weren't being isolated correctly, gaps that the synthetic fixtures never exercised because they didn't reproduce those exact page-layout quirks.

Choosing to roll back, instead of chasing the fix one more session

After the real-PDF regression, the next step was a hybrid fallback — try the layout-aware parser first, fall back to the simpler text parser if it came back empty. That shipped, but CBA and NAB were still unreliable against real statements. At that point the honest assessment was that three-bank support, done properly, needed real-PDF test fixtures from the start — not synthetic ones with fixes layered on top after the fact.

Rather than sink a tenth session into the same approach, the multi-bank branch was rolled back to ANZ-only — the one bank actually proven against real statements. That freed up the next session to build something that shipped cleanly instead: a calendar view giving by-day and by-week detail, which turned out to be the actual gap the bank's own app didn't cover.

Show the technical version

The full arc: 2026-04-29 multi-bank column-map scaffold → 05-02 three CBA/NAB bugs diagnosed → 05-04 fixes, 4 tests still red → 05-05 173 passing (synthetic, flagged not real-PDF-ready) → 05-18 17/17 multiBank suite green → 05-19 pdf2json ships to the API, real PDFs regress → 05-24 hybrid fallback added, still unresolved → 05-31 rolled back to ANZ-only → 07-09 Calendar View ships. Each session in that arc is logged individually in the timeline below, in order, with what was tried and why it did or didn't hold up.

Reference · (after pdf2json UAT regression)

Status Overview

The parser strategy, known issues, and feature-testing status as they stood mid-saga — the multi-bank detour's high-water mark before the rollback.

Why this exists

Like all my projects, it started out as a personal problem wanting to know how much I spend per month dowwn to the last detail. I wanted to know what I actually spend without scrolling through the bank app date by date, hunting for merchant and price that overwhelmed me because I would lose any pattern and gain nothing. More than once during the build someone asked "doesn't the bank app already do that?" and until late in the build, the honest answer was no until I saw 'Spend Summary' in my own bank app.

Not wanting the project to go to waste, it still bothered me and thus it just proved the real gap was finer than a monthly total. By-day and by-week detail, down to the transaction, was still missing — which is what the Calendar View (Jul 09) ended up solving.

The multi-bank arc

Nine sessions, one attempt at ANZ/CBA/NAB support, one regression, one rollback, one pivot. Click a node to jump to that session.

Parser strategy

PathLibraryUsed in APIReal PDF UAT
Textpdf-parseparseTransactions(text)No (removed)Was poor for CBA (0 txs)
Layoutpdf2jsonparseTransactionsFromPdfYesRegressed CBA/NAB May 2026
Probeprobe-pdf2json.tsNo (reference only)Used to inspect x/y columns

Known issues & fixes

IssueRoot causeSolutionStatus
PDF parser returns incorrect amountsRegex / column guess on flat textpdf2json x/y columnsPartial
Missing transaction filteringPage headers, totalsfilterSummaryRows()Done
CategorizationKeyword matchinggetCategoryFromDescriptionDone
Multiple bank formatsDifferent headers/columnsStructural header + pdf2jsonIn progress
CBA real PDFMixed dates, split header, multi-rowBlock merger attemptedFailed UAT May 2026
NAB real PDFDots, multi-row, orphan row dropBlock merger attemptedFailed UAT May 2026
ANZ official multi-rowMerchant + EFTPOS on separate ymergeDescriptionLinesNot re-UAT'd
Tests vs realitySynthetic fixtures onlyNeed probe-derived fixturesNot started
No API fallbackpdf2json-only routepdf-parse fallback when 0 txsRecommended

Feature testing status

FeatureUnitIntegrationUATNotes
File uploadYesYesYesPDF only
PDF parsing (pdf2json)Yes (synthetic rows)PartialFailed CBA/NABSee 2026-05-19
PDF parsing (text fallback)YesN/ANot wiredStill in codebase
Transaction parsing (ANZ)YesYesUnknownRe-test after block merger
Transaction parsing (CBA/NAB)Yes (fictional)NoFailed
CategorizationYesYesPartialmisc if description truncated
Monthly groupingYesYesPartial
Database persistenceNoNoNoBlocked

Session log (multi-bank)

DateSessionOutcome
Apr 29Multi-bank scaffoldColumn map wired
May 02CBA/NAB bug investigation3 text-parser bugs identified
May 04–05Text parser fixesmultiBank 17/17 synthetic
May 18blank→0.00, plural headersTests green
May 19pdf2json API + block mergerUAT worse for CBA/NAB
Blocked / pending
  • pdf2json UAT fixes — orphan rows, opening balance, CBA header, API fallback
  • Real PDF fixtures from probe output (not hand-written ideal rows)
  • Database integration — schema not finalized
  • Export reports — not implemented

Next session priorities (as of May 19)

  1. pdf-parse fallback in parse-data/route.ts
  2. Fix orphan-row drop + opening-balance block isolation in pdf2json-extractor.ts
  3. Probe snapshot → test fixture for one CBA page + one NAB page
  4. Re-UAT all four PDFs; update Notes.md probe table

Reference ·

Architecture & Parser Notes

Standing reference: file map, the parsing pipeline, category rules, and the running log of test-fixing and diagnosis sessions folded into this file over time.

Architecture

Frontend

app/layout.tsxRoot HTML layout, fonts, global wrapper
app/page.tsxHome page
app/global.cssGlobal Tailwind
components/HomePage.tsxClient page for upload, loading, error, display
components/FileUploaderDropzone UI, single-file checks, calls parse-data
components/TransactionDisplaySummary, monthly breakdown, categorized transactions
components/ui/input.tsxReusable styled input
components/ui/progress.tsxReusable progress bar
lib/util.tscn() Tailwind class merging

Backend

app/api/parse-data/route.tsUpload endpoint, PDF validation, temp-file handling, extraction, parser call
lib/tempFile.tsTemp dir / file writing helper
lib/types/index.tsTransaction, ParsedData, Summary interfaces
lib/categories.tsCategory keywords + colors, single source of truth
lib/transactionParser.tsRe-exports lib/parser/ for back-compat
lib/parser/index.tsMain orchestrator (parseTransactions)
lib/parser/types.tsFormatType, HeaderInfo
lib/parser/utils.tsgetMonthNumber
lib/parser/detector.tsfindTransactionHeader (format detection)
lib/parser/extractor.tsextractTransaction (line extraction)
lib/parser/filter.tsfilterSummaryRows
lib/parser/group.tscategorizeTransactions, groupByMonth
lib/parser/summarize.tsgenerateSummary

Core problem: PDF bank statement parsing

Pipeline

parseTransactions(rawText) detector: findTransactionHeader extractor: extractTransaction filter: filterSummaryRows group + summarize

extractor.ts — ANZ assumptions

ComponentCodeANZ assumption
Date regex/^\d{1,2}\s+[A-Z]{3}/iExpects DD MMM like "15 JAN"
Amount columnsnumericTokens.slice(-3)Last 3 values = withdrawal, deposit, balance
Descriptionsubstring before amountsText between date and amounts
Amount detectionchecks withdrawal OR depositWhich column has a number

detector.ts — format detection

Auto-detects between two formats: column (headers contain Balance + Withdrawal + Deposit) and line (single $ amount with keyword-based type detection).

Why other banks won't work as-is

BankRequired changes
WestpacDate format DD/MM/YY — regex needs update
CommBankDescription first, amounts at different positions
NABDifferent column order entirely
InternationalDifferent date formats (MM/DD/YY), currency symbols in different spots

Root issue: PDF text extraction gives flat text — no column information. The parser assumes date always at start, description in the middle, and 3 amount columns at the end.

Categories (lib/categories.ts)

Single source of truth for CATEGORY_KEYWORDS, CATEGORY_COLORS, and getCategoryFromDescription().

  • friends — mobile banking payment, transfer to/from, pay anyone, osko, pay id
  • food — restaurant, cafe, mcdonalds, hungry jacks, kfc, snack bar, pizza, burger, sushi, noodle, bakery, coffee…
  • utilities — vodafone, telstra, optus, energy, electricity, water, gas, internet

The AI can't read PDFs directly, so the workflow is console-logging past the transaction header to figure out format: multi-column table vs. single-line per transaction. Reminder: find other bank files to see whether the same formatting can be reused.

Transaction improvements (ideas, not yet built)

  • Categorization without hardcoding — richer rule-set, config-driven rules (JSON/TS) allowing multiple match kinds per category
  • A per-user database that expands over time, storing user-specific overrides — hardcoded strings that become data over time
  • An ML model, possibly trained on budgeting/transaction datasets (Kaggle), for merchant/category classification
  • Exact substring keywords vs. regex patterns vs. metadata per category
  • Deterministic evaluation order with explicit bank/merchant-code prefixes
  • Per-user or per-project override layer: overrides → global rules → fallback
  • Identify common ANZ description structures (EFTPOS WITHDRAWAL <MERCHANT> <SUBURB>) and encode as generic patterns — e.g. "EFTPOS" + food → food, "OSKO"/"PAY ID" → friends/transfer
  • Track frequency of "misc" descriptions by normalized merchant token

Testing (ATTD)

Test fixing session — March 20, 2026

Problem
Error: Failed to resolve import "@testing-library/jest-dom" from "tests/setup.ts"

Investigation: checked package.json (malformed entry, line 30) → checked tests/setup.ts (unnecessary jest-dom import) → removed both → 22 failures remained → analyzed each failure category systematically.

Issue 1 — malformed jest-dom package entry

// BEFORE (broken)
"@testing-library/jest-dom": "file:testing-library/user-event@^14.5.0",
// AFTER — removed, not needed

package.json, tests/setup.ts

Issue 2 — missing "woolies" keyword

// lib/transactionParser.ts line 215
groceries: ['coles', 'woolworths', 'woolies', 'iga', 'supermarket'],

Why: .includes() does substring matching — "woolies" doesn't match "woolworths".

Issue 3 — column format parsing

# 3-column format (explicit deposit column)
 2 Jan CARDLESS CREDIT   345.67   0.00    123456.78

# 2-column format (deposit implied zero)
 2 Jan CARDLESS CREDIT   345.67           123456.78

Solution — rewrite extractTransaction(): regex /(\d[\d,]*(?:\.\d{1,2})?)/g to find all amounts, filter out the date's day number, take the last 3 amounts as withdrawal/deposit/balance, use 0.00 placeholders for missing columns in fixtures.

Issue 4 — label accessibility in FileUploader

// BEFORE
<label {...getRootProps()}>
// AFTER
<label {...getRootProps()} htmlFor="dropzone-file">

Why: tests use getByLabelText(), which requires programmatic association.

Issue 5 — header detection for line format

// Added to headerPatterns
/date.*transaction.*description.*amount/i,
/date.*transaction.*amount/i,

Why: Transaction Reports have "Amount" instead of "Withdrawal/Deposit".

Test data format requirements

The parser requires the 3-column format for column-based statements — always include 0.00 placeholders; the parser uses the last 3 numeric values as amounts; the date's day number (e.g. "2" in "2 Jan") is filtered out.

Date Transaction Detail    Withdrawal  Deposit  Balance
 2 Jan CARDLESS CREDIT     345.67    0.00    123456.78
12 Jan Salary Deposit        0.00  3500.00    126786.78

Final test results (March 20)

Test suitePassedFailedTotal
Categorization92092
Transaction Parser23023
GroupByMonth9110
Summary~4~814
FileUploader~9211
Total14010150
Recommendations for junior devs
  1. Understand the test data format before debugging the parser — check what the parser expects, check what the test data provides, align them, don't force the parser to handle bad data.
  2. Use toBeCloseTo() for float comparisons, not toBe().
  3. Mock files need a proper size property: Object.defineProperty(file, 'size', {'{'} value: 1000, configurable: true {'}'}).
  4. react-dropzone testing needs userEvent, mocked callbacks, or testing behavior without triggering the dropzone directly.

Setup & test commands (previous testing content)

npm install -D vitest @testing-library/react@^16.0.0 @testing-library/jest-dom @testing-library/user-event@^14.5.0 jsdom

npm run test           # watch mode
npm run test:run      # once (headless)
npm run test:coverage # with coverage report

Note: @testing-library/react@^16 required for React 19; user-event latest stable is v14.

Test structure

tests/fixtures/sample-transactions.tsSample ANZ data (column/line format)
tests/unit/transactionParser.test.tsFormat detection, extraction
tests/unit/categorization.test.tsCategory keyword matching
tests/unit/groupByMonth.test.tsMonthly grouping logic
tests/unit/summary.test.tsDeposit/withdrawal/net calculations
tests/integration/FileUploader.test.tsxComponent tests, mocked API

Test fixing session — March 24, 2026

18 tests failing across 3 files: summary.test.ts (8), groupByMonth.test.ts (8), FileUploader.test.tsx (2).

Issue 1 — hardcoded year in tests vs runtime year in code

// lib/parser/group.ts:24
const key = `${'$'}{new Date().getFullYear()}-${'$'}{monthNum.toString().padStart(2, '0')}`;

Tests used hardcoded 2025, but the run date was 2026-03-24. Fix: use new Date().getFullYear() in tests instead of hardcoding.

Issue 2 — test data format didn't match parser requirements

// lib/parser/extractor.ts:126-140
if (numericTokens.length >= 3) {
  withdrawalAmt = parseNum(numericTokens[numericTokens.length - 3]);
  depositAmt = parseNum(numericTokens[numericTokens.length - 2]);
  balanceAmt = parseNum(numericTokens[numericTokens.length - 1]);
}

Parser expects exactly 3 numeric values for column format. Fixture rows were missing the 0.00 placeholder for whichever column was empty.

Issue 3 — test expectations didn't match test data

Example: 2 debit transactions totalling 176.23, but the assertion expected deposits === 3500.00 — leftover from a different fixture. Fixed by verifying transaction structure rather than mismatched totals.

Issue 4 — react-dropzone integration with testing-library

ApproachResultNotes
fireEvent.change(input, {'{'} files {'}'})FailedDoesn't trigger dropzone
userEvent.upload(input, file)PartialWorks for single files, not validation
Mock react-dropzone hookComplexRequires act() wrapping
Wrap in act()Warning"not configured to support act"
Skip untestable testsPassPractical solution — used

Before vs after (March 24)

MetricBeforeAfter
Total tests150149
Passed132148
Failed180
Skipped01
Key lessons
  • Don't hardcode years in tests — use new Date().getFullYear().
  • Match test data to parser assumptions before debugging either side.
  • Test data must actually produce the values the assertions expect — verify, don't assume.
  • Some component libraries (react-dropzone) don't test well in jsdom — prefer unit tests for logic, E2E for integration, skip flaky tests rather than leaving them broken.
  • Use descriptive test names ("should calculate deposits correctly for mixed transactions", not "test case 1").

When to seek help vs. debug further

SituationRecommendation
Clear assertion errorDebug — compare actual vs expected
Test times out repeatedlyCheck jsdom support for the library
Same test fails everywhereLikely a code bug, not a test issue
Fails only in CI/localEnvironment-specific issue
3+ attempts still failingConsider skipping and documenting

Regex analysis: extractMerchant failures — April 25, 2026

TestInputExpectedActual
1EFTPOS AUSTRALIA XIN DONG BEI PT\CLAYTON VIC AUAUSTRALIA XIN DONG BEIAUSTRALIA
2VISA DEBIT PURCHASE CARD 1127 SMOLBITEZ PTY LTD MELBOURNESMOLBITEZSMOLBITEZ MELBOURNE

Root cause — ADDRESS_SUFFIX regex too greedy (lib/categories.ts:30):

const ADDRESS_SUFFIX = /\s+(STREET|ST|ROAD|RD|AVENUE|AVE|DRIVE|DR|LANE|LN|COURT|CT)?\s*[A-Z]+(\s+[A-Z]+)*\s*(VIC|NSW|QLD|SA|WA|TAS|ACT|NT)?\s*(AU AUS)\s*$/i;

Trace for test 1: ANZ_PREFIXES strips EFTPOS → BUSINESS_NOISE strips PT\CLAYTON → ADDRESS_SUFFIX's unbounded (\s+[A-Z]+)* greedily consumes XIN DONG BEI VIC AU, leaving only AUSTRALIA. The pattern is meant for real addresses like "123 SMITH STREET MELBOURNE VIC AU" but strips any words between the start and the state code, including merchant name parts.

OptionDescriptionTradeoff
1Narrow ADDRESS_SUFFIX to street type + single stateCan't handle multi-word suburbs
2Two-pass: split cleaning into controlled stagesMore code, more explicit
3Accept current behavior, adjust test expectationsMay mis-categorize future merchants

Multi-bank PDF parsing diagnosis — May 12, 2026

Run via npx tsx probe-pdfs.ts — runs all PDFs through the parser, prints count + first 5 transactions. Key finding: sampleCBAFormatText and sampleNABFormatText in fixtures are fictional clean formats — they pass tests but don't reflect what pdf-parse actually outputs from real PDFs.

Bug 1 — plural column names (ANZ, NAB)

detector.ts:42-49 — ANZ header: "Withdrawals ($) Deposits ($)"; NAB header: "Debits Credits". indexOf('withdrawal')/indexOf('deposit') return -1 against plural tokens, so the extractor falls back to positional guessing — card/reference numbers in descriptions get picked up as amounts, displacing the real value.

const withdrawalAliases = ['withdrawal', 'withdrawals', 'debit', 'debits'];
const depositAliases = ['deposit', 'deposits', 'credit', 'credits'];

Bug 2 — CBA mixed date formats → 0 transactions

detector.ts:73, extractor.ts:13 — CBA mixes 01 Jul 2021 OPENING BALANCE (has year) with regular lines like 28 Oct THE BODY SHOP... (no year). Date-format sampling hits the opening-balance line first, locks to DD MMM YYYY, and every regular line then fails the year-required regex.

Bug 3 — CBA header split across lines

detector.ts:3 — header is 4 separate lines (Date / Transaction / Debit / Credit Balance). isTransactionHeader() checks one line for date+description+amounts at once and never fires; the date column index ends up wrong.

Bug 4 — NAB dot padding in descriptions

extractor.ts:51-61 — lines like X Li .....20.00 get their dot padding concatenated straight into the description. Fix: description.replace(/\.{'{'}3,{'}'}/g, '').trim().

Bug 5 — File_000.pdf columns fused

pdf-parse renders adjacent columns with no space (DateTransaction DetailsWithdrawalsDeposits), and merges bank name into date (05 MARANZ MOBILE BANKING). A pdf-parse rendering artifact specific to this file's encoding — not easily fixable without a post-processing space-reinjection pass. Low priority.

Probe output summary

FileTransactionsNotes
2025-01-06.pdf (ANZ)145All typed credit (wrong), card numbers leak into amounts
2025-07-04.pdf (ANZ)191Mixed, same column bug
2026-01-06.pdf (ANZ)282Same as above
Australia Commonwealth J C.pdf (CBA)0Bug 2 kills all parsing
Australia NAB.pdf (NAB)14Dot padding, wrong column mapping
File_000.pdf (ANZ)44Dates show as "05 MARANZ" (fused render)

Fix order (by impact)

  1. detector.ts — plural aliases for withdrawal/deposit → fixes ANZ + NAB column mapping
  2. detector.tsdetectDateFormat doesn't lock on opening-balance year → fixes CBA 0-transaction bug
  3. extractor.ts — strip dot sequences from descriptions → cleans NAB output

Fixed

Test suite fixes

A broken package.json entry cascaded into 22 test failures. Fixed the root cause, then the parser assumptions the tests actually depended on.

Initial error
Error: Failed to resolve import "@testing-library/jest-dom" from "tests/setup.ts"

Root cause

package.json line 30 had a malformed entry:

"@testing-library/jest-dom": "file:testing-library/user-event@^14.5.0",

Fixes applied

IssueFileFix
jest-dom importtests/setup.tsRemoved unused import
jest-dom packagepackage.jsonRemoved malformed entry
"woolies" not categorizedtransactionParser.tsAdded 'woolies' to groceries keywords
FileUploader label a11yFileUploader.tsxAdded htmlFor="dropzone-file"
Column parsing logictransactionParser.tsRewrote to regex-based amount extraction
Header detectiontransactionParser.tsAdded "Date…Amount" pattern
Amount thresholdtransactionParser.tsChanged to val >= 0 to handle 0.00
Test fixturesample-transactions.tsUpdated to 3-column format
Test tolerancetransactionParser.test.tstoBe()toBeCloseTo()

Final result

Test Files: 2 failed | 3 passed (5)
Tests: 10 failed | 140 passed (150)

Remaining: Summary tests (8 — inline data doesn't match 3-column format) and FileUploader tests (2 — fireEvent doesn't trigger react-dropzone callbacks).

Lessons learned
  • Always run npm install after package.json changes
  • Test data must match parser assumptions exactly
  • Float comparisons need toBeCloseTo(), not toBe()
  • react-dropzone tests need userEvent or proper mocking
  • Fix test data vs. changing the parser — don't over-engineer the parser for bad test data

Scaffolded

Multi-bank support scaffold

First step of the multi-bank attempt: column-position + date-format detection architected in, deliberately not bank-specific hardcoding.

What was built

  • Architecture decision: column-position detection + date-format inference, not bank-specific hardcoding
  • DateFormat union type added to types.ts
  • columnMap and dateFormat wired through detector.tsextractor.tsindex.ts
  • TRANSACTION_REGEX converted from a constant to a function that switches on dateFormat
  • Skeleton for detectDateFormat() created but not implemented
  • All 159 tests pass (fallback behavior intact)

Bugs to fix next session

  1. detectDateFormat (detector.ts:64) — pattern array declared but hardcoded to return 'unknown'. Needs to sample data rows, not the header line.
  2. extractColumnFormat (extractor.ts:134) — columnMap values are character offsets but code uses them as token indices. Needs offset→index conversion, or nearest-token lookup.
Habits observed

Good: reasoned through the architecture before touching code — identified column-position vs. date-format as separate concerns without being told. Made all the changes without asking AI to write the logic.

Watch: left detectDateFormat body empty — skeleton started but not finished; the commit captured incomplete work silently. Next time: finish it, or leave an explicit // TODO.

Watch: added extra DateFormat variants beyond what was discussed — good initiative, but untested additions are future bugs.

Ways to work better

When a function is scaffolded but not implemented, leave an explicit // TODO: so the gap is visible in review. After writing a function, ask "what input would break this?" — that question on detectDateFormat would have caught the always-'unknown' return immediately.

Diagnosed

Multi-bank format bug investigation

Uploaded real CBA and NAB statements to the UI and compared parsed output to actual PDF values. Three root causes found.

Bug 1 — year leaking as withdrawal amount

lib/parser/extractor.tsextractColumnFormat(). CBA transactions showed 2021 as the withdrawal amount with empty descriptions. amountRegex matches all digit sequences, including the year in DD MMM YYYY dates; the line-126 filter only removes the day number, not the year.

Fix needed: strip the full date (year included, if parts[2] matches /^\d{'{'}4{'}'}$/) from fullLine before running amountRegex.

Bug 2 — NAB format detected as 'unknown'

lib/parser/detector.tsdetectFormat(). NAB uses Debit/Credit headers instead of withdrawal/deposit, falling through to 'unknown'.

Fix needed: add a branch — hasDebit && hasCredit && hasBalance → 'column'.

Bug 3 — "Particulars" column not mapped to description

lib/parser/detector.tsfindTransactionHeader(). headerTokens.indexOf('description') returns -1 when the header says "Particulars".

Fix needed: alias lookup trying ['description', 'particulars', 'details', 'narrative'] in order.

Tests scaffolded

tests/unit/multiBank.test.ts — 14 tests covering CBA and NAB parsing. Fixtures added: sampleCBAFormatText, sampleNABFormatText.

9 failed | 5 passed

Fix order

  1. Bug 1 (year strip) — unblocks most CBA failures and description tests
  2. Bug 2 (detectFormat aliases) — unblocks NAB format detection
  3. Bug 3 (column name aliases) — unblocks NAB description extraction
Outstanding question — not yet resolved

Debit/credit type detection for 2-column formats (e.g. NAB salary = credit, Coles = debit) requires knowing which column the amount appeared in. A right-to-left token approach can't distinguish this without character offsets. Needs a decision next session.

Partial

Multi-bank parsing fixes (CBA/NAB)

Applied the three bugs diagnosed on May 2 — 11 of 15 new tests pass, but debit/credit typing for two-token rows is still wrong.

What was built

  • Fixed summary tests (14/14) by adding a pre-formatted text field to summaryTestData
  • Added DD MMM YYYY date format support for CBA statements
  • Added Debit/Credit column aliases in detector.ts
  • Added Particulars column alias for NAB description mapping
  • Restructured extractor.ts to check CBA/NAB format first, before ANZ fallback logic
  • Cleaned up duplicate dead code in extractor.ts (275 → 202 lines)

Test status after fixes

✔ tests/unit/summary.test.ts (14 tests)
✔ tests/unit/groupByMonth.test.ts (9 tests)
✔ tests/unit/transactionParser.test.ts (23 tests)
✘ tests/unit/multiBank.test.ts (11 passed | 4 failed)

4 remaining failures

TestIssue
Cash Deposit should be credit 4700.00type = 'debit' instead of 'credit'
Credit Interest should be credit 3.21type = 'debit' instead of 'credit'
Salary Credit should be credit 2000.00type = 'debit' instead of 'credit'
should have both debits and creditscredits.length = 0

Root cause: when numericTokens.length === 2 (amount + balance, one column empty), the 2-token case still defaults to type: 'debit' despite the CBA/NAB branch now running first.

Next session plan

  1. Debug what numericTokens actually contains for the CBA "Cash Deposit" line
  2. Confirm columnMap values are character positions, not token indices (they are — the code just treats them as indices)
  3. Fix the character-position → token-index conversion in extractColumnFormat()
Ways to work better

Better: test each bug fix individually before moving to the next — three bugs were fixed at once, making it hard to isolate which fix broke what.

Better: add debug logging before restructuring code, not after — restructuring first made debugging harder.

Watch: the file got corrupted with duplicate code during edits — verify file state with a read after each edit.

173 passing

Structural header detection + multi-bank fixes

Replaced rigid hardcoded header regexes with structural detection. All multiBank tests green — but real-world gaps were flagged before committing.

What was built

  • Replaced the 5-regex HEADER_PATTERNS array with isTransactionHeader(), checking 3 independent concepts: date + description + amounts
  • Alias lookup for columns: withdrawal/debit, deposit/credit, description/particulars/details/narrative
  • Fixed detectDateFormat to sample data lines (was sampling the header line)
  • Added DD MMM YYYY to the DateFormat union
  • extractColumnFormat now receives columnMap and dateFormat for position-aware parsing
  • CBA/NAB branch correctly handles 3-token and 2-token cases
  • Added isCreditTransaction heuristic (credit, deposit, interest, salary keywords)
  • 15 new multiBank.test.ts tests; removed debug console.log
✔ 173 tests passing, 1 skipped — all multiBank tests green (previously 4 failures)

Real-world testing issues — not ready to commit yet

1. ANZ fallback always returns 'debit'

extractor.ts:178-187 — the 2-token path (amount + balance, no 0.00 placeholders) has both branches returning 'debit'. isCreditTransaction exists but isn't applied here. A real ANZ file with "Debit" in a credit transaction's description would misclassify it.

2. CBA real file — only opening balance shows in app

Unknown root cause. Needs: checking whether the real file has 0.00 placeholders or blank columns, whether detectDateFormat identifies the real date format correctly, and whether rows are silently failing to parse.

3. filter.ts doesn't filter "Opening Balance"

Pattern /^balance/i only catches lines starting with "Balance" — "Opening Balance" slips through. Fix: add /opening balance/i.

Ready to commit

All files in a single commit: "Add multi-bank statement format support (CBA, NAB)". Skip: .claude/, agents/, sessions/, Notes.md — session artifacts.

Next session priorities

  1. Fix ANZ fallback — apply isCreditTransaction to the 2-token path
  2. Add "Opening Balance" to filter patterns
  3. Investigate the CBA real file — get sample lines or debug in-app
  4. Commit after fixes land

17/17 green

Regression test fixes + Bug 5 applied

Compile errors from language cross-contamination (a PHP/Rust arrow bled into TypeScript), a mis-transcribed test amount, and the blank→0.00 fix from the May 12 diagnosis, finally applied.

1. Diagnosed multiBank.test.ts compile errors

#LineErrorFix
1135() -> wrong arrow syntax (PHP/Rust bleed-in)() =>
2136–140Single-quoted multiline string — not valid JSBacktick template literal
3155Expected 130000.00 — extra zero misread from 13,000.0013000.00
4157Missing ; after closing {'}'}){'}'});

2. Rewrote the plural-header test fixture

Single-line format never worked for the credit test: the 2-token path (13,000.00 + 15,302.99) falls back to the isCreditTransaction keyword heuristic, and "TRANSFER FROM" doesn't contain credit/deposit/interest/salary. Switched to multiline format with blank markers (as pdf-parse actually outputs), so the blank→0.00 conversion produces 3 tokens and column position determines type correctly.

3. Applied Bug 5 — blank marker → 0.00

extractor.ts:65-67 — blank lines were silently skipped before; now a blank line appends ' 0.00', forming a 3-token structure the CBA/NAB branch can read.

// BEFORE
if (!BLANK_LINE_REGEX.test(lines[nextIndex])) {
  fullLine += ' ' + lines[nextIndex];
}

// AFTER
if (BLANK_LINE_REGEX.test(lines[nextIndex])) {
  fullLine += ' 0.00';
} else {
  fullLine += ' ' + lines[nextIndex];
}
✔ tests/unit/multiBank.test.ts — 17/17 passing

Cross-check against plan and prior sessions

Phase 1 status

FixStatusNotes
Fix 1 — plural aliasesDonedetector.ts — from May 05
Fix 2 — decimal-required amountRegexDoneextractor.ts:138 — from May 05
Fix 3 — detectDateFormat skips opening balanceTypodetector.ts:74: "opening blance" (missing 'a') — passes tests since no fixture has that line, but breaks on real CBA PDFs
Fix 4 — page break stop regexesDoneextractor.ts — from May 05
Bug 5 — blank → 0.00Doneextractor.ts — this session

From May 05 next-session priorities

ItemStatus
Fix ANZ 2-token fallback (isCreditTransaction)Done — line 190
Add "Opening Balance" to filter patternsAlready done — filter.ts:7
Investigate CBA real fileNot done
Commit after fixes landNot done

Pre-existing, unrelated: groupByMonth.test.ts has 2 failures not caused by this session's changes.

What's left before this feature is done

Immediate: fix the "opening blance" typo at detector.ts:74; commit Phase 1 + Bug 5; investigate the 2 pre-existing groupByMonth failures.

Medium term: investigate the CBA real file (only opening balance shows — likely the same typo/date-format lock); begin Phase 2, pdf2json-extractor.ts using x/y coordinates — probe-pdf2json.ts had already produced column x-positions for all banks.

Learning note

The single-quote vs. backtick distinction mirrors Python's single-quoted string limitation, except Python multiline uses triple-quotes. In JS/TS, the moment a string needs newlines, reach for backticks. The -> arrow is a language cross-contamination sign — worth a personal cheat-sheet entry.

Regressed

pdf2json UAT regression + multi-bank feasibility notes

The API switched from flat-text pdf-parse to x/y-aware pdf2json, plus a multi-row block merger — and real CBA/NAB uploads got worse, even with 189+ unit tests green. The turning point of the saga.

Executive summary

Unit tests still pass because fixtures are small, hand-built row layouts — not copies of real pdf2json output. Multi-bank parsing is feasible, but only if each bank is validated against real PDF geometry (probe snapshots → committed fixtures → fixes). Synthetic tests alone keep producing false confidence.

Architecture timeline

flowchart TB
  subgraph phase1 [Phase 1 — Text parser]
    pp[pdf-parse flat text]
    det[detector.ts]
    ext[extractor.ts merges continuation lines]
    pp --> det --> ext
  end
  subgraph phase2 [Phase 2 — pdf2json production]
    p2j[pdf2json x/y fragments]
    p2e[pdf2json-extractor.ts]
    api[parse-data/route.ts]
    p2j --> p2e --> api
  end
  subgraph phase2b [Phase 2b — Block merger UAT fix]
    blk[groupRowsIntoBlocks]
    merge[mergeBlockColumns]
    p2e --> blk --> merge
  end
  phase1 -->|replaced in API| phase2
  phase2 --> phase2b
WhenChangeFiles
Apr 29Column map + date format scaffolddetector.ts, extractor.ts, types.ts
May 02–05CBA/NAB text fixes, multiBank.test.tsdetector.ts, extractor.ts, fixtures
May 18Plural headers, blank→0.00, 17/17 multiBankextractor.ts, detector.ts
May 19 (earlier)API uses pdf2json onlypdf2json-extractor.ts, route.ts
May 19 (later)Block merger, sort, multi-row fixturespdf2json-extractor.ts, bank-rows.ts

Real UAT observations

Bank / fileReported behaviorLikely cause
CBAFailed to loadpdf2json warnings on 850KB PDF; possibly 0 transactions, or a parse error swallowed as 500. Split header + mixed date formats still fragile.
NABFirst visible tx ~16 Sep; many rows missinggroupRowsIntoBlocks drops orphan rows — rows before 16 Sep never form a valid block
NABDescription stuck on opening balanceOpening-balance row may start a block or merge into the first one; filter may run on the wrong field
ANZNot re-tested after block mergerRisk of the same multi-row issues recurring

Why tests pass but UAT fails

TestsReal PDFs
Ideal x/y rows, 1–5 per scenarioHundreds of rows, irregular y-rounding, split headers, wrapped descriptions
Fictional flat text (text parser)Not used by the API anymore
No test loads actual PDF buffers in CICBA/NAB geometry never asserted

Lesson: a passing pdf2json-extractor.test.ts does not prove production PDFs work.

Root causes in pdf2json-extractor.ts

1. Orphan row drop (NAB skips)

groupRowsIntoBlocks (~432–466): a row that's neither a transaction start nor a continuation gets the current block finalized and is never attached anywhere. Real NAB/CBA lines often lack a date in the date column on the first visual row.

2. Strict transaction-start detection

rowStartsTransaction requires a date at left or in the date column; CBA rows like "28 Oct" (no year) can fail if fragments split across columns.

3. Opening balance bleed

isSkipRow skips rows whose joined text matches "opening balance" — but split fragments may not join to that exact phrase, so a block can still absorb the balance-column amount or description text.

4. CBA header / page 1

Header split across 4 lines; if page 1 has no valid header, activeBounds stays null and the entire page is skipped.

5. No pdf-parse fallback

app/api/parse-data/route.ts only calls parseTransactionsFromPdf — any pdf2json failure is a 500 or empty state, no degraded path.

Is multi-bank feasible?

Verdict: yes, with constraints

pdf2json is the right direction for column debit/credit — text-only parsing can't fix CBA/NAB layout. But one extractor for all banks, without per-bank real fixtures and iterative UAT, will keep regressing.

Pragmatic path: (1) pdf-parse fallback for UX, (2) pdf2json per bank behind flags, (3) probe snapshots as tests, (4) fix one bank at a time.

Recommended next steps

Immediate: hybrid API (pdf2json primary, pdf-parse fallback on 0 txs/throw); hard-skip opening-balance blocks before column conversion; never silently drop orphan rows with an amount.

Short term: capture real probe fixtures for one failing CBA page and one NAB page; add PDF_PARSE_DEBUG=1 logging per block skip reason; re-run the CBA header/column probe.

Medium term: re-UAT ANZ multi-row EFTPOS; Westpac/File_000 stay out of scope until ANZ/CBA/NAB are stable; update the Notes.md probe table after each fix.

Process: don't merge parser changes without at least one new real-layout fixture from probe output; commit session notes and code separately once a bank hits its success criteria.

Success criteria (unchanged — still not met for CBA/NAB)

PDFTarget
CBALoads; no large date gaps; WORLDREMIT + COSTCO as separate transactions
NABAll rows; no year in description; not opening-balance text
ANZ officialMerchant + EFTPOS in description
ANZ unofficialChronological order
Ways to work better

Better: one bank per PR, with a probe snapshot test before merging.

Better: keep the pdf-parse fallback until pdf2json matches probe counts on all three banks.

Watch: large block-merger refactors without real PDF fixtures — tests green, UAT red.

Watch: assuming a chronological sort fixes "missing" rows — it only reorders what was actually parsed.

Improved, not finished

Hybrid PDF fallback + multi-bank parser hardening

Picked up from May 19: restored a usable path when pdf2json fails, and stopped the parser from dropping split rows before they had enough data to become a transaction.

What changed

AreaChangeFiles
Upload APITightened PDF validation — requires both MIME type and .pdf extensionroute.ts
Upload APIRemoved any size cast, used native File.sizeroute.ts
Upload APIConsistent JSON error responsesroute.ts
Parser APIHybrid parsing: pdf2json first, pdf-parse fallback on throw/zero txslib/parser/index.ts
TypesLocal declaration for untyped pdf-parse entrypointlib/types/pdf-parse.d.ts
pdf2json parserPreserved pending split rows instead of dropping incomplete onespdf2json-extractor.ts
TestsRegression coverage for split-date orphan rows + opening-balance non-leakagebank-rows.ts, pdf2json-extractor.test.ts

Verification

npm test -- --run
→ 191 passed, 1 skipped

npm run lint
→ still failing — pre-existing project-wide issues, not this session's changes
  (components/ui/input.tsx empty interface, next.config.ts require,
   probe-pdf2json.ts any, FileUploader.test.tsx any, tests/setup.ts any)

Important implementation notes

The pdf-parse fallback imports pdf-parse/lib/pdf-parse directly — importing the package root made Vitest execute the package's debug path and try to open a test PDF that doesn't exist in this repo.

pdf2json remains the correct primary parser (it's the only one with x/y layout for debit/credit/balance columns); pdf-parse restores usability when layout parsing fails but can't reliably solve every multi-column statement on its own.

The split-row fix is conservative: a row with an amount, description, or partial day value and no current block is kept pending; if the next row starts a transaction and the pending block has no date yet, it's merged in rather than forcing a new boundary.

Project explanation, for a junior developer

This is a bank transaction analyser: upload a PDF statement, extract transaction rows, filter out summary lines like opening balance or totals, categorize each transaction, group by month, return a spending summary. The hard part isn't the dashboard — it's PDF parsing. Bank PDFs look tabular but extraction libraries return fragmented text, and different banks place dates/descriptions/debits/credits/balances at different x/y positions. That's why the parser is split into detection, extraction, filtering, grouping, and summarizing. The project now has both a text-parser path (pdf-parse) and a layout-parser path (pdf2json) — layout is primary, text is fallback.

Portfolio assessment

Framed correctly, this is a good portfolio project — not a CRUD app or charting demo, but practical engineering around messy real-world data: parser design, file-upload validation, typed contracts, tests, incremental hardening from real failures. To make it portfolio-worthy: real redacted pdf2json fixture snapshots per bank; per-bank accuracy reporting (transaction count, known merchant rows, debit/credit correctness, no opening-balance leakage); a small parser-confidence/warnings UI section; a clean lint/test/build; a README with architecture diagram, screenshots, supported banks, limitations, test evidence. Keep scope tight before adding AI or database storage — a reliable parser is more impressive than a broad, inaccurate finance app.

Next recommended steps

  1. Capture one redacted pdf2json probe fixture each for ANZ, CBA, and NAB
  2. Add tests asserting the real-bank success criteria from the May 19 session
  3. Run real PDF UAT again after the fallback + split-row fixes
  4. Fix existing lint failures
  5. Update README/CHANGELOG once real-fixture validation is done

Rolled back

ANZ-only rollback and official ANZ PDF blocker

Multi-bank support removed from active code and tests. File 000 (unofficial ANZ) works; the official ANZ statement is still blocked — by corrupted PDF extraction, not a parser logic bug.

Executive summary

This session rolled the parser direction back toward ANZ-only support. File 000 is considered working. The official ANZ 2025-01-06 file is still blocked: most rows collapse because the parsed output contains corrupted amounts such as 2024.00 (from "EFFECTIVE DATE … 2024") and 432919512.00 (from a transfer/account reference). The parser now rejects those leaked values — but it can't recover the true amount once the PDF text extraction has already lost it.

Do not commit yet

Until the official ANZ raw extraction is inspected and either recovered or reported clearly as unsupported.

What changed in working tree

AreaChangeFiles
Parser scopeRemoved active multi-bank DateFormat/columnMap plumbingtypes.ts, detector.ts, extractor.ts, index.ts
TestsDeleted CBA/NAB multi-bank unit testmultiBank.test.ts
FixturesRemoved CBA/NAB fixture exports; ANZ fixture uses 3 amount columnssample-transactions.ts
ANZ normalizationSupport merged File 000 prefixes: "05 MARANZ", "04 MARVISA", "02 MARPAYMENT"index.ts
Official ANZ table shapeVertical header detection: Date/Description/Category/Withdrawal/Deposit/Amountdetector.ts
Official ANZ table shapeIgnored category-only continuation lines; normalized standalone "-" placeholdersindex.ts
Amount parsingAnchored to trailing ANZ amount columns; accepted signs, $, commas, decimalsextractor.ts
Safety guardRejected leaked description-number values (years near "EFFECTIVE DATE", large account refs)extractor.ts
Regression testsOfficial ANZ + File 000 regression coverage addedtransactionParser.test.ts

Verification

npm run test:run -- tests/unit/transactionParser.test.ts   → 27 passed
npm run test:run                                            → 162 passed, 1 skipped
git diff --check                                             → passed

npm run lint still fails on pre-existing project-wide issues outside this rollback (input.tsx, extract-pdf.js, next.config.ts, probe-pdf2json.ts, test any casts).

Current blocker: official ANZ 2025-01-06

Observed bad output:

08 JUL
VISA DEBIT PURCHASE CARD 2606 ... EFFECTIVE DATE 04 JUL
food
-$2024.00
-
$-2024.00
09 JUL
ANZ M-BANKING FUNDS TFER TRANSFER 311463 FROM
friends
-$432919512.00
-
$-432919512.00

The parser now rejects these as leaked values rather than displaying them as real spending. One row survives validation — $12.00 against "VODAFONE AUSTRALIA … EFFECTIVE DATE 28 DEC 2024" — because it's a plausible real amount, not a leaked year/reference. This explains why monthly grouping showed only one transaction: grouping works, but only one row survives validation.

Key diagnosis

File 000 works because the parser receives real amount tokens (-$23.32 0.00, 0.00 +$2000.00). The official ANZ file doesn't, because rows appear to reach the app already corrupted: 20.24 renders as 2024.00 (decimal loss / date-year leakage), and 432919512 appears as an amount on transfer rows. The true amounts for those rows aren't present in the displayed table output at all.

Boundary

Parser-only code must not guess missing financial amounts. It can reject impossible rows and preserve correct ones, but it cannot reconstruct cents that are absent from extraction.

Retrospective — what I'd change next attempt

The deeper lesson here wasn't any single bug — it was the architecture choice. The parser tried to work out which bank's format it was looking at from the text itself, and every format added to that detection made it more likely to misfire on a format that already worked. Getting CBA/NAB right kept regressing ANZ, and vice versa, until the regex collisions between formats made the whole thing less reliable than just supporting one bank well.

Next time: don't make the parser guess. Put a bank picker in the UI and let the user select the format before parsing, then run only that bank's rules. A little UX friction beats a parser silently misreading its own input.

Next required step

Capture the raw pdf-parse text for the official ANZ PDF before parseTransactions() transforms it. If it contains real tokens like $20.24, fix normalization/extraction to preserve them. If it already only contains corrupted tokens, the fix has to move to layout-aware extraction or a clear unsupported-format/low-confidence warning.

  1. Add a local-only debug capture for uploaded PDF raw text, gated by an env var (DEBUG_PDF_TEXT=1)
  2. Save raw extraction to a non-committed debug file or log during local dev
  3. Create a redacted fixture from the real raw text around failed official rows
  4. Add parser tests from that raw fixture
  5. Only commit once official ANZ raw text either parses correctly or fails with a clear warning instead of fake transactions
Current git state

Tracked parser/test changes still uncommitted. Untracked project/session files intentionally left alone: .claude/, agents/, sessions/, Notes.md, probe-pdf2json.ts. Do not use git reset --hard or broad cleanup commands unless explicitly requested — the working tree contains useful uncommitted rollback work.

Shipped

Calendar View feature + groupByMonth year-rollover fix

Started from a portfolio-veracity worry, resolved that the parser pipeline itself is the real skill signal, then shipped a genuinely different angle: a calendar grid that doubles as a categorization QA tool — and caught a real year-rollover bug along the way.

Executive summary

ANZ's own banking app already has a merchant/category spend summary, raising the question of whether this project still demonstrates anything beyond a CRUD app — the same question that kicked off the whole project (see Why this exists on the Overview page): the bank app answers "how much, by merchant, this month," but never by day or by week, which was the original itch. Resolved that the existing parser pipeline (PDF → categorize → 160+ tests → CI) is the actual skill signal, not novelty — but added a genuinely different angle anyway: a Google-Calendar-style day grid showing every transaction on its real date, color-coded by category, doubling as a QA tool for spotting categorization drift while lib/categories.ts is still being expanded.

That QA angle wasn't theoretical — laying transactions out on a calendar made it obvious, at a glance, exactly which merchant strings were landing in misc instead of their real category, in a way that scrolling a flat table never surfaced. Bugs that were invisible in a list became visible the moment they had a date and a color next to them.

A real correctness bug was found and fixed in groupByMonth (transactions defaulting to today's real-world year instead of the statement's actual year) — then found again in a different form during real UAT: the first fix assumed ascending chronological order, but real ANZ statements can list months descending, which ran the year away to 2030 on an actual uploaded statement. Both directions are now handled.

What changed in working tree

AreaChangeFiles
Bug fixgroupByMonth no longer defaults every transaction to today's calendar year; tracks year via an explicit year token when present, or rollover-detection (only on an actual Dec↔Jan adjacency, either direction) as fallbacklib/parser/group.ts
New helpergroupByDay — buckets one month's transactions by day, attaches merchant via extractMerchantgroup.ts, index.ts
New componentCalendarView — month grid, day chips (Merchant · Category · ±$Amount), colored via existing CATEGORY_COLORS, prev/next navigationcomponents/CalendarView.tsx
IntegrationNew "Calendar View" collapsible section, same pattern as "Monthly & Category Breakdown"TransactionDisplay.tsx
TestsRollover tests (ascending + descending), explicit-year-token precedence, groupByDay bucketing/merchant/field-preservationgroupByMonth.test.ts, groupByDay.test.ts (new)
DocsCalendar view feature noteREADME.md, CHANGELOG.md

No new dependencies, no persistence changes — reads from the same in-memory parsedData the app already produces per upload.

Decisions made this session

  • Color palette: reused the existing CATEGORY_COLORS map rather than hand-rolling a new one — already covers all 11 categories, already used elsewhere, zero new design decisions.
  • Scope cut (v1): no click-to-recategorize from the calendar, no multi-month wall view, no mobile layout pass, no persistence change. Deferred, not forgotten.
  • Year-rollover fix: accepted as an internal-consistency fix, not a guarantee of absolute year correctness — there's no statement-period metadata anywhere in the parser to derive the true year when no year token exists at all.

Bugs found during UAT (real ANZ PDF, not synthetic fixtures)

1. Year runaway to 2030

The first groupByMonth fix treated any month-number decrease as a year rollover. The real statement lists months descending (July, then June, ...), so every single-month step looked identical to an actual Dec→Jan wrap, incrementing the year every time. Fixed by only triggering on an exact Dec(12)↔Jan(1) adjacency, in whichever direction the statement is ordered — a normal one-month step, either direction, never touches the year now.

2. Day chips truncated with no way to read the full line

CalendarView used Tailwind truncate (ellipsis) plus a native title hover tooltip — not discoverable; the user could only see "Vodafone…" with no price visible and no working hover/click affordance. Fixed by dropping truncate and letting chip text wrap (whitespace-normal break-words); day cells grow to fit instead of hiding content.

Neither bug was something the still-green test suite could have caught — the descending-order case and the visual truncation weren't exercised by synthetic fixtures. Both were caught only by uploading a real statement and looking at the render.

Verification

npm test -- --run
→ 169 passed, 1 skipped (started session at 161/1; +8 net)

npx tsc --noEmit
→ clean, no errors

Manual UAT: real ANZ PDF (3–4 months, descending order) uploaded through the running app at localhost:3000. Confirmed after fixes: correct year per month, full chip text visible without hover, category colors matching the table view, month navigation working across the whole statement.

Current git state

Not yet committed — drafting README/CHANGELOG/session log first, then writing the commit message by hand. Untracked files present from before this session, intentionally left alone: .claude/, Notes.md, agents/, probe-misc-rate.ts, probe-pdf2json.ts, sessions/ (this file is the one new addition).