feat: initial Signage 0.1.0
This commit is contained in:
+122
@@ -0,0 +1,122 @@
|
||||
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<Value> =
|
||||
| { readonly status: "loading" }
|
||||
| { readonly status: "ready"; readonly value: Value }
|
||||
| { readonly status: "failed"; readonly failure: SignageApiFailure };
|
||||
|
||||
export interface Resource<Value> {
|
||||
readonly state: Loadable<Value>;
|
||||
/** 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<Value>(
|
||||
load: () => Promise<Value>,
|
||||
dependencies: readonly unknown[],
|
||||
): Resource<Value> {
|
||||
const [state, setState] = useState<Loadable<Value>>({ 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<unknown>): void;
|
||||
clearFailure(): void;
|
||||
}
|
||||
|
||||
export function useAction(onSettled?: () => void): Action {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [failure, setFailure] = useState<SignageApiFailure | undefined>(undefined);
|
||||
const settled = useRef(onSettled);
|
||||
settled.current = onSettled;
|
||||
|
||||
const run = useCallback((work: () => Promise<unknown>) => {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user