Files
kooltab-backoffice/apps/dashboard/src/lib/realtime.tsx
T
brammie15 bca6e88138
CI / build-and-test (push) Canceled after 0s
feat: add stuff
2026-10-27 23:27:56 +01:00

85 lines
2.9 KiB
TypeScript

'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { io } from 'socket.io-client';
import { API_URL, getAccessToken, refreshAccessToken } from '@/lib/api/client';
import { useSession } from '@/lib/session';
// Server topics mapped to the dashboard queries that show that data
const TOPIC_QUERIES: Record<string, string[][]> = {
// A settlement changes the tabs, so it comes with this topic
tabs: [['tabs'], ['reminder-candidates'], ['tab-history']],
commands: [['commands']],
'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
device: [['device']],
reminders: [['reminder-candidates'], ['reminder-history']],
users: [['users']],
stock: [['stock'], ['stock-history']],
};
const RealtimeContext = createContext({ connected: false });
export function RealtimeProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { venueId } = useSession();
const [connected, setConnected] = useState(false);
useEffect(() => {
const socket = io(`${new URL(API_URL).origin}/realtime`, {
// Read on every (re)connect so a refreshed token is used. The venue is only used for a superadmin viewing
// another venue; everyone else follows their own
auth: (cb) => cb({ token: getAccessToken(), venueId }),
transports: ['websocket', 'polling'],
});
let hasConnected = false;
let refreshing = false;
let rejections = 0;
socket.on('connect', () => {
setConnected(true);
rejections = 0;
// Changes may have been missed while disconnected
if (hasConnected) queryClient.invalidateQueries();
hasConnected = true;
});
socket.on('disconnect', () => setConnected(false));
socket.on('invalidate', ({ topics }: { topics: string[] }) => {
for (const topic of topics) {
for (const queryKey of TOPIC_QUERIES[topic] ?? []) {
queryClient.invalidateQueries({ queryKey });
}
}
});
// The access token expired: refresh it and reconnect (rejected handshakes are not retried
// automatically). If refreshing fails, the next API call sends the user to the login page.
socket.on('connect_error', async (error) => {
if (error.message !== 'unauthorized' || refreshing || ++rejections > 3) return;
refreshing = true;
const token = await refreshAccessToken();
refreshing = false;
if (token) socket.connect();
});
return () => {
socket.disconnect();
};
}, [queryClient, venueId]);
return <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
}
export function useRealtime() {
return useContext(RealtimeContext);
}
/** Polling interval to use only while live updates are unavailable. */
export function usePollingFallback(intervalMs: number): number | false {
const { connected } = useRealtime();
return connected ? false : intervalMs;
}