live update
This commit is contained in:
109
src/queries/useLiveEvents.ts
Normal file
109
src/queries/useLiveEvents.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user