A barcode/QR scanner is an HID "keyboard wedge" — it types the id + Enter into whatever holds focus. Previously that only worked while the ticket <input> was focused; a scan with focus elsewhere (or nowhere) went nowhere. New useScanner hook (apps/web/src/lib/use-scanner.ts): a document-level keydown listener that detects the scanner's FAST keystroke burst ended by Enter and opens the pay/exit modal via setActiveTicket — regardless of focus. A gap > 50ms resets the buffer, so human-paced typing with nothing focused never registers as a scan (min length 3 guards stray Enters). Keystrokes into an input/textarea/select/ contenteditable are ignored, so the manual ticket field still works by hand. The hook is paused while a modal is already open — a scan must not abandon an in-progress payment; the operator finishes/closes, then scans the next car. Verified at runtime (Playwright): a fast burst with focus on BODY opens the modal; a second scan while the modal is open is ignored; slow (120ms) human typing does NOT open it; the manual input submit still opens it. build+lint 14/14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
10 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||
|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-06-19 | open |
Booth Console (operator UI architecture)
The operator console — the real-time UI an attendant runs at a manned booth. Built 2026-06-17/18 on top of the react-vite-spa. This page covers the architecture (stack, live feed, layout); the booth's business flows live in booth-exit-flow, shift, parking-session.
Stack (added 2026-06-17, beyond plain React)
The operator UI outgrew "plain React + useState" once it needed live updates and a real layout:
- TanStack Query owns SERVER state (fetch/cache/refetch/loading-error), wrapping the existing thin
apiFetchclient. Server data is never duplicated into client state. - TanStack Router — real routes (
/booth,/shift,/setup,/tariff,/permits,/site), role-guarded (admin-only routes redirect non-admins to/booth). Code-based route tree. - Zustand — small CLIENT state only: the live WebSocket status + a rolling in-memory event feed + the latest pushed occupancy. Anything durable is re-fetched via Query.
- Tailwind v4 with a "Bloomberg-terminal" theme (
apps/web/src/index.css,@theme): near-black surfaces, amber/green/red/cyan status accents, monospace, dense/keyboard-first. Radix primitives (Dialog, etc.) for accessible unstyled components. The token VALUES are adopted from the "TRM" design system (Claude Design project; race-timing kit) — tokens only, no TRM components: theterm-*accents are aligned onto TRM'snight/semantic colours and TRM's full vocabulary (night/ink/paper scales, flag/amber/green/blue, the spacing/type/shadow scales) is exposed as utilities for new work. Offline appliance ⇒ no webfont@import; Goldplay (TRM's display face) not self-hosted yet — display text falls back to a sans stack. - react-i18next for i18n (Albanian default).
This SUPERSEDES the original "plain React, no framework" note on react-vite-spa — that held while the UI was a few admin forms; the live booth console justified the additions.
Live feed — one WebSocket (/api/ws)
The booth must reflect entries/exits/payments the instant they happen, so the console opens one
authenticated WebSocket app-wide instead of polling. See the WS tap in append-only-event-chain
(EventLog.append fires a read-side onAppended callback → the device bus emitLedger → the WS
route fans it out):
- On each signed ledger append (entry/exit/payment/void/anomaly/cash_movement/shift_*) the server pushes the event plus the recomputed capacity-occupancy (a fold over the same ledger, always authoritative). Printer-status changes (printer-status-monitoring) ride the same socket.
- The client appends to the Zustand feed for the live ticker AND invalidates the matching Query caches (events, occupancy, active-sessions) — so Query stays the source of truth; the WS is the freshness trigger. Auto-reconnect with capped backoff survives a server restart.
Auth — anti-CSWSH
The handshake is a normal GET through Fastify, so the HttpOnly JWT cookie that guards the REST API
guards the WS too. But a browser WebSocket can't send the CSRF double-submit header, which would
leave the socket open to Cross-Site WebSocket Hijacking (a malicious page opens
ws://<booth>/api/ws, the browser auto-attaches the cookie, the attacker reads the live feed). So the
WS route replaces CSRF with an Origin allowlist (same-origin always; extra origins via
WS_ALLOWED_ORIGINS for the dev SPA): a missing/cross origin is rejected before auth. The stream is
read-only — it can never mutate state. (Found + fixed by automated security review, 2026-06-17.)
The booth screen (/booth)
Dense terminal layout: a ticket input (HID-scanner-friendly — types the id + Enter) spanning the top; a left column with the occupancy gauge above the booth-exit-flow list; a right column with the live event ticker. Submitting/clicking a ticket opens the pay/exit modal (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via the WS.
Focus-independent scan capture (2026-06-21). A scan now opens the pay/exit modal regardless of focus — the operator needn't click the ticket field first. A document-level listener (
useScanner,apps/web/src/lib/use-scanner.ts) detects the HID scanner's fast keystroke BURST ended by Enter (a gap > 50ms resets the buffer, so human-paced typing with nothing focused never triggers it) and fires the samesetActiveTicket. It ignores keystrokes into an<input>/<textarea>/select/contenteditableso the manual ticket field still works by hand. It is paused while a modal is already open — a scan must not abandon an in-progress payment; the operator finishes/closes, then scans the next car.
Explainable activity log (2026-06-19)
The ticker used to flag an anomaly with no explanation — a red row with just an id, "nobody knows what happened." Now every event is self-describing and clickable:
- Inline reason + badges. Each row surfaces the localized reason (from the signed
reasonCode— see i18n) and computed classification badges (entry/exit-refused, lot-full, barrier-failed, manual-open…). Anomalies always show a reason line (or "no reason recorded") so a red flag is never silent. - Clickable → event-detail modal. A read-only modal with humanized labelled fields (not raw JSON),
the session's entry/exit snapshots, and the signed-chain provenance (signature / keyId /
prevHash) collapsed behind an "audit data" disclosure — operator sees the story, an auditor expands
for the crypto. Why show signatures at all: makes the append-only-event-chain
visible against the threat-model threat, and survives a signer swap (
keyId). - Subscriber names, not opaque keys. A subscription occurrence's
SUBSESS-…id now renders as the holder's name ("Aqif Kopertoni", fallback "Abonent"/"Subscriber"). Resolved read-time server-side (events API + WS push attach a non-signedsubscriberLabelfrompermitId → holder_name; cached, invalidated on subscription edit/delete) so it needs no extra client permission and the signed event stays minimal. - Failed-snapshot visibility. A camera that was attempted but unreachable now shows a "⚠ camera
unreachable" tile (from snapshot device-events) instead of a silent gap — so the
operator can tell "no camera" from "camera failed". (Surfaced a real incident: a subscriber's entry
snapshot failed
EHOSTUNREACHwhile the exit one succeeded — by design a snapshot is evidence, not a gate, so the open proceeded and only the image was missing.) - Subscriber access medium (
via). A subscription entry/exit row now shows HOW the subscriber was identified — QR code / RFID card·chip / plate — as a cyan chip in the ticker and a labelled "Entry medium" row in the detail modal. The flow already signedvia("qr"|"card"|"plate") into the subscription entry/exit payload; this just surfaces it (a lost-card investigation can now see which credential opened a barrier). Rides the existing localized-display pattern, not a new signed field. - Refused entry/exit now carry a snapshot too (2026-06-19). Previously only an accepted open
captured an image. Now refusal/hold anomalies fire the directional camera as well (the photo of a
turned-away car is exactly the evidence an operator/auditor wants) — so the detail modal's snapshot
strip is populated for "lot full", unpaid-exit, no-session, and refused-subscription events. See
entry-exit-points for the coverage list and the synthetic
REFUSED-…key used when a refused entry has no ticket id.
The shift control (header) + the booth gate
The header carries a single shift button that expresses the [[shift|site-wide single-open shift]] (added 2026-06-18):
- No shift open → "Open shift" (green, enabled).
- My shift open → "Close shift" (red, enabled — signs + prints the Z-report).
- Another operator's shift open → disabled, titled with who holds it. You can neither open yours nor close theirs until they hand over.
State comes from one shared Query (useShift() → GET /api/shift/current, returning { open: {startedAt, operator} | null, isMine }); the WS invalidates it on shift_open / shift_z_report /
cash_movement, so the button (and the per-shift log scope) update live without polling.
The booth screen gates on this: the pay/exit modal shows an "open a shift" banner (with a
one-click Open shift now) and disables pay/exit/voucher until this operator's shift is open;
the Active-Sessions "Open barrier" is disabled the same way. The server enforces it regardless
(requireShift 409 no_shift) — the UI just front-runs the rejection. The live feed is scoped to
the open shift's window (empty when no shift is open). See shift for the rule and the routes.
The device-status footer
A fixed footer in the app shell shows the live status of every configured device — relays,
readers, cameras, printers — one chip each (coloured dot + name + fault detail), with an "all ready
/ N offline" roll-up. Fed by the unified device-status-monitoring over the same
/api/ws socket (hello carries the initial set; a device-status frame per change), held in the
live store keyed by device id, with GET /api/devices/status as the seed/fallback. Visible on every
screen, so the operator always sees the barrier relay's reachability and the printer's paper state.
Dev notes
- Vite proxies
/api/ws(ws: true) to the backend; the backend's Origin allowlist must include the dev SPA origin (WS_ALLOWED_ORIGINS=http://localhost:5173). In production Fastify serves the SPA same-origin, so the allowlist isn't needed. - Start the dev SPA via
pnpm devfromapps/web(notnpx vite --host …, which has mangled args and served 404s in this environment).
Open
- No automated frontend tests — the booth/live-feed/modal logic is verified manually (Playwright + curl + DB inspection), not by a suite. The standing test-harness gap (see reconciliation-adjacent notes) now spans front and back.
- The pre-existing admin screens (Setup/Tariff/Permits/Site/Shift) still carry their old inline styles — reachable and functional, not yet on the terminal component system.