Inputs

Business Hours

A weekly opening-hours editor — per-day open/closed switches, split shifts, blocks that cross midnight, overlap detection that follows the spill into the next day, and a plain-language summary.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Copy, Plus, TriangleAlert, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

/** 0 = Sunday, matching `Date.prototype.getDay()`. */
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6

/** One opening block. A day may hold several of them — that is what a split shift is. */
export interface BusinessHoursRange {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/business-hours.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "BusinessHours" component: a weekly opening-hours
editor. lucide-react for icons, Intl.DateTimeFormat for every weekday name and clock face,
native <input type="time"> for the fields, no date library and no state library.

Contract
- forwardRef<HTMLDivElement>. The ref and every native prop that is not listed below land on
  the root, which is role="group" labelled by its own visible heading; className merges there.
- Props extend Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue">
  (defaultValue is dropped because HTMLAttributes already declares one and it means nothing
  here) and add: value: BusinessHoursValue (controlled, required),
  onChange: (next: BusinessHoursValue) => void, label?: string (default "Opening hours"),
  locale?: string (default "en-US"), weekStartsOn?: 0..6 (default 1),
  maxRangesPerDay?: number (default 3), stepMinutes?: number (default 15),
  defaultRange?: { start: string; end: string } (default 09:00–17:00),
  allowOvernight?: boolean (default true), showSummary?: boolean (default true),
  now?: Date, disabled?: boolean, onIssuesChange?: (issues: BusinessHoursIssue[]) => void.
- The model is deliberately flat and JSON-serialisable:
    type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6      // 0 = Sunday, like Date.getDay()
    BusinessHoursRange { id: string; start: string; end: string }   // "HH:MM"; "" = unset
    BusinessHoursDay   { closed: boolean; ranges: BusinessHoursRange[] }
    BusinessHoursValue = Record<Weekday, BusinessHoursDay>
    BusinessHoursIssue { day: Weekday; rangeIds: string[]; message: string }
  start/end use the 24-hour wire format of <input type="time">, never a locale string, so the
  value posts and diffs cleanly. id is identity only and is never rendered as a value.
- Ship the analysis as pure exported functions beside the component, so the same verdict can be
  reached on a server route: getBusinessHoursIssues(value, { locale, allowOvernight }),
  summarizeBusinessHours(value, { locale, weekStartsOn }), getBusinessHoursStatus(value, now),
  createBusinessHours(literal) for seeding, parseTime(text) -> minutes | null.
- Controlled only. Never own the week, never mutate it in place, always hand onChange a fresh
  object.

Behavior
- Segments are the whole idea. A range is not "start..end on one day": compute
  span = end > start ? end - start : end + 1440 - start, then project it onto real calendar
  days — the part before midnight stays on its own day, and anything past 1440 becomes a TAIL
  segment on the next weekday. end === start therefore means 24 hours, not zero. Every later
  rule (overlaps, the summary, open-now) reads those segments, so all three agree by
  construction.
- Overlap detection runs per calendar day over half-open intervals, so a block ending at 12:00
  and one starting at 12:00 touch rather than clash. Compare all pairs (a week holds a couple
  of dozen segments) and mark BOTH offenders — with one half highlighted the reader has to hunt
  for the other. Deduplicate by range-id pair, because two 24-hour blocks collide on two days.
- The message names what collided. Same day: "9:00 AM – 5:00 PM overlaps 1:00 PM – 6:00 PM."
  Across midnight: "Sunday 10:00 PM – 2:00 AM runs past midnight into Monday and overlaps
  1:00 AM – 5:00 AM." — filed under the day it lands on, while both ranges (on two different
  days) get aria-invalid and aria-describedby pointing at that one message.
