← All tests

Search Customer STAGING

Narong's red-flagged, fully-specced item from 2026-07-24: search by name / phone / customer ID; filter by customer type, order activity, joined date and loyalty status; sort by newest, A–Z, most orders, highest spending or highest points; and a count line reading "1 total customer · 1 app member · 0 POS customers". No migration.

Net −108 lines in nix-cafe despite adding the feature. The page carried two near-duplicate 140-line blocks (one per tier) that collapse into a single directory both tiers share.

The shape of it

His count line only parses one way — total = app members + POS customers — so the two populations become one list with type as a facet. That meant absorbing the separate "App Members" card, which he'd asked for on 07-10; Points and Chances survive as columns that fill in only for members.

The two populations are stored apart: POS customers in the cafe database, app members in the loyalty service behind an HTTP call. The thing that makes a merged list coherent rather than half-empty is that paid orders for both land in cafe.orders, tagged cafe_customer_id or loyalty_member_id — so "most orders" and "highest spending" are real for app members too, not just the POS half.

directory on lumiere
Lumière (Starter). The count line reads "1 total customers · 1 app members · 0 POS customers" — literally the example in his spec.
directory on demo
Demo (Pro), two app members. Type badge, customer ID, orders, spend, points and joined date in one row.
filter popover
The four filter groups: customer type, order activity, joined date, loyalty status.
no match empty state
A search with no matches gets a real empty state, not a blank table.

Two bugs found on the way

  1. The Pro customer list had a hard .limit(50) with no pagination and no indication — past 50 customers it silently truncated. The directory now loads the full set and says so if a cap is ever hit.
  2. "Search by customer ID" was unimplementable as specced. The loyalty admin API returned only the internal UUID, which no human can type. member_qr_id — what the QR actually encodes — is now returned and searchable. Seven lines in the loyalty backend; it's the only reason that third search field means anything.

A bug the tests only caught because a silent failure was made loud

The first staging run passed 11 of 13. One failure was my test's; the other was real, and worth recording: get-coffee rendered "0 total customers · 0 app members" despite having a member. The cause was hidden by a bare catch {} I'd written when absorbing the loyalty card — the page degraded silently and then asserted there were zero members, which isn't a graceful degradation, it's a false statement.

Restoring the error surface the old card had immediately produced the real message:

Couldn't load app members: c5.lastOrderAt.toISOString is not a function

MAX(created_at) was annotated sql<Date>, but drizzle returns a string for a bare aggregate — the type was a lie, and .toISOString() threw. It only ever fired for a customer who actually had orders, so get-coffee — the one staging tenant with orders tagged to a member — was the only place it surfaced. Normalised at the DAO boundary, and the failure is now visible rather than silently swallowed.

Checks

SuiteResult
Local — the pure search/filter/sort module, exercised directly (no browser)20/20
Staging — real SSO across Starter and Pro on the deployed Worker13/13
Staging smoke5/5
tsc --noEmit (nix-cafe + nix-loyalty-backend)clean

Expected values are read from Neon and the loyalty API at run time rather than hardcoded, so the suite can't quietly rot as staging data changes — and it fails loudly if the fixture stops proving anything (for instance, if the member-linked orders that make the cross-store assertion meaningful ever disappear).

Negative control, and a hole it exposed in my own test

Two regressions were injected. The first — conflating a POS customer's null points with a member sitting at zero — was caught precisely. The second, making the A–Z sort case-sensitive, was not: the fixture happened not to discriminate, so that assertion's name was claiming more than it verified. Changing one fixture name to lowercase made it real, and the same regression is now caught by three assertions:

✗ filter by order activity:  expected ["pos:c1","member:m3","member:m1"], got ["member:m3","member:m1","pos:c1"]
✗ filter by joined date window
✗ sort: A-Z is case-insensitive

Assumptions — worth confirming with Narong

nix-cafe staging 4c838e2 (Worker 7fe7fda2) · nix-loyalty-backend staging 7d8c71f (Fly staging deployed). No migration. nix-loyalty-backend is new to the promote bundle — it will need a prod Fly deploy alongside the usual steps. Parked for the scheduled prod promote.