{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "weekly-hours",
  "title": "Weekly Hours",
  "description": "A week of opening hours in one editor: seven rows, each a switch plus an opening and a closing time, handing back a plain object keyed by weekday. Reach for it wherever a form asks *when in the week* rather than when on the clock — store, shop and restaurant opening hours; a support desk’s staffed window; delivery, pickup and collection slots; a shift roster or rota template; a staff member’s bookable availability; clinic, gym, salon, library and office hours; per-day quiet hours or do-not-disturb; and the days and times a scheduled job, digest or backup is allowed to run. It settles the three rules that hand-rolled versions get wrong. **A closed day is `null`, never `00:00`–`00:00`** — mix those two and “closed on Sunday” becomes indistinguishable from “open around the clock on Sunday”, the one mistake in this domain that reaches customers. **A closing time earlier than the opening time is the night, not a typo** — 22:00–02:00 is the bar that shuts at two, measured across midnight as 4h instead of being flagged invalid. **Equal opening and closing times mean the whole day**, so “open 24 hours” stays expressible without inventing a third state. Each row says in words which of the three it read, as you type. The week is ordered by data, not by hand: Sunday first in en-US and ja-JP, Monday in de-DE and fr-FR, Saturday in ar-EG, taken from `Intl.Locale`’s week info, with the day names from `Intl.DateTimeFormat` — or pin it yourself with `weekStartsOn`. The fourteen time fields are this registry’s `time-input`, so each one follows the reader’s clock (12- or 24-hour, the AM/PM wording, the segment order) and is typed with the keyboard rather than picked from a dropdown. “Apply to all” copies one day across the week; a day switched off and back on returns the hours that were typed instead of a default; `incompleteDays(value)` lists the days that are open but only half filled in, which is what to check before saving. With `name` set, a hidden input carries the week as JSON, so `null` survives a native form post — which no flat field encoding manages. Common asks it answers: “opening hours input”, “business hours picker”, “store hours editor”, “hours of operation form”, “weekly schedule input”, “day of week time picker”, “operating hours component”, “working hours editor”, “availability editor”, “weekly availability picker”, “shift schedule input”, “rota editor”, “open closed per day”, “per-day time ranges”, “overnight hours input”, “quiet hours per day”, “office hours editor”, “restaurant hours input”, “shadcn opening hours”, “shadcn business hours”, “react opening hours picker”, “react business hours component”, “business hours without a library”. shadcn/ui has no surface for this: its `calendar` answers a date on a month grid, `item` and `field` are layout kits for assembling a row yourself, and fetching the source of all 63 items in its registry and grepping them for the clock — `type=\"time\"`, `hourCycle`, `hour12`, `dayPeriod`, `toLocaleTimeString`, `hour`, `minute` — returns nothing at all. Distinct from this registry’s `time-input`, which is the single field this one places fourteen of, and from `cron-expression`, which reads a cron string and explains when it fires rather than letting a person edit a week by hand. One span per day: a day with a midday break is two spans, and that is deliberately out of scope.",
  "dependencies": [],
  "registryDependencies": [
    "https://pulld.pages.dev/r/time-input.json"
  ],
  "files": [
    {
      "path": "registry/ui/weekly-hours.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { TimeInput } from \"@/registry/ui/time-input\"\n\nexport type Weekday = \"sun\" | \"mon\" | \"tue\" | \"wed\" | \"thu\" | \"fri\" | \"sat\"\n\n/** One span on a 24-hour clock. Either side is \"\" while it is still being typed. */\nexport interface DayHours {\n  /** Opening time as \"HH:mm\", or \"\" while incomplete. */\n  open: string\n  /** Closing time as \"HH:mm\", or \"\" while incomplete. */\n  close: string\n}\n\n/**\n * A whole week. `null` is a closed day, and it is deliberately not `{ open: \"00:00\", close: \"00:00\" }`:\n * those two have to stay different values or \"closed on Sunday\" and \"open around the clock on Sunday\"\n * collapse into the same row, which is the bug this component exists to make impossible.\n */\nexport type WeeklyHoursValue = Record<Weekday, DayHours | null>\n\n// Indexed by `Date.getUTCDay()`, which is what makes the Intl lookups below line up.\nconst DAYS: readonly Weekday[] = [\"sun\", \"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\"]\n\nconst FALLBACK_NAMES: Record<Weekday, string> = {\n  sun: \"Sunday\",\n  mon: \"Monday\",\n  tue: \"Tuesday\",\n  wed: \"Wednesday\",\n  thu: \"Thursday\",\n  fri: \"Friday\",\n  sat: \"Saturday\",\n}\n\nconst MINUTES_PER_DAY = 1440\n\n// `getWeekInfo()` is the method ECMA-402 settled on; Safari shipped the same data as a `weekInfo`\n// getter first and still answers to it. Reading only one of the two puts half the browsers on the\n// fallback, which is how a component ends up showing an American reader a Monday-first week.\ntype WeekInfoLike = { firstDay?: number }\ntype LocaleWithWeekInfo = Intl.Locale & {\n  getWeekInfo?: () => WeekInfoLike\n  weekInfo?: WeekInfoLike\n}\n\n/**\n * Which day this locale starts its week on. Sunday in en-US, Monday in de-DE and ja-JP, Saturday in\n * ar-EG — a hardcoded order is wrong for most of the world, and the order is not cosmetic: people\n * read the first row as \"the start of the week\" and fill the grid from there.\n */\nfunction resolveWeekStart(locale: string | undefined): Weekday {\n  try {\n    const tag = locale ?? new Intl.DateTimeFormat().resolvedOptions().locale\n    const loc = new Intl.Locale(tag) as LocaleWithWeekInfo\n    const info = typeof loc.getWeekInfo === \"function\" ? loc.getWeekInfo() : loc.weekInfo\n    const firstDay = info?.firstDay\n    // ECMA-402 numbers the days 1 = Monday … 7 = Sunday, so the modulo lands Sunday back on 0.\n    if (typeof firstDay === \"number\" && Number.isInteger(firstDay) && firstDay >= 1 && firstDay <= 7) {\n      return DAYS[firstDay % 7]\n    }\n  } catch {\n    // Intl.Locale missing, or the tag is malformed.\n  }\n  // ISO 8601's Monday is the one defensible guess when the runtime will not say.\n  return \"mon\"\n}\n\n/**\n * The weekday names as this locale writes them. 2026-01-04 is a Sunday, so adding the index of DAYS\n * to it walks the week in the same order the array is written. Read in UTC, because formatting a\n * midnight date in a zone west of UTC hands back the day before.\n */\nfunction resolveDayNames(locale: string | undefined): Record<Weekday, string> {\n  let fmt: Intl.DateTimeFormat | null = null\n  try {\n    fmt = new Intl.DateTimeFormat(locale, { weekday: \"long\", timeZone: \"UTC\" })\n  } catch {\n    fmt = null\n  }\n  const out = {} as Record<Weekday, string>\n  DAYS.forEach((day, i) => {\n    let name = FALLBACK_NAMES[day]\n    if (fmt) {\n      try {\n        name = fmt.format(new Date(Date.UTC(2026, 0, 4 + i)))\n      } catch {\n        name = FALLBACK_NAMES[day]\n      }\n    }\n    out[day] = name\n  })\n  return out\n}\n\n/** The week in reading order, starting from `start`. */\nfunction orderFrom(start: Weekday): Weekday[] {\n  const at = DAYS.indexOf(start)\n  const from = at === -1 ? 1 : at\n  return DAYS.map((_, i) => DAYS[(from + i) % DAYS.length])\n}\n\n// Accepts what a server is likely to send as well as what this component emits: \"9:00\" as well as\n// \"09:00\", and \"17:30:00\" as well as \"17:30\". Seconds are dropped rather than kept — opening hours\n// are not kept to the second, and passing them on would only make the two sides of a comparison\n// disagree about width.\nconst TIME_PATTERN = /^(\\d{1,2}):(\\d{2})(?::\\d{2})?$/\n\nfunction normalizeTime(text: unknown): string {\n  if (typeof text !== \"string\") return \"\"\n  const m = TIME_PATTERN.exec(text.trim())\n  if (m === null) return \"\"\n  const hour = Number(m[1])\n  const minute = Number(m[2])\n  if (minute > 59) return \"\"\n  // \"24:00\" is how a lot of stored data writes the end of the day. It is midnight, and the wrap rule\n  // below turns 09:00–24:00 into the fifteen hours it should be rather than throwing the value away.\n  if (hour === 24 && minute === 0) return \"00:00\"\n  if (hour > 23) return \"\"\n  return `${String(hour).padStart(2, \"0\")}:${m[2]}`\n}\n\nfunction normalizeDay(input: unknown): DayHours | null {\n  if (input === null || typeof input !== \"object\") return null\n  const day = input as Partial<DayHours>\n  return { open: normalizeTime(day.open), close: normalizeTime(day.close) }\n}\n\n/** Fills in the days the caller left out. A missing day is a closed day, not an empty open one. */\nfunction normalizeWeek(input: Partial<WeeklyHoursValue> | undefined): WeeklyHoursValue {\n  const out = {} as WeeklyHoursValue\n  for (const day of DAYS) out[day] = normalizeDay(input?.[day])\n  return out\n}\n\nfunction toMinutes(text: string): number | null {\n  const m = /^(\\d{2}):(\\d{2})$/.exec(text)\n  return m === null ? null : Number(m[1]) * 60 + Number(m[2])\n}\n\nexport type DaySpan =\n  /** Open, but nothing typed yet. */\n  | { kind: \"empty\" }\n  /** Open with one side filled in — the one state that is actually wrong. */\n  | { kind: \"partial\" }\n  /** Opening and closing time are equal, which is the whole day. */\n  | { kind: \"allDay\"; minutes: number }\n  /** Closes after midnight: the late bar, the night shift. */\n  | { kind: \"overnight\"; minutes: number }\n  | { kind: \"range\"; minutes: number }\n\n/**\n * Reads one day's two times as a span.\n *\n * A closing time earlier than the opening time is not an error, it is the night: 22:00–02:00 is a\n * bar that shuts at two in the morning, and comparing the two strings naively marks every late\n * business invalid. This is the same rule `time-input` applies to `min`/`max`, lifted from the\n * inside of one field to the pair of them.\n */\nexport function readDaySpan(hours: DayHours): DaySpan {\n  const open = toMinutes(hours.open)\n  const close = toMinutes(hours.close)\n  if (open === null && close === null) return { kind: \"empty\" }\n  if (open === null || close === null) return { kind: \"partial\" }\n  const minutes = (close - open + MINUTES_PER_DAY) % MINUTES_PER_DAY\n  if (minutes === 0) return { kind: \"allDay\", minutes: MINUTES_PER_DAY }\n  return close < open ? { kind: \"overnight\", minutes } : { kind: \"range\", minutes }\n}\n\n/**\n * The days that are open but only half filled in — what to check before saving. Days that are\n * closed, and days whose span merely runs past midnight, are not listed: neither is a mistake.\n */\nexport function incompleteDays(value: Partial<WeeklyHoursValue> | undefined): Weekday[] {\n  const week = normalizeWeek(value)\n  return DAYS.filter((day) => {\n    const hours = week[day]\n    return hours !== null && readDaySpan(hours).kind === \"partial\"\n  })\n}\n\nfunction formatSpan(minutes: number): string {\n  const hours = Math.floor(minutes / 60)\n  const rest = minutes % 60\n  if (hours === 0) return `${rest}m`\n  if (rest === 0) return `${hours}h`\n  return `${hours}h ${rest}m`\n}\n\n// Only the weekday names come from Intl. These are interface copy rather than data — there is one of\n// each, they sit next to the fields they describe, and this is a file you own and edit, so a\n// translation prop would be one more thing to thread through for something a find-and-replace does.\nfunction hintFor(span: DaySpan): string {\n  switch (span.kind) {\n    case \"empty\":\n      return \"\"\n    case \"partial\":\n      return \"Needs an opening and a closing time\"\n    case \"allDay\":\n      return \"Open 24 hours\"\n    case \"overnight\":\n      return `${formatSpan(span.minutes)}, closes the next day`\n    case \"range\":\n      return formatSpan(span.minutes)\n  }\n}\n\ninterface WeeklyHoursProps {\n  /** Controlled value. Days you leave out are closed. */\n  value?: Partial<WeeklyHoursValue>\n  /** Initial value when uncontrolled, same shape as `value`. */\n  defaultValue?: Partial<WeeklyHoursValue>\n  /**\n   * Fires with the whole week on every edit, always with all seven days present.\n   * Store what it hands you verbatim — a half-typed day arrives as `{ open: \"\", close: \"17:00\" }`,\n   * and dropping the incomplete side would take the keystrokes with it.\n   */\n  onChange?: (value: WeeklyHoursValue) => void\n  /** Day the week starts on. Left unset, the locale decides (Sunday in en-US, Monday in de-DE). */\n  weekStartsOn?: Weekday\n  /** BCP-47 locale for the day names, the week start and the clock the fields show. */\n  locale?: string\n  /** Force a 12- or 24-hour clock in the time fields. Left unset, the locale decides. */\n  hour12?: boolean\n  /** Arrow-key increment on the minute segments, in minutes. */\n  minuteStep?: number\n  /** Hours a day is given when it is switched on and has none yet. */\n  defaultDayHours?: DayHours\n  /** Disable every control. */\n  disabled?: boolean\n  /**\n   * When set, a hidden input of this name carries the week as JSON so it submits with a native form.\n   * JSON rather than one field per day because `null` has to survive the trip: a flat encoding has\n   * no way to tell a closed day from a day whose fields were left empty.\n   */\n  name?: string\n  /** Accessible label for the whole editor (default \"Opening hours\"). */\n  \"aria-label\"?: string\n  className?: string\n}\n\nexport function WeeklyHours({\n  value,\n  defaultValue,\n  onChange,\n  weekStartsOn,\n  locale,\n  hour12,\n  minuteStep = 1,\n  defaultDayHours = { open: \"09:00\", close: \"17:00\" },\n  disabled,\n  name,\n  className,\n  \"aria-label\": ariaLabel = \"Opening hours\",\n}: WeeklyHoursProps) {\n  const isControlled = value !== undefined\n  const [inner, setInner] = React.useState<WeeklyHoursValue>(() => normalizeWeek(defaultValue))\n\n  // Read straight from the prop when controlled rather than mirroring it into state. Everything on\n  // screen is in the value — an open day with nothing typed is `{ open: \"\", close: \"\" }`, which is a\n  // different value from `null` — so there is no on-screen state left over to lose, and no effect\n  // needed to keep a copy in step.\n  const week = isControlled ? normalizeWeek(value) : inner\n\n  // What each day had before it was switched off, so switching it back on returns the hours the\n  // person typed instead of the default. Not part of the value: it is a memory, not something shown.\n  const remembered = React.useRef<Partial<Record<Weekday, DayHours>>>({})\n\n  const dayNames = React.useMemo(() => resolveDayNames(locale), [locale])\n  const order = React.useMemo(\n    () => orderFrom(weekStartsOn ?? resolveWeekStart(locale)),\n    [weekStartsOn, locale]\n  )\n\n  const reactId = React.useId()\n  const hintId = (day: Weekday) => `${reactId}-${day}-hint`\n  const labelId = (day: Weekday) => `${reactId}-${day}-label`\n\n  function emit(next: WeeklyHoursValue) {\n    if (!isControlled) setInner(next)\n    onChange?.(next)\n  }\n\n  function setDay(day: Weekday, hours: DayHours | null) {\n    emit({ ...week, [day]: hours })\n  }\n\n  function toggleDay(day: Weekday, open: boolean) {\n    if (!open) {\n      const current = week[day]\n      // Only worth remembering if something was typed; an empty row is not a loss to restore.\n      if (current !== null && (current.open !== \"\" || current.close !== \"\")) {\n        remembered.current[day] = current\n      }\n      setDay(day, null)\n      return\n    }\n    setDay(day, remembered.current[day] ?? { ...defaultDayHours })\n  }\n\n  function applyToAll(source: Weekday) {\n    const hours = week[source]\n    if (hours === null) return\n    const next = {} as WeeklyHoursValue\n    for (const day of DAYS) next[day] = { ...hours }\n    emit(next)\n  }\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={ariaLabel}\n      className={cn(\"w-full text-sm\", disabled && \"opacity-50\", className)}\n    >\n      {order.map((day) => {\n        const hours = week[day]\n        const isOpen = hours !== null\n        const span = hours === null ? null : readDaySpan(hours)\n        const hint = span === null ? \"\" : hintFor(span)\n        const complete =\n          span !== null && (span.kind === \"range\" || span.kind === \"overnight\" || span.kind === \"allDay\")\n\n        return (\n          <div\n            key={day}\n            role=\"group\"\n            aria-labelledby={labelId(day)}\n            aria-describedby={hint === \"\" ? undefined : hintId(day)}\n            className=\"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border py-2 last:border-b-0\"\n          >\n            <div className=\"flex w-36 shrink-0 items-center gap-2\">\n              <input\n                type=\"checkbox\"\n                checked={isOpen}\n                disabled={disabled}\n                // The day name is right there, so the control says what checking it does rather than\n                // repeating the name on its own and leaving a reader to guess what \"Monday\" means.\n                aria-label={`Open on ${dayNames[day]}`}\n                onChange={(e) => toggleDay(day, e.target.checked)}\n                className={cn(\n                  \"h-4 w-4 shrink-0 cursor-pointer rounded-sm accent-primary\",\n                  \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n                  \"disabled:cursor-not-allowed\"\n                )}\n              />\n              <span id={labelId(day)} className={cn(!isOpen && \"text-muted-foreground\")}>\n                {dayNames[day]}\n              </span>\n            </div>\n\n            {hours !== null ? (\n              <div className=\"flex items-center gap-2\">\n                <TimeInput\n                  value={hours.open}\n                  onChange={(next) => setDay(day, { ...hours, open: next })}\n                  locale={locale}\n                  hour12={hour12}\n                  minuteStep={minuteStep}\n                  disabled={disabled}\n                  aria-label={`${dayNames[day]} opening time`}\n                />\n                <span aria-hidden=\"true\" className=\"text-muted-foreground\">\n                  –\n                </span>\n                <TimeInput\n                  value={hours.close}\n                  onChange={(next) => setDay(day, { ...hours, close: next })}\n                  locale={locale}\n                  hour12={hour12}\n                  minuteStep={minuteStep}\n                  disabled={disabled}\n                  aria-label={`${dayNames[day]} closing time`}\n                />\n              </div>\n            ) : (\n              <span className=\"text-muted-foreground\">Closed</span>\n            )}\n\n            <span\n              id={hintId(day)}\n              className={cn(\n                \"text-xs tabular-nums\",\n                span?.kind === \"partial\" ? \"text-destructive\" : \"text-muted-foreground\"\n              )}\n            >\n              {hint}\n            </span>\n\n            {isOpen ? (\n              <button\n                type=\"button\"\n                disabled={disabled || !complete}\n                onClick={() => applyToAll(day)}\n                // The visible text opens the accessible name, so \"click apply to all\" reaches this\n                // button by voice while a screen reader still hears which day is being copied.\n                aria-label={`Apply to all days, using ${dayNames[day]}'s hours`}\n                className={cn(\n                  \"ml-auto rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors\",\n                  \"hover:bg-accent hover:text-accent-foreground\",\n                  \"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n                  \"disabled:pointer-events-none disabled:opacity-0\"\n                )}\n              >\n                Apply to all\n              </button>\n            ) : null}\n          </div>\n        )\n      })}\n      {name ? <input type=\"hidden\" name={name} value={JSON.stringify(week)} /> : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}
