Slice 2 — Customers
The customer is the central entity. This slice builds search, profile, edit/delete-with-blockers, structured notes (separate from AIM's blob), marketing-consent pills, and bikes on record.
Status: Built (live against local D1 with 2,784 migrated records + 2,156 customer bikes).
Focus steward: #customers-search. Registered on screen activate; refocus returns to it after every modal close, print, and idle window. See focus stewardship.
Scope
customers+customer_notes+customer_marketing_prefs+customer_bikestables- CRUD endpoints + smart search
- Profile UI with in-situ editing
- Merge (two customer records → one)
- Delete with blockers (preflight check)
- Erasure (PII scrub) flow
- AIM migration ETL
Schema
customers— see customer entitycustomer_bikes— see customer-bike entitycustomer_notes— structured notes (separate from AIM'sgeneral_notesblob)customer_marketing_prefs— consent records with timestampscustomer_tags— free-form tags
Endpoints
GET /api/customers?q=...— smart searchGET /api/customers/:id— detailPOST /api/customers— createPUT /api/customers/:id— update (phone, email, address, marketing flags, etc.)DELETE /api/customers/:id— with blocker preflightPOST /api/customers/:id/merge?from=:other— merge two recordsPOST /api/customers/:id/notes— add a structured noteDELETE /api/customers/:id/notes/:noteId— remove a notePOST /api/customers/:id/bikes— add bike on recordPUT /api/customers/:id/bikes/:bikeId— edit bikeDELETE /api/customers/:id/bikes/:bikeId— with blocker preflightPOST /api/customers/:id/data-export— generate per-customer JSON+PDFPOST /api/customers/:id/erasure— PII scrub
UI
Customers screen:
- Search bar (smart search)
- Results list (name, phone, last visit)
- Customer profile view:
- Identity at top (name, phone, email, address)
- Marketing pills (clickable in view mode too — fast toggle)
- Bikes on record (with hover-history)
- Service history (recent tickets)
- Transaction history
- Structured notes (separate panel from popup note)
- Popup note (shown on customer load on Sales screen)
In-situ edit mode adds:
- Click name/email/phone/address fields to edit inline
-
- Add bike, + Add note, + Add tag
- × on bikes, notes, tags to remove
- Custom-field add (manager+)
What's built
- All endpoints above ✓
- Search with structured-field + free-text split ✓
- Profile with hero edit/delete buttons ✓
- Full edit form (mirrors New Customer) ✓
- Structured notes (not blob) ✓
- Marketing pills clickable in view mode ✓
- Bikes on record with hover-history ✓
- AIM migration ETL for 2,784 customers + 2,156 bikes ✓
- Customer merge endpoint (migration 012 added the support) ✓
Customer-history ledger (live 2026-05-23/24)
When a customer is attached to the Sales-Till the ledger flips into customer-history mode — every transaction this customer has touched, oldest-to-newest. Iterations across v0.5.1 → v0.5.6:
- Perf fix (v0.5.1, migration 054) — was 10-20s on the production dataset (~50K transactions, mostly migrated from AIM). Root cause: SQLite picked the
occurred_atindex for theORDER BY DESC LIMIT 200and filteredcustomer_idrow-by-row, scanning tens of thousands of rows for a customer with a handful of transactions. Compound index(customer_id, occurred_at DESC)landed in migration 054; query now returns in <100ms. - Preempt regressions (v0.5.2, migration 055) — three more endpoints had the same filter+sort+limit shape (the generic ticket list, the bike-tickets endpoint, the recent-customers endpoint). Compound indices added across
service_ticketsandcustomersto prevent the same regression from biting elsewhere. - Date instead of Time (v0.5.5) — the ledger date column was rendering time-of-day, useless for history older than a couple days. Now shows date.
- Group by year (v0.5.6) — a customer with 8 years of receipts gets year headers (
2026,2025,2024, …) so scrolling has anchor points. - Parked-sale filter (v0.5.4) — parked rows are scoped by the ledger's date-mode, not always-on. Reviewing yesterday's books no longer surfaces today's open parked tills.
- Recent Visits widget (v0.5.24) — Customers screen gets a "Recent Visits" widget showing customers who've had a transaction or service ticket in the last N days, sorted by recency. Replaces the older "active customers" surface; uses the same compound indices from migration 055.
- Customers tab moved (v0.5.24) — repositioned in the nav order to match the operator's actual usage frequency (Customers used during attach-to-cart flow more often than expected; demoted from primary nav, surfaced from Till instead).
- Customers tab back on the main nav (v0.6.14, 2026-05-24) ✓ — the v0.5.24 demote-into-Settings move was reverted. The Customers screen is a top-level nav tab again, gated by
screen.customers(sales / mechanic / junior / owner / sys_admin all hold it per migration 053). The Settings → Customer directory shortcut stays as a secondary entry point for owners doing bulk admin. Reason for the revert: with the v0.6.15 removal of customer rows from the Till search dropdown, the in-Settings shortcut wasn't reachable along the path operators actually walk to look up a customer mid-sale. Top-level tab is the single canonical entry point again. - Customers screen reshaped into the Sales-style Till layout (v0.6.43, 2026-05-25) ✓ — the Customers screen now matches the cross-tab shape: search + ledger on the left, work-area Till on the right. Left zone (
.till-left): customers-only search bar (top), then a single ledger card that flips between "Recent visits" (empty state — last 20 visits across all customers) and "<Customer> — Visit history" (when a customer is selected; thecd-visits-tablerenders inline). One card, two bodies; toggle is a display swap. Right zone (.till-right id=customers-till-right):till-headercarries customer name (DM Serif) + meta line +✎/🗑action icons;till-itemsbody holds the lifetime / last-visit / account stats row, popup-note banner, Bikes on record, Notes, Marketing, Identity;till-actionsfooter is← New search · ⇣ Merge · + New customer(relocated from the page-header so they sit where Charge / Save / etc. sit on other tills). Wiring discipline: everycd-*element ID is preserved exactly sorenderDetail()keeps populating without changes; two small additions inloadDetailand thecd-clearhandler swap the ledger heading + visibility of the visits table + till-header actions as the screen toggles empty ↔ selected. Empty-state guidance and search-suggestion chips relocated inside the empty-state ledger body. No behavioural changes to search, detail loading, account balance, merge flow, or new-customer modal. The Customers screen is now the fourth working surface to carry the unified work-area header (Till / Slot / Bucket / Customers detail).
Customers screen oscillation: v0.6.45 → v0.6.56
A twelve-commit run that iterated hard on the Customers screen shape. The end state (v0.6.55+) is the .till-line Sales/Service/Products vocabulary applied to every customer field, with the canonical edit surface being the customer's NAME (click → Edit Customer modal). The path there crossed itself a few times — captured here because the shape it settled on only makes sense in the context of what was tried.
- v0.6.45 — rolled back the v0.6.43–.44 reshape ✓ — the till/two-column experiment came off. Back to the original full-width layout with the page-header Clear / Merge / + New customer buttons + a sticky search bar (
position:sticky;top:0) so the operator can pivot mid-record without scrolling back up. Inline-edit added oncd-name(display_name) +cd-tier(pricing_tier) via a new.helm-editableclass — dotted-blue-underline hover + pencil glyph +contenteditable=trueon click, blur or Enter saves, Esc reverts. PUT to/api/customers/:id(already accepts partial updates). - v0.6.46 — inline-edit on phone, email, popup note ✓ — the hero meta line and popup-note banner gained click-to-edit on
primary_phone,primary_email, and the popup-note text itself. Empty fields show+ phone/+ email/+ Add popup noteplaceholders that clear on click.bindInlineEditpicks up new spans automatically via the existing MutationObserver, sorenderDetail()doesn't need explicit wiring beyond emitting the right markup. - v0.6.46.1 — hotfix: invalid regex killed the whole script ✓ —
/^(?:\+ |+ )/and/^+\s+/inbindInlineEdithad an unescaped+at the start (quantifier with nothing to repeat) — SyntaxError at parse time. Becausepublic/index.htmlis one giant script bundle, that error halted parsing of EVERYTHING below it, including the topnav tab click handlers. Tabs went dead. Fix: escape the+→\+. Lesson logged in naming things as "literal + at the start of a regex needs\+." - v0.6.48 — name-click opens the edit modal; nav reorder ✓ — the ✎ / 🗑 hero icons retired. The customer's NAME is now the click target → opens the existing Edit Customer modal (name / contact / popup / notes / marketing prefs / address / pricing tier / tax-exempt / credit limit, all in one place). Inline-edit on
cd-name,cd-tier, phone, email, popup note all retired in this pass — the modal is the canonical edit surface. (v0.6.51/.55 brought a different flavour of inline-edit back; see below.) Also: nav reorder —Owner · Sales · Service · Products · Customers · eComm · Trades. Products moved up from after Customers (operators reach for the catalog more often than the customer record); Rentals hidden from the main nav until the rental flows ship. - v0.6.49 — Customer history rows clickable ✓ — section heading renamed Visit history → Customer history. Each row gets a delegated click handler that dispatches by transaction type: sales / estimate / deposit / refund →
window.helmOpenTransaction(id); service tickets →window.helmOpenServiceTicket(id)(orswitchScreen('service') + helm:load-ticketevent if the helper isn't defined). Rows get.helm-clickablestyling +data-tx-id/data-tx-type/data-related-ticketattributes for the delegate to read. Sales detail is read-only today; ticket detail allows status / parts / notes edits. - v0.6.50 — Customers screen bounded to viewport height ✓ — matched the
.tillidiom Sales / Service / Products use:height: calc(100vh - 220px); min-height: 480px; overflow: hidden. Applied to both#customers-detail(flex column, hero pinned, body scrolls internally) and#customers-empty(Recent visits header pinned, list scrolls, suggestion chips pinned below). No more page-scroll to reach the bottom of a long record. - v0.6.51 — .till layout (second attempt, this time stays) ✓ — replaced v0.6.50's bounded full-width with the canonical
.tilltwo-zone layout that mirrors Sales / Service / Products..till-left: customers-only search + customer ledger card (one card, two bodies that swap via display — Recent visits · last 20 across all customers in empty state, the customer's transaction history table with the samedata-sortable/data-sort-keycolumn shape as Today's Sales when a customer is selected)..till-right: standardtill-headerwith+ Customerpill + Clear × ;till-itemsbody is the stacked customer form (Lifetime / Last visit / Account-balance stats row · popup-note banner · field grid for Name, Phone, Email, Tier, Status with.helm-editableinline-edit · Marketing toggles · Notes · Bikes on record · Identity);till-actionsfooter =← New search · Save · Merge · + New customer. Save is a refresh affordance — every inline edit auto-saves on blur; Save re-fetches the record from the server for operators wanting to pull in changes from another tab. The v0.6.48 "name-click opens modal" path stays for the canonical edit; the.helm-editableinline-edits are a faster path for one-field touches. - v0.6.52 — bug fix: renderDetail crash + customer search ranking ✓ — see the search engine for the search-ranking story. The other fix: v0.6.51 removed the
cd-visits-headerelement butrenderDetailstillgetElementById(...).innerHTML'd it; null-deref threw silently before the till show/hide toggles ran, so clicking a customer left the till empty and the ledger blank. Replaced with updates to the newcustomers-ledger-heading/customers-ledger-subIDs; added null guards on the table + body refs. - v0.6.53 — drop
← New searchbutton from footer ✓ — the till-header×is now the single drop-the-current-customer affordance. Footer is just Save / Merge / + New customer. Reset logic extracted intoresetCustomerScreen()so the header×can call it directly (was delegating viacd-clear.click). - v0.6.55 — Customer till body redesigned with the
.till-linevocabulary ✓ — every field renders as a.till-line(label LEFT, inline-editable value RIGHT), inheriting the standard11pxpadding + dotted row-divider treatment. Section headers (Marketing / Notes / Bikes / Identity) use the mutedbg-altstrip between groups. The three read-only stats (Lifetime spend, Last visit, Account balance) move into a.till-totalsblock at the bottom of the till, mirroring where Sales puts Subtotal / GST / PST / Total. Empty-state copy matches the till idiom: "No customer attached." Everycd-*element ID preserved. - v0.6.56 — page-header inline record count ✓ — records count moves up beside the title (same pattern Sales uses with "Till Open · cashier · register"). The
· search to find onehint is dropped — the prominent search bar below speaks for itself.
What's not yet built
- Merge UI — endpoint exists (migration 012 added the schema support); UI for the merge workflow doesn't
- Custom field definitions — schema exists for
custom_field_defs; UI not built - Per-customer data export bundle — endpoint partially exists; UI button is on the profile but PDF rendering still TBD
- Erasure UI — endpoint partially exists; an explicit "Delete on request" button isn't on the profile yet
Migration from AIM
- 2,783 records from
cust general_notesblob preserved incustomers.legacy_notes; not auto-parsed into structured notes (operator handles per-customer when they encounter the record)- Phone normalized to E.164 where possible
- Email lowercased; duplicates merged by hand during dry-run round 2
See migration from AIM.