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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/business-hours.jsonPrompt
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-invalidand 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-disabledrather than the native attribute that blurs to<body>. - The clock is injected —
nowarrives 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.
Cascading Select
Dependent selects — country then region then city: each rung loads from the one above, changing an upper rung clears the lower ones out loud, and an answer for a rung the reader already left is discarded.
Number Ticker
A count-up number that eases from 0 to its target the first time it scrolls into view — Intl-formatted, prefix/suffix aware.