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.
Core utilities
Section titled “Core utilities”import { normalizeToUTC, utcToLocal, isValidTimezone, getTimezoneOffset, InvalidTimezoneError,} from "@thebookingkit/core";normalizeToUTC(localTime, timezone)
Section titled “normalizeToUTC(localTime, timezone)”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)utcToLocal(utcDate, timezone)
Section titled “utcToLocal(utcDate, timezone)”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"isValidTimezone(timezone)
Section titled “isValidTimezone(timezone)”Validates an IANA timezone string:
isValidTimezone("America/New_York"); // trueisValidTimezone("US/Eastern"); // trueisValidTimezone("EST"); // false (abbreviation, not IANA)isValidTimezone("Invalid/Zone"); // falsegetTimezoneOffset(timezone, date)
Section titled “getTimezoneOffset(timezone, date)”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)How timezones flow through the system
Section titled “How timezones flow through the system”1. Rules are defined in provider timezone
Section titled “1. Rules are defined in provider timezone”{ rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", startTime: "09:00", endTime: "17:00", timezone: "America/New_York", // Provider's timezone}2. Slots are computed in UTC
Section titled “2. Slots are computed in UTC”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)4. Bookings are stored in UTC
Section titled “4. Bookings are stored in UTC”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.
Common pitfall: dateRange must be UTC
Section titled “Common pitfall: dateRange must be UTC”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 suffixconst 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 serversconst 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.
DST transitions
Section titled “DST transitions”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)
Practical Examples
Section titled “Practical Examples”Converting between timezones
Section titled “Converting between timezones”import { normalizeToUTC, utcToLocal } from "@thebookingkit/core";
// Convert a local time (9 AM Eastern) to UTCconst utc = normalizeToUTC("2026-03-10T09:00:00", "America/New_York");console.log(utc.toISOString());// Output: "2026-03-10T14:00:00.000Z" (Eastern is UTC-5 in March)
// Convert UTC back to local timeconst local = utcToLocal(new Date("2026-03-10T14:00:00.000Z"), "America/New_York");console.log(local);// Output: "2026-03-10T09:00:00" (back to 9 AM Eastern)
// Cross-timezone exampleconst tokyoTime = utcToLocal(new Date("2026-03-10T14:00:00.000Z"), "Asia/Tokyo");console.log(tokyoTime);// Output: "2026-03-10T23:00:00" (11 PM JST, UTC+9)Handling DST transitions
Section titled “Handling DST transitions”The Booking Kit handles DST correctly across time changes:
import { normalizeToUTC } from "@thebookingkit/core";
// Before spring forward (DST) — EST (UTC-5)const beforeDST = normalizeToUTC("2026-03-08T09:00:00", "America/New_York");console.log(beforeDST.toISOString());// Output: "2026-03-08T14:00:00.000Z"
// After spring forward — EDT (UTC-4)const afterDST = normalizeToUTC("2026-03-09T09:00:00", "America/New_York");console.log(afterDST.toISOString());// Output: "2026-03-09T13:00:00.000Z" (one hour earlier in UTC)
// Recurring bookings stay at the same local time// Every Monday at 9 AM local time, even when DST changesValidating timezones
Section titled “Validating timezones”import { isValidTimezone, InvalidTimezoneError } from "@thebookingkit/core";
isValidTimezone("America/New_York"); // trueisValidTimezone("Europe/London"); // trueisValidTimezone("Asia/Tokyo"); // true
isValidTimezone("US/Eastern"); // true (also IANA-compliant)isValidTimezone("EST"); // false (abbreviation, not IANA)isValidTimezone("Invalid/Zone"); // false
// Use in validation logictry { if (!isValidTimezone(userTimezone)) { throw new InvalidTimezoneError(`Invalid timezone: ${userTimezone}`); } // Proceed with timezone} catch (error) { console.error(error.message);}Getting timezone offset
Section titled “Getting timezone offset”import { getTimezoneOffset } from "@thebookingkit/core";
// Get offset in minutes for a specific date// March 10 is in EDT (UTC-4)const marchOffset = getTimezoneOffset("America/New_York", new Date("2026-03-10"));console.log(marchOffset); // Output: -240 (UTC-4 = -240 minutes)
// July 10 is in EDT (UTC-4)const julyOffset = getTimezoneOffset("America/New_York", new Date("2026-07-10"));console.log(julyOffset); // Output: -240 (still EDT in July)
// Tokyo doesn't observe DSTconst tokyoOffset = getTimezoneOffset("Asia/Tokyo", new Date("2026-03-10"));console.log(tokyoOffset); // Output: 540 (UTC+9 = 540 minutes, all year)Cross-timezone booking flow
Section titled “Cross-timezone booking flow”Complete example: customer in Tokyo booking with provider in New York:
import { getAvailableSlots, normalizeToUTC, utcToLocal } from "@thebookingkit/core";import type { AvailabilityRuleInput } from "@thebookingkit/core";
// Provider in New York, available Mon-Fri 9am-5pm ESTconst providerRules: AvailabilityRuleInput[] = [ { rrule: "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", startTime: "09:00", endTime: "17:00", timezone: "America/New_York", },];
// Customer is in Tokyoconst customerTimezone = "Asia/Tokyo";
// Compute available slots for the customerconst slots = getAvailableSlots( providerRules, [], [], { start: new Date("2026-03-09T00:00:00.000Z"), end: new Date("2026-03-13T23:59:59.999Z"), }, customerTimezone // Slots will show local Tokyo times);
// Customer sees times in their local timezoneconsole.log(slots[0]);// {// startTime: "2026-03-09T14:00:00.000Z", // UTC// endTime: "2026-03-09T14:30:00.000Z",// localStart: "2026-03-09T23:00:00", // 11 PM Tokyo time// localEnd: "2026-03-09T23:30:00",// }
// When customer selects a slot in their time, convert to UTC for bookingconst selectedLocalTime = "2026-03-10T08:00:00"; // Customer selects 8 AM Tokyoconst selectedUTC = normalizeToUTC(selectedLocalTime, customerTimezone);console.log(selectedUTC.toISOString());// Output: "2026-03-09T23:00:00.000Z" (11 PM New York time previous day)Preventing timezone pitfalls
Section titled “Preventing timezone pitfalls”import { getAvailableSlots } from "@thebookingkit/core";
// ✅ Correct: UTC dates with Z suffixconst correctRange = { start: new Date("2026-03-09T00:00:00.000Z"), end: new Date("2026-03-09T23:59:59.999Z"),};
// ✅ Also correct: using Date.UTC()const alsoCorrect = { start: new Date(Date.UTC(2026, 2, 9, 0, 0, 0)), end: new Date(Date.UTC(2026, 2, 9, 23, 59, 59, 999)),};
// ❌ WRONG: Missing Z suffix, parsed as server's local timeconst wrong = { start: new Date("2026-03-09T00:00:00"), end: new Date("2026-03-09T23:59:59"),};
// Pass UTC dates to getAvailableSlotsconst slots = getAvailableSlots( rules, [], [], correctRange, // Always use UTC! "America/New_York");Libraries used
Section titled “Libraries used”The Booking Kit uses date-fns and date-fns-tz for all date/time operations:
fromZonedTime()— Convert local time to UTCtoZonedTime()— Convert UTC to local timeformat()— Format dates in a specific timezone
These are peer dependencies of @thebookingkit/core.