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; /** 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 { const m: Record = {}; for (const d of list) m[d.deviceId] = d; return m; } export const useLiveStore = create((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 }), }));