import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api/client'; import { subscribeToRecovery } from './availability'; /* The two hooks every page is built from. * * They exist because the previous console's `Admin.onRefresh` did one thing this cannot: * it guaranteed that a poll never redrew markup the operator was working inside. React * makes that guarantee differently — a controlled input holds its own value and a re-render * cannot take it away — but the other half of the old rule still applies here and is easy * to lose: a response that arrives for a request the page has already moved on from must * be dropped, not rendered. Both hooks below do that with a generation counter. */ export interface Loadable { data: T | undefined; error: string; /** loading is the *first* load only. A refresh keeps the last data on screen, because a * page that blanks itself every thirty seconds is unreadable. */ loading: boolean; refreshing: boolean; reload: () => Promise; /** set replaces the data locally, for an optimistic update after a mutation. */ set: (next: T) => void; } export interface QueryOptions { /** pollMs re-fetches on an interval. It stops entirely while the tab is hidden: a * console left open on a second monitor is otherwise the gateway's most frequent * caller by a wide margin, and a background tab nobody is reading has no status worth * fetching. */ pollMs?: number; /** enabled false holds the request — a page whose URL parameter has not resolved yet. */ enabled?: boolean; } export function useQuery(path: string, options: QueryOptions = {}): Loadable { const { pollMs, enabled = true } = options; const [data, setData] = useState(); const [error, setError] = useState(''); const [loading, setLoading] = useState(enabled); const [refreshing, setRefreshing] = useState(false); // The generation counter is the whole correctness argument: a filter change fires a new // request while the old one is still in flight, and without this the slower of the two // wins whichever order they were asked in. const generation = useRef(0); const loaded = useRef(false); const run = useCallback(async () => { if (!enabled) return; const mine = ++generation.current; if (loaded.current) setRefreshing(true); try { const next = await api.get(path); if (mine !== generation.current) return; setData(next); setError(''); loaded.current = true; } catch (err) { if (mine !== generation.current) return; setError(err instanceof Error ? err.message : String(err)); } finally { if (mine === generation.current) { setLoading(false); setRefreshing(false); } } }, [path, enabled]); useEffect(() => { // A changed path is a different question, so the previous answer is no longer this // component's data — but it is kept on screen until the new one lands, which is what // makes changing a filter feel like a table updating rather than a page reloading. loaded.current = false; setLoading(true); void run(); return () => { generation.current += 1; }; }, [run]); useEffect(() => subscribeToRecovery(() => void run()), [run]); useEffect(() => { if (!pollMs || !enabled) return; let timer: number | undefined; const start = () => { window.clearInterval(timer); timer = document.hidden ? undefined : window.setInterval(() => void run(), pollMs); }; const onVisibility = () => { start(); if (!document.hidden) void run(); }; start(); document.addEventListener('visibilitychange', onVisibility); return () => { window.clearInterval(timer); document.removeEventListener('visibilitychange', onVisibility); }; }, [pollMs, enabled, run]); return { data, error, loading, refreshing, reload: run, set: setData }; } /** useAction runs a mutation and reports whether it is in flight, so a button can disable * itself. Every mutation in the console goes through it, which is what makes "pressed * twice" impossible without each page having to remember to guard against it. */ export function useAction(): { busy: string | null; run: (key: string, fn: () => Promise) => Promise; } { const [busy, setBusy] = useState(null); const alive = useRef(true); useEffect(() => () => { alive.current = false; }, []); const run = useCallback(async (key: string, fn: () => Promise) => { setBusy(key); try { await fn(); return true; } finally { if (alive.current) setBusy(null); } }, []); return { busy, run }; } /** useDebounced delays a value, for a search field that filters as it is typed. */ export function useDebounced(value: T, delayMs = 250): T { const [settled, setSettled] = useState(value); useEffect(() => { const timer = window.setTimeout(() => setSettled(value), delayMs); return () => window.clearTimeout(timer); }, [value, delayMs]); return settled; } /** useSorted is the shared table sort: stable, and it never re-sorts on a refresh, so a * row the operator is reading does not move under the pointer when the poll lands. */ export function useSorted( rows: readonly T[], key: keyof T | null, direction: 'asc' | 'desc', ): T[] { return useMemo(() => { const copy = [...rows]; if (!key) return copy; const sign = direction === 'asc' ? 1 : -1; return copy.sort((a, b) => { const left = a[key]; const right = b[key]; if (typeof left === 'number' && typeof right === 'number') return (left - right) * sign; return String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true }) * sign; }); }, [rows, key, direction]); } /* The product name every tab is suffixed with. It is deliberately not "Memby admin", which * is what the pre-mount title in index.html says: that one is a placeholder for the moment * before the console knows which page it is on, and these are real page names that already * say the console is what they belong to. */ const TITLE_SUFFIX = 'Memby'; /** useDocumentTitle names the browser tab after the page. * * It is called from PageHead rather than from each page, because PageHead is already the * one component that states a page's name and every route renders exactly one of them — * so the tab and the heading cannot come apart, and a page added tomorrow is titled * without anybody remembering to do it. That also means the dynamic pages are named after * the thing they are about ("Kitchen TV | Memby") rather than after their route. * * Nothing is restored on unmount: the next page's PageHead sets the title in the same * commit, and clearing it first would blink the product name into the tab between every * navigation. A blank name falls back to the suffix alone rather than printing a bare * separator. */ export function useDocumentTitle(title: string): void { useEffect(() => { const name = title.trim(); document.title = name ? `${name} | ${TITLE_SUFFIX}` : TITLE_SUFFIX; }, [title]); }