2026-08-14 09:40:03 +12:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
import { api } from '../api/client';
|
2026-08-24 22:56:46 +12:00
|
|
|
import { subscribeToRecovery } from './availability';
|
2026-08-14 09:40:03 +12:00
|
|
|
|
|
|
|
|
/* 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<T> {
|
|
|
|
|
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<void>;
|
|
|
|
|
/** 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<T>(path: string, options: QueryOptions = {}): Loadable<T> {
|
|
|
|
|
const { pollMs, enabled = true } = options;
|
|
|
|
|
const [data, setData] = useState<T>();
|
|
|
|
|
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<T>(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]);
|
|
|
|
|
|
2026-08-24 22:56:46 +12:00
|
|
|
useEffect(() => subscribeToRecovery(() => void run()), [run]);
|
|
|
|
|
|
2026-08-14 09:40:03 +12:00
|
|
|
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<unknown>) => Promise<boolean>;
|
|
|
|
|
} {
|
|
|
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
|
|
|
const alive = useRef(true);
|
|
|
|
|
useEffect(() => () => {
|
|
|
|
|
alive.current = false;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const run = useCallback(async (key: string, fn: () => Promise<unknown>) => {
|
|
|
|
|
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<T>(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<T>(
|
|
|
|
|
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]);
|
|
|
|
|
}
|
2026-08-19 14:25:44 +12:00
|
|
|
|
|
|
|
|
/* 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]);
|
|
|
|
|
}
|