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
This commit is contained in:
2026-06-28 12:24:46 +02:00
parent 38481f105f
commit 6734e9815e
7 changed files with 61 additions and 7 deletions
+2 -1
View File
@@ -83,7 +83,8 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
if (kind === "subscription" && !s.subscription) return false;
if (status && statusOf(s) !== status) return false;
if (q) {
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase();
// Include the enriched plate (`s.plate`, the displayed badge) so a plate search hits.
const hay = `${s.identity} ${s.subscriptionHolder ?? ""} ${s.plate ?? ""}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
+3 -2
View File
@@ -207,7 +207,8 @@ export function BoothScreen() {
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
// subscriber label, and any advisory plate on the payload.
// subscriber label, and the enriched advisory plate (`e.plate` — the displayed field;
// the plate is NOT in the signed payload, so `payload.plate` would never match).
const fq = feedSearch.trim().toLowerCase();
const events = scoped.filter((e) => {
if (feedType && feedCat(e.type) !== feedType) return false;
@@ -217,7 +218,7 @@ export function BoothScreen() {
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
}
if (fq) {
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase();
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.plate ?? ""}`.toLowerCase();
if (!hay.includes(fq)) return false;
}
return true;
+8
View File
@@ -52,6 +52,9 @@ interface LiveState {
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;
}
@@ -80,5 +83,10 @@ export const useLiveStore = create<LiveState>((set) => ({
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 }),
}));
+9 -2
View File
@@ -19,12 +19,14 @@ type WsMessage =
| { kind: "printer-status"; event: unknown }
| { kind: "device-status"; event: DeviceStatus }
| { kind: "lane-status"; lanes: LaneStatus }
| { kind: "lane-presence"; radar: LanePresence };
| { 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 } = useLiveStore();
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);
@@ -64,6 +66,11 @@ export function useLiveFeed(): void {
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);