Skip to content

Timezone Handling

Timezone handling is one of the hardest parts of scheduling software. The Booking Kit follows a simple principle: store and compute in UTC, display in local time.

import {
normalizeToUTC,
utcToLocal,
isValidTimezone,
getTimezoneOffset,
InvalidTimezoneError,
} from "@thebookingkit/core";

Converts a local time string to a UTC Date:

const utc = normalizeToUTC("2026-03-10T09:00:00", "America/New_York");
// Date: 2026-03-10T14:00:00.000Z (EST is UTC-5)

Converts a UTC Date to a local time string:

const local = utcToLocal(new Date("2026-03-10T14:00:00Z"), "America/New_York");
// "2026-03-10T09:00:00"

Validates an IANA timezone string:

isValidTimezone("America/New_York"); // true
isValidTimezone("US/Eastern"); // true
isValidTimezone("EST"); // false (abbreviation, not IANA)
isValidTimezone("Invalid/Zone"); // false

Returns the UTC offset in minutes for a timezone at a specific date:

getTimezoneOffset("America/New_York", new Date("2026-03-10")); // -300 (EST)
getTimezoneOffset("America/New_York", new Date("2026-07-10")); // -240 (EDT)
{
rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
startTime: "09:00",
endTime: "17:00",
timezone: "America/New_York", // Provider's timezone
}

The slot engine converts rule times to UTC before computing availability. All overlap checks, buffer calculations, and booking comparisons happen in UTC.

3. Slots are displayed in customer timezone

Section titled “3. Slots are displayed in customer timezone”

The customerTimezone parameter controls the localStart and localEnd fields on each slot:

const slots = getAvailableSlots(rules, overrides, bookings, dateRange, "Asia/Tokyo");
// slots[0].startTime → "2026-03-10T14:00:00.000Z" (UTC)
// slots[0].localStart → "11:00 PM" (JST, UTC+9)

The bookings table stores starts_at and ends_at as UTC timestamps. This ensures correct behavior regardless of DST changes between booking creation and the appointment date.

The dateRange parameter passed to getAvailableSlots() must use UTC Date objects. The RRULE expansion uses dateRange.start as dtstart, which determines the time-of-day reference for generated occurrences. Passing local-time dates on a non-UTC server silently shifts the boundary and can exclude an entire day’s occurrences.

// ✅ Correct — explicit UTC with Z suffix
const dateRange = {
start: new Date("2026-03-09T00:00:00.000Z"),
end: new Date("2026-03-09T23:59:59.999Z"),
};
// ✅ Also correct — Date.UTC()
const dateRange = {
start: new Date(Date.UTC(2026, 2, 9, 0, 0, 0)),
end: new Date(Date.UTC(2026, 2, 9, 23, 59, 59, 999)),
};
// ❌ WRONG — parsed as server's local time, breaks on non-UTC servers
const dateRange = {
start: new Date("2026-03-09T00:00:00"), // no Z!
end: new Date("2026-03-09T23:59:59"),
};

Why it breaks: On a server running in Australia/Sydney (UTC+11), new Date("2026-03-09T00:00:00") becomes 2026-03-08T13:00:00Z. The RRULE generates Monday’s occurrence at 13:00 UTC on March 9 — but dateRange.end is 12:59:59 UTC, so the entire day is excluded.

The Booking Kit handles DST transitions correctly:

  • Spring forward: If a provider is available 9am-5pm Eastern and DST springs forward on March 8, slots on March 9 correctly map to UTC-4 instead of UTC-5
  • Fall back: The extra hour during fall-back does not create duplicate slots
  • Recurring bookings: Weekly series maintain the same local time across DST boundaries (e.g., “every Monday at 9am” stays at 9am local time)

The Booking Kit uses date-fns and date-fns-tz for all date/time operations:

  • fromZonedTime() — Convert local time to UTC
  • toZonedTime() — Convert UTC to local time
  • format() — Format dates in a specific timezone

These are peer dependencies of @thebookingkit/core.