TL;DR: 09:00 is wall-clock time in a timezone, not a UTC instant. Resolve the offset in the business's zone and convert to UTC last, or you're an hour off across zones and grow a phantom slot on the DST change.
source → https://dashforge-ui.com/guides/availability-slots-timezones-dst
An 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.
"09:00" is not an instant
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 bug: computing slots in UTC (or the server's clock)
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:
Prove it: 09:00 in Paris in August is 07:00 UTC
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 spring-forward day
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.
Store UTC, compare on instants, render local
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.
The checklist
- A working window is wall-clock time in the business's timezone, not a UTC offset.
- Build each boundary with the zone attached; let the library resolve the offset on that wall clock. Never hardcode
+02:00. - Step candidate starts as zoned datetimes; convert to UTC only on output.
- Store bookings as UTC instants; compare with a half-open interval overlap on millis.
- Render in the customer's timezone in the UI, never on the server.
- Test the offset case and a spring-forward date.
A booking slot at "09:00" is not a point in time. That single wrong assumption is why availability engines ship, run fine all week, then quietly grow a slot that doesn't exist on the Sunday the clocks change.
"09:00" is wall-clock time in the business's timezone. In Paris it's 07:00 UTC in summer and 08:00 UTC in winter — same string, two different instants.
The fix is one rule: build the window in the business's timezone, let the date library resolve the offset there, and convert to UTC only at the end. Never write "+02:00" by hand.
Do the naive "midnight UTC + N hours" and two things break:
→ every customer in another timezone sees slots an hour off
→ the spring-forward day has 23 hours, so it grows a phantom slot at 02:00 — an hour that doesn't exist
I wrote it up with the real code and the two tests that pin it: the Paris offset, and a spring-forward case that returns 3 slots, not 4.
source → https://dashforge-ui.com/guides/availability-slots-timezones-dst
If you build scheduling, this is the bug you don't find until production.
Top comments (0)