Bed map A ward laid out as it is walked. Every cell is a person, which is why the colours have to be sober and why an unknown state must never look like a comfortable one. npx shadcn@latest add table Tokens this needs: --card, --card-foreground, --muted-foreground, --border, --status-critical, --status-warn, --state-stale 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. ──────────────────────────────────────────────────────────────────────── // TrackBoard.tsx ──────────────────────────────────────────────────────────────────────── import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import type { Status } from "../semantic-status-color/status"; /** Five triage categories, and no sixth. */ export type Triage = 1 | 2 | 3 | 4 | 5; /** * Every bed is one of these. An empty bed is a row, never a gap: it is * availability, and a board that drops it has hidden the most useful cell. */ export type Bed = | { id: string; state: "occupied"; patient: string; triage: Triage; waitingFor: string; since: Date } | { id: string; state: "empty"; ready: true } | { id: string; state: "empty"; ready: false; since: Date }; type Props = { beds: Bed[]; /** A wait this long is a critical row whatever the triage says. */ longWaitMs: number; /** The clock to measure waits against. Pass one to render on a server. */ now?: Date; }; /** Triage 1 is critical, 2 and 3 want a person, 4 and 5 are waiting normally. */ const TRIAGE_STATUS: Record = { 1: "critical", 2: "warn", 3: "warn", 4: "nominal", 5: "nominal" }; const TEXT: Record = { critical: "text-status-critical", warn: "text-status-warn", nominal: "text-card-foreground", unknown: "text-status-unknown", }; const EDGE: Record = { critical: "border-l-status-critical", warn: "border-l-status-warn", nominal: "border-l-border", unknown: "border-l-status-unknown", }; /** 41m, 2h 14m, 5h 02m. Minutes pad once there are hours, so the column lines up. */ export const elapsed = (since: Date, now: Date) => { const m = Math.max(0, Math.floor((now.getTime() - since.getTime()) / 60_000)); const h = Math.floor(m / 60); return h ? `${h}h ${String(m % 60).padStart(2, "0")}m` : `${m}m`; }; export function TrackBoard({ beds, longWaitMs, now = new Date() }: Props) { return (
Bed Patient Triage Waiting for Elapsed {beds.map((bed) => { if (bed.state === "empty") { // Ready is quiet. Needs-clean is a wait of its own, and it is // timed, because a bed nobody has cleaned is a bed nobody can use. return ( {bed.id} {bed.ready ? "empty · ready" : "empty · needs clean"} {bed.ready ? "—" : elapsed(bed.since, now)} ); } const wait = now.getTime() - bed.since.getTime(); const waitStatus: Status = wait >= longWaitMs ? "critical" : "nominal"; const triageStatus = TRIAGE_STATUS[bed.triage]; const row = waitStatus === "critical" ? "critical" : triageStatus; return ( {bed.id} {/* Initials only. The board hangs where visitors stand. */} {bed.patient} {bed.triage} {bed.waitingFor} {elapsed(bed.since, now)} ); })}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { TrackBoard, type Bed } from "./TrackBoard"; /** * Six beds on one bay. Two are empty, one of those still needs a clean. * The clock is fixed so the elapsed column renders the same on the server * and in the browser. */ const NOW = new Date("2026-09-15T09:00:00Z"); const ago = (minutes: number) => new Date(NOW.getTime() - minutes * 60_000); const BEDS: Bed[] = [ { id: "A1", state: "occupied", patient: "R. M.", triage: 1, waitingFor: "CT result", since: ago(134) }, { id: "A2", state: "occupied", patient: "J. O.", triage: 2, waitingFor: "bloods", since: ago(41) }, { id: "A3", state: "empty", ready: true }, { id: "A4", state: "occupied", patient: "S. K.", triage: 3, waitingFor: "bed on ward 7", since: ago(302) }, { id: "A5", state: "occupied", patient: "D. A.", triage: 2, waitingFor: "senior review", since: ago(28) }, { id: "A6", state: "empty", ready: false, since: ago(12) }, ]; export default function Demo() { return ; }