← All tasks

v0.2 Slice G — Customer Display LOCAL · GATE 1

H3.2 cross-device transport + H3.3 slideshow on the idle screen. Customer Display today only works on a second tab of the same iPad (BroadcastChannel = same-origin only). Slice G adds a server-state fallback so the customer can scan a QR code and watch the cart build on their own phone.

17/17 local checks green. tsc --noEmit clean. Migration applied to local Docker postgres + DAO upsert/get/delete + validateBranding accepts/rejects all probed end-to-end. New cafe.display_states table, three slideshow columns on cafe.tenant_config with a CHECK pinning speed ∈ {3,5,7}, one new shared modal component, three POS toolbars rewired, and the qrcode npm dep added (~10KB).

The four moving parts

H3.2Cross-device transport — hybrid (BC + polling)

Cashier still publishes to BroadcastChannel (instant, same-device) AND now mirrors the same payload to POST /cafe/api/display/<sessionId>/state (debounced 250ms; the paid event bypasses the debounce + uses fetch keepalive so it survives a tab close). Display subscribes to BC AND polls GET /cafe/api/display/<sessionId>/state?secret=… every 1500ms. Last-write- wins reconciliation via updated_at. Same-device cashiers keep the snappy UX they have today; QR/remote devices get ≤2s lag.

Cashier (POS tab) Customer phone (QR) │ │ │ BC postMessage (instant) │ │ ───────────► │ │ POST /api/display/.../state │ │ ─────────────► Postgres ◄──── │ GET /api/display/.../state │ (debounced 250ms) │ (poll every 1500ms) │ │ └─ same-device tab gets BC + poll ┘

The route bypasses Cafe's middleware (alongside /display/ + /api/kitchen/); both POST and GET gate solely on ?secret matching cafe.sessions.display_secret — bad/missing → 404 (don't leak which session ids exist).

H3.2Toolbar Display button → mode picker modal

Replaced the immediate window.open(displayUrl) in three POS toolbars (Pro lockable, Starter lockable, Starter in-app) with a shared <DisplayModeDialog> with two CTAs:

H3.3Slideshow on the idle screen

Three new columns on cafe.tenant_config: display_slideshow_images (jsonb array of URLs, soft cap 10), display_slideshow_enabled (bool, default false), and display_slideshow_speed_seconds (int, default 5, CHECK constraint pinned to {3,5,7}). The existing single promo_image_url stays — toggle picks which renders on the display's idle screen. Cross-fade transition between images via CSS opacity + a paired second that pre-loads the next URL.

Settings form gets a Slideshow card with: enable toggle, speed pills (3s/5s/7s), multi-upload (re-uses the existing UploadButton), a per-image list with remove, and live preview that mirrors the toggle.

Validation refuses to enable the slideshow with zero images + rejects speed values outside the enum + rejects URLs that aren't http(s):// + caps at 10 images. All probed in the DAO test below.

cleanupcloseShiftAction drops the display_state row

The cafe.display_states table has ON DELETE CASCADE on the FK to cafe.sessions, which catches hard-deletes, but sessions are only ever soft-closed (state='closed'). To keep the table from accumulating stale payloads, closeShiftAction now explicitly DELETEs the row. Best-effort: a failure here doesn't surface as a close failure (logged, swallowed).

Probe results

Schema probe: cafe.display_states (3 columns, FK confdeltype=c for CASCADE, PK on session_id) + cafe.tenant_config 3 slideshow columns + the cafe_tenant_config_slideshow_speed_check constraint all confirmed.

DAO write probe: upsert(idle) → get returns idle; upsert(cart) → get returns cart with the right line count + subtotal; delete → get returns null. Round-trip through the jsonb column preserves the nested shape.

validateBranding probe: accepts {2 https URLs, enabled, speed=3}; rejects enable-with-no-images; rejects speed=4 (not in enum); rejects 11-image array; rejects javascript: URL.

Raw artefacts: 01-schema.txt · 02-dao-probe.json · result.json

Risks flagged for Gate 2

Polling load. Per open session = 1 read every 1.5s = 0.67 reads/s/session. NIX scale (≤ a handful of open displays simultaneously) = trivial. Reads wrapped in db.transaction() to bypass Hyperdrive on the hot poll path.

Stale display_states rows. closeShiftAction handles normal-close. Manual interrupts / aborted closes might leak rows. Acceptable until a future cron sweep.

QR rendering on Cloudflare Workers. The qrcode lib runs client-side only (in the modal); should work fine on any modern browser. Local tsx probe verifies the import resolves.

Checks — 17/17

Files changed

1 migration:
nix-outdoor-sales-backend/migrations/20260515160000_cafe_display_states_and_slideshow.ts
nix-outdoor-sales-backend/migrate.js (registry entry)

nix-cafe — 3 new + 11 modified + 1 new dep:
New:
lib/db/display_states.ts · app/api/display/[sessionId]/state/route.ts · app/(authed)/pos/_components/display-mode-dialog.tsx
Modified:
lib/db/schema.ts · lib/db/display_branding.ts · lib/actions/display_branding.ts · lib/actions/register.ts · middleware.ts
app/(public-display)/display/[sessionId]/page.tsx · display-client.tsx
app/(authed)/settings/display/display-branding-client.tsx
app/(authed)/pos/_components/register-shell.tsx · starter-top-bar.tsx · starter-lockable-shell.tsx
app/(pos-fullscreen)/pos/register/[configId]/lockable-shell.tsx
Dep: qrcode + @types/qrcode