diff --git a/templates/calendar/actions/get-settings.ts b/templates/calendar/actions/get-settings.ts index 7f0742a48c..46902983ca 100644 --- a/templates/calendar/actions/get-settings.ts +++ b/templates/calendar/actions/get-settings.ts @@ -1,9 +1,8 @@ import { defineAction } from "@agent-native/core"; import { getRequestUserEmail } from "@agent-native/core/server"; -import { getUserSetting } from "@agent-native/core/settings"; import { z } from "zod"; -import { normalizeCalendarSettings } from "../shared/settings.js"; +import { readCalendarSettings } from "../server/lib/calendar-settings.js"; export default defineAction({ description: "Get calendar settings", @@ -12,8 +11,6 @@ export default defineAction({ run: async () => { const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); - return normalizeCalendarSettings( - await getUserSetting(email, "calendar-settings"), - ); + return readCalendarSettings(email); }, }); diff --git a/templates/calendar/actions/list-events.test.ts b/templates/calendar/actions/list-events.test.ts index b5fd8bb7b6..5e12f3ebc6 100644 --- a/templates/calendar/actions/list-events.test.ts +++ b/templates/calendar/actions/list-events.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const getRequestTimezoneMock = vi.hoisted(() => vi.fn()); const getRequestUserEmailMock = vi.hoisted(() => vi.fn()); @@ -252,6 +252,10 @@ describe("list-events inventory contract", () => { verifyShortLivedTokenMock.mockReturnValue({ ok: true }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("keeps legacy callers on CalendarEvent arrays", async () => { const result = await (listEventsAction as any).run( { from: "2026-06-17", to: "2026-06-18" }, @@ -704,6 +708,62 @@ describe("list-events inventory contract", () => { expect(listGoogleEventsMock).not.toHaveBeenCalled(); }); + it("uses the saved timezone for omitted-range inventory cursors", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-17T12:00:00.000Z")); + getRequestTimezoneMock.mockReturnValue("UTC"); + getUserSettingMock.mockResolvedValue({ timezone: "America/New_York" }); + listGoogleEventsMock.mockResolvedValue({ + events: [ + { + id: "google-event-1", + googleEventId: "event-1", + title: "First", + description: "", + start: "2026-06-17T16:00:00.000Z", + end: "2026-06-17T16:30:00.000Z", + location: "", + allDay: false, + source: "google", + accountEmail: "steve@example.com", + createdAt: "2026-06-12T10:13:39.746Z", + updatedAt: "2026-06-12T10:13:39.746Z", + }, + { + id: "google-event-2", + googleEventId: "event-2", + title: "Second", + description: "", + start: "2026-06-17T17:00:00.000Z", + end: "2026-06-17T17:30:00.000Z", + location: "", + allDay: false, + source: "google", + accountEmail: "steve@example.com", + createdAt: "2026-06-12T10:13:39.746Z", + updatedAt: "2026-06-12T10:13:39.746Z", + }, + ], + errors: [], + }); + + const first = await (listEventsAction as any).run( + { format: "inventory", pageSize: 1, sources: ["google"] }, + { caller: "mcp" }, + ); + const second = await (listEventsAction as any).run( + { + format: "inventory", + pageSize: 1, + sources: ["google"], + cursor: first.page.nextCursor, + }, + { caller: "mcp" }, + ); + + expect(second.items.map((item: any) => item.id)).toEqual(["event-2"]); + }); + it("rejects a malformed inventory cursor before provider reads", async () => { await expect( (listEventsAction as any).run( diff --git a/templates/calendar/actions/list-events.ts b/templates/calendar/actions/list-events.ts index 995e4d0dee..55a40e29bd 100644 --- a/templates/calendar/actions/list-events.ts +++ b/templates/calendar/actions/list-events.ts @@ -13,9 +13,16 @@ import { and, gte, inArray, lte, ne } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { getCalendarTimezone } from "../server/lib/calendar-settings.js"; import * as googleCalendar from "../server/lib/google-calendar.js"; import { fetchICalEvents } from "../server/lib/ical-fetcher.js"; import type { CalendarEvent, ExternalCalendar } from "../shared/api.js"; +import { + addDaysToDateKey, + dateKeyInTimezone, + dateTimeInTimezoneToIso, + isCalendarTimezone, +} from "../shared/timezone.js"; import { calendarEventMatchesQuery } from "./event-search.js"; const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -74,6 +81,7 @@ interface ListCalendarEventsArgs { interface ListCalendarEventsOptions { ownedAccounts?: string[]; range?: CalendarEventRange; + timezone?: string; } type CalendarInventorySource = "google" | "bookings" | "ics" | "overlays"; @@ -346,84 +354,9 @@ function compactInventoryEvent(event: CalendarEvent): CalendarInventoryItem { }; } -function normalizeTimezone(timezone?: string): string { - if (!timezone) return "UTC"; - try { - new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(); - return timezone; - } catch { - return "UTC"; - } -} - -function datePartsInTimezone(date: Date, timezone: string) { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - hourCycle: "h23", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }).formatToParts(date); - const get = (type: string) => - Number(parts.find((part) => part.type === type)?.value ?? "0"); - return { - year: get("year"), - month: get("month"), - day: get("day"), - hour: get("hour"), - minute: get("minute"), - second: get("second"), - }; -} - -function dateOnlyInTimezone(date: Date, timezone: string): string { - const parts = datePartsInTimezone(date, timezone); - return [ - String(parts.year).padStart(4, "0"), - String(parts.month).padStart(2, "0"), - String(parts.day).padStart(2, "0"), - ].join("-"); -} - -function addDaysToDateOnly(dateOnly: string, days: number): string { - const [year, month, day] = dateOnly.split("-").map(Number); - const date = new Date(Date.UTC(year, month - 1, day + days)); - return [ - String(date.getUTCFullYear()).padStart(4, "0"), - String(date.getUTCMonth() + 1).padStart(2, "0"), - String(date.getUTCDate()).padStart(2, "0"), - ].join("-"); -} - -function offsetMsForTimezone(date: Date, timezone: string): number { - const parts = datePartsInTimezone(date, timezone); - const asUtc = Date.UTC( - parts.year, - parts.month - 1, - parts.day, - parts.hour, - parts.minute, - parts.second, - ); - return asUtc - date.getTime(); -} - -function zonedDateOnlyToUtcIso(dateOnly: string, timezone: string): string { - const [year, month, day] = dateOnly.split("-").map(Number); - const wallClockUtc = Date.UTC(year, month - 1, day, 0, 0, 0); - const firstGuess = new Date(wallClockUtc); - const firstOffset = offsetMsForTimezone(firstGuess, timezone); - const secondGuess = new Date(wallClockUtc - firstOffset); - const secondOffset = offsetMsForTimezone(secondGuess, timezone); - return new Date(wallClockUtc - secondOffset).toISOString(); -} - function normalizeDateBound(value: string, timezone: string): string { if (DATE_ONLY_RE.test(value)) { - return zonedDateOnlyToUtcIso(value, timezone); + return dateTimeInTimezoneToIso(value, "00:00", timezone); } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { @@ -437,19 +370,20 @@ export function resolveCalendarEventRange(args: { to?: string; timezone?: string; }): CalendarEventRange { - const timezone = normalizeTimezone(args.timezone ?? getRequestTimezone()); - const today = dateOnlyInTimezone(new Date(), timezone); + const requested = args.timezone ?? getRequestTimezone(); + const timezone = isCalendarTimezone(requested) ? requested : "UTC"; + const today = dateKeyInTimezone(new Date(), timezone); let from = args.from?.trim(); let to = args.to?.trim(); let defaulted = false; if (!from && !to) { from = today; - to = addDaysToDateOnly(today, 1); + to = addDaysToDateKey(today, 1); defaulted = true; } else if (from && !to) { if (DATE_ONLY_RE.test(from)) { - to = addDaysToDateOnly(from, 1); + to = addDaysToDateKey(from, 1); } else { const start = new Date(from); if (Number.isNaN(start.getTime())) { @@ -578,11 +512,13 @@ export async function listCalendarEvents( ): Promise { const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); + const timezone = options.timezone ?? (await getCalendarTimezone(email)); const range = options.range ?? resolveCalendarEventRange({ from: args.from, to: args.to, + timezone, }); const sources = resolveInventorySources(args.sources); @@ -829,6 +765,9 @@ export default defineAction({ args.format === "inventory" || (ctx?.caller === "mcp" && !args.format); const owner = inventory ? getRequestUserEmail() : undefined; if (inventory && !owner) throw new Error("no authenticated user"); + const calendarTimezone = inventory + ? await getCalendarTimezone(owner!) + : undefined; // Reject invalid, expired, owner-bound, and query-bound cursors before any // provider call. Omitted account filters require the cheap owned-account @@ -842,6 +781,7 @@ export default defineAction({ preparedRange = resolveCalendarEventRange({ from: args.from, to: args.to, + timezone: calendarTimezone, }); preparedOwnedAccounts = args.accountEmails ? undefined @@ -868,6 +808,7 @@ export default defineAction({ { ownedAccounts: preparedOwnedAccounts, range: preparedRange, + timezone: calendarTimezone, }, ); diff --git a/templates/calendar/actions/update-settings.ts b/templates/calendar/actions/update-settings.ts index 820037cada..609c628780 100644 --- a/templates/calendar/actions/update-settings.ts +++ b/templates/calendar/actions/update-settings.ts @@ -1,18 +1,21 @@ import { defineAction } from "@agent-native/core"; import { getRequestUserEmail } from "@agent-native/core/server"; -import { - getUserSetting, - putUserSetting, - putSetting, -} from "@agent-native/core/settings"; import { z } from "zod"; -import { normalizeCalendarSettings } from "../shared/settings.js"; +import { saveCalendarSettings } from "../server/lib/calendar-settings.js"; +import { isCalendarTimezone } from "../shared/timezone.js"; export default defineAction({ description: "Update calendar settings", schema: z.object({ - timezone: z.string().optional().describe("Timezone"), + timezone: z + .string() + .trim() + .refine(isCalendarTimezone, { + message: "Timezone must be a valid IANA timezone.", + }) + .optional() + .describe("IANA timezone, e.g. Europe/Warsaw"), bookingPageTitle: z.string().optional().describe("Booking page title"), bookingPageDescription: z .string() @@ -32,14 +35,6 @@ export default defineAction({ run: async (args) => { const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); - const currentSettings = await getUserSetting(email, "calendar-settings"); - const settings = normalizeCalendarSettings({ - ...normalizeCalendarSettings(currentSettings), - ...args, - }); - const settingsRecord = settings as unknown as Record; - await putUserSetting(email, "calendar-settings", settingsRecord); - await putSetting("calendar-settings", settingsRecord); - return settings; + return saveCalendarSettings(email, args); }, }); diff --git a/templates/calendar/actions/view-screen.ts b/templates/calendar/actions/view-screen.ts index 28c4c61cc4..19f43cd081 100644 --- a/templates/calendar/actions/view-screen.ts +++ b/templates/calendar/actions/view-screen.ts @@ -6,11 +6,17 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { rowToBookingLink } from "../server/lib/booking-link-utils.js"; +import { getCalendarTimezone } from "../server/lib/calendar-settings.js"; import type { CalendarEvent, CalendarEventDraft } from "../shared/api.js"; import { CALENDAR_VIEW_PREFERENCES_KEY, normalizeCalendarViewPreferences, } from "../shared/calendar-view-preferences.js"; +import { + addDaysToDateKey, + dateKeyInTimezone, + dateTimeInTimezoneToIso, +} from "../shared/timezone.js"; import { extractVideoLink } from "./event-action-helpers.js"; import { listCalendarEvents } from "./list-events.js"; @@ -49,6 +55,11 @@ async function fetchEventsForRange( } } +function dateKeyFromParts(date: Date): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + export default defineAction({ description: "See what the user is currently looking at on screen. Returns the current view, date range, and visible events. Always call this first before taking any action.", @@ -67,18 +78,22 @@ export default defineAction({ const nav = navigation as any; if (nav?.view === "calendar" || !nav?.view) { - const now = new Date(); - const viewDate = nav?.date ? new Date(nav.date) : now; - - const from = new Date(viewDate); - from.setDate(from.getDate() - from.getDay()); - from.setHours(0, 0, 0, 0); - const to = new Date(from); - to.setDate(to.getDate() + 7); + const email = getRequestUserEmail(); + if (!email) throw new Error("no authenticated user"); + const timezone = await getCalendarTimezone(email); + // Work in calendar days, then resolve the two edges to instants once. + const viewDay = nav?.date ?? dateKeyInTimezone(new Date(), timezone); + // Noon UTC so the weekday can never be shifted by an offset. + const weekday = new Date(`${viewDay}T12:00:00Z`).getUTCDay(); + const weekStart = addDaysToDateKey(viewDay, -weekday); const eventResult = await fetchEventsForRange( - from.toISOString(), - to.toISOString(), + dateTimeInTimezoneToIso(weekStart, "00:00", timezone), + dateTimeInTimezoneToIso( + addDaysToDateKey(weekStart, 7), + "00:00", + timezone, + ), ); const { events } = eventResult; diff --git a/templates/calendar/app/components/calendar/CommandPalette.tsx b/templates/calendar/app/components/calendar/CommandPalette.tsx index 319af9c8d9..85b6468a4b 100644 --- a/templates/calendar/app/components/calendar/CommandPalette.tsx +++ b/templates/calendar/app/components/calendar/CommandPalette.tsx @@ -1,6 +1,7 @@ import { useT } from "@agent-native/core/client/i18n"; import { CommandMenu } from "@agent-native/core/client/navigation"; import type { CalendarEvent } from "@shared/api"; +import { timezoneFormatter } from "@shared/timezone"; import { IconCalendar, IconClock, @@ -28,6 +29,7 @@ interface CommandPaletteProps { open: boolean; onClose: () => void; events: CalendarEvent[]; + timezone?: string; onGoToDate: (date: Date) => void; onEventClick: (event: CalendarEvent) => void; onCreateEvent: () => void; @@ -80,6 +82,7 @@ export function CommandPalette({ open, onClose, events, + timezone, onGoToDate, onEventClick, onCreateEvent, @@ -192,7 +195,10 @@ export function CommandPalette({ /> {event.title} - {format(parseISO(event.start), "MMM d")} + {timezoneFormatter(event.allDay ? "UTC" : timezone, { + month: "short", + day: "numeric", + }).format(new Date(event.start))} ))} diff --git a/templates/calendar/app/components/calendar/EventCard.tsx b/templates/calendar/app/components/calendar/EventCard.tsx index b9410148c0..683a6bd32d 100644 --- a/templates/calendar/app/components/calendar/EventCard.tsx +++ b/templates/calendar/app/components/calendar/EventCard.tsx @@ -1,5 +1,6 @@ import { useT } from "@agent-native/core/client/i18n"; import type { CalendarEvent } from "@shared/api"; +import { timezoneFormatter } from "@shared/timezone"; import { IconAlertTriangleFilled, IconCalendarOff } from "@tabler/icons-react"; import { @@ -26,6 +27,7 @@ interface EventCardProps { onDragEnd?: () => void; dimmed?: boolean; colorPreferences?: CalendarColorPreferences; + timezone?: string; } export function EventCard({ @@ -37,6 +39,7 @@ export function EventCard({ onDragEnd, dimmed = false, colorPreferences, + timezone, }: EventCardProps) { const t = useT(); const workingLocationLabels = createWorkingLocationDisplayLabels(t); @@ -164,10 +167,10 @@ export function EventCard({ )} {!event.allDay && ( - {new Date(event.start).toLocaleTimeString([], { + {timezoneFormatter(timezone, { hour: "numeric", minute: "2-digit", - })} + }).format(new Date(event.start))} )} {event.ownerColor && ( diff --git a/templates/calendar/app/lib/calendar-timezone.ts b/templates/calendar/app/lib/calendar-timezone.ts index 1c7dd576cc..76d1685c87 100644 --- a/templates/calendar/app/lib/calendar-timezone.ts +++ b/templates/calendar/app/lib/calendar-timezone.ts @@ -1,4 +1,5 @@ import type { CalendarEvent } from "@shared/api"; +import { addDaysToDateKey, isCalendarTimezone } from "@shared/timezone"; import { addDays, endOfMonth, @@ -42,20 +43,8 @@ export function getBrowserTimezone(): string { } } -export function isValidTimezone(timezone: string): boolean { - try { - new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(); - return true; - } catch (error) { - if (error instanceof RangeError) return false; - throw error; - } -} - export function normalizeTimezone(timezone?: string): string { - return timezone && isValidTimezone(timezone) - ? timezone - : getBrowserTimezone(); + return isCalendarTimezone(timezone) ? timezone : getBrowserTimezone(); } /** Date carriers are kept at local noon so browser DST never changes their date. */ @@ -68,11 +57,7 @@ export function dateToCalendarDateKey(date: Date): string { return format(date, "yyyy-MM-dd"); } -export function addCalendarDays(date: string, amount: number): string { - const [year, month, day] = date.split("-").map(Number); - const next = new Date(Date.UTC(year, month - 1, day + amount)); - return next.toISOString().slice(0, 10); -} +export const addCalendarDays = addDaysToDateKey; function dateTimeParts(value: Date | string, timezone: string) { const parsed = value instanceof Date ? value : new Date(value); diff --git a/templates/calendar/app/lib/event-form-utils.test.ts b/templates/calendar/app/lib/event-form-utils.test.ts index 9867b238af..cec37309a4 100644 --- a/templates/calendar/app/lib/event-form-utils.test.ts +++ b/templates/calendar/app/lib/event-form-utils.test.ts @@ -38,14 +38,6 @@ describe("buildEventTitleUpdate", () => { }); }); -describe("dateTimeInTimezoneToIso", () => { - it("uses the first valid instant when a timezone skips local midnight", () => { - expect( - dateTimeInTimezoneToIso("2026-09-06", "00:00", "America/Santiago"), - ).toBe("2026-09-06T04:00:00.000Z"); - }); -}); - describe("resolveEventTimezone", () => { it("uses the browser timezone when a new event has no explicit zone", () => { expect(resolveEventTimezone()).toBe(getLocalTimezone()); diff --git a/templates/calendar/app/lib/event-form-utils.ts b/templates/calendar/app/lib/event-form-utils.ts index d498c1b830..61b2f42673 100644 --- a/templates/calendar/app/lib/event-form-utils.ts +++ b/templates/calendar/app/lib/event-form-utils.ts @@ -1,4 +1,7 @@ import type { CalendarEvent, UpdateEventScope } from "@shared/api"; +import { dateTimeInTimezoneToIso } from "@shared/timezone"; + +export { dateTimeInTimezoneToIso }; export type ReminderMethod = "popup" | "email"; export type ReminderMode = "default" | "none" | "custom"; @@ -235,68 +238,6 @@ export function resolveEventTimezone(timezone?: string | null) { return timezone?.trim() || getLocalTimezone(); } -function getTimezoneOffsetMs(date: Date, timezone: string) { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - hourCycle: "h23", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }).formatToParts(date); - const values = new Map(parts.map((part) => [part.type, part.value])); - const asUtc = Date.UTC( - Number(values.get("year")), - Number(values.get("month")) - 1, - Number(values.get("day")), - Number(values.get("hour")), - Number(values.get("minute")), - Number(values.get("second")), - ); - return asUtc - date.getTime(); -} - -export function dateTimeInTimezoneToIso( - date: string, - time: string, - timezone: string, -) { - const [year, month, day] = date.split("-").map(Number); - const [hour, minute] = time.split(":").map(Number); - const wallClockUtc = Date.UTC(year, month - 1, day, hour, minute, 0); - const offsets = new Set(); - for (let hours = -36; hours <= 36; hours += 6) { - offsets.add( - getTimezoneOffsetMs( - new Date(wallClockUtc + hours * 60 * 60 * 1000), - timezone, - ), - ); - } - - const candidates = [...offsets] - .map((offset) => new Date(wallClockUtc - offset)) - .map((candidate) => ({ - candidate, - localWallClock: - candidate.getTime() + getTimezoneOffsetMs(candidate, timezone), - })) - .sort((a, b) => { - const aDelta = a.localWallClock - wallClockUtc; - const bDelta = b.localWallClock - wallClockUtc; - if (aDelta === 0 && bDelta === 0) { - return a.candidate.getTime() - b.candidate.getTime(); - } - if (aDelta >= 0 && bDelta < 0) return -1; - if (aDelta < 0 && bDelta >= 0) return 1; - return Math.abs(aDelta) - Math.abs(bDelta); - }); - - return candidates[0].candidate.toISOString(); -} - export function formatTimezoneLabel(timezone: string) { const city = timezone.split("/").pop()?.replace(/_/g, " ") || timezone; return `${city} (${timezone})`; diff --git a/templates/calendar/changelog/2026-07-22-calendar-grid-local-time.md b/templates/calendar/changelog/2026-07-22-calendar-grid-local-time.md new file mode 100644 index 0000000000..ccedf8a833 --- /dev/null +++ b/templates/calendar/changelog/2026-07-22-calendar-grid-local-time.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-22 +--- + +Calendar views now render, navigate, and create events in the timezone selected in Calendar settings. diff --git a/templates/calendar/changelog/2026-08-19-the-calendar-loads-again-for-accounts-whose-saved-timezone-w.md b/templates/calendar/changelog/2026-08-19-the-calendar-loads-again-for-accounts-whose-saved-timezone-w.md new file mode 100644 index 0000000000..c4eb7be853 --- /dev/null +++ b/templates/calendar/changelog/2026-08-19-the-calendar-loads-again-for-accounts-whose-saved-timezone-w.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-19 +--- + +The calendar grid and settings load again for accounts whose saved timezone was stored in a format the calendar no longer understands diff --git a/templates/calendar/server/handlers/settings.ts b/templates/calendar/server/handlers/settings.ts index cde18e58b5..e44baa529a 100644 --- a/templates/calendar/server/handlers/settings.ts +++ b/templates/calendar/server/handlers/settings.ts @@ -1,13 +1,11 @@ import { readBody, getSession } from "@agent-native/core/server"; -import { - getSetting, - getUserSetting, - putUserSetting, - putSetting, -} from "@agent-native/core/settings"; import { defineEventHandler, setResponseStatus, type H3Event } from "h3"; -import { normalizeCalendarSettings } from "../../shared/settings.js"; +import { + readCalendarSettings, + readPublicCalendarSettings, + saveCalendarSettings, +} from "../lib/calendar-settings.js"; async function uEmail(event: H3Event): Promise { const session = await getSession(event); @@ -20,10 +18,7 @@ async function uEmail(event: H3Event): Promise { export const getSettings = defineEventHandler(async (event: H3Event) => { try { - const email = await uEmail(event); - return normalizeCalendarSettings( - await getUserSetting(email, "calendar-settings"), - ); + return await readCalendarSettings(await uEmail(event)); } catch (error: any) { setResponseStatus(event, 500); return { error: error.message }; @@ -31,24 +26,13 @@ export const getSettings = defineEventHandler(async (event: H3Event) => { }); export const getPublicSettings = defineEventHandler(async (_event: H3Event) => { - return normalizeCalendarSettings(await getSetting("calendar-settings")); + return readPublicCalendarSettings(); }); export const updateSettings = defineEventHandler(async (event: H3Event) => { try { const email = await uEmail(event); - const body = await readBody(event); - const settings = normalizeCalendarSettings({ - ...normalizeCalendarSettings( - await getUserSetting(email, "calendar-settings"), - ), - ...(body && typeof body === "object" ? body : {}), - }); - const settingsRecord = settings as unknown as Record; - await putUserSetting(email, "calendar-settings", settingsRecord); - // Also write to global key so the public booking/settings page can read it - await putSetting("calendar-settings", settingsRecord); - return settings; + return await saveCalendarSettings(email, await readBody(event)); } catch (error: any) { setResponseStatus(event, 500); return { error: error.message }; diff --git a/templates/calendar/server/lib/calendar-settings.spec.ts b/templates/calendar/server/lib/calendar-settings.spec.ts new file mode 100644 index 0000000000..0629b89e04 --- /dev/null +++ b/templates/calendar/server/lib/calendar-settings.spec.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getRequestTimezoneMock = vi.hoisted(() => vi.fn()); +const getSettingMock = vi.hoisted(() => vi.fn()); +const getUserSettingMock = vi.hoisted(() => vi.fn()); +const putSettingMock = vi.hoisted(() => vi.fn()); +const putUserSettingMock = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core/server", () => ({ + getRequestTimezone: getRequestTimezoneMock, +})); +vi.mock("@agent-native/core/settings", () => ({ + getSetting: getSettingMock, + getUserSetting: getUserSettingMock, + putSetting: putSettingMock, + putUserSetting: putUserSettingMock, +})); + +import { + getCalendarTimezone, + readCalendarSettings, + readPublicCalendarSettings, + saveCalendarSettings, +} from "./calendar-settings"; + +const EMAIL = "owner@example.com"; + +beforeEach(() => { + vi.clearAllMocks(); + getRequestTimezoneMock.mockReturnValue("Pacific/Auckland"); + putSettingMock.mockResolvedValue(undefined); + putUserSettingMock.mockResolvedValue(undefined); +}); + +describe("readCalendarSettings", () => { + it("keeps a usable saved timezone", async () => { + getUserSettingMock.mockResolvedValue({ timezone: "Europe/Warsaw" }); + await expect(readCalendarSettings(EMAIL)).resolves.toMatchObject({ + timezone: "Europe/Warsaw", + }); + }); + + it("uses the caller's zone when none is saved", async () => { + getUserSettingMock.mockResolvedValue(null); + await expect(readCalendarSettings(EMAIL)).resolves.toMatchObject({ + timezone: "Pacific/Auckland", + }); + }); + + it("replaces a timezone an older build stored in an unsupported format", async () => { + getUserSettingMock.mockResolvedValue({ timezone: "GMT+2" }); + await expect(readCalendarSettings(EMAIL)).resolves.toMatchObject({ + timezone: "Pacific/Auckland", + }); + }); +}); + +describe("readPublicCalendarSettings", () => { + // A visitor's own zone must never shift the owner's published booking times. + it("uses the fixed default rather than the visitor's zone", async () => { + getSettingMock.mockResolvedValue(null); + await expect(readPublicCalendarSettings()).resolves.toMatchObject({ + timezone: "America/New_York", + }); + }); +}); + +describe("saveCalendarSettings", () => { + it("merges a patch over the stored settings and writes both keys", async () => { + getUserSettingMock.mockResolvedValue({ + timezone: "Europe/Warsaw", + bookingPageTitle: "Book", + }); + + const saved = await saveCalendarSettings(EMAIL, { weekStart: "monday" }); + + expect(saved).toMatchObject({ + timezone: "Europe/Warsaw", + bookingPageTitle: "Book", + weekStart: "monday", + }); + expect(putUserSettingMock).toHaveBeenCalledWith( + EMAIL, + "calendar-settings", + saved, + ); + expect(putSettingMock).toHaveBeenCalledWith("calendar-settings", saved); + }); + + // Saving an unrelated field must not quietly move an account to the fixed + // default zone after it was read as the caller's. + it("does not overwrite the timezone a read would have returned", async () => { + getUserSettingMock.mockResolvedValue(null); + + const read = await readCalendarSettings(EMAIL); + const saved = await saveCalendarSettings(EMAIL, { weekStart: "monday" }); + + expect(saved.timezone).toBe(read.timezone); + expect(saved.timezone).toBe("Pacific/Auckland"); + }); + + it("ignores a patch that is not an object", async () => { + getUserSettingMock.mockResolvedValue({ timezone: "Europe/Warsaw" }); + await expect( + saveCalendarSettings(EMAIL, "nonsense"), + ).resolves.toMatchObject({ timezone: "Europe/Warsaw" }); + }); +}); + +describe("getCalendarTimezone", () => { + // The grid and the settings page resolve through the same read, so they can + // never render an account in different zones. + it("matches what the settings read returns", async () => { + for (const stored of [ + null, + {}, + { timezone: "Europe/Warsaw" }, + { timezone: "GMT+2" }, + { timezone: 42 }, + ]) { + getUserSettingMock.mockResolvedValue(stored); + await expect(getCalendarTimezone(EMAIL)).resolves.toBe( + (await readCalendarSettings(EMAIL)).timezone, + ); + } + }); + + it("resolves a usable zone for a legacy account instead of throwing", async () => { + getUserSettingMock.mockResolvedValue({ timezone: "not-a-timezone" }); + await expect(getCalendarTimezone(EMAIL)).resolves.toBe("Pacific/Auckland"); + }); +}); diff --git a/templates/calendar/server/lib/calendar-settings.ts b/templates/calendar/server/lib/calendar-settings.ts new file mode 100644 index 0000000000..22ba07307d --- /dev/null +++ b/templates/calendar/server/lib/calendar-settings.ts @@ -0,0 +1,58 @@ +import { getRequestTimezone } from "@agent-native/core/server"; +import { + getSetting, + getUserSetting, + putSetting, + putUserSetting, +} from "@agent-native/core/settings"; + +import type { Settings } from "../../shared/api.js"; +import { + DEFAULT_SETTINGS, + normalizeCalendarSettings, +} from "../../shared/settings.js"; +import { isCalendarTimezone } from "../../shared/timezone.js"; + +const SETTINGS_KEY = "calendar-settings"; + +function callerTimezone(): string { + const timezone = getRequestTimezone(); + return isCalendarTimezone(timezone) ? timezone : DEFAULT_SETTINGS.timezone; +} + +export async function readCalendarSettings(email: string): Promise { + return normalizeCalendarSettings(await getUserSetting(email, SETTINGS_KEY), { + timezone: callerTimezone(), + }); +} + +/** + * Settings for the public booking page. The fixed default applies here rather + * than the caller's zone: a visitor must not shift the owner's booking times. + */ +export async function readPublicCalendarSettings(): Promise { + return normalizeCalendarSettings(await getSetting(SETTINGS_KEY)); +} + +/** Merge a patch over the stored settings and persist the whole record. */ +export async function saveCalendarSettings( + email: string, + patch: unknown, +): Promise { + const settings = normalizeCalendarSettings({ + ...(await readCalendarSettings(email)), + ...(patch && typeof patch === "object" ? patch : {}), + }); + const record = settings as unknown as Record; + await Promise.all([ + putUserSetting(email, SETTINGS_KEY, record), + // Also write the global key so the public booking page can read it. + putSetting(SETTINGS_KEY, record), + ]); + return settings; +} + +/** The timezone to compute event ranges in — always a valid IANA zone. */ +export async function getCalendarTimezone(email: string): Promise { + return (await readCalendarSettings(email)).timezone; +} diff --git a/templates/calendar/server/lib/find-time.ts b/templates/calendar/server/lib/find-time.ts index 40f4937dca..eb19f89bcf 100644 --- a/templates/calendar/server/lib/find-time.ts +++ b/templates/calendar/server/lib/find-time.ts @@ -3,6 +3,12 @@ import type { FindTimeParticipant, FindTimeSlot, } from "../../shared/api.js"; +import { + addDaysToDateKey, + dateKeyInTimezone, + dateTimeInTimezoneToIso, + isCalendarTimezone, +} from "../../shared/timezone.js"; export interface AvailabilitySchedule { timezone: string; @@ -37,108 +43,24 @@ const DEFAULT_SCHEDULE: AvailabilitySchedule["schedule"] = { sunday: [], }; +/** + * These live in shared/timezone.ts so the grid, the actions, and this module + * cannot drift on DST edges. Kept under their original names because callers + * across the template import them from here. + */ export function normalizeTimezone(timezone?: string): string { - if (!timezone) return "UTC"; - try { - new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(); - return timezone; - } catch { - return "UTC"; - } -} - -function datePartsInTimezone(date: Date, timezone: string) { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - hourCycle: "h23", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }).formatToParts(date); - const get = (type: string) => - Number(parts.find((part) => part.type === type)?.value ?? "0"); - return { - year: get("year"), - month: get("month"), - day: get("day"), - hour: get("hour"), - minute: get("minute"), - second: get("second"), - }; + return isCalendarTimezone(timezone) ? timezone : "UTC"; } -export function dateOnlyInTimezone(date: Date, timezone: string): string { - const parts = datePartsInTimezone(date, timezone); - return [ - String(parts.year).padStart(4, "0"), - String(parts.month).padStart(2, "0"), - String(parts.day).padStart(2, "0"), - ].join("-"); -} - -export function addDaysToDateOnly(dateOnly: string, days: number): string { - const [year, month, day] = dateOnly.split("-").map(Number); - const date = new Date(Date.UTC(year, month - 1, day + days)); - return [ - String(date.getUTCFullYear()).padStart(4, "0"), - String(date.getUTCMonth() + 1).padStart(2, "0"), - String(date.getUTCDate()).padStart(2, "0"), - ].join("-"); -} - -function offsetMsForTimezone(date: Date, timezone: string): number { - const parts = datePartsInTimezone(date, timezone); - const asUtc = Date.UTC( - parts.year, - parts.month - 1, - parts.day, - parts.hour, - parts.minute, - parts.second, - ); - return asUtc - date.getTime(); -} +export const dateOnlyInTimezone = dateKeyInTimezone; +export const addDaysToDateOnly = addDaysToDateKey; export function zonedDateTimeToUtcIso( dateOnly: string, time: string, timezone: string, ): string { - const [year, month, day] = dateOnly.split("-").map(Number); - const [hour, minute] = time.split(":").map(Number); - const wallClockUtc = Date.UTC(year, month - 1, day, hour, minute, 0); - const offsets = new Set(); - for (let hours = -36; hours <= 36; hours += 6) { - offsets.add( - offsetMsForTimezone( - new Date(wallClockUtc + hours * 60 * 60 * 1000), - timezone, - ), - ); - } - - const candidates = [...offsets] - .map((offset) => new Date(wallClockUtc - offset)) - .map((candidate) => ({ - candidate, - localWallClock: - candidate.getTime() + offsetMsForTimezone(candidate, timezone), - })) - .sort((a, b) => { - const aDelta = a.localWallClock - wallClockUtc; - const bDelta = b.localWallClock - wallClockUtc; - if (aDelta === 0 && bDelta === 0) { - return a.candidate.getTime() - b.candidate.getTime(); - } - if (aDelta >= 0 && bDelta < 0) return -1; - if (aDelta < 0 && bDelta >= 0) return 1; - return Math.abs(aDelta) - Math.abs(bDelta); - }); - - return candidates[0].candidate.toISOString(); + return dateTimeInTimezoneToIso(dateOnly, time, timezone); } function normalizeDateBound(value: string, timezone: string): string { diff --git a/templates/calendar/server/lib/get-settings-action.spec.ts b/templates/calendar/server/lib/get-settings-action.spec.ts new file mode 100644 index 0000000000..4cfb7dcd2c --- /dev/null +++ b/templates/calendar/server/lib/get-settings-action.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getRequestTimezoneMock = vi.hoisted(() => vi.fn()); +const getRequestUserEmailMock = vi.hoisted(() => vi.fn()); +const getUserSettingMock = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core", () => ({ + defineAction: (action: T) => action, +})); +vi.mock("@agent-native/core/server", () => ({ + getRequestTimezone: getRequestTimezoneMock, + getRequestUserEmail: getRequestUserEmailMock, +})); +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: getUserSettingMock, +})); + +import action from "../../actions/get-settings"; + +describe("get-settings timezone default", () => { + beforeEach(() => { + vi.clearAllMocks(); + getRequestUserEmailMock.mockReturnValue("owner@example.com"); + getRequestTimezoneMock.mockReturnValue("Pacific/Auckland"); + }); + + it("uses the caller timezone for an account without saved settings", async () => { + getUserSettingMock.mockResolvedValue(null); + + await expect(action.run({})).resolves.toMatchObject({ + timezone: "Pacific/Auckland", + bookingPageTitle: "Book a Meeting", + defaultEventDuration: 30, + }); + }); + + it("keeps saved settings instead of replacing their timezone", async () => { + getUserSettingMock.mockResolvedValue({ + timezone: "America/New_York", + bookingPageTitle: "Saved title", + bookingPageDescription: "Saved description", + defaultEventDuration: 45, + }); + + await expect(action.run({})).resolves.toMatchObject({ + timezone: "America/New_York", + bookingPageTitle: "Saved title", + }); + }); +}); diff --git a/templates/calendar/server/lib/list-events-action.spec.ts b/templates/calendar/server/lib/list-events-action.spec.ts new file mode 100644 index 0000000000..2e0cc5ad74 --- /dev/null +++ b/templates/calendar/server/lib/list-events-action.spec.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getRequestTimezoneMock = vi.hoisted(() => vi.fn()); +const getRequestUserEmailMock = vi.hoisted(() => vi.fn()); +const getUserSettingMock = vi.hoisted(() => vi.fn()); +const isConnectedMock = vi.hoisted(() => vi.fn()); +const getOwnedAccountEmailsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core/server", () => ({ + getRequestTimezone: getRequestTimezoneMock, + getRequestUserEmail: getRequestUserEmailMock, +})); +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: getUserSettingMock, +})); +vi.mock("@agent-native/core/sharing", () => ({ + accessFilter: vi.fn(() => ({})), +})); +vi.mock("./google-calendar.js", () => ({ + getOwnedAccountEmails: getOwnedAccountEmailsMock, + isConnected: isConnectedMock, +})); +vi.mock("./ical-fetcher.js", () => ({ + fetchICalEvents: vi.fn(), +})); +vi.mock("../db/index.js", () => ({ + schema: { + bookingLinks: { slug: {}, title: {}, color: {} }, + bookingLinkShares: {}, + }, + getDb: () => ({ + select: () => ({ + from: () => ({ + where: async () => [], + }), + }), + }), +})); + +import { + listCalendarEvents, + resolveCalendarEventRange, +} from "../../actions/list-events"; + +describe("calendar event ranges", () => { + beforeEach(() => { + vi.clearAllMocks(); + getRequestUserEmailMock.mockReturnValue("owner@example.com"); + getUserSettingMock + .mockResolvedValueOnce({ timezone: "Europe/Warsaw" }) + .mockResolvedValue([]); + isConnectedMock.mockResolvedValue(false); + getOwnedAccountEmailsMock.mockResolvedValue([]); + }); + + it("uses Calendar settings for date-only list ranges", async () => { + const result = await listCalendarEvents({ + from: "2026-07-23", + to: "2026-07-24", + }); + + expect(result.range).toMatchObject({ + from: "2026-07-22T22:00:00.000Z", + to: "2026-07-23T22:00:00.000Z", + timezone: "Europe/Warsaw", + }); + }); + + it("handles a 23-hour spring-forward calendar day", () => { + expect( + resolveCalendarEventRange({ + from: "2026-03-08", + to: "2026-03-09", + timezone: "America/New_York", + }), + ).toMatchObject({ + from: "2026-03-08T05:00:00.000Z", + to: "2026-03-09T04:00:00.000Z", + }); + }); +}); diff --git a/templates/calendar/server/lib/update-settings-action.spec.ts b/templates/calendar/server/lib/update-settings-action.spec.ts new file mode 100644 index 0000000000..61e6488d6e --- /dev/null +++ b/templates/calendar/server/lib/update-settings-action.spec.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { z } from "zod"; + +const getRequestTimezoneMock = vi.hoisted(() => vi.fn()); +const getRequestUserEmailMock = vi.hoisted(() => vi.fn()); +const getUserSettingMock = vi.hoisted(() => vi.fn()); +const putSettingMock = vi.hoisted(() => vi.fn()); +const putUserSettingMock = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core", () => ({ + defineAction: (action: T) => action, +})); +vi.mock("@agent-native/core/server", () => ({ + getRequestTimezone: getRequestTimezoneMock, + getRequestUserEmail: getRequestUserEmailMock, +})); +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: getUserSettingMock, + putSetting: putSettingMock, + putUserSetting: putUserSettingMock, +})); + +import action from "../../actions/update-settings"; + +describe("update-settings timezone validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + getRequestTimezoneMock.mockReturnValue("America/New_York"); + getRequestUserEmailMock.mockReturnValue("owner@example.com"); + getUserSettingMock.mockResolvedValue(null); + putSettingMock.mockResolvedValue(undefined); + putUserSettingMock.mockResolvedValue(undefined); + }); + + it("rejects an invalid IANA timezone at the action boundary", () => { + // The framework validates against `schema` before `run`; the mocked + // defineAction hands the definition back as-is, so reach it directly. + const { schema } = action as unknown as { schema: z.ZodTypeAny }; + expect(schema.safeParse({ timezone: "not-a-timezone" }).success).toBe( + false, + ); + expect(schema.safeParse({ timezone: "Europe/Warsaw" }).success).toBe(true); + }); + + it("saves a valid timezone", async () => { + const settings = { + timezone: "Europe/Warsaw", + bookingPageTitle: "Book a Meeting", + bookingPageDescription: "Select a time.", + defaultEventDuration: 30, + }; + + const saved = { ...settings, weekStart: "sunday" }; + await expect(action.run(settings)).resolves.toEqual(saved); + expect(putUserSettingMock).toHaveBeenCalledWith( + "owner@example.com", + "calendar-settings", + saved, + ); + }); +}); diff --git a/templates/calendar/shared/settings.test.ts b/templates/calendar/shared/settings.test.ts index 9c44b22b3a..54ddcd7c1f 100644 --- a/templates/calendar/shared/settings.test.ts +++ b/templates/calendar/shared/settings.test.ts @@ -25,4 +25,44 @@ describe("calendar settings", () => { "monday", ); }); + + it("replaces a timezone an older build stored in an unsupported format", () => { + expect( + normalizeCalendarSettings({ timezone: "Pacific Standard Time" }).timezone, + ).toBe("America/New_York"); + }); + + it("uses a caller's fallback zone when the stored one is unusable", () => { + expect( + normalizeCalendarSettings( + { timezone: "GMT+2" }, + { timezone: "Pacific/Auckland" }, + ).timezone, + ).toBe("Pacific/Auckland"); + expect( + normalizeCalendarSettings({}, { timezone: "Pacific/Auckland" }).timezone, + ).toBe("Pacific/Auckland"); + }); + + it("ignores a fallback that is not a real zone", () => { + expect( + normalizeCalendarSettings({}, { timezone: "Pacific Standard Time" }) + .timezone, + ).toBe("America/New_York"); + }); + + it("keeps a stored zone even when a fallback is given", () => { + expect( + normalizeCalendarSettings( + { timezone: "Europe/London" }, + { timezone: "Asia/Tokyo" }, + ).timezone, + ).toBe("Europe/London"); + }); + + it("keeps a valid IANA timezone", () => { + expect( + normalizeCalendarSettings({ timezone: "Europe/Warsaw" }).timezone, + ).toBe("Europe/Warsaw"); + }); }); diff --git a/templates/calendar/shared/settings.ts b/templates/calendar/shared/settings.ts index 54eb4b1fa8..4b6b6a0a63 100644 --- a/templates/calendar/shared/settings.ts +++ b/templates/calendar/shared/settings.ts @@ -3,6 +3,7 @@ import { DEFAULT_CALENDAR_WEEK_START, isCalendarWeekStart, } from "./calendar-week.js"; +import { isCalendarTimezone } from "./timezone.js"; export const DEFAULT_SETTINGS: Settings = { timezone: "America/New_York", @@ -12,33 +13,38 @@ export const DEFAULT_SETTINGS: Settings = { weekStart: DEFAULT_CALENDAR_WEEK_START, }; -export function normalizeCalendarSettings(input: unknown): Settings { +export function normalizeCalendarSettings( + input: unknown, + fallbacks?: Partial, +): Settings { + const defaults = fallbacks + ? normalizeCalendarSettings(fallbacks) + : DEFAULT_SETTINGS; const raw = input && typeof input === "object" ? (input as Partial) : ({} as Partial); return { - timezone: - typeof raw.timezone === "string" - ? raw.timezone - : DEFAULT_SETTINGS.timezone, + timezone: isCalendarTimezone(raw.timezone) + ? raw.timezone + : defaults.timezone, bookingPageTitle: typeof raw.bookingPageTitle === "string" ? raw.bookingPageTitle - : DEFAULT_SETTINGS.bookingPageTitle, + : defaults.bookingPageTitle, bookingPageDescription: typeof raw.bookingPageDescription === "string" ? raw.bookingPageDescription - : DEFAULT_SETTINGS.bookingPageDescription, + : defaults.bookingPageDescription, defaultEventDuration: typeof raw.defaultEventDuration === "number" && Number.isFinite(raw.defaultEventDuration) && raw.defaultEventDuration > 0 ? raw.defaultEventDuration - : DEFAULT_SETTINGS.defaultEventDuration, + : defaults.defaultEventDuration, weekStart: isCalendarWeekStart(raw.weekStart) ? raw.weekStart - : DEFAULT_SETTINGS.weekStart, + : defaults.weekStart, }; } diff --git a/templates/calendar/shared/timezone.test.ts b/templates/calendar/shared/timezone.test.ts new file mode 100644 index 0000000000..7b848053e8 --- /dev/null +++ b/templates/calendar/shared/timezone.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { + addDaysToDateKey, + dateKeyInTimezone, + dateTimeInTimezoneToIso, + isCalendarTimezone, +} from "./timezone"; + +describe("isCalendarTimezone", () => { + it("accepts a valid IANA zone", () => { + expect(isCalendarTimezone("Europe/Warsaw")).toBe(true); + }); + + it("rejects a zone Intl does not know", () => { + expect(isCalendarTimezone("Pacific Standard Time")).toBe(false); + expect(isCalendarTimezone("GMT+2")).toBe(false); + }); + + it("rejects a missing or non-string value instead of throwing", () => { + expect(isCalendarTimezone(undefined)).toBe(false); + expect(isCalendarTimezone(null)).toBe(false); + expect(isCalendarTimezone("")).toBe(false); + expect(isCalendarTimezone(" ")).toBe(false); + expect(isCalendarTimezone(42)).toBe(false); + }); + + it("does not report a non-RangeError fault as an invalid zone", () => { + const format = Intl.DateTimeFormat; + const boom = new TypeError("Intl is broken"); + // @ts-expect-error — replacing the constructor for this assertion only + Intl.DateTimeFormat = function () { + throw boom; + }; + try { + expect(() => isCalendarTimezone("Europe/Warsaw")).toThrow(boom); + } finally { + Intl.DateTimeFormat = format; + } + }); +}); + +describe("dateTimeInTimezoneToIso", () => { + it("resolves an ordinary wall clock", () => { + expect( + dateTimeInTimezoneToIso("2026-08-19", "09:00", "America/New_York"), + ).toBe("2026-08-19T13:00:00.000Z"); + }); + + // Santiago jumps 00:00 -> 01:00, so local midnight never happens. Collapsing + // backward would put the day boundary at 23:00 the previous day. + it("uses the first instant after a skipped midnight", () => { + expect( + dateTimeInTimezoneToIso("2026-09-06", "00:00", "America/Santiago"), + ).toBe("2026-09-06T04:00:00.000Z"); + }); + + // Collapsing backward here would turn a 60-minute event into a 0-minute one. + it("keeps a duration whose end lands in a spring-forward gap", () => { + const start = dateTimeInTimezoneToIso( + "2026-03-08", + "01:30", + "America/New_York", + ); + const end = dateTimeInTimezoneToIso( + "2026-03-08", + "02:30", + "America/New_York", + ); + expect(new Date(end).getTime() - new Date(start).getTime()).toBe( + 60 * 60_000, + ); + }); + + it("picks the earlier instant when a wall clock happens twice", () => { + expect( + dateTimeInTimezoneToIso("2026-11-01", "01:30", "America/New_York"), + ).toBe("2026-11-01T05:30:00.000Z"); + }); +}); + +describe("date keys", () => { + it("reads the calendar day an instant falls on", () => { + const instant = new Date("2026-08-20T01:00:00Z"); // still Aug 19 in New York + expect(dateKeyInTimezone(instant, "America/New_York")).toBe("2026-08-19"); + expect(dateKeyInTimezone(instant, "Europe/Warsaw")).toBe("2026-08-20"); + }); + + it("shifts a key across a month boundary", () => { + expect(addDaysToDateKey("2026-08-30", 7)).toBe("2026-09-06"); + expect(addDaysToDateKey("2026-03-01", -1)).toBe("2026-02-28"); + }); +}); diff --git a/templates/calendar/shared/timezone.ts b/templates/calendar/shared/timezone.ts new file mode 100644 index 0000000000..71fe22e223 --- /dev/null +++ b/templates/calendar/shared/timezone.ts @@ -0,0 +1,127 @@ +/** + * Constructing an `Intl.DateTimeFormat` costs ~45µs, and resolving one wall + * clock probes the zone a dozen times — so formatters are cached per zone and + * option set. Only the date varies per call, and that is an argument to + * `format`/`formatToParts`, never part of the formatter. + */ +const formatterCache = new Map(); + +export function timezoneFormatter( + timezone: string | undefined, + options: Intl.DateTimeFormatOptions, + locale?: string, +): Intl.DateTimeFormat { + const key = `${locale ?? ""}\u0000${timezone ?? ""}\u0000${JSON.stringify(options)}`; + let formatter = formatterCache.get(key); + if (!formatter) { + formatter = new Intl.DateTimeFormat(locale, { + ...options, + timeZone: timezone, + }); + formatterCache.set(key, formatter); + } + return formatter; +} + +/** + * Whether a value names a zone this calendar can use. Only a `RangeError` means + * Intl rejected the zone; any other failure is a real fault and must surface + * instead of being reported as "invalid". + * + * Several older helpers around the template still run their own version of this + * check with a bare `catch`; prefer this one and delete those as you touch them. + */ +export function isCalendarTimezone(value: unknown): value is string { + if (typeof value !== "string" || !value.trim()) return false; + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(); + return true; + } catch (error) { + if (error instanceof RangeError) return false; + throw error; + } +} + +const OFFSET_PROBE_OPTIONS: Intl.DateTimeFormatOptions = { + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", +}; + +function offsetMsInTimezone(date: Date, timezone: string): number { + const parts = timezoneFormatter( + timezone, + OFFSET_PROBE_OPTIONS, + "en-US", + ).formatToParts(date); + const values = new Map(parts.map((part) => [part.type, part.value])); + const asUtc = Date.UTC( + Number(values.get("year")), + Number(values.get("month")) - 1, + Number(values.get("day")), + Number(values.get("hour")), + Number(values.get("minute")), + Number(values.get("second")), + ); + return asUtc - date.getTime(); +} + +export function dateTimeInTimezoneToIso( + date: string, + time: string, + timezone: string, +): string { + const [year, month, day] = date.split("-").map(Number); + const [hour, minute] = time.split(":").map(Number); + const wallClockUtc = Date.UTC(year, month - 1, day, hour, minute, 0); + const offsets = new Set(); + for (let hours = -36; hours <= 36; hours += 6) { + offsets.add( + offsetMsInTimezone( + new Date(wallClockUtc + hours * 60 * 60 * 1000), + timezone, + ), + ); + } + + const candidates = [...offsets] + .map((offset) => new Date(wallClockUtc - offset)) + .map((candidate) => ({ + candidate, + localWallClock: + candidate.getTime() + offsetMsInTimezone(candidate, timezone), + })) + .sort((a, b) => { + const aDelta = a.localWallClock - wallClockUtc; + const bDelta = b.localWallClock - wallClockUtc; + if (aDelta === 0 && bDelta === 0) { + return a.candidate.getTime() - b.candidate.getTime(); + } + if (aDelta >= 0 && bDelta < 0) return -1; + if (aDelta < 0 && bDelta >= 0) return 1; + return Math.abs(aDelta) - Math.abs(bDelta); + }); + + return candidates[0].candidate.toISOString(); +} + +export function dateKeyInTimezone(date: Date, timezone: string): string { + const parts = timezoneFormatter( + timezone, + { year: "numeric", month: "2-digit", day: "2-digit" }, + "en-CA", + ).formatToParts(date); + const value = (type: string) => + parts.find((part) => part.type === type)!.value; + return `${value("year")}-${value("month")}-${value("day")}`; +} + +export function addDaysToDateKey(date: string, amount: number): string { + const [year, month, day] = date.split("-").map(Number); + const shifted = new Date(Date.UTC(year, month - 1, day + amount)); + return shifted.toISOString().slice(0, 10); +}