Skip to content
KONIGI

Dashboards / Visual representation / Bed map and track board

2 of 22

Bed map and track board

Clinical staff need every patient's state and location in one board, ordered by urgency.

Updated September 10, 2026

Problem

Thirty beds, thirty people, and each of them is waiting on something different: a scan, a lab result, a consult, a discharge signature. Anyone walking onto the floor needs to know the state of all thirty in the time it takes to cross the room.

Solution

One row or cell per bed, always present whether occupied or not, carrying who is in it and what it is waiting for. Status is colour and icon; the layout mirrors the physical space or the triage order.

The clinical informatics literature on electronic ED whiteboards is unusually direct about the purpose. These boards replaced physical whiteboards as the department’s central access point for operational and patient information, displaying it in real time and integrating with the patient record and order-entry systems so a glance can become detail. Evaluations describe the gains as improved patient flow, transparency and accountability, and better communication inside and outside the department, which is a coordination claim rather than a visualisation one.

That is the thing that distinguishes this pattern from the infrastructure maps it resembles. A host map exists so one person can find an anomaly. A track board exists so a dozen people with different jobs can share one model of the room without speaking. The nurse, the attending, the porter and the bed manager read different columns off the same board.

Two design consequences follow.

Empty is information. An unoccupied bed is availability, often the most valuable cell on the board, so it needs a row of its own rather than a gap. This is the opposite of most dashboards, where nothing means nothing.

Waiting-for is the real payload. Occupancy is easy. The column that changes behaviour is what each patient is blocked on and how long they have been blocked, because that is what a person walking past can act on.

Use when

A fixed set of physical slots has occupants moving through them, several roles coordinate around the same set, and the display is glanced at constantly rather than studied.

Don’t use when

The resource is elastic or virtual. If capacity can be added on demand there is no fixed grid to mirror, and a queue or a ranked list serves better.

Trade-offs

These boards live in shared space, which makes every design decision a privacy decision: what is legible from the corridor is legible to visitors, and the pressure to abbreviate collides with the need to be unambiguous. They are also the display most likely to be running on a decade-old screen at an awkward angle. Status vocabularies proliferate as each unit adds a state nobody else uses, and a board with fourteen statuses has stopped being scannable. And because the board is glanced at rather than read, anything requiring interaction to reveal is effectively invisible.

Checklist

  • Is every slot always shown, including empty ones?
  • Does each row say what the occupant is waiting on, and for how long?
  • How many distinct statuses exist, and can staff name them all?
  • Is status readable without colour alone, for both accessibility and a bad screen?
  • Does the layout mirror the physical space or the priority order, and is that consistent?
  • What is visible to people who should not see it, from where they actually stand?
  • Is anything important behind hover or a click on a display nobody touches?
  • How does the board behave when the source system is unreachable?
  • Is elapsed time shown, and does it make long waits obvious?
  • Who owns the status vocabulary, and what stops it growing?

Compare

Epic and Cerner ship the dominant hospital versions as configurable track boards inside the record system, so the board inherits the EHR’s data model and its constraints, and customisation happens through configuration rather than design. TeleTracking and similar operations platforms approach it from capacity management rather than clinical care, so the same grid is oriented toward throughput and bed turnover. Airport and rail operations boards solve a structurally identical problem for gates and platforms, and are worth studying because their audience is partly the public, which forces a clarity most clinical boards do not attempt. Netdata’s node view is the infrastructure cousin, and the contrast is instructive: it optimises for one operator finding an anomaly, where a track board optimises for many people sharing a model.

Host map is the infrastructure equivalent and the entry that covers spatial scanning. Data table is what a track board becomes when the layout stops mirroring physical space. Wallboard mode covers the viewing conditions, which dominate this pattern. Semantic status color governs the vocabulary. Status history is the record of how long things actually take, which the live board cannot show.

