Files
parking_solution/apps/web/src/lib/live-store.ts
T
julian 6734e9815e fix(booth): backfill the live-feed plate + make plate search work
Two booth feed fixes:

- Plate not showing until refresh. Plate recognition is async/advisory
  (snapshot.ts recognizePlate → a kind:"read" device_event keyed by the session
  identity), so it lands AFTER the entry/exit event already shipped over the WS
  without a plate; a refresh re-fetched via the bulk enrich path and showed it.
  Added a `plate-recognized` bus event (device-events.ts) emitted when the read
  is written; ws.ts forwards it; the client patchPlate(identity, plate)
  (live-store) backfills the already-rendered feed row in place and invalidates
  the Query-owned active-sessions list. No refresh.

- Plate search didn't filter. Both the live-feed (BoothScreen) and active-sessions
  (ActiveSessions) search haystacks matched the wrong field — the displayed plate
  is the ENRICHED top-level e.plate/s.plate (set by enrichEvent), not payload.plate
  (the plate is unsigned, never in the signed payload). Switched the haystacks to
  the displayed field.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 12:24:46 +02:00

93 lines
4.1 KiB
TypeScript

import { create } from "zustand";
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
// CLIENT state for the live booth feed — deliberately small. Server data (the
// authoritative event list, occupancy totals) is owned by TanStack Query; this
// store holds only what Query shouldn't: the WS connection status, the latest
// pushed occupancy snapshot, and a rolling in-memory tail of recent events for the
// live ticker. Anything durable is re-fetched via Query. See lib/query.ts.
/** Connection state of the booth WebSocket, for a status indicator in the UI. */
export type WsStatus = "connecting" | "open" | "closed";
/** Per-lane busy/free from camera vehicle detection (advisory barrier lights). */
export interface LaneStatus {
entry: boolean; // true = busy
exit: boolean; // true = busy
}
/** Per-lane RADAR presence — a presence input (loop/radar) is shorted at the barrier,
* i.e. "something is in the lane" BEFORE the camera confirms a vehicle. Drives the
* barrier light's BLINK (the same signal as the physical button lamp / relay 3). */
export interface LanePresence {
entry: boolean; // true = a radar/presence input on an entry barrier is active
exit: boolean; // true = … on an exit barrier
}
/** Cap the in-memory live feed so a long-running booth session can't grow it
* unbounded — the full history is always available via the /api/events query. */
const MAX_FEED = 200;
interface LiveState {
status: WsStatus;
/** Most recent occupancy pushed by the server (rides on every ledger event). */
occupancy: Occupancy | null;
/** Newest-first tail of recently pushed ledger events (for the live ticker). */
feed: LedgerEvent[];
/** Live device status keyed by device id (for the footer): set from the WS
* hello snapshot, then upserted per device on each device-status push. */
devices: Record<string, DeviceStatus>;
/** Per-lane busy/free (camera vehicle detection). Null until the first WS hello. */
lanes: LaneStatus | null;
/** Per-lane radar presence (advisory blink). Null until the first WS hello. */
radar: LanePresence | null;
setStatus: (s: WsStatus) => void;
setOccupancy: (o: Occupancy) => void;
pushEvent: (e: LedgerEvent) => void;
/** Replace the whole device-status set (WS hello / reconnect snapshot). */
setDevices: (list: DeviceStatus[]) => void;
/** Upsert one device's status (a device-status push). */
upsertDevice: (d: DeviceStatus) => void;
/** Set lane busy/free (WS hello + each lane-status push). */
setLanes: (l: LaneStatus) => void;
/** Set lane radar presence (WS hello + each lane-presence push). */
setRadar: (r: LanePresence) => void;
/** Backfill the enriched plate on every feed event matching `identity` (a late async
* recognition that landed after the event's own push). No-op if no row matches. */
patchPlate: (identity: string, plate: string) => void;
reset: () => void;
}
/** Index a device-status list by device id. */
function byId(list: DeviceStatus[]): Record<string, DeviceStatus> {
const m: Record<string, DeviceStatus> = {};
for (const d of list) m[d.deviceId] = d;
return m;
}
export const useLiveStore = create<LiveState>((set) => ({
status: "connecting",
occupancy: null,
feed: [],
devices: {},
lanes: null,
radar: null,
setStatus: (status) => set({ status }),
setOccupancy: (occupancy) => set({ occupancy }),
pushEvent: (e) =>
set((s) => ({
// Newest first; de-dupe by id (a reconnect can replay) and cap the length.
feed: s.feed.some((x) => x.id === e.id) ? s.feed : [e, ...s.feed].slice(0, MAX_FEED),
})),
setDevices: (list) => set({ devices: byId(list) }),
upsertDevice: (d) => set((s) => ({ devices: { ...s.devices, [d.deviceId]: d } })),
setLanes: (lanes) => set({ lanes }),
setRadar: (radar) => set({ radar }),
patchPlate: (identity, plate) =>
set((s) => {
if (!s.feed.some((e) => e.identity === identity && !e.plate)) return s; // nothing to fill
return { feed: s.feed.map((e) => (e.identity === identity && !e.plate ? { ...e, plate } : e)) };
}),
reset: () => set({ status: "connecting", occupancy: null, feed: [], devices: {}, lanes: null, radar: null }),
}));