{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "roster-1",
  "title": "Roster 1: weekly shift planner",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A weekly shift roster: a row per person and a column per day, shifts you set, change or mark as time off, hours per person with overtime and rest flags, a coverage row against what each day needs, weeks to page through and a publish step.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/dialog.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/return-focus.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/roster-1/roster-1.tsx",
      "content": "// Ballmac UI: Roster 1. https://ui.ballmac.com/blocks/roster-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CalendarOff, ChevronLeft, ChevronRight, CircleAlert, CircleCheck, Loader2, Plus, Send, TriangleAlert, Users } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from \"@/components/ballmac/dialog\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { useReturnFocus } from \"@/lib/ballmac/return-focus\"\nimport { cn } from \"@/lib/utils\"\n\ntype Roster1Person = { id: string; name: string; role: string; /** Most hours a week before it counts as overtime. */ maxHours?: number }\ntype Roster1Shift = { start: string; end: string; position: string } | { off: true }\ntype Roster1Position = { id: string; label: string; tone: string }\n\ntype Roster1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    staff?: Roster1Person[]\n    positions?: Roster1Position[]\n    /** Shifts by \"personId|2026-10-12\". */\n    shifts?: Record<string, Roster1Shift>\n    /** ISO date of the Monday of the first week. */\n    weekStart?: string\n    /** How many people each day needs, Monday first. */\n    required?: number[]\n    /** The least hours between the end of one shift and the start of the next. */\n    minRest?: number\n    locale?: string\n    /** ISO dates of weeks already published. */\n    publishedWeeks?: string[]\n    onChange?: (key: string, shift: Roster1Shift | null) => void\n    /** Called to publish a week to the team. Return a promise; reject to keep it as a draft with the reason. */\n    onPublish?: (weekStart: string) => void | Promise<void>\n  }\n\nconst DAY = 86_400_000\nconst toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`)\nconst iso = (t: number) => new Date(t).toISOString().slice(0, 10)\nconst mins = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3, 5))\nconst hrs = (s: Roster1Shift) => (\"off\" in s ? 0 : Math.max(0, (mins(s.end) - mins(s.start)) / 60))\n\nconst staff0: Roster1Person[] = [\n  { id: \"p1\", name: \"Amara Okoye\", role: \"Barista\", maxHours: 40 },\n  { id: \"p2\", name: \"Diego Marín\", role: \"Barista\", maxHours: 40 },\n  { id: \"p3\", name: \"Mia Laurent\", role: \"Kitchen\", maxHours: 40 },\n  { id: \"p4\", name: \"Kofi Mensah\", role: \"Kitchen\", maxHours: 32 },\n  { id: \"p5\", name: \"Yuki Tanaka\", role: \"Front of house\", maxHours: 40 },\n  { id: \"p6\", name: \"Oliver Reed\", role: \"Front of house\", maxHours: 24 },\n]\nconst positions0: Roster1Position[] = [\n  { id: \"bar\", label: \"Bar\", tone: \"bg-chart-1/15 border-chart-1/40\" },\n  { id: \"kit\", label: \"Kitchen\", tone: \"bg-chart-3/15 border-chart-3/40\" },\n  { id: \"foh\", label: \"Floor\", tone: \"bg-chart-2/15 border-chart-2/40\" },\n]\nconst S = (start: string, end: string, position: string): Roster1Shift => ({ start, end, position })\nconst shifts0: Record<string, Roster1Shift> = {\n  \"p1|2026-10-12\": S(\"07:00\", \"15:00\", \"bar\"), \"p1|2026-10-13\": S(\"07:00\", \"15:00\", \"bar\"), \"p1|2026-10-14\": S(\"07:00\", \"15:00\", \"bar\"), \"p1|2026-10-15\": S(\"07:00\", \"15:00\", \"bar\"), \"p1|2026-10-16\": S(\"07:00\", \"15:00\", \"bar\"),\n  \"p2|2026-10-12\": S(\"14:00\", \"22:00\", \"bar\"), \"p2|2026-10-13\": S(\"14:00\", \"22:00\", \"bar\"), \"p2|2026-10-14\": { off: true }, \"p2|2026-10-15\": S(\"14:00\", \"22:00\", \"bar\"), \"p2|2026-10-16\": S(\"14:00\", \"23:00\", \"bar\"), \"p2|2026-10-17\": S(\"12:00\", \"22:00\", \"bar\"),\n  \"p3|2026-10-12\": S(\"09:00\", \"17:00\", \"kit\"), \"p3|2026-10-13\": S(\"09:00\", \"17:00\", \"kit\"), \"p3|2026-10-14\": S(\"09:00\", \"17:00\", \"kit\"), \"p3|2026-10-15\": S(\"09:00\", \"17:00\", \"kit\"), \"p3|2026-10-16\": S(\"09:00\", \"17:00\", \"kit\"), \"p3|2026-10-17\": S(\"09:00\", \"18:00\", \"kit\"),\n  \"p4|2026-10-12\": S(\"16:00\", \"23:30\", \"kit\"), \"p4|2026-10-13\": { off: true }, \"p4|2026-10-14\": S(\"16:00\", \"23:30\", \"kit\"), \"p4|2026-10-15\": S(\"16:00\", \"23:30\", \"kit\"), \"p4|2026-10-16\": S(\"08:00\", \"16:00\", \"kit\"),\n  \"p5|2026-10-13\": S(\"11:00\", \"19:00\", \"foh\"), \"p5|2026-10-14\": S(\"11:00\", \"19:00\", \"foh\"), \"p5|2026-10-15\": S(\"11:00\", \"19:00\", \"foh\"), \"p5|2026-10-17\": S(\"10:00\", \"20:00\", \"foh\"), \"p5|2026-10-18\": S(\"10:00\", \"18:00\", \"foh\"),\n  \"p6|2026-10-16\": S(\"17:00\", \"23:00\", \"foh\"), \"p6|2026-10-17\": S(\"17:00\", \"23:00\", \"foh\"), \"p6|2026-10-18\": S(\"12:00\", \"18:00\", \"foh\"),\n}\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"bg-card rounded-2xl border p-5\">\n      <Skeleton className=\"h-8 w-60\" />\n      <Skeleton className=\"mt-6 h-80\" />\n    </div>\n  )\n}\n\n/**\n * A weekly shift roster: a row for each person, a column for each day, each cell a shift you can set, change or mark as time off. Hours are totalled for every\n * person with a flag when they go over their limit or have too little rest between shifts, and a coverage row shows how many people each day has against how\n * many it needs. The week can be paged through, and publishing tells the team and locks nothing until it succeeds.\n */\nfunction Roster1({\n  title = \"Shift roster\",\n  staff = staff0,\n  positions = positions0,\n  shifts: shiftsProp = shifts0,\n  weekStart = \"2026-10-12\",\n  required = [3, 3, 3, 4, 4, 5, 4],\n  minRest = 11,\n  locale = \"en\",\n  publishedWeeks = [],\n  onChange,\n  onPublish,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Roster1Props) {\n  const id = React.useId()\n  const [shifts, setShifts] = React.useState(shiftsProp)\n  React.useEffect(() => setShifts(shiftsProp), [shiftsProp])\n  const [start, setStart] = React.useState(weekStart)\n  const [published, setPublished] = React.useState<string[]>(publishedWeeks)\n  const [editing, setEditing] = React.useState<{ person: Roster1Person; date: string } | null>(null)\n  const [form, setForm] = React.useState({ start: \"09:00\", end: \"17:00\", position: positions[0]!.id, off: false })\n  const [formError, setFormError] = React.useState<string>()\n  const [publishing, setPublishing] = React.useState(false)\n  const [publishError, setPublishError] = React.useState<string>()\n  const [announce, setAnnounce] = React.useState(\"\")\n  const ret = useReturnFocus(!!editing)\n  const list = state === \"empty\" ? [] : staff\n  const days = Array.from({ length: 7 }, (_, i) => iso(toT(start) + i * DAY))\n  const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" }), [locale])\n  const numFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", timeZone: \"UTC\" }), [locale])\n  const isPublished = published.includes(start)\n  const pos = (pid: string) => positions.find((p) => p.id === pid) ?? positions[0]!\n  const get = (p: Roster1Person, d: string) => shifts[`${p.id}|${d}`]\n\n  const weekHours = (p: Roster1Person) => days.reduce((n, d) => n + (get(p, d) ? hrs(get(p, d)!) : 0), 0)\n  // The gap between the end of a shift and the start of the next day's shift.\n  const restIssue = (p: Roster1Person, d: string) => {\n    const a = get(p, d)\n    const next = get(p, iso(toT(d) + DAY))\n    if (!a || \"off\" in a || !next || \"off\" in next) return null\n    const gap = (24 * 60 - mins(a.end) + mins(next.start)) / 60\n    return gap < minRest ? gap : null\n  }\n  const covered = (d: string) => list.filter((p) => { const s = get(p, d); return s && !(\"off\" in s) }).length\n  const issues = list.reduce((n, p) => n + (weekHours(p) > (p.maxHours ?? 40) ? 1 : 0) + days.filter((d) => restIssue(p, d) !== null).length, 0) + days.filter((d, i) => covered(d) < (required[i] ?? 0)).length\n\n  function open(p: Roster1Person, d: string) {\n    const cur = get(p, d)\n    setFormError(undefined)\n    setForm(cur && !(\"off\" in cur) ? { start: cur.start, end: cur.end, position: cur.position, off: false } : cur ? { start: \"09:00\", end: \"17:00\", position: positions[0]!.id, off: true } : { start: \"09:00\", end: \"17:00\", position: positions.find((x) => x.label === p.role || x.id === \"bar\")?.id ?? positions[0]!.id, off: false })\n    setEditing({ person: p, date: d })\n  }\n  function save(e: React.FormEvent) {\n    e.preventDefault()\n    if (!editing) return\n    const key = `${editing.person.id}|${editing.date}`\n    if (!form.off && mins(form.end) <= mins(form.start)) return setFormError(\"The shift has to end after it starts.\")\n    const next: Roster1Shift = form.off ? { off: true } : { start: form.start, end: form.end, position: form.position }\n    setShifts((s) => ({ ...s, [key]: next }))\n    onChange?.(key, next)\n    setAnnounce(`${editing.person.name}, ${dayFmt.format(toT(editing.date))}: ${form.off ? \"time off\" : `${form.start} to ${form.end}`}`)\n    setEditing(null)\n  }\n  function clear() {\n    if (!editing) return\n    const key = `${editing.person.id}|${editing.date}`\n    setShifts((s) => { const { [key]: _gone, ...rest } = s; return rest })\n    onChange?.(key, null)\n    setAnnounce(`${editing.person.name}, ${dayFmt.format(toT(editing.date))}: shift removed`)\n    setEditing(null)\n  }\n  async function publish() {\n    setPublishError(undefined)\n    setPublishing(true)\n    try {\n      await onPublish?.(start)\n      setPublished((p) => [...p, start])\n      setAnnounce(\"Week published to the team\")\n    } catch (e) {\n      setPublishError(e instanceof Error ? e.message : \"The week could not be published. It is still a draft, so try again.\")\n    } finally {\n      setPublishing(false)\n    }\n  }\n\n  const content = !list.length ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <Users className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No staff on this roster\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">{emptyState?.description ?? \"Add the people who work here and you can start planning their weeks.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"bg-card rounded-2xl border\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3.5 sm:px-5\">\n        <div className=\"flex items-center gap-1\">\n          <Button variant=\"outline\" size=\"icon\" aria-label=\"Previous week\" onClick={() => setStart(iso(toT(start) - 7 * DAY))}>\n            <ChevronLeft aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n          </Button>\n          <h2 className=\"min-w-44 px-3 text-center font-semibold tracking-tight tabular-nums\">\n            {numFmt.format(toT(days[0]!))} to {numFmt.format(toT(days[6]!))}\n          </h2>\n          <Button variant=\"outline\" size=\"icon\" aria-label=\"Next week\" onClick={() => setStart(iso(toT(start) + 7 * DAY))}>\n            <ChevronRight aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n          </Button>\n        </div>\n        <div className=\"flex flex-wrap items-center gap-3\">\n          <p role=\"status\" className={cn(\"flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium\", issues ? \"border-amber-500/40 bg-amber-500/15\" : isPublished && \"bg-chart-2/10 border-chart-2/30\")}>\n            {issues ? <TriangleAlert aria-hidden=\"true\" className=\"size-4\" /> : <CircleCheck aria-hidden=\"true\" className=\"size-4\" />}\n            {issues ? `${issues} ${issues === 1 ? \"thing needs\" : \"things need\"} a look` : \"No problems found\"}\n          </p>\n          <p className=\"text-muted-foreground flex items-center gap-1.5 text-sm\">{isPublished ? \"Published\" : \"Draft\"}</p>\n        </div>\n      </div>\n\n      <div role=\"region\" tabIndex={0} aria-label=\"Roster grid\" className=\"focus-visible:ring-ring/50 relative overflow-x-auto outline-none focus-visible:ring-[3px] focus-visible:ring-inset\">\n        <table className=\"w-full min-w-[56rem] border-collapse text-sm\">\n          <caption className=\"sr-only\">Shifts for each person for the week of {numFmt.format(toT(start))}</caption>\n          <thead>\n            <tr className=\"text-muted-foreground\">\n              <th scope=\"col\" className=\"w-52 px-5 py-3 text-start font-medium\">Team</th>\n              {days.map((d, i) => (\n                <th key={d} scope=\"col\" className=\"px-1 py-3 text-center font-medium\">\n                  <span className=\"block text-xs\">{dayFmt.format(toT(d))}</span>\n                  <span className={cn(\"text-foreground block tabular-nums\", i > 4 && \"text-muted-foreground\")}>{numFmt.format(toT(d))}</span>\n                </th>\n              ))}\n              <th scope=\"col\" className=\"px-5 py-3 text-end font-medium\">Hours</th>\n            </tr>\n          </thead>\n          <tbody className=\"border-t\">\n            {list.map((p) => {\n              const h = weekHours(p)\n              const over = h > (p.maxHours ?? 40)\n              return (\n                <tr key={p.id} className=\"border-b\">\n                  <th scope=\"row\" className=\"px-5 py-2.5 text-start font-normal\">\n                    <span className=\"flex items-center gap-3\">\n                      <span aria-hidden=\"true\" className=\"relative flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border text-xs font-semibold\">\n                        <CoverArt seed={p.name} variant=\"mesh\" className=\"absolute inset-0\" />\n                        <span className=\"relative\">{p.name.split(\" \").map((w) => w[0]).join(\"\")}</span>\n                      </span>\n                      <span className=\"min-w-0\">\n                        <span className=\"block truncate font-medium\">{p.name}</span>\n                        <span className=\"text-muted-foreground block text-xs\">{p.role}</span>\n                      </span>\n                    </span>\n                  </th>\n                  {days.map((d) => {\n                    const s = get(p, d)\n                    const rest = restIssue(p, d)\n                    const label = `${p.name}, ${dayFmt.format(toT(d))} ${numFmt.format(toT(d))}`\n                    return (\n                      <td key={d} className=\"px-1 py-1.5 align-top\">\n                        {s ? (\n                          <button type=\"button\" onClick={() => open(p, d)} aria-label={`${label}: ${\"off\" in s ? \"time off\" : `${s.start} to ${s.end}, ${pos(s.position).label}`}${rest !== null ? `, only ${rest.toFixed(1)} hours before the next shift` : \"\"}. Edit`} className={cn(\"focus-visible:ring-ring/50 relative block h-14 w-full min-w-[6.25rem] rounded-lg border px-2 py-1.5 text-start outline-none focus-visible:ring-[3px]\", \"off\" in s ? \"bg-muted/50 text-muted-foreground border-dashed\" : pos(s.position).tone, rest !== null && \"ring-1 ring-amber-500\")}>\n                            {\"off\" in s ? (\n                              <span className=\"flex h-full items-center gap-1.5 text-xs font-medium\">\n                                <CalendarOff aria-hidden=\"true\" className=\"size-3.5\" />\n                                Time off\n                              </span>\n                            ) : (\n                              <>\n                                <span className=\"block text-xs font-semibold tabular-nums\">\n                                  {s.start} to {s.end}\n                                </span>\n                                <span className=\"text-muted-foreground block text-[11px]\">{pos(s.position).label} · {hrs(s)} h</span>\n                              </>\n                            )}\n                            {rest !== null ? <TriangleAlert aria-hidden=\"true\" className=\"absolute end-1.5 bottom-1.5 size-3.5 text-amber-600 dark:text-amber-400\" /> : null}\n                          </button>\n                        ) : (\n                          <button type=\"button\" onClick={() => open(p, d)} aria-label={`${label}: no shift. Add one`} className=\"text-muted-foreground hover:bg-accent focus-visible:ring-ring/50 flex h-14 w-full min-w-[6.25rem] items-center justify-center rounded-lg border border-dashed outline-none focus-visible:ring-[3px]\">\n                            <Plus aria-hidden=\"true\" className=\"size-4\" />\n                          </button>\n                        )}\n                      </td>\n                    )\n                  })}\n                  <td className=\"px-5 py-2.5 text-end tabular-nums\">\n                    <span className={cn(\"font-semibold\", over && \"text-amber-700 dark:text-amber-400\")}>{h} h</span>\n                    <span className=\"text-muted-foreground block text-xs\">{over ? `${h - (p.maxHours ?? 40)} h over ${p.maxHours ?? 40}` : `of ${p.maxHours ?? 40}`}</span>\n                  </td>\n                </tr>\n              )\n            })}\n          </tbody>\n          <tfoot>\n            <tr>\n              <th scope=\"row\" className=\"px-5 py-3 text-start font-semibold\">Covered</th>\n              {days.map((d, i) => {\n                const c = covered(d)\n                const need = required[i] ?? 0\n                const short = c < need\n                return (\n                  <td key={d} className=\"px-1 py-3 text-center\">\n                    <span className={cn(\"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold tabular-nums\", short ? \"border-amber-500/40 bg-amber-500/15\" : \"bg-chart-2/10 border-chart-2/30\")}>\n                      {short ? <CircleAlert aria-hidden=\"true\" className=\"size-3\" /> : <CircleCheck aria-hidden=\"true\" className=\"size-3\" />}\n                      {c} of {need}\n                      <span className=\"sr-only\">{short ? `, short by ${need - c}` : \", covered\"}</span>\n                    </span>\n                  </td>\n                )\n              })}\n              <td />\n            </tr>\n          </tfoot>\n        </table>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-3 border-t px-4 py-4 sm:px-5\">\n        <ul aria-label=\"Positions\" className=\"text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs\">\n          {positions.map((p) => (\n            <li key={p.id} className=\"flex items-center gap-1.5\">\n              <span aria-hidden=\"true\" className={cn(\"size-3.5 rounded border\", p.tone)} />\n              {p.label}\n            </li>\n          ))}\n        </ul>\n        {isPublished ? (\n          <p className=\"text-muted-foreground text-sm\">Everyone has been told about this week.</p>\n        ) : (\n          <Button onClick={() => void publish()} disabled={publishing}>\n            {publishing ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : <Send aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />}\n            {publishing ? \"Publishing\" : \"Publish week\"}\n          </Button>\n        )}\n      </div>\n      {publishError ? (\n        <p role=\"alert\" className=\"text-destructive flex items-start gap-2 border-t px-5 py-3 text-sm\">\n          <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n          {publishError}\n        </p>\n      ) : null}\n    </div>\n  )\n\n  return (\n    <section data-slot=\"roster-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <h1 id={`${id}-title`} className=\"mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance\">\n        {title}\n      </h1>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the roster\" loading={<Skel />} error={{ title: errorState?.title ?? \"The roster could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n\n      <Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>\n        <DialogContent {...ret}>\n          <form onSubmit={save} noValidate>\n            <DialogHeader>\n              <DialogTitle>{editing ? `${editing.person.name}, ${dayFmt.format(toT(editing.date))} ${numFmt.format(toT(editing.date))}` : \"Shift\"}</DialogTitle>\n              <DialogDescription>Set the hours, or mark the day as time off.</DialogDescription>\n            </DialogHeader>\n            <div className=\"mt-5 grid gap-4\">\n              <label className=\"flex items-center gap-2.5 text-sm font-medium\">\n                <input type=\"checkbox\" checked={form.off} onChange={(e) => setForm({ ...form, off: e.target.checked })} className=\"accent-foreground size-4\" />\n                Time off\n              </label>\n              {!form.off ? (\n                <div className=\"grid grid-cols-2 gap-3\">\n                  <div>\n                    <label htmlFor={`${id}-s`} className=\"text-muted-foreground mb-1.5 block text-xs font-medium\">Starts</label>\n                    <Input id={`${id}-s`} type=\"time\" value={form.start} onChange={(e) => setForm({ ...form, start: e.target.value })} />\n                  </div>\n                  <div>\n                    <label htmlFor={`${id}-e`} className=\"text-muted-foreground mb-1.5 block text-xs font-medium\">Ends</label>\n                    <Input id={`${id}-e`} type=\"time\" value={form.end} onChange={(e) => setForm({ ...form, end: e.target.value })} />\n                  </div>\n                  <div className=\"col-span-2\">\n                    <p id={`${id}-p`} className=\"text-muted-foreground mb-1.5 text-xs font-medium\">Position</p>\n                    <Select value={form.position} onValueChange={(v) => setForm({ ...form, position: v })}>\n                      <SelectTrigger aria-labelledby={`${id}-p`} className=\"w-full\">\n                        <SelectValue />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {positions.map((p) => (\n                          <SelectItem key={p.id} value={p.id}>\n                            {p.label}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                  </div>\n                </div>\n              ) : null}\n              {formError ? (\n                <p role=\"alert\" className=\"text-destructive text-sm\">\n                  {formError}\n                </p>\n              ) : null}\n            </div>\n            <DialogFooter className=\"mt-6\">\n              {editing && get(editing.person, editing.date) ? (\n                <Button type=\"button\" variant=\"ghost\" onClick={clear} className=\"me-auto\">\n                  Remove shift\n                </Button>\n              ) : null}\n              <Button type=\"button\" variant=\"outline\" onClick={() => setEditing(null)}>\n                Cancel\n              </Button>\n              <Button type=\"submit\">Save</Button>\n            </DialogFooter>\n          </form>\n        </DialogContent>\n      </Dialog>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Roster1, type Roster1Props, type Roster1Person, type Roster1Shift, type Roster1Position }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/roster-1/roster-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "roster",
      "shifts",
      "schedule",
      "rota",
      "staff scheduling",
      "rostering",
      "workforce",
      "hospitality",
      "retail",
      "coverage"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A shift roster. <Roster1 staff={[{ id, name, role, maxHours }]} positions={[{ id, label, tone }]} shifts={{ 'p1|2026-10-12': { start: '07:00', end: '15:00', position: 'bar' } | { off: true } }} weekStart='2026-10-12' /* a Monday */ required={[3,3,3,4,4,5,4]} minRest={11} locale onChange={(key, shift | null) => ...} onPublish={(weekStart) => Promise} />. Hours, overtime against each maxHours, rest between one day's end and the next day's start (under minRest is flagged) and coverage against required are worked out from the shifts. Press a cell to add, edit or remove a shift or mark time off. Reject onPublish to keep the week a draft with the reason.",
      "whenToUse": [
        "Scheduling staff in hospitality, retail, care and support teams",
        "Planning who covers what each day"
      ],
      "whenNotToUse": [
        "A project timeline (use gantt-1)",
        "Booking one meeting (use a calendar)"
      ],
      "composesWith": [
        "timesheet-1",
        "directory-1",
        "calendar-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter",
          "action": "Every cell is a button that reads the person, day, hours and position ('Amara Okoye, Mon 12 Oct: 07:00 to 15:00, Bar. Edit') or 'no shift. Add one'; the editor is a dialog that returns focus to the cell"
        },
        {
          "keys": "Flags",
          "action": "Overtime ('4 h over 32'), too little rest and short coverage ('3 of 4, short by 1') are written in words with icons, and the summary counts them"
        },
        {
          "keys": "Table",
          "action": "A real table with caption, headers and a coverage footer; it scrolls sideways on a phone and is a focusable region"
        }
      ],
      "customization": [
        "positions carry their own color classes",
        "minRest and each person's maxHours are plain numbers",
        "Weeks page with Previous and Next; the shifts are keyed by person and date so any range can be loaded"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "roster-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton grid under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No staff",
        "example": "roster-1-empty",
        "trigger": "state=\"empty\" (or staff={[]})",
        "note": "Says to add the people who work here; emptyState sets the copy and can add a button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "roster-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A publish that fails says the week is still a draft, in place under the grid."
      }
    ],
    "examples": [
      "roster-1-demo",
      "roster-1-published",
      "roster-1-loading",
      "roster-1-empty",
      "roster-1-error"
    ],
    "url": "https://ui.ballmac.com/components/roster-1"
  },
  "categories": [
    "blocks",
    "roster"
  ],
  "type": "registry:block"
}