11 min read
Implemented in Booking Kit
Full-stack booking & appointment starter: timezone-correct availability engine, specialist picker, Stripe pay-on-booking, and admin — React + Go or Node + MongoDB
See it running — Booking Kit demoAn availability engine looks trivial until it ships. It works on your machine, in your timezone, on an ordinary week. Then a customer in another timezone sees every slot an hour off, or the clocks change and a Sunday grows a slot that doesn't exist. Every one of these is the same mistake: treating a wall-clock time as if it were a fixed point in time.
A working window — open 09:00 to 12:00 — is wall-clock time in the business's timezone. It is not a UTC offset and not a number of minutes from midnight UTC. 09:00 in a Paris salon is 07:00 UTC in summer and 08:00 UTC in winter, because Paris is UTC+2 under DST and UTC+1 otherwise. The same "09:00" maps to two different instants depending on the date.
So the rule that makes the whole engine correct: build the window in the business's timezone, resolve the offset there, and only then convert to UTC.
The tempting shortcut is to take midnight UTC, add the window's minutes, and step by the slot length:
// WRONG: minutes from a UTC midnight
const base = Date.UTC(2026, 7, 17, 0, 0); // 2026-08-17 00:00 UTC
const start = new Date(base + 9 * 60 * 60_000); // "09:00" — but 09:00 where?That is 09:00 UTC, not 09:00 in Paris. Every slot is off by the zone's offset. Use the server's local time instead of UTC and it is the same class of bug, only hidden until you deploy to a box in a different region than the customer. And across a DST boundary it gets worse: add a fixed number of hours across the spring-forward night and you land an hour off, because that day does not have 24 hours.
Build each boundary as a wall-clock instant in the availability timezone and let the date library resolve the offset on the resulting wall clock. Here that is luxon; the Go edition uses time.Date(y, mo, d, 0, minute, 0, 0, loc) — the same idea.
import { DateTime } from 'luxon';
// "minutes from midnight" → an instant, resolved IN `zone`.
function wallClock(year, month, day, minutesFromMidnight, zone) {
const days = Math.floor(minutesFromMidnight / 1440);
const rem = minutesFromMidnight - days * 1440; // 0..1439
const hour = Math.floor(rem / 60);
const minute = rem % 60;
return DateTime.fromObject({ year, month, day, hour, minute }, { zone })
.plus({ days });
}The whole trick is { zone }: luxon resolves the UTC offset for that wall clock, on that date — so 09:00 in August gets +02:00 and 09:00 in January gets +01:00, automatically. You never write an offset down.
Then step candidate starts in the zone and keep the ones that fit the window, converting to UTC only at the end:
const windowStart = wallClock(y, m, d, w.startMinute, tz);
const windowEnd = wallClock(y, m, d, w.endMinute, tz);
for (let start = windowStart; ; start = start.plus({ minutes: step })) {
const end = start.plus({ minutes: duration });
if (end.toMillis() > windowEnd.toMillis()) break; // no full slot fits
// ... minimum-lead check, conflict check against existing bookings ...
out.push({ startAt: start.toJSDate(), endAt: end.toJSDate() }); // UTC instants
}Because start is a zoned DateTime, .plus({ minutes: step }) steps the wall clock, and the conversion to a UTC instant (toJSDate) is exact on both sides of a DST change.
This is the single assertion that pins the whole thing. Paris is UTC+2 in August, so a 09:00 window start must come out as 07:00 UTC — not 09:00 UTC:
const slots = computeSlots({
availability: weekdayAvail('Europe/Paris', MONDAY),
date: '2026-08-17',
durationMinutes: 60,
now: new Date('2026-08-01T00:00:00Z'),
});
expect(slots[0].startAt.toISOString()).toBe('2026-08-17T07:00:00.000Z');Compute slots in UTC or the server's clock and this comes out as 09:00Z — an hour wrong, silently, for every customer, forever.
The reason to build in the zone rather than add fixed offsets is the transition days. On spring-forward night the local clock jumps 02:00 → 03:00, so the day has 23 hours and the 02:00–03:00 wall time never happens (on fall-back it happens twice). Because each boundary is resolved as a wall clock in the zone — not as "midnight UTC plus N hours" — the offset flips at the right instant and the slots land where a human expects.
That is pinned by a test. A 01:00–05:00 window on the EU spring-forward Sunday is four wall-clock hours but only three real ones, so a 60-minute service yields three slots, not four — the 02:00 local slot is absent, because that hour does not exist:
// 2026-03-29, Europe/Paris — the clock jumps 02:00 → 03:00
const slots = computeSlots({
availability, // Sunday 01:00–05:00, Europe/Paris
date: '2026-03-29',
durationMinutes: 60,
now: new Date('2026-03-01T00:00:00Z'),
});
// local 01:00, 03:00, 04:00 → UTC 00:00, 01:00, 02:00
expect(slots.map((s) => s.startAt.toISOString())).toEqual([
'2026-03-29T00:00:00.000Z',
'2026-03-29T01:00:00.000Z',
'2026-03-29T02:00:00.000Z',
]);A "midnight UTC + N hours" engine emits a phantom fourth slot here; this one does not.
The honest part: these two assertions pin the offset case and the spring-forward gap for Europe/Paris. The fall-back day (the hour that runs twice) and every other zone are the same wall-clock resolution, not special-cased code — they follow from the same property, but only these two are behind a test. Copy the wallClock approach and you inherit the property; copy an "add hours to UTC" approach and no test will save you.
Slots come out as UTC instants; store bookings the same way. Conflict detection is then timezone-free — a half-open [start, end) overlap on millisecond instants:
function intervalsOverlap(aStart, aEnd, bStart, bEnd) {
return aStart < bEnd && bStart < aEnd; // instants, location-independent
}The customer's browser renders those instants in their timezone. The business defines availability in its timezone. Nothing in between ever stores a wall-clock string as if it were an instant.
+02:00.The Booking Kit demo runs this engine live — pick a service and watch the open slots come back. The full computeSlots (Go and Node editions, plus buffers, minimum lead time, conflict detection against existing bookings, and per-day exceptions) ships in the kit as a pure, unit-tested function.