Skip to content

NWP-101: let ops choose export columns and scope - #1

Open
kumarjith wants to merge 2 commits into
mainfrom
NWP-101-export-options
Open

kumarjith wants to merge 2 commits into
mainfrom
NWP-101-export-options

Conversation

@kumarjith

Copy link
Copy Markdown
Owner

Ticket

Closes NWP-101

What changed

The payments export was fixed — every column, current filter only — so the card last four shipped in every file and anything going to a merchant was cleaned up by hand first. The Export button on /payments now opens an options dialog: ops picks the columns (card last four off by default) and the scope (current filter or all payments), sees the row count for both before downloading, and gets a filename that names the scope. Column names are validated server-side against an allowlist, and both scopes run through the existing query builder rather than a second filter path.

Business impact: removes the 3–4 hours a month ops spends hand-editing exports, and removes the class of near-miss where an unedited file carrying card last four goes to the wrong merchant.

How I verified it

npm test37 passed (37), 3 files. Baseline before the change was 28; the 9 new tests are all in src/lib/csv.test.ts. npx next lint and npx tsc --noEmit both clean.

Route, via curl:

Request Result
?columns=nope 400 {"message":"Select at least one column to export."}
no columns param header is id,created_at,merchant,description,status,method,card_brand,amount,currency — no last4
?columns=amount,currency,id exactly those, in that order, $473.78 formatted once with currency beside it
?status=disputed payments-disputed-2026-09-10.csv
?scope=all payments-all-2026-09-10.csv
?search=order payments-filtered-2026-09-10.csv
filtered vs scope=all row count 32 vs 1657 — past the 20-row page, so pagination is not the source

In the browser on /payments?status=disputed: Export opens the dialog; Card last four starts unchecked; scope defaults to Current filter with both counts visible (33 / 1,658). Unticking every column turns Download into a disabled button with an inline message — there is no clickable path to an empty file. The Download href reads columns=id%2Cstatus&scope=filtered, and switching to All payments drops the filter params. Escape closes the dialog and focus returns to the trigger.

  • npm test passes
  • New behavior is covered by a test
  • Checked it in the browser

Acceptance criteria

  • Ops can choose which columns are included. Card last-four is off by default.
  • Ops can choose scope: current filter or all payments. Current filter is the default, and the row count is visible before download.
  • The filename reflects the scope and the date, for example payments-disputed-2026-08-13.csv.
  • Amounts stay in minor units internally and are formatted once on the way out, with currency in its own column.
  • Deselecting every column disables Download rather than producing an empty file.

Bugs fixed along the way

None fixed — but three found, all outside this ticket, all pre-existing:

  • src/data/metrics.ts:25 — bucket keys come from lastUtcDays() (UTC) on line 18, but payments are bucketed with new Date(...).toLocaleDateString("en-CA"), which is server local time. West of UTC, late-evening payments land in the wrong key or match no bucket at all and hit the continue on line 27 — silently dropped from the volume chart. utcDayKey in src/lib/dates.ts is the fix.
  • src/data/metrics.ts:31,34 — money accumulated as a float in major units (payment.amount / 100) and re-multiplied via Math.round(bucket.captured * 100). The rounding hides drift instead of preventing it; summing the integer minor units needs no rounding.
  • src/data/queries.ts:81sortPayments compares amounts as strings: String(a.amount).localeCompare(String(b.amount)), so 9000 sorts before 25000. The export reuses sortPayments, so it inherits this. The inline comment claims it makes the order "match what the table shows," which is a rationalisation — the table is wrong too.

Left all three alone: none are in NWP-101's acceptance criteria, and fixing the sort would change the ordering of every existing export in the same PR that changes its columns.

Notes for the reviewer

  • Drawer, not Dialog. .claude/rules/components.md says src/components/ has a Dialog — it does not. Drawer.tsx is the Radix Dialog wrapper that exists, and it had no consumers before this; I'm the first. Worth correcting the rule file or adding the component.
  • Raw checkbox and radio inputs. No Checkbox component exists and @radix-ui/react-checkbox is not a dependency, so these are native inputs styled with Tailwind rather than a new dependency. Every one has an htmlFor label and both groups have a <legend>.
  • The 400 is the enforcement. The disabled Download button is a convenience; parseExportColumns returning [] is what actually refuses the request. Unknown column names are dropped rather than erroring, so a stale bookmark degrades to fewer columns instead of failing.
  • Scope all drops the filter params client-side and the route also ignores filters when scope=all, so the two agree whichever way the URL is constructed.
  • One expectation differs from what I planned. toCsv([payment], []) returns "\n", not "" — no header, no cells, just the row separator. I pinned the real behaviour rather than changing the serializer, since it is not a reachable download path.
  • src/app/payments/page.tsx:36-45 re-implements the status allowlist inline instead of calling parseFilters, so there are two allowlists for one field. Pre-existing and not a security issue (the values only reach in-memory predicates), but it will drift. Left as-is.
  • The second commit (docs:) is unrelated to the ticket — it adds a Release Standards section to the root CLAUDE.md. Happy to split it out if you would rather review it separately.

🤖 Generated with Claude Code

peris611 and others added 2 commits September 10, 2026 10:46
The payments export was fixed: every column, current filter only. The card
last four shipped in every file, so anything going to a merchant was edited
by hand first.

Adds an options dialog on the existing Export button:

- Columns are selectable, with the card last four off by default
  (DEFAULT_EXPORT_COLUMNS in src/lib/csv.ts).
- Scope is current filter or all payments, with both row counts shown before
  download. Both scopes go through the existing query builder, so an export
  is never limited to the current page.
- parseExportColumns validates column names against the allowlist server
  side; the route returns 400 rather than serving an empty file.
- The filename names the scope: payments-disputed-2026-08-13.csv.

Amounts stay in minor units and are still formatted once, in cell(), beside
their own currency column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the rules we are working to: no direct commits to main, test
evidence attached before merging, and a one-line business impact summary on
every pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants