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.

Slots on a daylight-saving changeover night A timeline of wall-clock hours in Europe/Berlin on 29 March 2026 showing that times between 02:00 and 03:00 do not exist because the clocks jump forward. Wall clock exists skipped exists Requested 02:30 rejected Offered slots 01:00 01:30 03:00 03:30 0 1 2 3 4 5 6 wall-clock hour, 29 Mar
On the spring changeover night, wall-clock times from 02:00 to 02:59 never happen in Berlin; a booking for 02:30 must be rejected, not silently moved.

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 validation order A chosen date and time are converted to a zoned instant in the business's zone, then checked for existence, slot grid alignment, opening hours and lead time. Date + time wall clock values Business zone Europe/Berlin, reject gaps Slot grid multiple of 15 min Opening hours weekday ranges, slot fits Lead time instant ≥ now + 2 h
Existence is checked first, because every later rule assumes the wall-clock time is a real moment in the business's zone.

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.

Slot taken between choosing and submitting The user picks 14:00, another customer books it, the user submits, the server's atomic reservation fails and the error is mapped back onto the time input with fresh slots. User Browser Booking API choose 14:00, fill in details slotError() passes another customer reserves 14:00 submit POST reserve 14:00 409, field time: "just booked", nearest slots setCustomValidity + reportValidity on time
Static rules pass in the browser, but only the server's atomic reservation knows the slot is still free.

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.

← Back to Date and Time Validation