Bed map and track board anatomy Every bed in the department gets a row whether or not anyone is in it. Two rows are empty, which is availability and often the most useful cell on the board. A "waiting for" column carries what each patient is blocked on and how long, which is the column that changes what a passer-by does. Every bed present, occupied or not Bed Patient Triage Waiting for Elapsed A1 1 CT result 2h 14m A2 2 bloods 41m A3 empty · ready A4 3 bed on ward 7 5h 02m A5 2 senior review 28m A6 empty · needs clean 12m 1 2 3 1 EMPTY IS INFORMATION An unoccupied bed isn't an absent row, it's availability, and it's often the most valuable cell on the board. That's the opposite of most dashboards, where nothing means nothing. 2 WAITING FOR Occupancy is easy. The column that changes behaviour is what each patient is blocked on and how long they've been blocked, because that is what a passer-by can act on. 3 A SHARED MODEL, NOT A FINDING A host map exists so one person can find an anomaly. A track board exists so a dozen people with different jobs read different columns off the same board.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

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.

shadcn
npx shadcn@latest add table
Tokens
--card--card-foreground--muted-foreground--border--status-critical--status-warn--state-stale
BedPatientTriageWaiting forElapsed
A1R. M.1CT result2h 14m
A2J. O.2bloods41m
A3empty · ready
A4S. K.3bed on ward 75h 02m
A5D. A.2senior review28m
A6empty · needs clean12m

TrackBoard.tsxOne row per bed, empty ones included. Elapsed is computed from a timestamp, and a wait past the threshold turns the row critical whatever the triage says.

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<Triage, Status> = { 1: "critical", 2: "warn", 3: "warn", 4: "nominal", 5: "nominal" };

const TEXT: Record<Status, string> = {
  critical: "text-status-critical", warn: "text-status-warn", nominal: "text-card-foreground", unknown: "text-status-unknown",
};
const EDGE: Record<Status, string> = {
  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 (
    <div className="overflow-hidden rounded-lg border bg-card">
      <Table className="text-xs">
        <TableHeader>
          <TableRow className="text-[11px] uppercase tracking-wide">
            <TableHead className="h-9">Bed</TableHead>
            <TableHead className="h-9">Patient</TableHead>
            <TableHead className="h-9">Triage</TableHead>
            <TableHead className="h-9">Waiting for</TableHead>
            <TableHead className="h-9 text-right">Elapsed</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {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 (
                <TableRow key={bed.id} className={cn("border-l-4", bed.ready ? "border-l-border" : "border-l-state-stale")}>
                  <TableCell className="tabular-nums text-card-foreground">{bed.id}</TableCell>
                  <TableCell className={bed.ready ? "text-muted-foreground" : "text-state-stale"}>
                    {bed.ready ? "empty · ready" : "empty · needs clean"}
                  </TableCell>
                  <TableCell />
                  <TableCell />
                  <TableCell className="text-right tabular-nums text-muted-foreground">{bed.ready ? "—" : elapsed(bed.since, now)}</TableCell>
                </TableRow>
              );
            }
            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 (
              <TableRow key={bed.id} className={cn("border-l-4", EDGE[row])}>
                <TableCell className="tabular-nums text-card-foreground">{bed.id}</TableCell>
                {/* Initials only. The board hangs where visitors stand. */}
                <TableCell className="text-card-foreground">{bed.patient}</TableCell>
                <TableCell className={cn("tabular-nums", TEXT[triageStatus])}>{bed.triage}</TableCell>
                <TableCell className="text-card-foreground">{bed.waitingFor}</TableCell>
                <TableCell className={cn("text-right tabular-nums", TEXT[waitStatus])}>{elapsed(bed.since, now)}</TableCell>
              </TableRow>
            );
          })}
        </TableBody>
      </Table>
    </div>
  );
}

demo.tsxHow it is called: six beds, a fixed clock, a two-hour long-wait threshold.

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 <TrackBoard beds={BEDS} longWaitMs={2 * 60 * 60_000} now={NOW} />;
}
What it renders. Identical markup in all three panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.