# Roster 1: weekly shift planner

> 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.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/roster-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/roster-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/roster-1-light-b864616499917256.png
- Registry item: https://ui.ballmac.com/r/roster-1.json
- Categories: blocks, roster
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/roster-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `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

### `registry/ballmac/components/blocks/roster-1/roster-1.tsx`

```tsx
// Ballmac UI: Roster 1. https://ui.ballmac.com/blocks/roster-1
"use client"

import * as React from "react"
import { CalendarOff, ChevronLeft, ChevronRight, CircleAlert, CircleCheck, Loader2, Plus, Send, TriangleAlert, Users } from "lucide-react"

import { Button } from "@/components/ballmac/button"
import { CoverArt } from "@/components/ballmac/cover-art"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ballmac/dialog"
import { Input } from "@/components/ballmac/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ballmac/select"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { useReturnFocus } from "@/lib/ballmac/return-focus"
import { cn } from "@/lib/utils"

type Roster1Person = { id: string; name: string; role: string; /** Most hours a week before it counts as overtime. */ maxHours?: number }
type Roster1Shift = { start: string; end: string; position: string } | { off: true }
type Roster1Position = { id: string; label: string; tone: string }

type Roster1Props = Omit<React.ComponentProps<"section">, "title"> &
  StateProps & {
    title?: string
    staff?: Roster1Person[]
    positions?: Roster1Position[]
    /** Shifts by "personId|2026-10-12". */
    shifts?: Record<string, Roster1Shift>
    /** ISO date of the Monday of the first week. */
    weekStart?: string
    /** How many people each day needs, Monday first. */
    required?: number[]
    /** The least hours between the end of one shift and the start of the next. */
    minRest?: number
    locale?: string
    /** ISO dates of weeks already published. */
    publishedWeeks?: string[]
    onChange?: (key: string, shift: Roster1Shift | null) => void
    /** Called to publish a week to the team. Return a promise; reject to keep it as a draft with the reason. */
    onPublish?: (weekStart: string) => void | Promise<void>
  }

