{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "weekly-hours",
  "title": "Weekly Hours",
  "description": "Weekly availability, the settings screen of every booking tool. Each day is on or off and holds one or more time ranges, and mistakes are flagged where they are made.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/weekly-hours/weekly-hours.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Copy, Plus, X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type Weekday =\n  | \"sunday\"\n  | \"monday\"\n  | \"tuesday\"\n  | \"wednesday\"\n  | \"thursday\"\n  | \"friday\"\n  | \"saturday\"\n\n/** Times are 24 hour \"HH:mm\" strings. An end of \"24:00\" is midnight. */\nexport type TimeRange = { start: string; end: string }\n\n/** An empty list means the day is unavailable. */\nexport type WeeklyHoursValue = Record<Weekday, TimeRange[]>\n\nexport type WeeklyHoursIssue = {\n  day: Weekday\n  index: number\n  kind: \"order\" | \"overlap\"\n  /** For an overlap, the index of the range it runs into. */\n  other?: number\n}\n\nexport type WeeklyHoursProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onChange\" | \"defaultValue\" | \"children\"\n> & {\n  value?: WeeklyHoursValue\n  defaultValue?: WeeklyHoursValue\n  onValueChange?: (value: WeeklyHoursValue) => void\n  /** Minutes between the times on offer. */\n  step?: 15 | 30\n  /** Weekday names, the first day of the week, and the clock follow it. */\n  locale?: string\n  hourCycle?: \"h12\" | \"h23\"\n  firstDay?: Weekday\n  /** What a day gets when it is switched on for the first time. */\n  defaultRange?: TimeRange\n  label?: string\n  disabled?: boolean\n}\n\n/** In JavaScript's order, so getUTCDay() indexes it. */\nconst WEEKDAYS: readonly Weekday[] = [\n  \"sunday\",\n  \"monday\",\n  \"tuesday\",\n  \"wednesday\",\n  \"thursday\",\n  \"friday\",\n  \"saturday\",\n]\n\nconst DAY = 24 * 60\n\nconst NINE_TO_FIVE: TimeRange = { start: \"09:00\", end: \"17:00\" }\n\nconst WORK_WEEK: WeeklyHoursValue = {\n  sunday: [],\n  monday: [NINE_TO_FIVE],\n  tuesday: [NINE_TO_FIVE],\n  wednesday: [NINE_TO_FIVE],\n  thursday: [NINE_TO_FIVE],\n  friday: [NINE_TO_FIVE],\n  saturday: [],\n}\n\nconst toMinutes = (time: string) => {\n  const [hours = \"0\", minutes = \"0\"] = time.split(\":\")\n  return Number(hours) * 60 + Number(minutes)\n}\n\nconst toTime = (minutes: number) =>\n  `${String(Math.floor(minutes / 60)).padStart(2, \"0\")}:${String(minutes % 60).padStart(2, \"0\")}`\n\n/**\n * End before start, and ranges on the same day that run into each other. It\n * is exported so a form can refuse to save the week the editor is flagging.\n */\nexport function getWeeklyHoursIssues(\n  value: Partial<WeeklyHoursValue>\n): WeeklyHoursIssue[] {\n  const issues: WeeklyHoursIssue[] = []\n\n  for (const day of WEEKDAYS) {\n    const ranges = (value[day] ?? []).map((range) => ({\n      start: toMinutes(range.start),\n      end: toMinutes(range.end),\n    }))\n\n    ranges.forEach((range, index) => {\n      if (range.end <= range.start) {\n        issues.push({ day, index, kind: \"order\" })\n        return\n      }\n\n      const other = ranges.findIndex(\n        (candidate, at) =>\n          at !== index &&\n          candidate.end > candidate.start &&\n          range.start < candidate.end &&\n          candidate.start < range.end\n      )\n      if (other >= 0) issues.push({ day, index, kind: \"overlap\", other })\n    })\n  }\n\n  return issues\n}\n\nconst noop = () => () => undefined\n\n/** Firefox has no week info yet, so it gets Monday, the ISO first day. */\nfunction firstDayOf(locale: string): Weekday {\n  const info = new Intl.Locale(locale) as Intl.Locale & {\n    getWeekInfo?: () => { firstDay: number }\n    weekInfo?: { firstDay: number }\n  }\n  const firstDay = (info.getWeekInfo?.() ?? info.weekInfo)?.firstDay\n  return firstDay ? WEEKDAYS[firstDay % 7]! : \"monday\"\n}\n\n/**\n * Weekly availability, the settings screen of every booking tool. Each day is\n * on or off, holds one or more ranges, and says inline when a range ends\n * before it starts or runs into another.\n */\nexport function WeeklyHours({\n  value,\n  defaultValue = WORK_WEEK,\n  onValueChange,\n  step = 30,\n  locale,\n  hourCycle,\n  firstDay,\n  defaultRange = NINE_TO_FIVE,\n  label = \"Weekly hours\",\n  disabled,\n  className,\n  ...rootProps\n}: WeeklyHoursProps) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const week = value ?? uncontrolled\n  const [parked, setParked] = React.useState<\n    Partial<Record<Weekday, TimeRange[]>>\n  >({})\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const root = React.useRef<HTMLDivElement>(null)\n  const focusNext = React.useRef<string | null>(null)\n  const id = React.useId()\n\n  // The server renders in its own language; the reader's takes over once the\n  // page is theirs, without a hydration mismatch.\n  const readerLocale = React.useSyncExternalStore(\n    noop,\n    () => navigator.language,\n    () => \"en-US\"\n  )\n  const resolvedLocale = locale ?? readerLocale\n\n  const text = React.useMemo(() => {\n    const cycle =\n      hourCycle ??\n      (new Intl.DateTimeFormat(resolvedLocale, { hour: \"numeric\" })\n        .resolvedOptions()\n        .hourCycle?.startsWith(\"h1\")\n        ? \"h12\"\n        : \"h23\")\n    const clock = new Intl.DateTimeFormat(resolvedLocale, {\n      hour: cycle === \"h12\" ? \"numeric\" : \"2-digit\",\n      minute: \"2-digit\",\n      hourCycle: cycle,\n      timeZone: \"UTC\",\n    })\n    const weekday = new Intl.DateTimeFormat(resolvedLocale, {\n      weekday: \"long\",\n      timeZone: \"UTC\",\n    })\n\n    const time = (minutes: number) =>\n      minutes >= DAY && cycle === \"h23\"\n        ? \"24:00\"\n        : clock.format(Date.UTC(2026, 0, 4, 0, minutes % DAY))\n\n    // 4 January 2026 was a Sunday.\n    const day = (weekdayName: Weekday) =>\n      weekday.format(Date.UTC(2026, 0, 4 + WEEKDAYS.indexOf(weekdayName)))\n\n    return { time, day }\n  }, [hourCycle, resolvedLocale])\n\n  const start = WEEKDAYS.indexOf(firstDay ?? firstDayOf(resolvedLocale))\n  const days = WEEKDAYS.map((_, offset) => WEEKDAYS[(start + offset) % 7]!)\n\n  const issues = getWeeklyHoursIssues(week)\n\n  const message = (issue: WeeklyHoursIssue, ranges: TimeRange[]) => {\n    if (issue.kind === \"order\") return \"End time must be after the start time.\"\n    const other = ranges[issue.other!]!\n    return `Overlaps ${text.time(toMinutes(other.start))} to ${text.time(toMinutes(other.end))}.`\n  }\n\n  const commit = (next: WeeklyHoursValue) => {\n    if (value === undefined) setUncontrolled(next)\n    onValueChange?.(next)\n  }\n\n  const setDay = (day: Weekday, ranges: TimeRange[]) =>\n    commit({ ...week, [day]: ranges })\n\n  React.useEffect(() => {\n    const target = focusNext.current\n    if (!target) return\n    focusNext.current = null\n    root.current?.querySelector<HTMLElement>(target)?.focus()\n  })\n\n  const toggle = (day: Weekday) => {\n    const ranges = week[day] ?? []\n    if (ranges.length > 0) {\n      setParked((current) => ({ ...current, [day]: ranges }))\n      setDay(day, [])\n      return\n    }\n    setDay(day, parked[day] ?? [defaultRange])\n  }\n\n  const edit = (\n    day: Weekday,\n    index: number,\n    field: keyof TimeRange,\n    time: string\n  ) => {\n    const ranges = week[day].map((range, at) =>\n      at === index ? { ...range, [field]: time } : range\n    )\n    setDay(day, ranges)\n\n    const issue = getWeeklyHoursIssues({ [day]: ranges }).find(\n      (candidate) => candidate.index === index\n    )\n    if (issue) setAnnouncement(`${text.day(day)}: ${message(issue, ranges)}`)\n  }\n\n  const nextRange = (ranges: TimeRange[]): TimeRange | null => {\n    const latest = Math.max(...ranges.map((range) => toMinutes(range.end)))\n    if (latest >= DAY) return null\n    const begin = latest + 60 + 60 <= DAY ? latest + 60 : latest\n    return { start: toTime(begin), end: toTime(Math.min(DAY, begin + 60)) }\n  }\n\n  const add = (day: Weekday) => {\n    const ranges = week[day]\n    const range = nextRange(ranges)\n    if (!range) return\n    focusNext.current = `[data-day=\"${day}\"][data-index=\"${ranges.length}\"][data-field=\"start\"]`\n    setDay(day, [...ranges, range])\n  }\n\n  const remove = (day: Weekday, index: number) => {\n    const ranges = week[day].filter((_, at) => at !== index)\n    if (ranges.length === 0) {\n      setParked((current) => ({ ...current, [day]: [week[day][index]!] }))\n      focusNext.current = `[data-day=\"${day}\"][role=\"switch\"]`\n    } else {\n      focusNext.current = `[data-day=\"${day}\"][data-index=\"${Math.min(index, ranges.length - 1)}\"][data-field=\"remove\"]`\n    }\n    setDay(day, ranges)\n  }\n\n  const copyToAll = (day: Weekday) => {\n    const ranges = week[day]\n    const next = Object.fromEntries(\n      WEEKDAYS.map((weekday) => [\n        weekday,\n        ranges.map((range) => ({ ...range })),\n      ])\n    ) as WeeklyHoursValue\n    commit(next)\n    setAnnouncement(`Copied ${text.day(day)} hours to every day.`)\n  }\n\n  const times = (from: number, to: number, current: number) => {\n    const grid: number[] = []\n    for (let minutes = from; minutes <= to; minutes += step) grid.push(minutes)\n    if (!grid.includes(current)) grid.push(current)\n    return grid.sort((a, b) => a - b)\n  }\n\n  const field =\n    \"border-input bg-background focus-visible:ring-ring h-9 min-w-0 rounded-md border px-2 text-sm tabular-nums focus-visible:ring-2 focus-visible:outline-none aria-invalid:border-destructive disabled:opacity-50\"\n\n  const iconButton =\n    \"text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:ring-ring inline-flex size-9 shrink-0 items-center justify-center rounded-md transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none\"\n\n  return (\n    <div\n      ref={root}\n      role=\"group\"\n      aria-label={label}\n      data-slot=\"weekly-hours\"\n      className={cn(\"divide-border grid divide-y\", className)}\n      {...rootProps}\n    >\n      {days.map((day) => {\n        const ranges = week[day] ?? []\n        const available = ranges.length > 0\n        const shown = available ? ranges : (parked[day] ?? [defaultRange])\n        const dayName = text.day(day)\n        const roomForMore = available && nextRange(ranges) !== null\n\n        return (\n          <div\n            key={day}\n            data-slot=\"weekly-hours-day\"\n            data-state={available ? \"available\" : \"unavailable\"}\n            className=\"py-2\"\n          >\n            <div className=\"flex min-h-11 items-center gap-2\">\n              <button\n                type=\"button\"\n                role=\"switch\"\n                aria-checked={available}\n                data-day={day}\n                disabled={disabled}\n                onClick={() => toggle(day)}\n                className=\"focus-visible:ring-ring -ml-1 flex min-h-11 items-center gap-3 rounded-md px-1 text-sm font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50\"\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 motion-reduce:transition-none\",\n                    available ? \"bg-primary\" : \"bg-input\"\n                  )}\n                >\n                  <span\n                    className={cn(\n                      \"bg-background absolute top-0.5 left-0.5 size-4 rounded-full shadow-sm transition-transform duration-200 ease-out motion-reduce:transition-none\",\n                      available && \"translate-x-4\"\n                    )}\n                  />\n                </span>\n                {dayName}\n              </button>\n\n              <span className=\"flex-1\" />\n\n              {available ? (\n                <>\n                  <button\n                    type=\"button\"\n                    data-day={day}\n                    disabled={disabled || !roomForMore}\n                    aria-label={`Add hours to ${dayName}`}\n                    title=\"Add hours\"\n                    onClick={() => add(day)}\n                    className={iconButton}\n                  >\n                    <Plus aria-hidden=\"true\" size={16} />\n                  </button>\n                  <button\n                    type=\"button\"\n                    disabled={disabled}\n                    aria-label={`Copy ${dayName} hours to every day`}\n                    title=\"Copy to every day\"\n                    onClick={() => copyToAll(day)}\n                    className={iconButton}\n                  >\n                    <Copy aria-hidden=\"true\" size={15} />\n                  </button>\n                </>\n              ) : (\n                <span className=\"text-muted-foreground pr-1 text-sm\">\n                  Unavailable\n                </span>\n              )}\n            </div>\n\n            <div\n              className={cn(\n                \"grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none\",\n                available\n                  ? \"grid-rows-[1fr] opacity-100\"\n                  : \"grid-rows-[0fr] opacity-0\"\n              )}\n            >\n              <div className=\"min-h-0 overflow-hidden\" inert={!available}>\n                <ul className=\"grid gap-2 p-1 sm:pl-12\">\n                  {shown.map((range, index) => {\n                    const startMinutes = toMinutes(range.start)\n                    const endMinutes = toMinutes(range.end)\n                    const issue = available\n                      ? issues.find(\n                          (candidate) =>\n                            candidate.day === day && candidate.index === index\n                        )\n                      : undefined\n                    const errorId = `${id}-${day}-${index}-error`\n                    const suffix = shown.length > 1 ? ` ${index + 1}` : \"\"\n                    const span = `${text.time(startMinutes)} to ${text.time(endMinutes)}`\n\n                    return (\n                      <li key={index} data-slot=\"weekly-hours-range\">\n                        <div className=\"flex items-center gap-2\">\n                          <select\n                            aria-label={`${dayName} start time${suffix}`}\n                            aria-invalid={issue ? true : undefined}\n                            aria-describedby={issue ? errorId : undefined}\n                            data-day={day}\n                            data-index={index}\n                            data-field=\"start\"\n                            disabled={disabled}\n                            value={range.start}\n                            onChange={(event) =>\n                              edit(day, index, \"start\", event.target.value)\n                            }\n                            className={field}\n                          >\n                            {times(0, DAY - step, startMinutes).map(\n                              (minutes) => (\n                                <option key={minutes} value={toTime(minutes)}>\n                                  {text.time(minutes)}\n                                </option>\n                              )\n                            )}\n                          </select>\n                          <span className=\"text-muted-foreground text-sm\">\n                            to\n                          </span>\n                          <select\n                            aria-label={`${dayName} end time${suffix}`}\n                            aria-invalid={issue ? true : undefined}\n                            aria-describedby={issue ? errorId : undefined}\n                            data-day={day}\n                            data-index={index}\n                            data-field=\"end\"\n                            disabled={disabled}\n                            value={range.end}\n                            onChange={(event) =>\n                              edit(day, index, \"end\", event.target.value)\n                            }\n                            className={field}\n                          >\n                            {times(step, DAY, endMinutes).map((minutes) => (\n                              <option key={minutes} value={toTime(minutes)}>\n                                {text.time(minutes)}\n                              </option>\n                            ))}\n                          </select>\n                          <button\n                            type=\"button\"\n                            data-day={day}\n                            data-index={index}\n                            data-field=\"remove\"\n                            disabled={disabled}\n                            aria-label={`Remove ${span} on ${dayName}`}\n                            title=\"Remove\"\n                            onClick={() => remove(day, index)}\n                            className={iconButton}\n                          >\n                            <X aria-hidden=\"true\" size={16} />\n                          </button>\n                        </div>\n                        {issue ? (\n                          <p\n                            id={errorId}\n                            className=\"text-destructive mt-1 text-xs\"\n                          >\n                            {message(issue, ranges)}\n                          </p>\n                        ) : null}\n                      </li>\n                    )\n                  })}\n                </ul>\n              </div>\n            </div>\n          </div>\n        )\n      })}\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "form",
    "schedule",
    "time",
    "control"
  ],
  "type": "registry:ui"
}