- The other refusals: a range missing an endpoint ("A time range needs both an opening and a
  closing time."), a day the caller marked open with no ranges at all, and — when
  allowOvernight is false — any end <= start, which then occupies nothing so it cannot
  manufacture overlap noise on top of its own error. Nothing is ever clamped, rounded or
  rewritten: what was typed stays in the field and the verdict is a message.
- The summary is the value read back in one sentence. Walk the days in display order, collapse
  consecutive days with an identical signature into one span, and print
  "Mon – Fri 9:00 AM – 5:00 PM, Sat 10:00 AM – 2:00 PM, closed Sun." Seven identical days
  become "Every day", an all-closed week becomes "Closed all week.", a wrapping block reads
  "5:30 PM – 2:00 AM next day" and a full one reads "open 24 hours".
- Structural edits, each with an announcement and a deliberate focus successor:
  · switching a day off KEEPS its ranges, so switching it back on restores what it had;
  · "+" on an open day appends a block starting an hour after the last one and lasting two
    hours (falling back to defaultRange when that would cross midnight), then focuses its start
    field; "+" on a closed day that still remembers hours simply reopens them;
  · removing the last range of a day does not leave an open day with no hours — it closes the
    day and moves focus to that day's switch, which is the control that can undo it;
  · removing any other range focuses the remove button that slid into its place;
  · copy-to-the-week writes this day onto the other six with FRESHLY minted ids (two days
    sharing an id would make a block collide with itself and break reconciliation on removal),
    and parks focus on the copy button, because Safari does not focus a button on click and the
    rows that just unmounted may still have held the caret.
- Ids come from a ref counter that is read AND written synchronously inside the handler, so
  copying one day into six cannot hand two blocks the same id the way a state counter would.
- Caps and inert states never use the native disabled attribute: the browser blurs a control
  the instant it goes disabled, and the day that hits maxRangesPerDay is usually the one under
  the finger. Use aria-disabled plus an early return in the handler; the refusal is spoken by
  the live region. disabled makes the time fields readOnly + aria-disabled — focusable and
  readable, not ripped out of the tab order — and every handler returns early.
- Keyboard: the whole editor is one flat tab order — switch, start, end, remove for each row,
  then the day's add and copy buttons. Enter/Space activate every button, and the native time
  inputs keep their own segment editing (arrows step the focused segment, digits type into it,
  AM/PM by letter), which is exactly why they are not replaced by a custom popover. No arrow
  hijacking, no focus trap, no roving tabindex: nothing here is a grid.
- A permanently mounted polite live region (role="status", visually hidden) speaks every
  structural edit and every refusal, then clears itself after ~4s so the next identical result
  is announced again. Its timer is cleared on unmount; it is the only timer in the component.
- onIssuesChange fires only when the SET of problems changes — serialise the issue list and
  compare — and reads the callback through a ref so an inline arrow never re-runs the effect.
- now is an INJECTED instant, never new Date(): pass one to get an "Open now / Closed now"
  badge with the closing time, or the next opening found by scanning the segments forward a
  week (skipping tails — a shop that closes at 2 AM does not "open" at midnight). Omit it and
  no clock is consulted at all, so the component server-renders without a hydration mismatch.
  Weekday names and clock faces are formatted against a FIXED anchor week for the same reason.

Rendering & styling
- Semantic tokens only: bg-card + border + divide-y for the day list, bg-primary/border-primary
  for the on switch against bg-muted/border-border for the off one, bg-background for the
  thumb, text-muted-foreground for the closed label, the day chips and the summary,
  text-destructive + TriangleAlert for a refusal, and aria-invalid on the offending fields
  (the Input primitive already carries the destructive ring).
- cn() merges every className. focus-visible:ring-2 ring-ring on the switch, and the Button /
  Input primitives bring their own focus rings.
- Motion is decoration: a 150ms thumb slide and colour transitions, all behind
  motion-reduce:transition-none. With motion off the switch simply snaps.
- Layout: each day is a row that stacks under sm — a fixed-width switch column, a flexible
  column of ranges (which wrap rather than squeeze) plus the refusal message, and a shrink-0
  column with the add and copy buttons. Time fields are w-32 tabular-nums.
- ARIA: role="group" on the root labelled by the heading; role="switch" + aria-checked per day,
  whose accessible name is just the weekday, so it reads "Monday, switch, on"; every time field
  carries an explicit aria-label ("Monday range 2 closes at") because a shared column header
  would not survive split shifts.

Customization levers
- Density: rows are p-2.5 with h-8 fields — drop to p-2 and h-7 for a settings-drawer build;
  nothing is measured in JS, so the layout follows.
- Sub-blocks are independent: pass showSummary={false} to drop the sentence, omit now to drop
  the badge, or delete the copy button entirely if a single-location product does not need it.
- Swap the native time inputs for your own time picker as long as it keeps the "HH:MM" wire
  contract — parseTime and every rule downstream stay untouched.
- Rules: maxRangesPerDay caps split shifts, stepMinutes sets the granularity (1 allows any
  minute), defaultRange seeds a freshly opened day, allowOvernight={false} turns a backwards
  block into a refusal for businesses that must not run past midnight.
- Locale and week start: one locale string drives weekday names, the 12/24-hour clock in the
  fields, the messages and the summary; weekStartsOn reorders the rows without touching the
  data, which stays keyed by Date.getDay().
- Tokens: recolour the on-state switch to var(--chart-2) for a brand that reserves primary for
  submit buttons; keep the refusal on destructive so it never competes with a chart palette.
- Holiday closures and per-date exceptions belong OUTSIDE this component: it describes a
  repeating week, so layer a date-keyed override list on top rather than bending the model.

Concepts

  • Segments, not strings — a range is projected onto real calendar days before anything judges it, so 22:00–02:00 on Friday occupies Friday evening and Saturday small hours; that single move is what makes the overlap check, the summary and the open-now badge agree instead of drifting apart.
  • Refusal is a message, never a rewrite — an overlap, a half-typed range or a backwards block sets aria-invalid and names both offenders in words; nothing is clamped, swapped or silently dropped, so the operator can see what they typed and fix it.
  • Closing keeps the hours — switching a day off is a state, not a deletion: the ranges stay in the value, which is why switching back on restores last week's schedule instead of handing back an empty 9-to-5.
  • Copy is a mint, not a share — pushing one day onto the other six clones the blocks with brand-new ids, because two days pointing at the same id would make a block overlap itself and would break reconciliation the moment one is removed.
  • Focus always has a successor — every control that can vanish under the user (the last range of a day, six days closed by one copy) hands focus somewhere deliberate first, and caps use aria-disabled rather than the native attribute that blurs to <body>.
  • The clock is injectednow arrives as a prop and weekday names are formatted against a fixed anchor week, so the editor renders identically on the server and after hydration; without that prop it consults no clock at all.

On This Page