6734e9815e
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
124 lines
5.1 KiB
TypeScript
124 lines
5.1 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
|
import { qk } from "./query.js";
|
|
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
|
import { wsUrl } from "./origin.js";
|
|
|
|
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
|
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
|
// so TanStack Query remains the source of truth for durable server data. The browser
|
|
// attaches the auth cookie automatically; the backend gates by cookie + Origin
|
|
// (see routes/ws.ts). Auto-reconnects with capped backoff so a booth left running
|
|
// recovers from a server restart without a manual refresh.
|
|
|
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
|
type WsMessage =
|
|
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
|
| { kind: "printer-status"; event: unknown }
|
|
| { kind: "device-status"; event: DeviceStatus }
|
|
| { kind: "lane-status"; lanes: LaneStatus }
|
|
| { kind: "lane-presence"; radar: LanePresence }
|
|
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
|
|
|
|
|
export function useLiveFeed(): void {
|
|
const qc = useQueryClient();
|
|
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
|
useLiveStore();
|
|
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
|
// double-invoke and unmount.
|
|
const sockRef = useRef<WebSocket | null>(null);
|
|
const retryRef = useRef(0);
|
|
const closedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
closedRef.current = false;
|
|
|
|
const connect = () => {
|
|
if (closedRef.current) return;
|
|
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
|
const sock = new WebSocket(wsUrl("/api/ws"));
|
|
sockRef.current = sock;
|
|
|
|
sock.onopen = () => {
|
|
retryRef.current = 0;
|
|
setStatus("open");
|
|
};
|
|
|
|
sock.onmessage = (ev) => {
|
|
let msg: WsMessage;
|
|
try {
|
|
msg = JSON.parse(ev.data as string) as WsMessage;
|
|
} catch {
|
|
return; // ignore malformed frames
|
|
}
|
|
if (msg.kind === "hello") {
|
|
setOccupancy(msg.occupancy);
|
|
// Initial device-status snapshot for the footer.
|
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
|
if (msg.lanes) setLanes(msg.lanes);
|
|
if (msg.radar) setRadar(msg.radar);
|
|
} else if (msg.kind === "device-status") {
|
|
upsertDevice(msg.event);
|
|
} else if (msg.kind === "lane-status") {
|
|
setLanes(msg.lanes);
|
|
} else if (msg.kind === "lane-presence") {
|
|
setRadar(msg.radar);
|
|
} else if (msg.kind === "plate-recognized") {
|
|
// Backfill the badge on the already-rendered feed row, and refetch the
|
|
// Query-owned active-sessions list (re-runs enrichEvents → the now-written plate).
|
|
patchPlate(msg.plate.identity, msg.plate.plate);
|
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
|
} else if (msg.kind === "ledger") {
|
|
setOccupancy(msg.occupancy);
|
|
pushEvent(msg.event);
|
|
// Keep Query authoritative: the durable event list, occupancy totals,
|
|
// and active-sessions list refetch on the next read instead of trusting
|
|
// the pushed copy alone.
|
|
void qc.invalidateQueries({ queryKey: qk.events });
|
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
|
// A shift open/close (or a drawer movement) changes the header control
|
|
// state and the per-shift log window — refresh the shift status too.
|
|
if (
|
|
msg.event.type === "shift_open" ||
|
|
msg.event.type === "shift_z_report" ||
|
|
msg.event.type === "cash_movement" ||
|
|
msg.event.type === "cash_in" ||
|
|
msg.event.type === "cash_out"
|
|
) {
|
|
void qc.invalidateQueries({ queryKey: qk.shift });
|
|
}
|
|
} else if (msg.kind === "printer-status") {
|
|
void qc.invalidateQueries({ queryKey: ["printers"] });
|
|
}
|
|
};
|
|
|
|
const scheduleReconnect = () => {
|
|
if (closedRef.current) return;
|
|
setStatus("closed");
|
|
// Capped exponential backoff: 0.5s, 1s, 2s, … up to 10s.
|
|
const delay = Math.min(500 * 2 ** retryRef.current, 10_000);
|
|
retryRef.current += 1;
|
|
window.setTimeout(connect, delay);
|
|
};
|
|
|
|
sock.onclose = scheduleReconnect;
|
|
// onerror fires before onclose; let onclose own the reconnect to avoid double.
|
|
sock.onerror = () => sock.close();
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
closedRef.current = true;
|
|
sockRef.current?.close();
|
|
sockRef.current = null;
|
|
};
|
|
// qc / store setters are stable; run once on mount.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
}
|