Skip to main content

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.

Drafted from planning · v0.1

Scope

  • customers + customer_notes + customer_marketing_prefs + customer_bikes tables
  • 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 entity
  • customer_bikes — see customer-bike entity
  • customer_notes — structured notes (separate from AIM's general_notes blob)
  • customer_marketing_prefs — consent records with timestamps
  • customer_tags — free-form tags

Endpoints

  • GET /api/customers?q=... — smart search
  • GET /api/customers/:id — detail
  • POST /api/customers — create
  • PUT /api/customers/:id — update (phone, email, address, marketing flags, etc.)
  • DELETE /api/customers/:id — with blocker preflight
  • POST /api/customers/:id/merge?from=:other — merge two records
  • POST /api/customers/:id/notes — add a structured note
  • DELETE /api/customers/:id/notes/:noteId — remove a note
  • POST /api/customers/:id/bikes — add bike on record
  • PUT /api/customers/:id/bikes/:bikeId — edit bike
  • DELETE /api/customers/:id/bikes/:bikeId — with blocker preflight
  • POST /api/customers/:id/data-export — generate per-customer JSON+PDF
  • POST /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_at index for the ORDER BY DESC LIMIT 200 and filtered customer_id row-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_tickets and customers to 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; the cd-visits-table renders inline). One card, two bodies; toggle is a display swap. Right zone (.till-right id=customers-till-right): till-header carries customer name (DM Serif) + meta line + / 🗑 action icons; till-items body holds the lifetime / last-visit / account stats row, popup-note banner, Bikes on record, Notes, Marketing, Identity; till-actions footer is ← New search · ⇣ Merge · + New customer (relocated from the page-header so they sit where Charge / Save / etc. sit on other tills). Wiring discipline: every cd-* element ID is preserved exactly so renderDetail() keeps populating without changes; two small additions in loadDetail and the cd-clear handler 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 on cd-name (display_name) + cd-tier (pricing_tier) via a new .helm-editable class — dotted-blue-underline hover + pencil glyph + contenteditable=true on 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 note placeholders that clear on click. bindInlineEdit picks up new spans automatically via the existing MutationObserver, so renderDetail() doesn't need explicit wiring beyond emitting the right markup.
  • v0.6.46.1 — hotfix: invalid regex killed the whole script ✓ — /^(?:\+ |+ )/ and /^+\s+/ in bindInlineEdit had an unescaped + at the start (quantifier with nothing to repeat) — SyntaxError at parse time. Because public/index.html is 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 reorderOwner · 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 historyCustomer 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) (or switchScreen('service') + helm:load-ticket event if the helper isn't defined). Rows get .helm-clickable styling + data-tx-id / data-tx-type / data-related-ticket attributes 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 .till idiom 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 .till two-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 same data-sortable / data-sort-key column shape as Today's Sales when a customer is selected). .till-right: standard till-header with + Customer pill + Clear × ; till-items body 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-editable inline-edit · Marketing toggles · Notes · Bikes on record · Identity); till-actions footer = ← 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-editable inline-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-header element but renderDetail still getElementById(...).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 new customers-ledger-heading / customers-ledger-sub IDs; added null guards on the table + body refs.
  • v0.6.53 — drop ← New search button from footer ✓ — the till-header × is now the single drop-the-current-customer affordance. Footer is just Save / Merge / + New customer. Reset logic extracted into resetCustomerScreen() so the header × can call it directly (was delegating via cd-clear.click).
  • v0.6.55 — Customer till body redesigned with the .till-line vocabulary ✓ — every field renders as a .till-line (label LEFT, inline-editable value RIGHT), inheriting the standard 11px padding + dotted row-divider treatment. Section headers (Marketing / Notes / Bikes / Identity) use the muted bg-alt strip between groups. The three read-only stats (Lifetime spend, Last visit, Account balance) move into a .till-totals block at the bottom of the till, mirroring where Sales puts Subtotal / GST / PST / Total. Empty-state copy matches the till idiom: "No customer attached." Every cd-* 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 one hint 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_notes blob preserved in customers.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.

See also