Status history Ninety days of state as one row of cells. The value is the shape over time, which is why every cell is the same size and no incident gets a bigger box than another. npx shadcn@latest add switch Tokens this needs: --card, --muted-foreground, --border, --status-nominal, --status-warn, --status-critical, --status-unknown 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. ──────────────────────────────────────────────────────────────────────── // StatusHistory.tsx ──────────────────────────────────────────────────────────────────────── import { cn } from "@/lib/utils"; import { STATUS_META, type Status } from "../semantic-status-color/status"; /** * Thirty days in a strip. One row per thing, time left to right, colour for * state. The thresholding happened upstream: a sample arrives as one of the * closed status set, and this draws the conclusion rather than a value. * * `merge` joins equal consecutive samples into one region, so a month of * nominal is one bar and the outage is a notch. Off, every sample is its * own cell, which is what you want when the checks run on a schedule and * the gaps between them are real. Either way every cell is the same width; * no incident gets a bigger box than another. */ export type Row = { key: string; samples: Status[] }; export type Region = { row: string; status: Status; from: Date; to: Date; samples: number }; type Props = { rows: Row[]; /** When the first sample was taken, and how far apart they are. */ start: Date; intervalMs: number; merge: boolean; onSelect?: (region: Region) => void; }; const FILL: Record = { nominal: "bg-status-nominal", warn: "bg-status-warn", critical: "bg-status-critical", unknown: "bg-status-unknown", }; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const day = (d: Date) => `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; /** Runs of equal status, or one run per sample when not merging. */ function regions(row: Row, start: Date, intervalMs: number, merge: boolean): Region[] { const out: Region[] = []; row.samples.forEach((status, i) => { const last = out[out.length - 1]; if (merge && last && last.status === status) { last.samples++; last.to = new Date(start.getTime() + (i + 1) * intervalMs); } else { out.push({ row: row.key, status, samples: 1, from: new Date(start.getTime() + i * intervalMs), to: new Date(start.getTime() + (i + 1) * intervalMs) }); } }); return out; } export function StatusHistory({ rows, start, intervalMs, merge, onSelect }: Props) { const n = rows[0]?.samples.length ?? 0; const end = new Date(start.getTime() + n * intervalMs); return (
{rows.map((row) => (
{row.key}
{regions(row, start, intervalMs, merge).map((r) => { const label = `${STATUS_META[r.status].label}, ${day(r.from)} to ${day(r.to)} (${r.samples} ${r.samples === 1 ? "sample" : "samples"})`; return (
))}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { Switch } from "@/components/ui/switch"; import { StatusHistory, type Row } from "./StatusHistory"; import type { Status } from "../semantic-status-color/status"; /** * Eight services, one daily check each, the thirty days ending 15 September. * auth had a six-day incident with two days at critical in the middle; * search and billing each degraded for a day. Merged first, because that is * how the notch reads from across the room; the switch shows every sample. */ const code: Record = { n: "nominal", w: "warn", c: "critical", u: "unknown" }; const row = (key: string, s: string): Row => ({ key, samples: [...s].map((ch) => code[ch]) }); const ROWS: Row[] = [ row("checkout", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"), row("search", "nnnnnnnnwnnnnnnnnnnnnnnnnnnnnn"), row("cart", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"), row("user", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"), row("auth", "nnnnnnnnnnnnnnnwwccwwnnnnnnnnn"), row("media", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"), row("billing", "nnnnnnnnnnnnnnnnnnnnnnnnwnnnnn"), row("jobs", "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"), ]; const START = new Date("2026-08-16T00:00:00Z"); const DAY = 24 * 60 * 60 * 1000; export default function Demo() { const [merge, setMerge] = useState(true); return (
{ location.hash = `#incidents/${r.row}/${r.from.toISOString().slice(0, 10)}`; }} />
); }