{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "oncall-1",
  "title": "On-call 1: weekly rota",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An on-call rota for the week: who is on call now per layer, a grid of days by primary and secondary, days nobody covers called out in words, and a selected shift you can take or ask the person on it to swap.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/oncall-1/oncall-1.tsx",
      "content": "// Ballmac UI: On-call 1. https://ui.ballmac.com/blocks/oncall-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRightLeft, CircleAlert, PhoneCall, UserRoundCheck } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Oncall1Day = {\n  /** Short label for the column, such as \"Mon 20\". */\n  label: string\n  /** Full text for assistive technology, such as \"Monday 20 October\". */\n  full: string\n}\n\ntype Oncall1Layer = {\n  id: string\n  /** Primary, Secondary, Escalation. */\n  name: string\n  /** Who is on call each day, in the same order as the days. null means nobody. */\n  people: (string | null)[]\n}\n\ntype Oncall1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    days?: Oncall1Day[]\n    layers?: Oncall1Layer[]\n    title?: string\n    /** Which day is today, as an index into days. */\n    todayIndex?: number\n    /** The hour the rota hands over, shown as text. */\n    handover?: string\n    /** The person using the page, so \"Take this shift\" and \"Request a swap\" make sense. */\n    me?: string\n    /** Called when someone takes an uncovered or other person's shift. */\n    onTake?: (shift: { layer: string; day: string }) => void | Promise<void>\n    /** Called when someone asks to swap a shift with the person on it. */\n    onRequestSwap?: (shift: { layer: string; day: string; person: string }) => void | Promise<void>\n  }\n\nconst days: Oncall1Day[] = [\n  { label: \"Mon 20\", full: \"Monday 20 October\" },\n  { label: \"Tue 21\", full: \"Tuesday 21 October\" },\n  { label: \"Wed 22\", full: \"Wednesday 22 October\" },\n  { label: \"Thu 23\", full: \"Thursday 23 October\" },\n  { label: \"Fri 24\", full: \"Friday 24 October\" },\n  { label: \"Sat 25\", full: \"Saturday 25 October\" },\n  { label: \"Sun 26\", full: \"Sunday 26 October\" },\n]\nconst sample: Oncall1Layer[] = [\n  { id: \"primary\", name: \"Primary\", people: [\"Priya Shah\", \"Priya Shah\", \"Theo Nakamura\", \"Theo Nakamura\", \"Theo Nakamura\", \"Kofi Mensah\", \"Kofi Mensah\"] },\n  { id: \"secondary\", name: \"Secondary\", people: [\"Noor Haddad\", \"Noor Haddad\", \"Noor Haddad\", \"Ana Souza\", \"Ana Souza\", null, \"Oliver Reed\"] },\n]\n\nconst initials = (n: string) => n.split(/\\s+/).map((w) => w[0]).slice(0, 2).join(\"\").toUpperCase()\n/** The same person always gets the same one of five colours, so a run of days reads as one shift. Names are always written too. */\nconst TINTS = [\"bg-chart-1/15 border-chart-1/40\", \"bg-chart-2/15 border-chart-2/40\", \"bg-chart-3/15 border-chart-3/40\", \"bg-chart-4/15 border-chart-4/40\", \"bg-chart-5/15 border-chart-5/40\"]\nconst tint = (n: string) => TINTS[[...n].reduce((a, c) => a + c.charCodeAt(0), 0) % TINTS.length]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-3\">\n      <Skeleton className=\"h-20 rounded-xl\" />\n      <Skeleton className=\"h-52 rounded-xl\" />\n    </div>\n  )\n}\n\n/**\n * An on-call rota for the week: a row per layer (primary, secondary), a column per day, who is on call now at the top, and any day nobody covers called out\n * in words. Select a shift to take it, or to ask the person on it for a swap.\n */\nfunction Oncall1({ days: daysProp = days, layers = sample, title = \"On-call this week\", todayIndex = 4, handover = \"09:00\", me = \"Priya Shah\", onTake, onRequestSwap, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Oncall1Props) {\n  const id = React.useId()\n  const [picked, setPicked] = React.useState<{ layer: number; day: number } | null>(null)\n  const [note, setNote] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const [busy, setBusy] = React.useState(false)\n  const view = state === \"ready\" && layers.length === 0 ? \"empty\" : state\n\n  const gaps = layers.flatMap((l) => l.people.map((p, d) => (p ? null : { layer: l.name, day: daysProp[d] }))).filter(Boolean) as { layer: string; day: Oncall1Day }[]\n  const now = layers.map((l) => ({ name: l.name, person: l.people[todayIndex] ?? null }))\n  const sel = picked ? { layer: layers[picked.layer], day: daysProp[picked.day], person: layers[picked.layer].people[picked.day] } : null\n\n  async function run(kind: \"take\" | \"swap\") {\n    if (!sel) return\n    setBusy(true)\n    setNote(null)\n    try {\n      if (kind === \"take\") await onTake?.({ layer: sel.layer.name, day: sel.day.full })\n      else await onRequestSwap?.({ layer: sel.layer.name, day: sel.day.full, person: sel.person ?? \"\" })\n      setNote({ ok: true, text: kind === \"take\" ? `You are now ${sel.layer.name.toLowerCase()} on ${sel.day.full}.` : `Swap request sent to ${sel.person}.` })\n    } catch (e) {\n      setNote({ ok: false, text: e instanceof Error ? e.message : \"That did not go through. Nothing was changed.\" })\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  return (\n    <section data-slot=\"oncall-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">Who gets the page, day by day. The rota hands over at {handover}.</p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the rota\" loading={<Skel />} empty={{ title: \"No rota yet\", description: \"Add a primary layer and the people who share it, and this week will fill in.\", ...emptyState }} error={{ title: \"The rota could not be loaded\", description: \"Nobody was changed. Check your connection and try again.\", ...errorState }}>\n        <div className=\"bg-card rounded-xl border p-4 sm:p-5\">\n          <p className=\"text-muted-foreground flex items-center gap-2 text-xs font-medium\">\n            <PhoneCall aria-hidden=\"true\" className=\"size-3.5\" /> On call now\n          </p>\n          <ul className=\"mt-3 grid gap-3 sm:grid-cols-2\">\n            {now.map((n) => (\n              <li key={n.name} className=\"flex items-center gap-3\">\n                <span aria-hidden=\"true\" className={cn(\"flex size-10 shrink-0 items-center justify-center rounded-full border text-sm font-semibold\", n.person ? tint(n.person) : \"border-destructive/50 text-destructive\")}>{n.person ? initials(n.person) : \"?\"}</span>\n                <span className=\"min-w-0\">\n                  <span className=\"text-muted-foreground block text-xs\">{n.name}</span>\n                  <span className={cn(\"block truncate font-medium\", !n.person && \"text-destructive\")}>{n.person ?? \"Nobody\"}</span>\n                </span>\n              </li>\n            ))}\n          </ul>\n        </div>\n\n        {gaps.length > 0 && (\n          <p role=\"status\" className=\"border-destructive/40 bg-destructive/5 mt-4 flex items-start gap-2 rounded-xl border px-4 py-3 text-sm\">\n            <CircleAlert aria-hidden=\"true\" className=\"text-destructive mt-0.5 size-4 shrink-0\" />\n            <span>\n              <strong>{gaps.length === 1 ? \"1 shift has\" : `${gaps.length} shifts have`} no one on call:</strong> {gaps.map((g) => `${g.layer} on ${g.day.full}`).join(\", \")}.\n            </span>\n          </p>\n        )}\n\n        <div className=\"mt-4 overflow-x-auto rounded-xl border\">\n          <table className=\"w-full min-w-[40rem] border-collapse text-sm\">\n            <caption className=\"sr-only\">{title}</caption>\n            <thead className=\"bg-muted/40 text-xs\">\n              <tr>\n                <th scope=\"col\" className=\"w-28 px-3 py-2.5 text-start font-medium\"><span className=\"sr-only\">Layer</span></th>\n                {daysProp.map((d, i) => (\n                  <th key={d.full} scope=\"col\" aria-label={d.full} className={cn(\"px-2 py-2.5 text-center font-medium\", i === todayIndex ? \"text-foreground\" : \"text-muted-foreground\")}>\n                    {d.label}\n                    {i === todayIndex && <span className=\"bg-foreground text-background ms-1.5 rounded px-1 py-px text-[10px]\">Today</span>}\n                  </th>\n                ))}\n              </tr>\n            </thead>\n            <tbody className=\"divide-y\">\n              {layers.map((l, li) => (\n                <tr key={l.id}>\n                  <th scope=\"row\" className=\"px-3 py-3 text-start font-medium\">{l.name}</th>\n                  {l.people.map((p, di) => {\n                    const on = picked?.layer === li && picked.day === di\n                    return (\n                      <td key={di} className={cn(\"p-1.5\", di === todayIndex && \"bg-muted/30\")}>\n                        <button\n                          type=\"button\"\n                          aria-pressed={on}\n                          aria-label={p ? `${l.name}, ${daysProp[di].full}: ${p}` : `${l.name}, ${daysProp[di].full}: nobody on call`}\n                          onClick={() => { setPicked(on ? null : { layer: li, day: di }); setNote(null) }}\n                          className={cn(\"focus-visible:ring-ring/50 flex h-11 w-full items-center justify-center rounded-lg border px-1 text-xs font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", p ? cn(tint(p), \"hover:brightness-95\") : \"border-destructive/50 text-destructive border-dashed\", on && \"ring-foreground/70 ring-2\")}\n                        >\n                          <span className=\"truncate\">{p ? p.split(\" \")[0] : \"Nobody\"}</span>\n                        </button>\n                      </td>\n                    )\n                  })}\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n\n        <div aria-live=\"polite\" className=\"bg-card mt-4 min-h-[5rem] rounded-xl border p-4\">\n          {sel ? (\n            <div className=\"flex flex-wrap items-center justify-between gap-3\">\n              <div>\n                <p className=\"font-medium\">{sel.layer.name}, {sel.day.full}</p>\n                <p className=\"text-muted-foreground text-sm\">{sel.person ? `${sel.person} is on call, ${handover} to ${handover}.` : \"Nobody is on call for this shift.\"}</p>\n                {note && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"mt-1 text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n              </div>\n              <div className=\"flex flex-wrap gap-2\">\n                {sel.person !== me && (\n                  <Button size=\"sm\" disabled={busy} onClick={() => void run(\"take\")}>\n                    <UserRoundCheck aria-hidden=\"true\" /> Take this shift\n                  </Button>\n                )}\n                {sel.person && sel.person !== me && (\n                  <Button size=\"sm\" variant=\"outline\" disabled={busy} onClick={() => void run(\"swap\")}>\n                    <ArrowRightLeft aria-hidden=\"true\" /> Ask {sel.person.split(\" \")[0]} to swap\n                  </Button>\n                )}\n              </div>\n            </div>\n          ) : (\n            <p className=\"text-muted-foreground text-sm\">Select a shift to see who is on it, take it, or ask for a swap.</p>\n          )}\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Oncall1, type Oncall1Props, type Oncall1Layer, type Oncall1Day }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/oncall-1/oncall-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "on-call",
      "rota",
      "schedule",
      "incident",
      "pager",
      "sre",
      "devops",
      "shifts"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A weekly on-call rota. <Oncall1 days={[{ label: 'Mon 20', full: 'Monday 20 October' }]} layers={[{ id, name: 'Primary', people: ['Priya Shah', null, ...] }]} todayIndex={4} handover='09:00' me='Priya Shah' onTake={(shift) => Promise} onRequestSwap={(shift) => Promise} />. null in people means nobody; uncovered shifts are listed in words. Select a shift to take it or ask for a swap.",
      "whenToUse": [
        "An operations page showing who gets paged this week",
        "A team calendar for support or engineering cover"
      ],
      "whenNotToUse": [
        "A shift roster for hourly staff; use roster-1",
        "A calendar of events; use calendar-1"
      ],
      "composesWith": [
        "incident-1",
        "status-1",
        "access-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Table",
          "action": "Layers are row headers and days are column headers named in full ('Friday 24 October'); each shift button reads 'Primary, Friday 24 October: Theo Nakamura'"
        },
        {
          "keys": "Select a shift",
          "action": "A toggle button (aria-pressed); the details and the result of taking or swapping are a polite live region"
        },
        {
          "keys": "Gaps",
          "action": "Written as a sentence listing each uncovered shift, and 'Nobody' in the cell; red only reinforces it"
        },
        {
          "keys": "Colour",
          "action": "A person's tint helps a run of days read as one shift; names are always written"
        }
      ],
      "customization": [
        "days, layers and todayIndex",
        "handover text",
        "me: the person using the page",
        "onTake and onRequestSwap; reject and the message says it did not go through"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "oncall-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton for the banner and the grid under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No rota yet",
        "example": "oncall-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no rota exists yet; emptyState sets the copy and can add a button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "oncall-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nobody was changed, with Try again (onRetry)."
      }
    ],
    "examples": [
      "oncall-1-demo",
      "oncall-1-covered",
      "oncall-1-loading",
      "oncall-1-empty",
      "oncall-1-error"
    ],
    "url": "https://ui.ballmac.com/components/oncall-1"
  },
  "categories": [
    "blocks",
    "oncall"
  ],
  "type": "registry:block"
}