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)
import { normalizeToUTC, utcToLocal } from "@thebookingkit/core";
// Convert a local time (9 AM Eastern) to UTC
const 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 time
const 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 example
const 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)

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 changes
import { isValidTimezone, InvalidTimezoneError } from "@thebookingkit/core";
isValidTimezone("America/New_York"); // true
isValidTimezone("Europe/London"); // true
isValidTimezone("Asia/Tokyo"); // true
isValidTimezone("US/Eastern"); // true (also IANA-compliant)
isValidTimezone("EST"); // false (abbreviation, not IANA)
isValidTimezone("Invalid/Zone"); // false
// Use in validation logic
try {
if (!isValidTimezone(userTimezone)) {
throw new InvalidTimezoneError(`Invalid timezone: ${userTimezone}`);
}
// Proceed with timezone
} catch (error) {
console.error(error.message);
}
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 DST
const tokyoOffset = getTimezoneOffset("Asia/Tokyo", new Date("2026-03-10"));
console.log(tokyoOffset); // Output: 540 (UTC+9 = 540 minutes, all year)

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 EST
const 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 Tokyo
const customerTimezone = "Asia/Tokyo";
// Compute available slots for the customer
const 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 timezone
console.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 booking
const selectedLocalTime = "2026-03-10T08:00:00"; // Customer selects 8 AM Tokyo
const selectedUTC = normalizeToUTC(selectedLocalTime, customerTimezone);
console.log(selectedUTC.toISOString());
// Output: "2026-03-09T23:00:00.000Z" (11 PM New York time previous day)
import { getAvailableSlots } from "@thebookingkit/core";
// ✅ Correct: UTC dates with Z suffix
const 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 time
const wrong = {
start: new Date("2026-03-09T00:00:00"),
end: new Date("2026-03-09T23:59:59"),
};
// Pass UTC dates to getAvailableSlots
const slots = getAvailableSlots(
rules,
[],
[],
correctRange, // Always use UTC!
"America/New_York"
);

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.