diff --git a/src/lib/booking.ts b/src/lib/booking.ts
index 2c7abba..3fda534 100644
--- a/src/lib/booking.ts
+++ b/src/lib/booking.ts
@@ -1,4 +1,5 @@
import type { CategoryKey } from "./marketplace";
+import { PROVIDER_TIME_ZONE, todayIsoInZone } from "./timezone";
export interface Service {
id: string;
@@ -60,18 +61,26 @@ const DOW_TITLE = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Compute the next `count` days on the server so the client hydrates
- from stable prop values (no midnight hydration mismatch). */
+ from stable prop values (no midnight hydration mismatch).
+
+ "Today" is anchored to PROVIDER_TIME_ZONE (Lagos) rather than the host
+ machine's own clock — serverless hosts typically run in UTC, so around
+ midnight WAT the old `new Date()` could compute the wrong "toda */
export function buildDates(count = 7): DateChip[] {
- const today = new Date();
+ const todayIso = todayIsoInZone(PROVIDER_TIME_ZONE);
+ const [y, m, d] = todayIso.split("-").map(Number);
+ // Noon UTC avoids landing on a different calendar day when this Date is
+ // later read back with .getDate()/.getDay() in the host's own zone.
+ const today = new Date(Date.UTC(y, m - 1, d, 12));
return Array.from({ length: count }, (_, i) => {
- const d = new Date(today);
- d.setDate(today.getDate() + i);
- const dayIdx = d.getDay();
+ const day = new Date(today);
+ day.setUTCDate(today.getUTCDate() + i);
+ const dayIdx = day.getUTCDay();
return {
- iso: d.toISOString().slice(0, 10),
+ iso: day.toISOString().slice(0, 10),
dow: DOW_SHORT[dayIdx],
- num: String(d.getDate()),
- label: `${DOW_TITLE[dayIdx]} ${d.getDate()} ${MONTHS[d.getMonth()]}`,
+ num: String(day.getUTCDate()),
+ label: `${DOW_TITLE[dayIdx]} ${day.getUTCDate()} ${MONTHS[day.getUTCMonth()]}`,
disabled: dayIdx === 0,
};
});
diff --git a/src/lib/slotLock.ts b/src/lib/slotLock.ts
new file mode 100644
index 0000000..48ce527
--- /dev/null
+++ b/src/lib/slotLock.ts
@@ -0,0 +1,151 @@
+/**
+ * Client-side slot locking, to stop a visitor from double-booking the same
+ * worker/date/time.
+ *
+ * WHY THIS IS CLIENT-SIDE ONLY (read before extending this file)
+ * Double-booking is really a backend concern — the source of truth for "is
+ * this worker already busy at 9:30am Thursday" has to live wherever
+ * appointments are stored. Today `guildworkman-core` doesn't expose an
+ * endpoint to list a *worker's* upcoming appointments — only
+ * `viewAllAppointment`, which returns the logged-in *client's own*
+ * bookings (see `viewAllAppointmentApi` in lib/api.ts). Without a
+ * per-worker availability endpoint, the frontend has no way to ask "is
+ * this slot already taken by someone else" before submitting.
+ *
+ * Until that endpoint exists, this module owns the piece that *is*
+ * legitimately solvable on the frontend: stopping a visitor from
+ * double-booking *themselves* — e.g. opening the same worker's page in two
+ * tabs and paying for the same slot twice, or refreshing mid-checkout and
+ * losing track of a slot they already committed to. It combines
+ * `localStorage` (survives a refresh) with `BroadcastChannel` (near-
+ * instant sync across tabs of the same browser) and a short TTL, so a lock
+ * left behind by a crashed tab or an abandoned checkout releases itself
+ * automatically instead of blocking that slot forever.
+ *
+ * ARCHITECTURAL NOTE: this does NOT prevent two different
+ * visitors (different browsers) from racing for the same slot — that
+ * needs a backend change (a per-worker availability/hold endpoint) which
+ * is out of scope for a Frontend-only issue. Tracking that as a follow-up
+ * is recommended once this lands.
+ */
+
+const CHANNEL_NAME = "gw-slot-locks";
+const STORAGE_PREFIX = "gw-slot-lock:";
+
+/** How long a selected slot stays held before it's released automatically.
+ Long enough to pick a service, fill in the job address, and pay. */
+export const LOCK_TTL_MS = 5 * 60_000;
+
+export interface SlotLock {
+ key: string;
+ holderId: string;
+ expiresAt: number;
+}
+
+function storageKey(key: string): string {
+ return `${STORAGE_PREFIX}${key}`;
+}
+
+/** Builds the lock key for a given worker + date + time slot. */
+export function slotKey(workerId: string, dateIso: string, time: string): string {
+ return `${workerId}|${dateIso}|${time}`;
+}
+
+function readLock(key: string): SlotLock | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = window.localStorage.getItem(storageKey(key));
+ if (!raw) return null;
+ const lock = JSON.parse(raw) as SlotLock;
+ if (!lock.expiresAt || lock.expiresAt <= Date.now()) {
+ window.localStorage.removeItem(storageKey(key));
+ return null;
+ }
+ return lock;
+ } catch {
+ return null;
+ }
+}
+
+function writeLock(key: string, lock: SlotLock | null): void {
+ if (typeof window === "undefined") return;
+ if (lock) window.localStorage.setItem(storageKey(key), JSON.stringify(lock));
+ else window.localStorage.removeItem(storageKey(key));
+}
+
+function getChannel(): BroadcastChannel | null {
+ if (typeof window === "undefined" || typeof BroadcastChannel === "undefined") return null;
+ try {
+ return new BroadcastChannel(CHANNEL_NAME);
+ } catch {
+ return null;
+ }
+}
+
+/** Returns the current lock on `key`, or null if it's free / expired. */
+export function getSlotLock(key: string): SlotLock | null {
+ return readLock(key);
+}
+
+/** True if `key` is currently locked by someone other than `holderId`. */
+export function isLockedByOther(key: string, holderId: string): boolean {
+ const lock = readLock(key);
+ return Boolean(lock && lock.holderId !== holderId);
+}
+
+/** Attempts to acquire (or renew) the lock on `key` for `holderId`.
+ Returns false without changing anything if another holder already has
+ an unexpired lock on it. */
+export function acquireSlotLock(key: string, holderId: string, ttlMs = LOCK_TTL_MS): boolean {
+ const existing = readLock(key);
+ if (existing && existing.holderId !== holderId) return false;
+
+ const lock: SlotLock = { key, holderId, expiresAt: Date.now() + ttlMs };
+ writeLock(key, lock);
+ const channel = getChannel();
+ channel?.postMessage({ type: "lock", key });
+ channel?.close();
+ return true;
+}
+
+/** Releases `key`, but only if `holderId` is the one holding it — so a
+ stale release firing from an old/reloaded tab can't steal a lock that a
+ newer tab has since (legitimately) taken over. */
+export function releaseSlotLock(key: string, holderId: string): void {
+ const existing = readLock(key);
+ if (!existing || existing.holderId !== holderId) return;
+ writeLock(key, null);
+ const channel = getChannel();
+ channel?.postMessage({ type: "release", key });
+ channel?.close();
+}
+
+/** Subscribes to lock changes coming from other tabs — both same-tick
+ updates via `BroadcastChannel` and the native cross-document `storage`
+ event (fires in *other* tabs, not the one that wrote the value, which
+ is exactly the case `BroadcastChannel` covers for same-runtime
+ listeners already active). Returns an unsubscribe function. */
+export function subscribeToLockChanges(onChange: () => void): () => void {
+ if (typeof window === "undefined") return () => {};
+
+ const channel = getChannel();
+ channel?.addEventListener("message", onChange);
+
+ const onStorage = (e: StorageEvent) => {
+ if (e.key?.startsWith(STORAGE_PREFIX)) onChange();
+ };
+ window.addEventListener("storage", onStorage);
+
+ return () => {
+ channel?.removeEventListener("message", onChange);
+ channel?.close();
+ window.removeEventListener("storage", onStorage);
+ };
+}
+
+/** A per-tab identity so a tab's own lock never reads as "locked by
+ someone else" to itself. Call once per component/session, not per
+ render. */
+export function createHolderId(): string {
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+}
diff --git a/src/lib/test/slotLock.test.ts b/src/lib/test/slotLock.test.ts
new file mode 100644
index 0000000..e71a305
--- /dev/null
+++ b/src/lib/test/slotLock.test.ts
@@ -0,0 +1,160 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ LOCK_TTL_MS,
+ acquireSlotLock,
+ getSlotLock,
+ isLockedByOther,
+ releaseSlotLock,
+ slotKey,
+ subscribeToLockChanges,
+} from "../slotLock";
+
+describe("slotKey", () => {
+ it("combines worker, date, and time into a stable key", () => {
+ expect(slotKey("gw-chidi", "2026-07-24", "09:00")).toBe("gw-chidi|2026-07-24|09:00");
+ });
+
+ it("produces different keys for different slots", () => {
+ const a = slotKey("gw-chidi", "2026-07-24", "09:00");
+ const b = slotKey("gw-chidi", "2026-07-24", "09:30");
+ const c = slotKey("gw-chidi", "2026-07-25", "09:00");
+ const d = slotKey("gw-ada", "2026-07-24", "09:00");
+ expect(new Set([a, b, c, d]).size).toBe(4);
+ });
+});
+
+describe("acquireSlotLock / releaseSlotLock / getSlotLock", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("acquires a free slot", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ expect(acquireSlotLock(key, "tab-a")).toBe(true);
+ expect(getSlotLock(key)?.holderId).toBe("tab-a");
+ });
+
+ it("lets the same holder re-acquire (renew) its own lock", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ acquireSlotLock(key, "tab-a");
+ expect(acquireSlotLock(key, "tab-a")).toBe(true);
+ });
+
+ it("refuses to hand a locked slot to a different holder", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ acquireSlotLock(key, "tab-a");
+ expect(acquireSlotLock(key, "tab-b")).toBe(false);
+ // the original holder keeps the lock
+ expect(getSlotLock(key)?.holderId).toBe("tab-a");
+ });
+
+ it("frees the slot once the original holder releases it", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ acquireSlotLock(key, "tab-a");
+ releaseSlotLock(key, "tab-a");
+ expect(getSlotLock(key)).toBeNull();
+ expect(acquireSlotLock(key, "tab-b")).toBe(true);
+ });
+
+ it("does not let a non-holder release someone else's lock", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ acquireSlotLock(key, "tab-a");
+ releaseSlotLock(key, "tab-b"); // wrong holder — should be a no-op
+ expect(getSlotLock(key)?.holderId).toBe("tab-a");
+ });
+});
+
+describe("isLockedByOther", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("is false for a free slot", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ expect(isLockedByOther(key, "tab-a")).toBe(false);
+ });
+
+ it("is false for the slot's own holder", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ acquireSlotLock(key, "tab-a");
+ expect(isLockedByOther(key, "tab-a")).toBe(false);
+ });
+
+ it("is true when a different holder has the lock", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ acquireSlotLock(key, "tab-a");
+ expect(isLockedByOther(key, "tab-b")).toBe(true);
+ });
+});
+
+describe("lock expiry (TTL)", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("releases the lock on its own once the TTL has elapsed", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ acquireSlotLock(key, "tab-a");
+ expect(getSlotLock(key)).not.toBeNull();
+
+ vi.advanceTimersByTime(LOCK_TTL_MS + 1);
+
+ expect(getSlotLock(key)).toBeNull();
+ // and since it's expired, a different holder can now take it
+ expect(acquireSlotLock(key, "tab-b")).toBe(true);
+ });
+
+ it("does not expire early", () => {
+ const key = slotKey("gw-chidi", "2026-07-24", "09:00");
+ acquireSlotLock(key, "tab-a");
+
+ vi.advanceTimersByTime(LOCK_TTL_MS - 1_000);
+
+ expect(getSlotLock(key)?.holderId).toBe("tab-a");
+ expect(acquireSlotLock(key, "tab-b")).toBe(false);
+ });
+});
+
+describe("subscribeToLockChanges", () => {
+ it("notifies subscribers when another tab's storage write fires the native storage event", () => {
+ const onChange = vi.fn();
+ const unsubscribe = subscribeToLockChanges(onChange);
+
+ // The native `storage` event only fires in *other* documents than the
+ // one that wrote the value, so we dispatch it manually here to
+ // simulate that cross-tab signal.
+ window.dispatchEvent(
+ new StorageEvent("storage", { key: "gw-slot-lock:gw-chidi|2026-07-24|09:00" })
+ );
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ unsubscribe();
+ });
+
+ it("ignores storage events for unrelated keys", () => {
+ const onChange = vi.fn();
+ const unsubscribe = subscribeToLockChanges(onChange);
+
+ window.dispatchEvent(new StorageEvent("storage", { key: "some-unrelated-key" }));
+
+ expect(onChange).not.toHaveBeenCalled();
+ unsubscribe();
+ });
+
+ it("stops notifying after unsubscribe", () => {
+ const onChange = vi.fn();
+ const unsubscribe = subscribeToLockChanges(onChange);
+ unsubscribe();
+
+ window.dispatchEvent(
+ new StorageEvent("storage", { key: "gw-slot-lock:gw-chidi|2026-07-24|09:00" })
+ );
+
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
\ No newline at end of file
diff --git a/src/lib/test/timezone.test.ts b/src/lib/test/timezone.test.ts
new file mode 100644
index 0000000..7430063
--- /dev/null
+++ b/src/lib/test/timezone.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it, vi, afterEach } from "vitest";
+import {
+ PROVIDER_TIME_ZONE,
+ convertSlotToZone,
+ getVisitorTimeZone,
+ offsetLabel,
+ todayIsoInZone,
+ zonedTimeToUtc,
+} from "../timezone";
+
+// Fixed zones with no DST, chosen deliberately so the tests are stable
+// year-round instead of depending on today's date relative to a DST
+// boundary:
+// Africa/Lagos UTC+1 (the provider zone itself)
+// Asia/Tokyo UTC+9 (always ahead of Lagos -> exercises +1 day)
+// America/Phoenix UTC-7 (always behind Lagos -> exercises -1 day)
+
+describe("zonedTimeToUtc", () => {
+ it("converts a Lagos wall-clock reading to the matching UTC instant", () => {
+ // 09:00 in Lagos (UTC+1) is 08:00 UTC.
+ const utc = zonedTimeToUtc("2026-07-24", "09:00", PROVIDER_TIME_ZONE);
+ expect(utc.toISOString()).toBe("2026-07-24T08:00:00.000Z");
+ });
+
+ it("is a no-op offset for midnight", () => {
+ const utc = zonedTimeToUtc("2026-01-01", "00:00", PROVIDER_TIME_ZONE);
+ expect(utc.toISOString()).toBe("2025-12-31T23:00:00.000Z");
+ });
+});
+
+describe("convertSlotToZone", () => {
+ it("keeps the same calendar day when the visitor zone is far enough ahead of Lagos midday", () => {
+ // 09:00 Lagos -> Tokyo is Lagos+8h -> 17:00 same day.
+ const zoned = convertSlotToZone("2026-07-24", "09:00", "Asia/Tokyo");
+ expect(zoned).toEqual({ time: "17:00", dayOffset: 0 });
+ });
+
+ it("rolls forward a day when the visitor zone is ahead and the slot is late", () => {
+ // 20:00 Lagos -> Tokyo is Lagos+8h -> 04:00 the next day.
+ const zoned = convertSlotToZone("2026-07-24", "20:00", "Asia/Tokyo");
+ expect(zoned).toEqual({ time: "04:00", dayOffset: 1 });
+ });
+
+ it("keeps the same calendar day when the visitor zone is behind but the slot is late enough", () => {
+ // 09:00 Lagos -> Phoenix is Lagos-8h -> 01:00 same day.
+ const zoned = convertSlotToZone("2026-07-24", "09:00", "America/Phoenix");
+ expect(zoned).toEqual({ time: "01:00", dayOffset: 0 });
+ });
+
+ it("rolls back a day when the visitor zone is behind and the slot is early", () => {
+ // 06:00 Lagos -> Phoenix is Lagos-8h -> 22:00 the previous day.
+ const zoned = convertSlotToZone("2026-07-24", "06:00", "America/Phoenix");
+ expect(zoned).toEqual({ time: "22:00", dayOffset: -1 });
+ });
+
+ it("is a no-op when converting to the provider's own zone", () => {
+ const zoned = convertSlotToZone("2026-07-24", "14:30", PROVIDER_TIME_ZONE);
+ expect(zoned).toEqual({ time: "14:30", dayOffset: 0 });
+ });
+});
+
+describe("offsetLabel", () => {
+ const at = new Date("2026-07-24T12:00:00Z");
+
+ it("formats the provider zone", () => {
+ expect(offsetLabel(PROVIDER_TIME_ZONE, at)).toBe("GMT+1");
+ });
+
+ it("formats a zone ahead of UTC", () => {
+ expect(offsetLabel("Asia/Tokyo", at)).toBe("GMT+9");
+ });
+
+ it("formats a zone behind UTC", () => {
+ expect(offsetLabel("America/Phoenix", at)).toBe("GMT-7");
+ });
+});
+
+describe("todayIsoInZone", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("reports the Lagos calendar date even when the host clock is a UTC instant that has already rolled to the next day locally", () => {
+ // 23:30 UTC on the 24th is already 00:30 on the 25th in Lagos (UTC+1).
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-07-24T23:30:00Z"));
+ expect(todayIsoInZone(PROVIDER_TIME_ZONE)).toBe("2026-07-25");
+ });
+
+ it("reports the visitor's own calendar date the same way", () => {
+ // 01:00 UTC on the 24th is still 17:00 on the 23rd in Phoenix (UTC-7).
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-07-24T01:00:00Z"));
+ expect(todayIsoInZone("America/Phoenix")).toBe("2026-07-23");
+ });
+});
+
+describe("getVisitorTimeZone", () => {
+ it("falls back to the provider zone when Intl is unavailable", () => {
+ const original = globalThis.Intl;
+ // @ts-expect-error deliberately simulating an environment without Intl
+ delete globalThis.Intl;
+ expect(getVisitorTimeZone()).toBe(PROVIDER_TIME_ZONE);
+ globalThis.Intl = original;
+ });
+
+ it("otherwise returns a non-empty IANA zone string", () => {
+ expect(getVisitorTimeZone().length).toBeGreaterThan(0);
+ });
+});
\ No newline at end of file
diff --git a/src/lib/timezone.ts b/src/lib/timezone.ts
new file mode 100644
index 0000000..3f71421
--- /dev/null
+++ b/src/lib/timezone.ts
@@ -0,0 +1,136 @@
+/**
+ * Timezone-aware helpers for the booking calendar.
+ *
+ * BACKGROUND
+ * The backend's `scheduleTime` is a bare `LocalDateTime` with no zone info
+ * (see `ViewAllAppointmentsResponse.scheduleTime` in lib/types.ts) — every
+ * date/time string the API sends and receives is implicitly a wall-clock
+ * reading in the provider's own local time, which for GuildWorkman today
+ * is always Lagos, Nigeria. There's no per-visitor zone stored anywhere on
+ * the backend.
+ *
+ * So "timezone-aware" here means: the source of truth for a slot is a wall
+ * clock time in PROVIDER_TIME_ZONE. This module turns that into a real
+ * point in time (a UTC instant) and re-renders it in whatever zone the
+ * visitor's browser reports, so someone browsing from London or New York
+ * sees slot times in *their* local time instead of silently seeing WAT and
+ * showing up at the wrong hour.
+ */
+
+/** The single provider zone every `scheduleTime` on the backend is anchored
+ to. Becomes a lookup (per worker/region) if GuildWorkman ever operates
+ outside Lagos — hardcoded for now since the backend has no concept of
+ worker timezone either. */
+export const PROVIDER_TIME_ZONE = "Africa/Lagos";
+
+export function getVisitorTimeZone(): string {
+ if (typeof Intl === "undefined") return PROVIDER_TIME_ZONE;
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || PROVIDER_TIME_ZONE;
+ } catch {
+ return PROVIDER_TIME_ZONE;
+ }
+}
+
+interface DateTimeParts {
+ year: number;
+ month: number; // 1-12
+ day: number;
+ hour: number;
+ minute: number;
+}
+
+/** Reads the wall-clock date/time `instant` shows when viewed in `zone`. */
+function getZonedParts(instant: Date, zone: string): DateTimeParts {
+ const dtf = new Intl.DateTimeFormat("en-US", {
+ timeZone: zone,
+ hour12: false,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ const parts = dtf.formatToParts(instant).reduce>((acc, p) => {
+ if (p.type !== "literal") acc[p.type] = p.value;
+ return acc;
+ }, {});
+ return {
+ year: Number(parts.year),
+ month: Number(parts.month),
+ day: Number(parts.day),
+ // Intl reports midnight as "24" in some environments with hour12:false.
+ hour: Number(parts.hour) % 24,
+ minute: Number(parts.minute),
+ };
+}
+
+/** Minutes to ADD to a UTC instant to get the wall-clock reading in `zone`
+ at that moment (e.g. Lagos/WAT is UTC+1 → returns +60). Computed by
+ asking Intl what `zone` reads at `instant` and diffing against UTC,
+ rather than hardcoding an offset, so zones that observe DST stay
+ correct across the year. */
+function getOffsetMinutes(instant: Date, zone: string): number {
+ const zoned = getZonedParts(instant, zone);
+ const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute);
+ return Math.round((asUtc - instant.getTime()) / 60_000);
+}
+
+/**
+ * Converts a "wall clock" date + time meant to be read in `zone` into a
+ * real UTC instant (a `Date`).
+ */
+export function zonedTimeToUtc(dateIso: string, time: string, zone: string): Date {
+ const [year, month, day] = dateIso.split("-").map(Number);
+ const [hour, minute] = time.split(":").map(Number);
+ const naiveUtc = Date.UTC(year, month - 1, day, hour, minute);
+
+ const firstOffset = getOffsetMinutes(new Date(naiveUtc), zone);
+ const candidate = naiveUtc - firstOffset * 60_000;
+
+ const secondOffset = getOffsetMinutes(new Date(candidate), zone);
+ return new Date(naiveUtc - secondOffset * 60_000);
+}
+
+export interface ZonedSlot {
+ /** "HH:mm" as it reads in the target zone. */
+ time: string;
+ dayOffset: number;
+}
+
+/** Converts a provider-local `dateIso` + `time` into how it reads in
+ `zone`, plus how many calendar days that reading has drifted. */
+export function convertSlotToZone(dateIso: string, time: string, zone: string): ZonedSlot {
+ const utcInstant = zonedTimeToUtc(dateIso, time, PROVIDER_TIME_ZONE);
+ const zoned = getZonedParts(utcInstant, zone);
+
+ const zonedDayUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day);
+ const [y, m, d] = dateIso.split("-").map(Number);
+ const providerDayUtc = Date.UTC(y, m - 1, d);
+ const dayOffset = Math.round((zonedDayUtc - providerDayUtc) / 86_400_000);
+
+ return {
+ time: `${String(zoned.hour).padStart(2, "0")}:${String(zoned.minute).padStart(2, "0")}`,
+ dayOffset,
+ };
+}
+
+/** A short human label for a zone's current offset, e.g. "GMT+1" — used in
+ the "times shown in your timezone" banner. Falls back to the bare zone
+ name if the runtime doesn't support `timeZoneName: "shortOffset"`. */
+export function offsetLabel(zone: string, at: Date = new Date()): string {
+ try {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: zone,
+ timeZoneName: "shortOffset",
+ }).formatToParts(at);
+ return parts.find((p) => p.type === "timeZoneName")?.value ?? zone;
+ } catch {
+ return zone;
+ }
+}
+
+export function todayIsoInZone(zone: string): string {
+ const { year, month, day } = getZonedParts(new Date(), zone);
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..50a6d0c
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom",
+ include: ["src/**/*.test.ts"],
+ globals: false,
+ },
+});
\ No newline at end of file