Validating Time Slots and Business Hours
How do you make sure a booking form only accepts times when the business is open, on the right 15-minute grid, at least two hours from now — when the user may be in a different time zone from the business, and one night a year the clock skips from 02:00 to 03:00? This recipe treats the chosen date and time as wall-clock values in the business’s IANA time zone, converts them to an instant with Temporal, rejects nonexistent and ambiguous times explicitly, and checks opening hours, slot steps and lead time against that instant. Every verdict is reported through setCustomValidity() on the time input, keeping the standard Constraint Validation API submit flow.
When to Use Zone-Aware Slot Validation
Use this approach whenever a time the user picks refers to a real event at a real place: appointments, table reservations, deliveries with time windows, click-and-collect slots, video calls with a host in another country. It is necessary when:
- Users can be in another time zone from the business — travellers, remote customers, or simply a server-rendered page viewed abroad.
- Opening hours vary by weekday or have breaks.
- Bookings fall near daylight-saving transitions, which happen at 01:00–03:00 local time in many regions — exactly when some businesses (clinics, 24-hour services) take bookings.
For calendar-only dates with no time of day, none of this applies; keep them as ISO strings as described in the date and time validation topic.
Minimal Working Slot Validator
import { Temporal } from "@js-temporal/polyfill"; // use globalThis.Temporal where available
const BUSINESS_ZONE = "Europe/Berlin";
const SLOT_MINUTES = 15;
const LEAD_MINUTES = 120;
// Opening hours per ISO weekday (1 = Monday … 7 = Sunday). Each day may have several ranges.
const HOURS: Record<number, Array<[string, string]>> = {
1: [["09:00", "12:30"], ["13:30", "18:00"]],
2: [["09:00", "18:00"]],
3: [["09:00", "18:00"]],
4: [["09:00", "20:00"]],
5: [["09:00", "16:00"]],
6: [["10:00", "13:00"]],
7: [],
};
export function slotError(dateISO: string, timeHHMM: string, now = Temporal.Now.instant()): string {
if (!dateISO || !timeHHMM) return "Choose a date and a time.";
const local = Temporal.PlainDateTime.from(`${dateISO}T${timeHHMM}`);
// 1. Does this wall-clock time exist (once) in the business's zone?
let zoned: Temporal.ZonedDateTime;
try {
zoned = local.toZonedDateTime(BUSINESS_ZONE, { disambiguation: "reject" });
} catch {
return "The clocks change that night, so that time doesn't exist. Choose another time.";
}
// 2. On the slot grid?
if ((local.hour * 60 + local.minute) % SLOT_MINUTES !== 0 || local.second !== 0) {
return `Choose a time on the quarter hour, for example ${timeHHMM.slice(0, 2)}:00 or ${timeHHMM.slice(0, 2)}:15.`;
}
// 3. Inside opening hours for that weekday? End time must fit the slot length.
const ranges = HOURS[local.dayOfWeek];
const start = local.toPlainTime();
const end = start.add({ minutes: SLOT_MINUTES });
const open = ranges.some(([a, b]) =>
Temporal.PlainTime.compare(start, Temporal.PlainTime.from(a)) >= 0 &&
Temporal.PlainTime.compare(end, Temporal.PlainTime.from(b)) <= 0,
);
if (!open) {
return ranges.length
? `We're open ${ranges.map(([a, b]) => `${a}–${b}`).join(" and ")} that day. Choose a time within those hours.`
: "We're closed that day. Choose another date.";
}
// 4. Far enough ahead? Compare instants, so the user's own time zone never matters.
const earliest = now.add({ minutes: LEAD_MINUTES });
if (Temporal.Instant.compare(zoned.toInstant(), earliest) < 0) {
return "Bookings need at least 2 hours' notice. Choose a later time.";
}
return "";
}
// Wiring: the time input carries the verdict; the date input re-triggers it.
const form = document.querySelector<HTMLFormElement>("#appointment")!;
const date = form.querySelector<HTMLInputElement>("#appt-date")!;
const time = form.querySelector<HTMLInputElement>("#appt-time")!; // type="time" step="900"
function validateSlot(): void {
time.setCustomValidity("");
if (time.validity.badInput) return; // native message covers partial times
time.setCustomValidity(slotError(date.value, time.value));
}
date.addEventListener("change", validateSlot);
time.addEventListener("change", validateSlot);
form.addEventListener("submit", (event) => {
validateSlot();
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity();
}
});
Setting step="900" on the time input (900 seconds is 15 minutes) makes the native control prefer quarter-hour values and raise stepMismatch for others; the script’s grid check repeats the rule with a friendlier message and guards browsers where users can still type arbitrary minutes.
Slot Rule Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
BUSINESS_ZONE |
IANA zone string | Europe/Berlin |
Zone the opening hours are defined in |
SLOT_MINUTES |
number |
15 |
Grid step and slot length |
LEAD_MINUTES |
number |
120 |
Minimum notice, measured between instants |
HOURS |
weekday → ranges | per business | Supports split shifts and closed days |
disambiguation |
"reject" |
reject |
Turns nonexistent/ambiguous times into errors |
step on type=time |
seconds | 900 |
Native grid hint and stepMismatch |
| Holidays | date set | fetched | Closed dates override the weekday table |
Show the business’s zone in the UI whenever it can differ from the user’s: “Times are in Berlin time (CEST)”. A user in London booking 10:00 must know whether that is their 10:00 or the clinic’s; without the label, a correct validator still produces a missed appointment.
Verification Steps
import { describe, it, expect } from "vitest";
import { Temporal } from "@js-temporal/polyfill";
import { slotError } from "./slots";
const NOW = Temporal.Instant.from("2026-09-18T08:00:00Z"); // 10:00 in Berlin (CEST)
describe("slotError", () => {
it("accepts an open slot with enough notice", () => expect(slotError("2026-09-18", "14:00", NOW)).toBe(""));
it("enforces lead time", () => expect(slotError("2026-09-18", "11:00", NOW)).toMatch(/2 hours/));
it("rejects the lunch break", () => expect(slotError("2026-09-21", "12:45", NOW)).toMatch(/09:00–12:30 and 13:30–18:00/));
it("rejects times skipped by DST", () => {
expect(slotError("2026-03-29", "02:30", Temporal.Instant.from("2026-03-01T00:00:00Z"))).toMatch(/clocks change/);
});
});
Edge Cases and Failure Modes
Using the browser’s time zone. new Date("2026-09-21T10:00") is 10:00 in the user’s zone. For a Berlin clinic and a user in New York, that is six hours off. Always attach the business zone explicitly.
Autumn ambiguity. When clocks go back, 02:30 happens twice. disambiguation: "reject" refuses it; if your business operates at that hour, ask which one (“the first 02:30, before the clocks change”) rather than guessing. Most businesses simply do not offer slots in that hour.
Slots that straddle closing time. A 30-minute slot at 17:45 ends after an 18:00 close. The recipe checks the slot’s end against the range end, not just its start.
Stale “now”. The lead-time rule uses the current instant, which moves while the form is open. Re-validate on submit (the recipe does) and on the server, where the clock is authoritative.
Offering Only Valid Slots Instead of Validating Free Input
The best slot validation is often not needing it: render the available slots for the chosen date as a list of radio buttons, generated from the same HOURS, SLOT_MINUTES and lead-time rules plus live availability from the server. The user can then only pick a valid slot, and the validator’s job shrinks to “a slot is selected” and “it is still available on submit”. Free time entry remains useful as a fallback — for example for a “request a specific time” option — and there the validator above applies unchanged. If you render slots, make the group a <fieldset> with a <legend> naming the date, and announce when the list changes after a date change; the radio-group validation pattern is covered in validating radio groups and fieldsets.
export function availableSlots(dateISO: string, now = Temporal.Now.instant()): string[] {
const day = Temporal.PlainDate.from(dateISO);
const out: string[] = [];
for (const [a, b] of HOURS[day.dayOfWeek]) {
for (let t = Temporal.PlainTime.from(a); Temporal.PlainTime.compare(t.add({ minutes: SLOT_MINUTES }), Temporal.PlainTime.from(b)) <= 0; t = t.add({ minutes: SLOT_MINUTES })) {
const hhmm = t.toString().slice(0, 5);
if (slotError(dateISO, hhmm, now) === "") out.push(hhmm);
}
}
return out;
}
Generating the list through slotError guarantees the two paths agree: a slot shown as available is by construction one the validator accepts.
Re-Checking Availability at Submission
Opening hours are static rules, but availability is live state: another customer can take the 14:00 slot while this user is typing their name. The server must check and reserve atomically on submission and, if the slot has gone, return a field-level error on the time input — “That time was just booked. Here are the nearest free times.” — rather than a generic failure. Map it back exactly like any other server error, following handling server validation errors after submit, and refresh the slot list so the user can pick again without reloading.
Frequently Asked Questions
Which time zone should booking validation use?
The business's IANA time zone, such as Europe/Berlin, because opening hours are defined there. Convert the chosen date and time into that zone explicitly, and label the times in the UI so users know which clock they are reading.
How do I handle times that do not exist because of daylight saving?
Convert with disambiguation: "reject" in Temporal, which throws for nonexistent and ambiguous wall-clock times, and show a message asking for another time instead of silently shifting the booking by an hour.
How do I restrict a time input to 15-minute slots?
Set step="900" on the time input to get native stepMismatch, and repeat the grid check in script with a clear message, since some browsers still let users type other minutes.
Should I show free slots or validate typed times?
Show free slots where you can, generated from the same rules as the validator, so users can only pick valid times. Keep the validator for typed times and for re-checking on submit.
Related Guides
- Date and Time Validation — calendar dates, instants and zones.
- Validating Date Input Min, Max and Format — the date half of the booking form.
- Implementing Async Email Availability Checks — the same live-availability pattern for another field.
- Preventing Double Form Submission — avoiding two bookings from one click.