const DAY = 86_400_000
const toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`)
const iso = (t: number) => new Date(t).toISOString().slice(0, 10)
const mins = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3, 5))
const hrs = (s: Roster1Shift) => ("off" in s ? 0 : Math.max(0, (mins(s.end) - mins(s.start)) / 60))

const staff0: Roster1Person[] = [
  { id: "p1", name: "Amara Okoye", role: "Barista", maxHours: 40 },
  { id: "p2", name: "Diego Marín", role: "Barista", maxHours: 40 },
  { id: "p3", name: "Mia Laurent", role: "Kitchen", maxHours: 40 },
  { id: "p4", name: "Kofi Mensah", role: "Kitchen", maxHours: 32 },
  { id: "p5", name: "Yuki Tanaka", role: "Front of house", maxHours: 40 },
  { id: "p6", name: "Oliver Reed", role: "Front of house", maxHours: 24 },
]
const positions0: Roster1Position[] = [
  { id: "bar", label: "Bar", tone: "bg-chart-1/15 border-chart-1/40" },
  { id: "kit", label: "Kitchen", tone: "bg-chart-3/15 border-chart-3/40" },
  { id: "foh", label: "Floor", tone: "bg-chart-2/15 border-chart-2/40" },
]
const S = (start: string, end: string, position: string): Roster1Shift => ({ start, end, position })
const shifts0: Record<string, Roster1Shift> = {
  "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"),
  "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"),
  "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"),
  "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"),
  "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"),
  "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"),
}

function Skel() {
  return (
    <div aria-hidden="true" className="bg-card rounded-2xl border p-5">
      <Skeleton className="h-8 w-60" />
      <Skeleton className="mt-6 h-80" />
    </div>
  )
}

/**
 * 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
 * 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
 * many it needs. The week can be paged through, and publishing tells the team and locks nothing until it succeeds.
 */
function Roster1({
  title = "Shift roster",
  staff = staff0,
  positions = positions0,
  shifts: shiftsProp = shifts0,
  weekStart = "2026-10-12",
  required = [3, 3, 3, 4, 4, 5, 4],
  minRest = 11,
  locale = "en",
  publishedWeeks = [],
  onChange,
  onPublish,
  state = "ready",
  onRetry,
  emptyState,
  errorState,
  className,
  ...props
}: Roster1Props) {
  const id = React.useId()
  const [shifts, setShifts] = React.useState(shiftsProp)
  React.useEffect(() => setShifts(shiftsProp), [shiftsProp])
  const [start, setStart] = React.useState(weekStart)
  const [published, setPublished] = React.useState<string[]>(publishedWeeks)
  const [editing, setEditing] = React.useState<{ person: Roster1Person; date: string } | null>(null)
  const [form, setForm] = React.useState({ start: "09:00", end: "17:00", position: positions[0]!.id, off: false })
  const [formError, setFormError] = React.useState<string>()
  const [publishing, setPublishing] = React.useState(false)
  const [publishError, setPublishError] = React.useState<string>()
  const [announce, setAnnounce] = React.useState("")
  const ret = useReturnFocus(!!editing)
  const list = state === "empty" ? [] : staff
  const days = Array.from({ length: 7 }, (_, i) => iso(toT(start) + i * DAY))
  const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }), [locale])
  const numFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", timeZone: "UTC" }), [locale])
  const isPublished = published.includes(start)
  const pos = (pid: string) => positions.find((p) => p.id === pid) ?? positions[0]!
  const get = (p: Roster1Person, d: string) => shifts[`${p.id}|${d}`]

  const weekHours = (p: Roster1Person) => days.reduce((n, d) => n + (get(p, d) ? hrs(get(p, d)!) : 0), 0)
  // The gap between the end of a shift and the start of the next day's shift.
  const restIssue = (p: Roster1Person, d: string) => {
    const a = get(p, d)
    const next = get(p, iso(toT(d) + DAY))
    if (!a || "off" in a || !next || "off" in next) return null
    const gap = (24 * 60 - mins(a.end) + mins(next.start)) / 60
    return gap < minRest ? gap : null
  }
  const covered = (d: string) => list.filter((p) => { const s = get(p, d); return s && !("off" in s) }).length
  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

  function open(p: Roster1Person, d: string) {
    const cur = get(p, d)
    setFormError(undefined)
    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 })
    setEditing({ person: p, date: d })
  }
  function save(e: React.FormEvent) {
    e.preventDefault()
    if (!editing) return
    const key = `${editing.person.id}|${editing.date}`
    if (!form.off && mins(form.end) <= mins(form.start)) return setFormError("The shift has to end after it starts.")
    const next: Roster1Shift = form.off ? { off: true } : { start: form.start, end: form.end, position: form.position }
    setShifts((s) => ({ ...s, [key]: next }))
    onChange?.(key, next)
    setAnnounce(`${editing.person.name}, ${dayFmt.format(toT(editing.date))}: ${form.off ? "time off" : `${form.start} to ${form.end}`}`)
    setEditing(null)
  }
  function clear() {
    if (!editing) return
    const key = `${editing.person.id}|${editing.date}`
    setShifts((s) => { const { [key]: _gone, ...rest } = s; return rest })
    onChange?.(key, null)
    setAnnounce(`${editing.person.name}, ${dayFmt.format(toT(editing.date))}: shift removed`)
    setEditing(null)
  }
  async function publish() {
    setPublishError(undefined)
    setPublishing(true)
    try {
      await onPublish?.(start)
      setPublished((p) => [...p, start])
      setAnnounce("Week published to the team")
    } catch (e) {
      setPublishError(e instanceof Error ? e.message : "The week could not be published. It is still a draft, so try again.")
    } finally {
      setPublishing(false)
    }
  }

  const content = !list.length ? (
    <div className="bg-card rounded-2xl border px-6 py-16 text-center">
      <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
        <Users className="size-6" />
      </span>
      <h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No staff on this roster"}</h2>
      <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>
      {emptyState?.action}
    </div>
  ) : (
    <div className="bg-card rounded-2xl border">
      <div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3.5 sm:px-5">
        <div className="flex items-center gap-1">
          <Button variant="outline" size="icon" aria-label="Previous week" onClick={() => setStart(iso(toT(start) - 7 * DAY))}>
            <ChevronLeft aria-hidden="true" className="rtl:rotate-180" />
          </Button>
          <h2 className="min-w-44 px-3 text-center font-semibold tracking-tight tabular-nums">
            {numFmt.format(toT(days[0]!))} to {numFmt.format(toT(days[6]!))}
          </h2>
          <Button variant="outline" size="icon" aria-label="Next week" onClick={() => setStart(iso(toT(start) + 7 * DAY))}>
            <ChevronRight aria-hidden="true" className="rtl:rotate-180" />
          </Button>
        </div>
        <div className="flex flex-wrap items-center gap-3">
          <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")}>
            {issues ? <TriangleAlert aria-hidden="true" className="size-4" /> : <CircleCheck aria-hidden="true" className="size-4" />}
            {issues ? `${issues} ${issues === 1 ? "thing needs" : "things need"} a look` : "No problems found"}
          </p>
          <p className="text-muted-foreground flex items-center gap-1.5 text-sm">{isPublished ? "Published" : "Draft"}</p>
        </div>
      </div>

      <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">
        <table className="w-full min-w-[56rem] border-collapse text-sm">
          <caption className="sr-only">Shifts for each person for the week of {numFmt.format(toT(start))}</caption>
          <thead>
            <tr className="text-muted-foreground">
              <th scope="col" className="w-52 px-5 py-3 text-start font-medium">Team</th>
              {days.map((d, i) => (
                <th key={d} scope="col" className="px-1 py-3 text-center font-medium">
                  <span className="block text-xs">{dayFmt.format(toT(d))}</span>
                  <span className={cn("text-foreground block tabular-nums", i > 4 && "text-muted-foreground")}>{numFmt.format(toT(d))}</span>
                </th>
              ))}
              <th scope="col" className="px-5 py-3 text-end font-medium">Hours</th>
            </tr>
          </thead>
          <tbody className="border-t">
            {list.map((p) => {
              const h = weekHours(p)
              const over = h > (p.maxHours ?? 40)
              return (
                <tr key={p.id} className="border-b">
                  <th scope="row" className="px-5 py-2.5 text-start font-normal">
                    <span className="flex items-center gap-3">
                      <span aria-hidden="true" className="relative flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border text-xs font-semibold">
                        <CoverArt seed={p.name} variant="mesh" className="absolute inset-0" />
                        <span className="relative">{p.name.split(" ").map((w) => w[0]).join("")}</span>
                      </span>
                      <span className="min-w-0">
                        <span className="block truncate font-medium">{p.name}</span>
                        <span className="text-muted-foreground block text-xs">{p.role}</span>
                      </span>
                    </span>
                  </th>
                  {days.map((d) => {
                    const s = get(p, d)
                    const rest = restIssue(p, d)
                    const label = `${p.name}, ${dayFmt.format(toT(d))} ${numFmt.format(toT(d))}`
                    return (
                      <td key={d} className="px-1 py-1.5 align-top">
                        {s ? (
                          <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")}>
                            {"off" in s ? (
                              <span className="flex h-full items-center gap-1.5 text-xs font-medium">
                                <CalendarOff aria-hidden="true" className="size-3.5" />
                                Time off
                              </span>
                            ) : (
                              <>
                                <span className="block text-xs font-semibold tabular-nums">
                                  {s.start} to {s.end}
                                </span>
                                <span className="text-muted-foreground block text-[11px]">{pos(s.position).label} · {hrs(s)} h</span>
                              </>
                            )}
                            {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}
                          </button>
                        ) : (
                          <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]">
                            <Plus aria-hidden="true" className="size-4" />
                          </button>
                        )}
                      </td>
                    )
                  })}
                  <td className="px-5 py-2.5 text-end tabular-nums">
                    <span className={cn("font-semibold", over && "text-amber-700 dark:text-amber-400")}>{h} h</span>
                    <span className="text-muted-foreground block text-xs">{over ? `${h - (p.maxHours ?? 40)} h over ${p.maxHours ?? 40}` : `of ${p.maxHours ?? 40}`}</span>
                  </td>
                </tr>
              )
            })}
          </tbody>
          <tfoot>
            <tr>
              <th scope="row" className="px-5 py-3 text-start font-semibold">Covered</th>
              {days.map((d, i) => {
                const c = covered(d)
                const need = required[i] ?? 0
                const short = c < need
                return (
                  <td key={d} className="px-1 py-3 text-center">
                    <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")}>
                      {short ? <CircleAlert aria-hidden="true" className="size-3" /> : <CircleCheck aria-hidden="true" className="size-3" />}
                      {c} of {need}
                      <span className="sr-only">{short ? `, short by ${need - c}` : ", covered"}</span>
                    </span>
                  </td>
                )
              })}
              <td />
            </tr>
          </tfoot>
        </table>
      </div>

      <div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-4 sm:px-5">
        <ul aria-label="Positions" className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs">
          {positions.map((p) => (
            <li key={p.id} className="flex items-center gap-1.5">
              <span aria-hidden="true" className={cn("size-3.5 rounded border", p.tone)} />
              {p.label}
            </li>
          ))}
        </ul>
        {isPublished ? (
          <p className="text-muted-foreground text-sm">Everyone has been told about this week.</p>
        ) : (
          <Button onClick={() => void publish()} disabled={publishing}>
            {publishing ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Send aria-hidden="true" className="rtl:-scale-x-100" />}
            {publishing ? "Publishing" : "Publish week"}
          </Button>
        )}
      </div>
      {publishError ? (
        <p role="alert" className="text-destructive flex items-start gap-2 border-t px-5 py-3 text-sm">
          <CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
          {publishError}
        </p>
      ) : null}
    </div>
  )

  return (
    <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)}>
      <h1 id={`${id}-title`} className="mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance">
        {title}
      </h1>
      <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 }}>
        {content}
      </StateView>

      <Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
        <DialogContent {...ret}>
          <form onSubmit={save} noValidate>
            <DialogHeader>
              <DialogTitle>{editing ? `${editing.person.name}, ${dayFmt.format(toT(editing.date))} ${numFmt.format(toT(editing.date))}` : "Shift"}</DialogTitle>
              <DialogDescription>Set the hours, or mark the day as time off.</DialogDescription>
            </DialogHeader>
            <div className="mt-5 grid gap-4">
              <label className="flex items-center gap-2.5 text-sm font-medium">
                <input type="checkbox" checked={form.off} onChange={(e) => setForm({ ...form, off: e.target.checked })} className="accent-foreground size-4" />
                Time off
              </label>
              {!form.off ? (
                <div className="grid grid-cols-2 gap-3">
                  <div>
                    <label htmlFor={`${id}-s`} className="text-muted-foreground mb-1.5 block text-xs font-medium">Starts</label>
                    <Input id={`${id}-s`} type="time" value={form.start} onChange={(e) => setForm({ ...form, start: e.target.value })} />
                  </div>
                  <div>
                    <label htmlFor={`${id}-e`} className="text-muted-foreground mb-1.5 block text-xs font-medium">Ends</label>
                    <Input id={`${id}-e`} type="time" value={form.end} onChange={(e) => setForm({ ...form, end: e.target.value })} />
                  </div>
                  <div className="col-span-2">
                    <p id={`${id}-p`} className="text-muted-foreground mb-1.5 text-xs font-medium">Position</p>
                    <Select value={form.position} onValueChange={(v) => setForm({ ...form, position: v })}>
                      <SelectTrigger aria-labelledby={`${id}-p`} className="w-full">
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        {positions.map((p) => (
                          <SelectItem key={p.id} value={p.id}>
                            {p.label}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </div>
                </div>
              ) : null}
              {formError ? (
                <p role="alert" className="text-destructive text-sm">
                  {formError}
                </p>
              ) : null}
            </div>
            <DialogFooter className="mt-6">
              {editing && get(editing.person, editing.date) ? (
                <Button type="button" variant="ghost" onClick={clear} className="me-auto">
                  Remove shift
                </Button>
              ) : null}
              <Button type="button" variant="outline" onClick={() => setEditing(null)}>
                Cancel
              </Button>
              <Button type="submit">Save</Button>
            </DialogFooter>
          </form>
        </DialogContent>
      </Dialog>
      <p aria-live="polite" className="sr-only">
        {announce}
      </p>
    </section>
  )
}

export { Roster1, type Roster1Props, type Roster1Person, type Roster1Shift, type Roster1Position }
```
