Failed, stale, empty Three states that look alike and mean different things. A panel with no data because nothing matched, a panel showing a value nobody can vouch for, and a panel that broke. Collapsing them into one spinner is how a dashboard lies. npx shadcn@latest add card Tokens this needs: --card, --muted-foreground, --border, --status-critical, --state-stale, --state-live The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // PanelState.tsx ──────────────────────────────────────────────────────────────────────── /** * The states a panel can be in, as a closed union. * * `stale` is the one that gets lost. A panel showing a real number from twenty * minutes ago looks exactly like a panel showing a real number from four * seconds ago, and only one of them is safe to act on. Keeping the last good * value is right; keeping it unlabelled is not. */ export type PanelState = | { kind: "ok"; lastGoodAt: Date } | { kind: "loading" } | { kind: "empty"; because: string } | { kind: "stale"; lastGoodAt: Date; reason: string } | { kind: "partial"; have: number; of: number } | { kind: "error"; message: string; retry: () => void }; /** "4s ago", "4m ago". Relative, because the viewer wants age, not a clock. */ export function ago(then: Date, now: Date) { const s = Math.max(0, Math.round((now.getTime() - then.getTime()) / 1000)); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.round(s / 60)}m ago`; return `${Math.round(s / 3600)}h ago`; } /** * Every non-ok state owes the viewer a next action. An error with no retry, or * an empty with no explanation, is a dead end dressed as information. */ export function stateMessage(s: PanelState, now = new Date()): { text: string; tone: string } | null { switch (s.kind) { case "ok": return { text: ago(s.lastGoodAt, now), tone: "hsl(var(--muted-foreground))" }; case "loading": return null; case "empty": return { text: `No data—${s.because}`, tone: "hsl(var(--muted-foreground))" }; case "stale": return { text: `last sample ${ago(s.lastGoodAt, now)}—${s.reason}`, tone: "hsl(var(--state-stale))" }; case "partial": return { text: `${s.have} of ${s.of} series`, tone: "hsl(var(--state-stale))" }; case "error": return { text: s.message, tone: "hsl(var(--status-critical))" }; } } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { Sparkline } from "../sparkline/Sparkline"; import { stateMessage, type PanelState } from "./PanelState"; /** * Four panels in four states. The middle two are the ones that look fine: a * stale panel still draws and a partial one still draws, so each says in * words what its picture cannot. The clock is fixed for the server. */ const NOW = new Date("2026-09-15T09:00:00Z"); const SERIES = [40, 34, 38, 22, 28, 18]; const PANELS: { title: string; state: PanelState; values: number[] }[] = [ { title: "Healthy", state: { kind: "ok", lastGoodAt: new Date(NOW.getTime() - 4_000) }, values: SERIES }, { title: "Stale", state: { kind: "stale", lastGoodAt: new Date(NOW.getTime() - 240_000), reason: "collector is behind" }, values: SERIES.slice(0, 5) }, { title: "Partial", state: { kind: "partial", have: 4, of: 6 }, values: SERIES }, { title: "Broken", state: { kind: "error", message: "query failed", retry: () => {} }, values: [] }, ]; function Panel({ title, state, values }: (typeof PANELS)[number]) { const msg = stateMessage(state, NOW); const flagged = state.kind === "stale" || state.kind === "partial"; return (
{title}
{state.kind === "ok" ? `6 of 6 · ${msg.text}` : msg.text}
)} {state.kind === "error" && ( )}