/** * The live stream. * * The console reads through TanStack Query on a 30-second poll. That is the * floor, not the ceiling: a bill rung at 10:00:01 waits until 10:00:30 to * appear. This connects to the backend's event stream and, when a till actually * sells something, invalidates the queries that just went stale — so the screen * updates in about a second instead of averaging fifteen. * * ── What it does NOT do ────────────────────────────────────────────────────── * * It never writes data into the cache. An event says "outlet 1185 sold * something, products 42 and 77 were involved" and nothing more; the numbers * still come from the API, because the API is the only thing that knows the * total after the backend's locks, dedup and rejections have had their say. * Painting figures straight from a broker message would put totals on screen * that the database never agreed to. * * It also does not replace the poll. Every hook keeps its `refetchInterval`. If * this connection drops, the broker is down, or MQTT_URL is unset on the * server, the console behaves exactly as it did before — slower, never wrong. * That is the whole reason this is safe to add. */ import { useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { API_BASE } from '@/api/client'; import { queryKeys } from './keys'; /** Mirrors `messaging.LiveEvent` on the backend. */ interface LiveEvent { type: 'sale' | 'customer' | 'terminal'; locationid: number; productids?: number[]; terminalid?: string; at: string; } /** * Watch one outlet. * * Pass `undefined` for either id and nothing connects — which is what the Store * Admin's "All branches" view does, since a stream is per-outlet and opening * one per branch would mean six connections for a screen that is already * polling. Under All branches the poll remains the only refresh, deliberately. */ export function useLiveEvents(tenantid: number | undefined, locationid: number | undefined) { const queryClient = useQueryClient(); useEffect(() => { if (!tenantid || !locationid) return; const url = `${API_BASE}/live/api/v1/web/live/events?tenantid=${tenantid}&locationid=${locationid}`; const source = new EventSource(url); /** * A sale moved stock and added a bill. * * Invalidated by prefix rather than by exact key: the product list is keyed * by page and the statement by its filters, and this has no idea which page * or filter the operator is looking at. `invalidateQueries` with a prefix * only refetches what is actually mounted, so the cost is the queries on * screen — not every page ever visited. */ const onSale = () => { void queryClient.invalidateQueries({ queryKey: queryKeys.stock.all }); void queryClient.invalidateQueries({ queryKey: queryKeys.products.all }); void queryClient.invalidateQueries({ queryKey: queryKeys.insights.posSales(locationid) }); void queryClient.invalidateQueries({ queryKey: [...queryKeys.insights.all, 'pos-bills', locationid], }); void queryClient.invalidateQueries({ queryKey: [...queryKeys.insights.all, 'pos-summary', locationid], }); }; /** A heartbeat — the presence board, and nothing else. */ const onTerminal = () => { void queryClient.invalidateQueries({ queryKey: queryKeys.insights.posHealth(locationid) }); }; const onCustomer = () => { void queryClient.invalidateQueries({ queryKey: queryKeys.customers.all }); }; source.addEventListener('sale', onSale); source.addEventListener('terminal', onTerminal); source.addEventListener('customer', onCustomer); /** * Errors are left alone on purpose. * * `EventSource` reconnects by itself, honouring the `retry:` the server * sends, and it fires `error` on every one of those attempts. Logging here * fills the console with noise during an ordinary backend deploy, and * surfacing it to the operator would be reporting a failure that costs them * nothing — the poll is still running underneath. */ return () => { source.removeEventListener('sale', onSale); source.removeEventListener('terminal', onTerminal); source.removeEventListener('customer', onCustomer); source.close(); }; }, [tenantid, locationid, queryClient]); } export type { LiveEvent };