Files
daily_console_web/src/queries/useLiveEvents.ts
2026-08-27 11:26:04 +05:30

110 lines
4.4 KiB
TypeScript

/**
* 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 };