import { useCallback, useEffect, useRef, useState } from "react"; import { type SignageApiFailure, SignageRequestError } from "./api.js"; /** * One loading state, used everywhere, so every panel in the dashboard fails the same way. * * The states are the ones an operator can actually act on: still loading, loaded, or failed with a * reason Signage itself gave. There is no fourth state for "loaded but empty" — a panel decides what * empty means for its own data, because "no timelines yet" and "no displays yet" are different * sentences. */ export type Loadable = | { readonly status: "loading" } | { readonly status: "ready"; readonly value: Value } | { readonly status: "failed"; readonly failure: SignageApiFailure }; export interface Resource { readonly state: Loadable; /** Re-reads without flashing the panel back to a spinner: the previous value stays on screen * until the new one arrives, which is what makes a five-second refresh bearable to look at. */ reload(): void; } export function useResource( load: () => Promise, dependencies: readonly unknown[], ): Resource { const [state, setState] = useState>({ status: "loading" }); const generation = useRef(0); const loadRef = useRef(load); loadRef.current = load; const run = useCallback(() => { generation.current += 1; const token = generation.current; void loadRef .current() .then((value) => { // A response from a request the panel has already moved on from must never overwrite a // newer one. if (token === generation.current) { setState({ status: "ready", value }); } }) .catch((error: unknown) => { if (token !== generation.current) { return; } setState({ failure: error instanceof SignageRequestError ? error.failure : { code: "SIGNAGE_REQUEST_FAILED", message: "Signage could not be reached.", status: 0, }, status: "failed", }); }); }, []); // The caller's dependency list is spread deliberately: a resource reloads when *its* inputs // change, which is the whole reason this takes one. useEffect(run, [run, ...dependencies]); return { reload: run, state }; } /** A mutation with a pending flag and a sanitized failure, so a button can disable itself and a form * can say why a save was refused. */ export interface Action { readonly pending: boolean; readonly failure: SignageApiFailure | undefined; run(work: () => Promise): void; clearFailure(): void; } export function useAction(onSettled?: () => void): Action { const [pending, setPending] = useState(false); const [failure, setFailure] = useState(undefined); const settled = useRef(onSettled); settled.current = onSettled; const run = useCallback((work: () => Promise) => { setPending(true); setFailure(undefined); void work() .then(() => { setPending(false); settled.current?.(); }) .catch((error: unknown) => { setPending(false); setFailure( error instanceof SignageRequestError ? error.failure : { code: "SIGNAGE_REQUEST_FAILED", message: "Signage could not be reached.", status: 0, }, ); }); }, []); return { clearFailure: () => setFailure(undefined), failure, pending, run }; } /** Re-renders on an interval, so a countdown or a "last seen" reads correctly without every panel * inventing its own timer. Paused when the interval is zero. */ export function useTick(intervalMs: number): number { const [tick, setTick] = useState(0); useEffect(() => { if (intervalMs <= 0) { return; } const handle = setInterval(() => setTick((value) => value + 1), intervalMs); return () => clearInterval(handle); }, [intervalMs]); return tick; }