From 9cf011c29c5e59cfc4f5d88e1568df7408de0fab Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Wed, 22 Jul 2026 18:40:49 +0200 Subject: [PATCH 01/12] fix(calendar): respect configured timezone --- pnpm-lock.yaml | 12 ++ .../components/calendar/CommandPalette.tsx | 10 +- .../app/components/calendar/DayView.tsx | 45 +++++-- .../app/components/calendar/EventCard.tsx | 8 +- .../components/calendar/EventDetailPanel.tsx | 20 ++- .../calendar/EventDetailPopover.tsx | 111 +++++++++------- .../app/components/calendar/EventDialog.tsx | 18 ++- .../app/components/calendar/FindTimePanel.tsx | 3 +- .../app/components/calendar/MonthView.tsx | 15 ++- .../app/components/calendar/WeekView.tsx | 89 ++++++------- .../calendar/app/hooks/use-event-drag.ts | 21 ++- .../app/hooks/use-navigation-state.ts | 16 ++- templates/calendar/app/pages/CalendarView.tsx | 122 +++++++++++------- templates/calendar/app/routes/event.tsx | 15 ++- .../2026-07-22-calendar-grid-local-time.md | 6 + templates/calendar/package.json | 1 + 16 files changed, 327 insertions(+), 185 deletions(-) create mode 100644 templates/calendar/changelog/2026-07-22-calendar-grid-local-time.md diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ed2566060..05542fcda0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1704,6 +1704,9 @@ importers: '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) + date-fns-tz: + specifier: 3.2.0 + version: 3.2.0(date-fns@4.1.0) dotenv: specifier: ^17.2.1 version: 17.4.0 @@ -12183,6 +12186,11 @@ packages: date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -26119,6 +26127,10 @@ snapshots: date-fns-jalali@4.1.0-0: {} + date-fns-tz@3.2.0(date-fns@4.1.0): + dependencies: + date-fns: 4.1.0 + date-fns@4.1.0: {} dayjs@1.11.20: {} diff --git a/templates/calendar/app/components/calendar/CommandPalette.tsx b/templates/calendar/app/components/calendar/CommandPalette.tsx index c7811df491..24d1db11ea 100644 --- a/templates/calendar/app/components/calendar/CommandPalette.tsx +++ b/templates/calendar/app/components/calendar/CommandPalette.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, useEffect } from "react"; import { format, parseISO, parse, isValid } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; import { IconCalendar, IconClock, @@ -20,6 +21,7 @@ interface CommandPaletteProps { open: boolean; onClose: () => void; events: CalendarEvent[]; + timezone: string; onGoToDate: (date: Date) => void; onEventClick: (event: CalendarEvent) => void; onCreateEvent: () => void; @@ -45,6 +47,7 @@ export function CommandPalette({ open, onClose, events, + timezone, onGoToDate, onEventClick, onCreateEvent, @@ -140,7 +143,12 @@ export function CommandPalette({ /> {event.title} - {format(parseISO(event.start), "MMM d")} + {format( + event.allDay + ? parseISO(event.start) + : toZonedTime(event.start, timezone), + "MMM d", + )} ))} diff --git a/templates/calendar/app/components/calendar/DayView.tsx b/templates/calendar/app/components/calendar/DayView.tsx index 398329a7c5..f120351699 100644 --- a/templates/calendar/app/components/calendar/DayView.tsx +++ b/templates/calendar/app/components/calendar/DayView.tsx @@ -7,7 +7,6 @@ import { startOfDay, isSameDay, set, - isToday, addMinutes, addDays, min, @@ -22,10 +21,12 @@ import type { CalendarEvent } from "@shared/api"; import { useEventDrag } from "@/hooks/use-event-drag"; import { useCalendarContext } from "@/components/layout/AppLayout"; import { useViewPreferences } from "@/hooks/use-view-preferences"; +import { toZonedTime } from "date-fns-tz"; interface DayViewProps { events: CalendarEvent[]; date: Date; + timezone: string; onDeleteEvent: (eventId: string) => void; onEventTimeChange?: (eventId: string, newStart: Date, newEnd: Date) => void; onClickTimeSlot?: (date: Date, startTime: string, endTime: string) => void; @@ -72,22 +73,28 @@ interface LayoutInfo { totalCols: number; } -function computeLayout(dayEvents: CalendarEvent[]): Map { +function computeLayout( + dayEvents: CalendarEvent[], + timezone: string, +): Map { const result = new Map(); if (dayEvents.length === 0) return result; const sorted = [...dayEvents].sort((a, b) => { - const aStart = parseISO(a.start).getTime(); - const bStart = parseISO(b.start).getTime(); + const aStart = toZonedTime(a.start, timezone).getTime(); + const bStart = toZonedTime(b.start, timezone).getTime(); if (aStart !== bStart) return aStart - bStart; - return parseISO(b.end).getTime() - parseISO(a.end).getTime(); + return ( + toZonedTime(b.end, timezone).getTime() - + toZonedTime(a.end, timezone).getTime() + ); }); const times = new Map(); for (const ev of sorted) { times.set(ev.id, { - start: parseISO(ev.start).getTime(), - end: parseISO(ev.end).getTime(), + start: toZonedTime(ev.start, timezone).getTime(), + end: toZonedTime(ev.end, timezone).getTime(), }); } @@ -116,6 +123,7 @@ function computeLayout(dayEvents: CalendarEvent[]): Map { export function DayView({ events, date, + timezone, onDeleteEvent, onEventTimeChange, onClickTimeSlot, @@ -173,8 +181,8 @@ export function DayView({ }); function getEventStyle(event: CalendarEvent) { - const start = parseISO(event.start); - const end = parseISO(event.end); + const start = toZonedTime(event.start, timezone); + const end = toZonedTime(event.end, timezone); const dayStart = set(startOfDay(date), { hours: START_HOUR }); const dayEnd = addDays(startOfDay(date), 1); const cappedEnd = min([end, dayEnd]); @@ -192,10 +200,18 @@ export function DayView({ const allDayEvents = useMemo(() => events.filter((e) => e.allDay), [events]); const timedEvents = useMemo(() => events.filter((e) => !e.allDay), [events]); - const layout = useMemo(() => computeLayout(timedEvents), [timedEvents]); + const layout = useMemo( + () => computeLayout(timedEvents, timezone), + [timedEvents, timezone], + ); - const today = isToday(date); - const nowMinutes = (now.getHours() - START_HOUR) * 60 + now.getMinutes(); + const calendarNow = useMemo( + () => toZonedTime(now, timezone), + [now, timezone], + ); + const today = isSameDay(date, calendarNow); + const nowMinutes = + (calendarNow.getHours() - START_HOUR) * 60 + calendarNow.getMinutes(); const nowTop = (nowMinutes / 60) * HOUR_HEIGHT; const showNowIndicator = today && nowMinutes >= 0 && nowMinutes <= (END_HOUR - START_HOUR) * 60; @@ -220,6 +236,7 @@ export function DayView({ scrollContainerRef, onEventTimeChange: handleEventTimeChange, events, + timezone, }); return ( @@ -401,8 +418,8 @@ export function DayView({ } : getEventStyle(event); const color = getEventDisplayColor(event, prefs); - const evStart = parseISO(event.start); - const rawEnd = parseISO(event.end); + const evStart = toZonedTime(event.start, timezone); + const rawEnd = toZonedTime(event.end, timezone); const midnight = addDays(startOfDay(date), 1); const evEnd = min([rawEnd, midnight]); const isOvernightCapped = rawEnd > midnight; diff --git a/templates/calendar/app/components/calendar/EventCard.tsx b/templates/calendar/app/components/calendar/EventCard.tsx index a7ac7e032e..61d059928f 100644 --- a/templates/calendar/app/components/calendar/EventCard.tsx +++ b/templates/calendar/app/components/calendar/EventCard.tsx @@ -7,9 +7,11 @@ import { } from "@/lib/event-colors"; import { IconAlertTriangleFilled } from "@tabler/icons-react"; import type { CalendarEvent } from "@shared/api"; +import { formatInTimeZone } from "date-fns-tz"; interface EventCardProps { event: CalendarEvent; + timezone: string; onClick?: () => void; compact?: boolean; draggable?: boolean; @@ -21,6 +23,7 @@ interface EventCardProps { export function EventCard({ event, + timezone, onClick, compact = false, draggable = false, @@ -100,10 +103,7 @@ export function EventCard({ {!event.allDay && ( - {new Date(event.start).toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", - })} + {formatInTimeZone(event.start, timezone, "h:mm a")} )} diff --git a/templates/calendar/app/components/calendar/EventDetailPanel.tsx b/templates/calendar/app/components/calendar/EventDetailPanel.tsx index ec5b70cbc5..b2883b0eba 100644 --- a/templates/calendar/app/components/calendar/EventDetailPanel.tsx +++ b/templates/calendar/app/components/calendar/EventDetailPanel.tsx @@ -31,6 +31,9 @@ import { useUpdateEvent } from "@/hooks/use-events"; import { useViewPreferences } from "@/hooks/use-view-preferences"; import { toast } from "sonner"; import { useGuestNotificationPrompt } from "@/components/calendar/GuestNotificationDialog"; +import { useSettings } from "@/hooks/use-settings"; +import { toZonedTime } from "date-fns-tz"; +import { getLocalTimezone } from "@/lib/event-form-utils"; interface EventDetailPanelProps { event: CalendarEvent | null; @@ -86,6 +89,8 @@ export function EventDetailPanel({ onTitleSave, }: EventDetailPanelProps) { const { setEventDetailSidebar } = useCalendarContext(); + const { data: settings } = useSettings(); + const displayTimezone = settings?.timezone || getLocalTimezone(); useViewPreferences(); const isOpen = event !== null; const [isEditingTitle, setIsEditingTitle] = useState(false); @@ -287,15 +292,24 @@ export function EventDetailPanel({ ) : ( <> - {format(parseISO(event.start), "h:mm a")} + {format( + toZonedTime(event.start, displayTimezone), + "h:mm a", + )} {" → "} - {format(parseISO(event.end), "h:mm a")} + {format( + toZonedTime(event.end, displayTimezone), + "h:mm a", + )} {formatDuration(event.start, event.end)}
- {format(parseISO(event.start), "EEE MMM d")} + {format( + toZonedTime(event.start, displayTimezone), + "EEE MMM d", + )}
)} diff --git a/templates/calendar/app/components/calendar/EventDetailPopover.tsx b/templates/calendar/app/components/calendar/EventDetailPopover.tsx index 8d42626d7a..16a0954afb 100644 --- a/templates/calendar/app/components/calendar/EventDetailPopover.tsx +++ b/templates/calendar/app/components/calendar/EventDetailPopover.tsx @@ -91,6 +91,8 @@ import { import { getGoogleEventColorHex } from "@/lib/event-colors"; import { shortcutModifierLabel } from "@/lib/utils"; import { useIsMobile } from "@/hooks/use-mobile"; +import { useSettings } from "@/hooks/use-settings"; +import { toZonedTime } from "date-fns-tz"; function formatDuration(start: string, end: string): string { const totalMinutes = differenceInMinutes(parseISO(end), parseISO(start)); @@ -101,8 +103,8 @@ function formatDuration(start: string, end: string): string { return `${hours}h ${minutes}min`; } -function formatTimeShort(dateStr: string): string { - const d = parseISO(dateStr); +function formatTimeShort(dateStr: string, timezone: string): string { + const d = toZonedTime(dateStr, timezone); const h = d.getHours(); const m = d.getMinutes(); const period = h >= 12 ? "PM" : "AM"; @@ -240,8 +242,8 @@ function isUrl(str: string): boolean { } /** Convert ISO date string to local date input value (YYYY-MM-DD) */ -function toDateInputValue(iso: string): string { - const d = parseISO(iso); +function toDateInputValue(iso: string, timezone: string): string { + const d = toZonedTime(iso, timezone); return format(d, "yyyy-MM-dd"); } @@ -251,14 +253,19 @@ function toAllDayEndDateInputValue(iso: string): string { } /** Convert ISO date string to local time input value (HH:mm) */ -function toTimeInputValue(iso: string): string { - const d = parseISO(iso); +function toTimeInputValue(iso: string, timezone: string): string { + const d = toZonedTime(iso, timezone); return format(d, "HH:mm"); } -function formatEventDateRange(start: string, end: string, allDay?: boolean) { - const startDate = parseISO(start); - const endDate = parseISO(end); +function formatEventDateRange( + start: string, + end: string, + allDay: boolean, + timezone: string, +) { + const startDate = allDay ? parseISO(start) : toZonedTime(start, timezone); + const endDate = allDay ? parseISO(end) : toZonedTime(end, timezone); const displayEndDate = allDay ? new Date(endDate.getTime() - 1) : endDate; const startLabel = format(startDate, "EEE MMM d"); const endLabel = format(displayEndDate, "EEE MMM d"); @@ -308,6 +315,9 @@ export function EventDetailPopover({ onDraftDiscard, }: EventDetailPopoverProps) { const isMobile = useIsMobile(); + const { data: settings } = useSettings(); + const displayTimezone = settings?.timezone || getLocalTimezone(); + const eventTimezone = event.startTimeZone || displayTimezone; const [open, setOpen] = useState(defaultOpen); const [editingTitle, setEditingTitle] = useState( defaultOpen ? event.title : "", @@ -329,20 +339,22 @@ export function EventDetailPopover({ event.description || "", ); const [editLocation, setEditLocation] = useState(event.location || ""); - const [editDate, setEditDate] = useState(() => toDateInputValue(event.start)); + const [editDate, setEditDate] = useState(() => + toDateInputValue(event.start, eventTimezone), + ); const [editEndDate, setEditEndDate] = useState(() => event.allDay ? toAllDayEndDateInputValue(event.end) - : toDateInputValue(event.end), + : toDateInputValue(event.end, eventTimezone), ); const [editStartTime, setEditStartTime] = useState(() => - toTimeInputValue(event.start), + toTimeInputValue(event.start, eventTimezone), ); const [editEndTime, setEditEndTime] = useState(() => - toTimeInputValue(event.end), + toTimeInputValue(event.end, eventTimezone), ); const [editTimezone, setEditTimezone] = useState( - event.startTimeZone || getLocalTimezone(), + event.startTimeZone || displayTimezone, ); const [editReminderMode, setEditReminderMode] = useState( () => remindersToDraftState(event).mode, @@ -394,15 +406,16 @@ export function EventDetailPopover({ setEditDescription(event.description || ""); if (editingField !== "location") setEditLocation(event.location || ""); if (editingField !== "time") { - setEditDate(toDateInputValue(event.start)); + const nextEventTimezone = event.startTimeZone || displayTimezone; + setEditDate(toDateInputValue(event.start, nextEventTimezone)); setEditEndDate( event.allDay ? toAllDayEndDateInputValue(event.end) - : toDateInputValue(event.end), + : toDateInputValue(event.end, nextEventTimezone), ); - setEditStartTime(toTimeInputValue(event.start)); - setEditEndTime(toTimeInputValue(event.end)); - setEditTimezone(event.startTimeZone || getLocalTimezone()); + setEditStartTime(toTimeInputValue(event.start, nextEventTimezone)); + setEditEndTime(toTimeInputValue(event.end, nextEventTimezone)); + setEditTimezone(event.startTimeZone || displayTimezone); setEditTimeScope("single"); } if (editingField !== "reminders") { @@ -425,6 +438,7 @@ export function EventDetailPopover({ event.start, event.end, event.allDay, + displayTimezone, event.startTimeZone, event.reminders, event.remindersUseDefault, @@ -574,7 +588,7 @@ export function EventDetailPopover({ context: `Event id: ${event.id} Title: ${event.title} When: ${event.start} to ${event.end} -Timezone: ${event.startTimeZone || getLocalTimezone()} +Timezone: ${event.startTimeZone || displayTimezone} Location: ${event.location || "(none)"} Attendees: ${(event.attendees ?? []).map((attendee) => attendee.email).join(", ") || "(none)"} Current description: ${event.description || "(empty)"} @@ -582,7 +596,7 @@ Current description: ${event.description || "(empty)"} Write a short, useful meeting description. If I ask you to apply it, update this event with the update-event action.`, submit: true, }); - }, [event]); + }, [displayTimezone, event]); const handleAddGoogleMeet = useCallback(() => { if (!event.id || updateEvent.isPending) return; @@ -791,7 +805,7 @@ Write a short, useful meeting description. If I ask you to apply it, update this [event.accountEmail, event.attendees], ); const findTimeTimezone = - editTimezone || event.startTimeZone || getLocalTimezone(); + editTimezone || event.startTimeZone || displayTimezone; const findTimeDurationMinutes = Math.max( 5, differenceInMinutes(parseISO(event.end), parseISO(event.start)), @@ -799,10 +813,10 @@ Write a short, useful meeting description. If I ask you to apply it, update this const handleSelectFindTimeSlot = useCallback( (slot: FindTimeSlot) => { - setEditDate(toDateInputValue(slot.start)); - setEditEndDate(toDateInputValue(slot.end)); - setEditStartTime(toTimeInputValue(slot.start)); - setEditEndTime(toTimeInputValue(slot.end)); + setEditDate(toDateInputValue(slot.start, findTimeTimezone)); + setEditEndDate(toDateInputValue(slot.end, findTimeTimezone)); + setEditStartTime(toTimeInputValue(slot.start, findTimeTimezone)); + setEditEndTime(toTimeInputValue(slot.end, findTimeTimezone)); setEditTimezone(findTimeTimezone); setEditingField(null); setFindTimeOpen(false); @@ -941,16 +955,7 @@ Write a short, useful meeting description. If I ask you to apply it, update this ? "Loading repeat..." : formatRecurrenceText(recurrenceRules) || (isRecurringEvent ? "Repeats" : null); - // Show the browser's local timezone offset (this is what the user sees times in) - const localOffsetMinutes = -new Date().getTimezoneOffset(); - const localOffsetSign = localOffsetMinutes >= 0 ? "+" : "-"; - const localOffsetH = Math.floor(Math.abs(localOffsetMinutes) / 60); - const localOffsetM = Math.abs(localOffsetMinutes) % 60; - const tzLabel = event.startTimeZone - ? formatTimezoneLabel(event.startTimeZone) - : localOffsetM - ? `GMT${localOffsetSign}${localOffsetH}:${String(localOffsetM).padStart(2, "0")}` - : `GMT${localOffsetSign}${localOffsetH}`; + const tzLabel = formatTimezoneLabel(displayTimezone); const handleOpenChange = useCallback( (newOpen: boolean) => { @@ -1237,16 +1242,22 @@ Write a short, useful meeting description. If I ask you to apply it, update this size="sm" className="h-6 text-xs" onClick={() => { - setEditDate(toDateInputValue(event.start)); + setEditDate( + toDateInputValue(event.start, eventTimezone), + ); setEditEndDate( event.allDay ? toAllDayEndDateInputValue(event.end) - : toDateInputValue(event.end), + : toDateInputValue(event.end, eventTimezone), + ); + setEditStartTime( + toTimeInputValue(event.start, eventTimezone), + ); + setEditEndTime( + toTimeInputValue(event.end, eventTimezone), ); - setEditStartTime(toTimeInputValue(event.start)); - setEditEndTime(toTimeInputValue(event.end)); setEditTimezone( - event.startTimeZone || getLocalTimezone(), + event.startTimeZone || displayTimezone, ); setEditTimeScope("single"); setEditingField(null); @@ -1281,7 +1292,8 @@ Write a short, useful meeting description. If I ask you to apply it, update this {formatEventDateRange( event.start, event.end, - event.allDay, + true, + displayTimezone, )} @@ -1289,20 +1301,25 @@ Write a short, useful meeting description. If I ask you to apply it, update this <>
- {formatTimeShort(event.start)} + {formatTimeShort(event.start, displayTimezone)} - {formatTimeShort(event.end)} + {formatTimeShort(event.end, displayTimezone)} {formatDuration(event.start, event.end)}
- {formatEventDateRange(event.start, event.end)} + {formatEventDateRange( + event.start, + event.end, + false, + displayTimezone, + )}
)} @@ -1332,7 +1349,9 @@ Write a short, useful meeting description. If I ask you to apply it, update this onOpenChange={setFindTimeOpen} title="Find a time" subtitle={event.title} - date={editDate || toDateInputValue(event.start)} + date={ + editDate || toDateInputValue(event.start, eventTimezone) + } timezone={findTimeTimezone} durationMinutes={findTimeDurationMinutes} attendees={schedulingAttendees} diff --git a/templates/calendar/app/components/calendar/EventDialog.tsx b/templates/calendar/app/components/calendar/EventDialog.tsx index 88344ad0fe..aab2386193 100644 --- a/templates/calendar/app/components/calendar/EventDialog.tsx +++ b/templates/calendar/app/components/calendar/EventDialog.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { format, parseISO } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; import { IconMapPin, IconClock, @@ -30,6 +31,8 @@ import { useViewPreferences } from "@/hooks/use-view-preferences"; import { toast } from "sonner"; import type { CalendarEvent } from "@shared/api"; import { useGuestNotificationPrompt } from "@/components/calendar/GuestNotificationDialog"; +import { useSettings } from "@/hooks/use-settings"; +import { getLocalTimezone } from "@/lib/event-form-utils"; interface EventDialogProps { event: CalendarEvent | null; @@ -56,6 +59,8 @@ export function EventDialog({ const { promptGuestNotification, guestNotificationDialog } = useGuestNotificationPrompt(); const { prefs } = useViewPreferences(); + const { data: settings } = useSettings(); + const displayTimezone = settings?.timezone || getLocalTimezone(); useEffect(() => { if (event) { @@ -256,10 +261,17 @@ export function EventDialog({ ) : ( - {format(parseISO(event.start), "EEEE, MMMM d, yyyy")} + {format( + toZonedTime(event.start, displayTimezone), + "EEEE, MMMM d, yyyy", + )}
- {format(parseISO(event.start), "h:mm a")} –{" "} - {format(parseISO(event.end), "h:mm a")} + {format( + toZonedTime(event.start, displayTimezone), + "h:mm a", + )}{" "} + –{" "} + {format(toZonedTime(event.end, displayTimezone), "h:mm a")}
)} diff --git a/templates/calendar/app/components/calendar/FindTimePanel.tsx b/templates/calendar/app/components/calendar/FindTimePanel.tsx index 5f2d7af95b..21bbdf260a 100644 --- a/templates/calendar/app/components/calendar/FindTimePanel.tsx +++ b/templates/calendar/app/components/calendar/FindTimePanel.tsx @@ -8,6 +8,7 @@ import { startOfWeek, } from "date-fns"; import { useActionQuery } from "@agent-native/core/client"; +import { formatInTimeZone } from "date-fns-tz"; import { IconAlertCircle, IconCalendarTime, @@ -567,7 +568,7 @@ export function FindTimePanel({ > - {format(parseISO(slot.start), "EEE, MMM d")} + {formatInTimeZone(slot.start, timezone, "EEE, MMM d")} {timeLabel(slot.start, timezone)} -{" "} diff --git a/templates/calendar/app/components/calendar/MonthView.tsx b/templates/calendar/app/components/calendar/MonthView.tsx index 1c900ce0db..9bc6a71c2b 100644 --- a/templates/calendar/app/components/calendar/MonthView.tsx +++ b/templates/calendar/app/components/calendar/MonthView.tsx @@ -7,10 +7,10 @@ import { eachDayOfInterval, isSameMonth, isSameDay, - isToday, format, parseISO, } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; import { cn } from "@/lib/utils"; import { EventCard } from "./EventCard"; import { EventDetailPopover } from "./EventDetailPopover"; @@ -21,6 +21,7 @@ import type { CalendarEvent } from "@shared/api"; interface MonthViewProps { events: CalendarEvent[]; selectedDate: Date; + timezone: string; onDateSelect: (date: Date) => void; onDeleteEvent?: (eventId: string) => void; onEventDrop?: (eventId: string, newDate: Date) => void; @@ -62,6 +63,7 @@ const WEEKDAY_HEADERS_SHORT = ["S", "M", "T", "W", "T", "F", "S"]; export function MonthView({ events, selectedDate, + timezone, onDateSelect, onDeleteEvent, onEventDrop, @@ -75,6 +77,7 @@ export function MonthView({ const { prefs } = useViewPreferences(); const [dragOverDay, setDragOverDay] = useState(null); const [draggingId, setDraggingId] = useState(null); + const calendarToday = toZonedTime(new Date(), timezone); const monthStart = startOfMonth(selectedDate); const monthEnd = endOfMonth(selectedDate); @@ -100,13 +103,16 @@ export function MonthView({ const eventsByDay = useMemo(() => { const map = new Map(); for (const e of events) { - const key = format(parseISO(e.start), "yyyy-MM-dd"); + const key = format( + e.allDay ? parseISO(e.start) : toZonedTime(e.start, timezone), + "yyyy-MM-dd", + ); const list = map.get(key); if (list) list.push(e); else map.set(key, [e]); } return map; - }, [events]); + }, [events, timezone]); function handleDragOver(e: React.DragEvent, dayKey: string) { e.preventDefault(); @@ -149,7 +155,7 @@ export function MonthView({ {days.map((day) => { const dayEvents = eventsByDay.get(format(day, "yyyy-MM-dd")) ?? []; const inMonth = isSameMonth(day, selectedDate); - const today = isToday(day); + const today = isSameDay(day, calendarToday); const selected = isSameDay(day, selectedDate); const dayKey = day.toISOString(); const isDragTarget = dragOverDay === dayKey; @@ -223,6 +229,7 @@ export function MonthView({
e.stopPropagation()}> void; onDeleteEvent: (eventId: string) => void; onEventTimeChange?: (eventId: string, newStart: Date, newEnd: Date) => void; @@ -127,6 +128,7 @@ interface LayoutInfo { function computeLayout( dayEvents: CalendarEvent[], day: Date, + timezone: string, ): Map { const result = new Map(); if (dayEvents.length === 0) return result; @@ -139,8 +141,8 @@ function computeLayout( dayEvents.map((ev) => [ ev.id, { - start: Math.max(parseISO(ev.start).getTime(), dayStartMs), - end: Math.min(parseISO(ev.end).getTime(), dayEndMs), + start: Math.max(toZonedTime(ev.start, timezone).getTime(), dayStartMs), + end: Math.min(toZonedTime(ev.end, timezone).getTime(), dayEndMs), }, ]), ); @@ -202,6 +204,7 @@ function getAllDaySpan( export function WeekView({ events, selectedDate, + timezone, onDateSelect, onDeleteEvent, onEventTimeChange, @@ -269,6 +272,10 @@ export function WeekView({ const allDayEvents = useMemo(() => events.filter((e) => e.allDay), [events]); const timedEvents = useMemo(() => events.filter((e) => !e.allDay), [events]); + const calendarNow = useMemo( + () => toZonedTime(now, timezone), + [now, timezone], + ); // Pre-compute all-day event spans const allDaySpans = useMemo(() => { @@ -289,18 +296,18 @@ export function WeekView({ const dayStart = startOfDay(day); const dayEnd = addDays(dayStart, 1); const dayEvents = timedEvents.filter((e) => { - const evStart = parseISO(e.start); - const evEnd = parseISO(e.end); + const evStart = toZonedTime(e.start, timezone); + const evEnd = toZonedTime(e.end, timezone); return evStart < dayEnd && evEnd > dayStart; }); - const layout = computeLayout(dayEvents, day); + const layout = computeLayout(dayEvents, day, timezone); return { day, events: dayEvents, layout }; }); - }, [days, timedEvents]); + }, [days, timedEvents, timezone]); function getSegmentStyle(event: CalendarEvent, day: Date) { - const evStart = parseISO(event.start); - const evEnd = parseISO(event.end); + const evStart = toZonedTime(event.start, timezone); + const evEnd = toZonedTime(event.end, timezone); const dayBase = set(startOfDay(day), { hours: START_HOUR }); const dayEnd = addDays(dayBase, 1); const segStart = evStart > dayBase ? evStart : dayBase; @@ -314,7 +321,8 @@ export function WeekView({ } // Current time indicator - const nowMinutes = (now.getHours() - START_HOUR) * 60 + now.getMinutes(); + const nowMinutes = + (calendarNow.getHours() - START_HOUR) * 60 + calendarNow.getMinutes(); const nowTop = (nowMinutes / 60) * HOUR_HEIGHT; const showNowIndicator = nowMinutes >= 0 && nowMinutes <= (END_HOUR - START_HOUR) * 60; @@ -424,41 +432,13 @@ export function WeekView({ // Timezone label: prefer the short generic name (e.g. "PT", "ET") // over the offset form ("GMT-7"), and fall back to the IANA id when // the locale data has no friendlier rendering. - const { tzShort, tzLong, tzIana } = useMemo(() => { - function nameForToken(token: "shortGeneric" | "longGeneric" | "short") { - try { - return ( - new Intl.DateTimeFormat("en-US", { timeZoneName: token }) - .formatToParts(now) - .find((p) => p.type === "timeZoneName")?.value ?? "" - ); - } catch { - return ""; - } - } - - let iana = ""; - try { - iana = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; - } catch {} - - const longGeneric = nameForToken("longGeneric"); - let shortGeneric = nameForToken("shortGeneric"); - - // shortGeneric falls back to the offset form for zones with no short name - // (e.g. "Etc/GMT-7" → "GMT-7"). When that happens, the IANA city is more - // useful than the offset. - if (!shortGeneric || /^GMT[+-]/.test(shortGeneric)) { - const city = iana.split("/").pop()?.replace(/_/g, " ") ?? ""; - shortGeneric = city || nameForToken("short") || shortGeneric; - } - - return { - tzShort: shortGeneric, - tzLong: longGeneric || iana, - tzIana: iana, - }; - }, []); + const { tzShort, tzLong } = useMemo( + () => ({ + tzShort: formatInTimeZone(now, timezone, "zzz"), + tzLong: formatInTimeZone(now, timezone, "zzzz"), + }), + [timezone, now], + ); // Drag-to-move and drag-to-resize const handleEventTimeChange = useCallback( @@ -481,6 +461,7 @@ export function WeekView({ days, onEventTimeChange: handleEventTimeChange, events, + timezone, }); return ( @@ -501,8 +482,10 @@ export function WeekView({

{tzLong}

- {tzIana && tzIana !== tzLong ? ( -

{tzIana}

+ {timezone !== tzLong ? ( +

+ {timezone} +

) : null}
@@ -515,7 +498,9 @@ export function WeekView({ onClick={() => onDateSelect(day)} className={cn( "flex flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 border-r border-border py-1.5 sm:flex-row sm:gap-1.5 sm:py-2.5 last:border-r-0", - isToday(day) ? "bg-primary/5" : "hover:bg-accent/40", + isSameDay(day, calendarNow) + ? "bg-primary/5" + : "hover:bg-accent/40", )} > @@ -524,7 +509,7 @@ export function WeekView({ { - const isCurrentDay = isToday(day); + const isCurrentDay = isSameDay(day, calendarNow); // Collect events that were dragged into this column from another day const draggedInEvents: CalendarEvent[] = []; @@ -768,8 +753,8 @@ export function WeekView({ }; const overrides = getDragOverrides(event.id); const isBeingDragged = dragEventId === event.id; - const start = parseISO(event.start); - const end = parseISO(event.end); + const start = toZonedTime(event.start, timezone); + const end = toZonedTime(event.end, timezone); const dayBase = startOfDay(day); const segDayEnd = addDays(dayBase, 1); const isStart = isSameDay(start, day); diff --git a/templates/calendar/app/hooks/use-event-drag.ts b/templates/calendar/app/hooks/use-event-drag.ts index b2ef84d3bd..3ac5d59a9e 100644 --- a/templates/calendar/app/hooks/use-event-drag.ts +++ b/templates/calendar/app/hooks/use-event-drag.ts @@ -1,6 +1,7 @@ import { useState, useRef, useCallback, useEffect } from "react"; -import { parseISO, startOfDay, set, addMinutes } from "date-fns"; +import { startOfDay, set, addMinutes } from "date-fns"; import type { CalendarEvent } from "@shared/api"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; const SNAP_MINUTES = 15; @@ -48,6 +49,8 @@ export interface UseEventDragOptions { onEventTimeChange: (eventId: string, newStart: Date, newEnd: Date) => void; /** All events (to find the event being dragged) */ events: CalendarEvent[]; + /** Timezone used by the calendar grid */ + timezone: string; } export function useEventDrag({ @@ -57,6 +60,7 @@ export function useEventDrag({ days, onEventTimeChange, events, + timezone, }: UseEventDragOptions) { const [dragState, setDragState] = useState(null); const dragStateRef = useRef(null); @@ -122,8 +126,8 @@ export function useEventDrag({ const pointerYInGrid = e.clientY - gridTop + scrollTop; // Compute current event position - const evStart = parseISO(event.start); - const evEnd = parseISO(event.end); + const evStart = toZonedTime(event.start, timezone); + const evEnd = toZonedTime(event.end, timezone); const dayStart = set(startOfDay(evStart), { hours: startHour, }); @@ -169,6 +173,7 @@ export function useEventDrag({ getScrollTop, startHour, hourHeight, + timezone, ], ); @@ -251,7 +256,7 @@ export function useEventDrag({ ); // Determine the base day - const originalStart = parseISO(state.event.start); + const originalStart = toZonedTime(state.event.start, timezone); let baseDay: Date; if (days && state.currentDayIndex !== state.startDayIndex) { baseDay = days[state.currentDayIndex]; @@ -265,12 +270,16 @@ export function useEventDrag({ ); const newEnd = addMinutes(newStart, heightMinutes); - onEventTimeChange(state.eventId, newStart, newEnd); + onEventTimeChange( + state.eventId, + fromZonedTime(newStart, timezone), + fromZonedTime(newEnd, timezone), + ); } dragStateRef.current = null; setDragState(null); - }, [pxToMinutes, days, startHour, onEventTimeChange]); + }, [pxToMinutes, days, startHour, onEventTimeChange, timezone]); const cancelDrag = useCallback(() => { dragStateRef.current = null; diff --git a/templates/calendar/app/hooks/use-navigation-state.ts b/templates/calendar/app/hooks/use-navigation-state.ts index 682eb85ca6..db69dedd48 100644 --- a/templates/calendar/app/hooks/use-navigation-state.ts +++ b/templates/calendar/app/hooks/use-navigation-state.ts @@ -6,6 +6,10 @@ import { } from "@/components/layout/AppLayout"; import type { CalendarEvent, CalendarEventDraft } from "@shared/api"; import { agentNativePath } from "@agent-native/core/client"; +import { format, parseISO } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; +import { useSettings } from "@/hooks/use-settings"; +import { getLocalTimezone } from "@/lib/event-form-utils"; interface NavigationState { view: string; @@ -73,6 +77,8 @@ async function loadEventDraft( } export function useNavigationState() { + const { data: settings } = useSettings(); + const calendarTimezone = settings?.timezone || getLocalTimezone(); const { selectedDate, viewMode, @@ -124,7 +130,7 @@ export function useNavigationState() { // Include the currently selected date if (selectedDate) { - state.date = selectedDate.toISOString().split("T")[0]; + state.date = format(selectedDate, "yyyy-MM-dd"); } // Include the selected event if one is open @@ -185,7 +191,9 @@ export function useNavigationState() { ); if (!evt || evt.error || !evt.id) return; if (!cmd.date && typeof evt.start === "string" && evt.start) { - const startDate = new Date(evt.start); + const startDate = evt.allDay + ? parseISO(evt.start) + : toZonedTime(evt.start, calendarTimezone); if (!Number.isNaN(startDate.getTime())) { setSelectedDateRef.current(startDate); } @@ -207,7 +215,9 @@ export function useNavigationState() { const draft = await loadEventDraft(cmd); if (!draft) return; if (draft.start) { - const startDate = new Date(draft.start); + const startDate = draft.allDay + ? parseISO(draft.start) + : toZonedTime(draft.start, calendarTimezone); if (!Number.isNaN(startDate.getTime())) { setSelectedDateRef.current(startDate); } diff --git a/templates/calendar/app/pages/CalendarView.tsx b/templates/calendar/app/pages/CalendarView.tsx index bdfb9f5e75..47709383ad 100644 --- a/templates/calendar/app/pages/CalendarView.tsx +++ b/templates/calendar/app/pages/CalendarView.tsx @@ -83,6 +83,7 @@ import { getLocalTimezone, } from "@/lib/event-form-utils"; import { getGoogleEventColorHex } from "@/lib/event-colors"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; import type { ViewMode } from "@/components/layout/AppLayout"; @@ -320,6 +321,7 @@ export default function CalendarView() { const googleStatus = useGoogleAuthStatus(); const settingsQuery = useSettings(); const { data: settings } = settingsQuery; + const calendarTimezone = settings?.timezone || getLocalTimezone(); const { data: rawOverlayPeople } = useOverlayPeople(); const overlayPeople = Array.isArray(rawOverlayPeople) ? rawOverlayPeople : []; const overlayEmails = useMemo( @@ -339,25 +341,33 @@ export default function CalendarView() { const ms = startOfMonth(selectedDate); const me = endOfMonth(selectedDate); return { - from: startOfWeek(ms).toISOString(), - to: endOfWeek(me).toISOString(), + from: fromZonedTime(startOfWeek(ms), calendarTimezone).toISOString(), + to: fromZonedTime(endOfWeek(me), calendarTimezone).toISOString(), }; } case "week": { return { - from: startOfWeek(selectedDate).toISOString(), - to: endOfWeek(selectedDate).toISOString(), + from: fromZonedTime( + startOfWeek(selectedDate), + calendarTimezone, + ).toISOString(), + to: fromZonedTime( + endOfWeek(selectedDate), + calendarTimezone, + ).toISOString(), }; } case "day": { - const dayStart = new Date(selectedDate); - dayStart.setHours(0, 0, 0, 0); + const dayStart = startOfDay(selectedDate); const dayEnd = new Date(selectedDate); dayEnd.setHours(23, 59, 59, 999); - return { from: dayStart.toISOString(), to: dayEnd.toISOString() }; + return { + from: fromZonedTime(dayStart, calendarTimezone).toISOString(), + to: fromZonedTime(dayEnd, calendarTimezone).toISOString(), + }; } } - }, [viewMode, selectedDate]); + }, [viewMode, selectedDate, calendarTimezone]); const { data: rawEventsData, @@ -490,23 +500,38 @@ export default function CalendarView() { () => viewMode === "day" ? events.filter((e) => { - const evStart = parseISO(e.start); - const evEnd = parseISO(e.end); + // parseISO is used for all-day events as timezone projection could shift midnight into an adjacent calendar day + const evStart = e.allDay + ? parseISO(e.start) + : toZonedTime(e.start, calendarTimezone); + const evEnd = e.allDay + ? parseISO(e.end) + : toZonedTime(e.end, calendarTimezone); const dayStart = startOfDay(selectedDate); const dayEnd = addDays(dayStart, 1); return evStart < dayEnd && evEnd > dayStart; }) : events, - [events, viewMode, selectedDate], + [events, viewMode, selectedDate, calendarTimezone], ); const openNotificationEvent = useCallback( (event: CalendarEvent) => { - setSelectedDate(parseISO(event.start)); + setSelectedDate( + event.allDay + ? parseISO(event.start) + : toZonedTime(event.start, calendarTimezone), + ); setViewMode("day"); setSidebarEvent(event); setFocusedEvent(event); }, - [setFocusedEvent, setSelectedDate, setSidebarEvent, setViewMode], + [ + calendarTimezone, + setFocusedEvent, + setSelectedDate, + setSidebarEvent, + setViewMode, + ], ); useMeetingStartNotifications(events, openNotificationEvent); @@ -572,7 +597,7 @@ export default function CalendarView() { const eventType = draft.eventType ?? "default"; const location = draft.location ?? draft.workingLocationLabel ?? ""; - const timezone = draft.startTimeZone ?? getLocalTimezone(); + const timezone = draft.startTimeZone ?? calendarTimezone; const statusPatch = eventType === "default" ? {} @@ -663,7 +688,14 @@ export default function CalendarView() { }, ); }, - [createEvent, deleteEvent, eventDraft, selectedDate, setEventDraft], + [ + calendarTimezone, + createEvent, + deleteEvent, + eventDraft, + selectedDate, + setEventDraft, + ], ); const updateDraftEvent = useCallback( @@ -720,7 +752,7 @@ export default function CalendarView() { } function handleToday() { - setSelectedDate(new Date()); + setSelectedDate(toZonedTime(new Date(), calendarTimezone)); } function handleDateSelect(date: Date) { @@ -845,32 +877,14 @@ export default function CalendarView() { const event = events.find((e) => e.id === eventId); if (!event) return; - if (calendarDraftIdFromEventId(eventId)) { - const originalStart = parseISO(event.start); - const originalEnd = parseISO(event.end); - const newStart = new Date(originalStart); - const newEnd = new Date(originalEnd); - newStart.setFullYear( - newDate.getFullYear(), - newDate.getMonth(), - newDate.getDate(), - ); - newEnd.setFullYear( - newDate.getFullYear(), - newDate.getMonth(), - newDate.getDate(), - ); - updateDraftEvent(eventId, { - start: newStart.toISOString(), - end: newEnd.toISOString(), - }); - return; - } - const oldStartISO = event.start; const oldEndISO = event.end; - const originalStart = parseISO(event.start); - const originalEnd = parseISO(event.end); + const originalStart = event.allDay + ? parseISO(event.start) + : toZonedTime(event.start, calendarTimezone); + const originalEnd = event.allDay + ? parseISO(event.end) + : toZonedTime(event.end, calendarTimezone); const newStart = new Date(originalStart); const newEnd = new Date(originalEnd); @@ -885,6 +899,20 @@ export default function CalendarView() { newDate.getDate(), ); + const updates = { + start: event.allDay + ? newStart.toISOString() + : fromZonedTime(newStart, calendarTimezone).toISOString(), + end: event.allDay + ? newEnd.toISOString() + : fromZonedTime(newEnd, calendarTimezone).toISOString(), + }; + + if (calendarDraftIdFromEventId(eventId)) { + updateDraftEvent(eventId, updates); + return; + } + const undo = () => { updateEvent.mutate({ id: eventId, @@ -893,10 +921,6 @@ export default function CalendarView() { sendUpdates: "none", }); }; - const updates = { - start: newStart.toISOString(), - end: newEnd.toISOString(), - }; const guestNotification = await promptGuestNotification({ event, action: "update", @@ -1411,6 +1435,7 @@ export default function CalendarView() { setCommandPaletteOpen(false)} events={events} + timezone={calendarTimezone} onGoToDate={handleGoToDate} onEventClick={(event) => { setCommandPaletteOpen(false); - handleGoToDate(parseISO(event.start)); + handleGoToDate( + event.allDay + ? parseISO(event.start) + : toZonedTime(event.start, calendarTimezone), + ); }} onCreateEvent={() => { setCommandPaletteOpen(false); diff --git a/templates/calendar/app/routes/event.tsx b/templates/calendar/app/routes/event.tsx index d2fbdff4fe..55a9de903e 100644 --- a/templates/calendar/app/routes/event.tsx +++ b/templates/calendar/app/routes/event.tsx @@ -1,5 +1,6 @@ import { useSearchParams } from "react-router"; import { format, parseISO, differenceInMinutes } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; import { IconClock, IconMapPin, @@ -13,6 +14,8 @@ import { Spinner } from "@/components/ui/spinner"; import { useActionQuery } from "@agent-native/core/client"; import { postNavigate, isInAgentEmbed } from "@agent-native/core/client"; import type { CalendarEvent } from "@shared/api"; +import { useSettings } from "@/hooks/use-settings"; +import { getLocalTimezone } from "@/lib/event-form-utils"; type EventPreviewResult = CalendarEvent | { error: string }; @@ -31,6 +34,8 @@ function formatDuration(start: string, end: string): string { function EventCard({ event }: { event: CalendarEvent }) { const inEmbed = isInAgentEmbed(); + const { data: settings } = useSettings(); + const displayTimezone = settings?.timezone || getLocalTimezone(); return (
@@ -63,15 +68,19 @@ function EventCard({ event }: { event: CalendarEvent }) { ) : ( <> - {format(parseISO(event.start), "h:mm a")} + {formatInTimeZone(event.start, displayTimezone, "h:mm a")} {" – "} - {format(parseISO(event.end), "h:mm a")} + {formatInTimeZone(event.end, displayTimezone, "h:mm a")} {formatDuration(event.start, event.end)}
- {format(parseISO(event.start), "EEEE, MMMM d")} + {formatInTimeZone( + event.start, + displayTimezone, + "EEEE, MMMM d", + )}
)} 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/package.json b/templates/calendar/package.json index 44f308510a..a60ab5a374 100644 --- a/templates/calendar/package.json +++ b/templates/calendar/package.json @@ -22,6 +22,7 @@ "@libsql/client": "^0.15.0", "@resvg/resvg-js": "^2.6.2", "@tabler/icons-react": "catalog:", + "date-fns-tz": "3.2.0", "dotenv": "^17.2.1", "drizzle-orm": "^0.45.2", "h3": "^2.0.1-rc.20", From 258a71e251b554700e93257734478c45236403ad Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Sat, 25 Jul 2026 20:18:26 +0200 Subject: [PATCH 02/12] fix(calendar): initialize timezone from request context --- templates/calendar/actions/get-settings.ts | 16 +++--- .../calendar/server/handlers/settings.ts | 13 ++--- .../calendar/server/lib/calendar-settings.ts | 23 +++++++++ .../server/lib/get-settings-action.spec.ts | 50 +++++++++++++++++++ 4 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 templates/calendar/server/lib/calendar-settings.ts create mode 100644 templates/calendar/server/lib/get-settings-action.spec.ts diff --git a/templates/calendar/actions/get-settings.ts b/templates/calendar/actions/get-settings.ts index 954e6188d1..0503e64cd3 100644 --- a/templates/calendar/actions/get-settings.ts +++ b/templates/calendar/actions/get-settings.ts @@ -3,13 +3,7 @@ import { getRequestUserEmail } from "@agent-native/core/server"; import { getUserSetting } from "@agent-native/core/settings"; import { z } from "zod"; import type { Settings } from "../shared/api.js"; - -const DEFAULT_SETTINGS: Settings = { - timezone: "America/New_York", - bookingPageTitle: "Book a Meeting", - bookingPageDescription: "Select a time that works for you.", - defaultEventDuration: 30, -}; +import { getDefaultSettings } from "../server/lib/calendar-settings.js"; export default defineAction({ description: "Get calendar settings", @@ -18,8 +12,10 @@ export default defineAction({ run: async () => { const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); - const settings = - (await getUserSetting(email, "calendar-settings")) || DEFAULT_SETTINGS; - return settings; + const settings = (await getUserSetting( + email, + "calendar-settings", + )) as Settings | null; + return settings || getDefaultSettings(); }, }); diff --git a/templates/calendar/server/handlers/settings.ts b/templates/calendar/server/handlers/settings.ts index 68e70956e2..2edb6daed5 100644 --- a/templates/calendar/server/handlers/settings.ts +++ b/templates/calendar/server/handlers/settings.ts @@ -7,13 +7,7 @@ import { putSetting, } from "@agent-native/core/settings"; import { readBody, getSession } from "@agent-native/core/server"; - -const DEFAULT_SETTINGS: Settings = { - timezone: "America/New_York", - bookingPageTitle: "Book a Meeting", - bookingPageDescription: "Select a time that works for you.", - defaultEventDuration: 30, -}; +import { getDefaultSettings } from "../lib/calendar-settings.js"; async function uEmail(event: H3Event): Promise { const session = await getSession(event); @@ -28,7 +22,8 @@ export const getSettings = defineEventHandler(async (event: H3Event) => { try { const email = await uEmail(event); const settings = - (await getUserSetting(email, "calendar-settings")) || DEFAULT_SETTINGS; + (await getUserSetting(email, "calendar-settings")) || + getDefaultSettings(); return settings; } catch (error: any) { setResponseStatus(event, 500); @@ -39,7 +34,7 @@ export const getSettings = defineEventHandler(async (event: H3Event) => { export const getPublicSettings = defineEventHandler(async (_event: H3Event) => { const settings = ((await getSetting("calendar-settings")) as unknown as Settings | null) || - DEFAULT_SETTINGS; + getDefaultSettings(); return settings; }); diff --git a/templates/calendar/server/lib/calendar-settings.ts b/templates/calendar/server/lib/calendar-settings.ts new file mode 100644 index 0000000000..06ab6d953b --- /dev/null +++ b/templates/calendar/server/lib/calendar-settings.ts @@ -0,0 +1,23 @@ +import { getRequestTimezone } from "@agent-native/core/server"; +import type { Settings } from "../../shared/api.js"; + +function defaultTimezone() { + const timezone = getRequestTimezone(); + if (!timezone) return "America/New_York"; + + try { + new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(); + return timezone; + } catch { + return "America/New_York"; + } +} + +export function getDefaultSettings(): Settings { + return { + timezone: defaultTimezone(), + bookingPageTitle: "Book a Meeting", + bookingPageDescription: "Select a time that works for you.", + defaultEventDuration: 30, + }; +} 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", + }); + }); +}); From 5948a44dddb20c599adad6727b3066f2705517bd Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Mon, 27 Jul 2026 16:00:39 +0200 Subject: [PATCH 03/12] fix(calendar): use configured timezone for event ranges --- templates/calendar/actions/list-events.ts | 3 + templates/calendar/actions/update-settings.ts | 10 ++- templates/calendar/actions/view-screen.ts | 23 +++--- .../calendar/server/lib/calendar-settings.ts | 22 ++++++ .../server/lib/list-events-action.spec.ts | 78 +++++++++++++++++++ .../server/lib/update-settings-action.spec.ts | 48 ++++++++++++ 6 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 templates/calendar/server/lib/list-events-action.spec.ts create mode 100644 templates/calendar/server/lib/update-settings-action.spec.ts diff --git a/templates/calendar/actions/list-events.ts b/templates/calendar/actions/list-events.ts index 20fc7ef364..35a256deb0 100644 --- a/templates/calendar/actions/list-events.ts +++ b/templates/calendar/actions/list-events.ts @@ -11,6 +11,7 @@ import * as googleCalendar from "../server/lib/google-calendar.js"; import { fetchICalEvents } from "../server/lib/ical-fetcher.js"; import { getUserSetting } from "@agent-native/core/settings"; import { getDb, schema } from "../server/db/index.js"; +import { getCalendarTimezone } from "../server/lib/calendar-settings.js"; const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -248,9 +249,11 @@ export async function listCalendarEvents( ): Promise { const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); + const timezone = await getCalendarTimezone(email); const range = resolveCalendarEventRange({ from: args.from, to: args.to, + timezone, }); // Fetch Google Calendar events diff --git a/templates/calendar/actions/update-settings.ts b/templates/calendar/actions/update-settings.ts index 5048263955..f5643dfde1 100644 --- a/templates/calendar/actions/update-settings.ts +++ b/templates/calendar/actions/update-settings.ts @@ -3,11 +3,19 @@ import { getRequestUserEmail } from "@agent-native/core/server"; import { z } from "zod"; import { putUserSetting, putSetting } from "@agent-native/core/settings"; import type { Settings } from "../shared/api.js"; +import { isCalendarTimezone } from "../server/lib/calendar-settings.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() diff --git a/templates/calendar/actions/view-screen.ts b/templates/calendar/actions/view-screen.ts index 8a0ab5626f..63cbd270a6 100644 --- a/templates/calendar/actions/view-screen.ts +++ b/templates/calendar/actions/view-screen.ts @@ -1,9 +1,12 @@ import { defineAction } from "@agent-native/core"; import { readAppState } from "@agent-native/core/application-state"; import { getRequestUserEmail } from "@agent-native/core/server"; +import { addDays, parseISO, startOfWeek } from "date-fns"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; import { z } from "zod"; import { extractVideoLink } from "./event-action-helpers.js"; import { listCalendarEvents } from "./list-events.js"; +import { getCalendarTimezone } from "../server/lib/calendar-settings.js"; import { CALENDAR_VIEW_PREFERENCES_KEY, normalizeCalendarViewPreferences, @@ -63,18 +66,18 @@ 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); + const viewDate = nav?.date + ? parseISO(nav.date) + : toZonedTime(new Date(), timezone); + const from = startOfWeek(viewDate); + const to = addDays(from, 7); const eventResult = await fetchEventsForRange( - from.toISOString(), - to.toISOString(), + fromZonedTime(from, timezone).toISOString(), + fromZonedTime(to, timezone).toISOString(), ); const { events } = eventResult; diff --git a/templates/calendar/server/lib/calendar-settings.ts b/templates/calendar/server/lib/calendar-settings.ts index 06ab6d953b..238425488c 100644 --- a/templates/calendar/server/lib/calendar-settings.ts +++ b/templates/calendar/server/lib/calendar-settings.ts @@ -1,4 +1,5 @@ import { getRequestTimezone } from "@agent-native/core/server"; +import { getUserSetting } from "@agent-native/core/settings"; import type { Settings } from "../../shared/api.js"; function defaultTimezone() { @@ -21,3 +22,24 @@ export function getDefaultSettings(): Settings { defaultEventDuration: 30, }; } + +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 { + return false; + } +} + +export async function getCalendarTimezone(email: string): Promise { + const settings = (await getUserSetting(email, "calendar-settings")) as { + timezone?: unknown; + } | null; + if (settings?.timezone === undefined) return getDefaultSettings().timezone; + if (!isCalendarTimezone(settings.timezone)) { + throw new Error("Saved calendar timezone must be a valid IANA timezone."); + } + return settings.timezone; +} 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..36a31afa76 --- /dev/null +++ b/templates/calendar/server/lib/list-events-action.spec.ts @@ -0,0 +1,78 @@ +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()); + +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", () => ({ + 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); + }); + + 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..682cdbab8b --- /dev/null +++ b/templates/calendar/server/lib/update-settings-action.spec.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getRequestUserEmailMock = 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", () => ({ + getRequestUserEmail: getRequestUserEmailMock, +})); +vi.mock("@agent-native/core/settings", () => ({ + putSetting: putSettingMock, + putUserSetting: putUserSettingMock, +})); + +import action from "../../actions/update-settings"; +import { isCalendarTimezone } from "./calendar-settings"; + +describe("update-settings timezone validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + getRequestUserEmailMock.mockReturnValue("owner@example.com"); + putSettingMock.mockResolvedValue(undefined); + putUserSettingMock.mockResolvedValue(undefined); + }); + + it("rejects invalid IANA timezones", () => { + expect(isCalendarTimezone("not-a-timezone")).toBe(false); + }); + + it("saves a valid timezone", async () => { + const settings = { + timezone: "Europe/Warsaw", + bookingPageTitle: "Book a Meeting", + bookingPageDescription: "Select a time.", + defaultEventDuration: 30, + }; + + await expect(action.run(settings)).resolves.toEqual(settings); + expect(putUserSettingMock).toHaveBeenCalledWith( + "owner@example.com", + "calendar-settings", + settings, + ); + }); +}); From 2c3a6eb9f3d36030fc538ac15d3ca8584942188f Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Tue, 28 Jul 2026 08:19:02 +0200 Subject: [PATCH 04/12] fix(calendar): address timezone CI failures --- templates/calendar/actions/get-settings.ts | 2 +- templates/calendar/actions/list-events.ts | 2 +- templates/calendar/actions/update-settings.ts | 2 +- templates/calendar/app/components/calendar/CommandPalette.tsx | 2 +- templates/calendar/app/components/calendar/DayView.tsx | 3 +-- templates/calendar/app/components/calendar/MonthView.tsx | 2 +- templates/calendar/app/components/calendar/WeekView.tsx | 3 +-- templates/calendar/app/pages/CalendarView.tsx | 2 +- templates/calendar/server/lib/calendar-settings.ts | 1 + 9 files changed, 9 insertions(+), 10 deletions(-) diff --git a/templates/calendar/actions/get-settings.ts b/templates/calendar/actions/get-settings.ts index 7a5e6e8022..f0aa82b271 100644 --- a/templates/calendar/actions/get-settings.ts +++ b/templates/calendar/actions/get-settings.ts @@ -3,8 +3,8 @@ import { getRequestUserEmail } from "@agent-native/core/server"; import { getUserSetting } from "@agent-native/core/settings"; import { z } from "zod"; -import type { Settings } from "../shared/api.js"; import { getDefaultSettings } from "../server/lib/calendar-settings.js"; +import type { Settings } from "../shared/api.js"; export default defineAction({ description: "Get calendar settings", diff --git a/templates/calendar/actions/list-events.ts b/templates/calendar/actions/list-events.ts index b8c8270793..f4ca601f4e 100644 --- a/templates/calendar/actions/list-events.ts +++ b/templates/calendar/actions/list-events.ts @@ -13,11 +13,11 @@ 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 { calendarEventMatchesQuery } from "./event-search.js"; -import { getCalendarTimezone } from "../server/lib/calendar-settings.js"; const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; diff --git a/templates/calendar/actions/update-settings.ts b/templates/calendar/actions/update-settings.ts index 145aa025f7..de7bf96f37 100644 --- a/templates/calendar/actions/update-settings.ts +++ b/templates/calendar/actions/update-settings.ts @@ -3,8 +3,8 @@ import { getRequestUserEmail } from "@agent-native/core/server"; import { putUserSetting, putSetting } from "@agent-native/core/settings"; import { z } from "zod"; -import type { Settings } from "../shared/api.js"; import { isCalendarTimezone } from "../server/lib/calendar-settings.js"; +import type { Settings } from "../shared/api.js"; export default defineAction({ description: "Update calendar settings", diff --git a/templates/calendar/app/components/calendar/CommandPalette.tsx b/templates/calendar/app/components/calendar/CommandPalette.tsx index bdb653644e..2176e69f4d 100644 --- a/templates/calendar/app/components/calendar/CommandPalette.tsx +++ b/templates/calendar/app/components/calendar/CommandPalette.tsx @@ -29,7 +29,7 @@ interface CommandPaletteProps { open: boolean; onClose: () => void; events: CalendarEvent[]; - timezone: string; + timezone?: string; onGoToDate: (date: Date) => void; onEventClick: (event: CalendarEvent) => void; onCreateEvent: () => void; diff --git a/templates/calendar/app/components/calendar/DayView.tsx b/templates/calendar/app/components/calendar/DayView.tsx index 50e371a40b..999fce4dea 100644 --- a/templates/calendar/app/components/calendar/DayView.tsx +++ b/templates/calendar/app/components/calendar/DayView.tsx @@ -14,8 +14,8 @@ import { addDays, min, } from "date-fns"; -import { useState, useEffect, useRef, useMemo, useCallback, memo } from "react"; import { toZonedTime } from "date-fns-tz"; +import { useState, useEffect, useRef, useMemo, useCallback, memo } from "react"; import { useCalendarSetters } from "@/components/layout/AppLayout"; import { @@ -1054,7 +1054,6 @@ export const DayView = memo(function DayView({ Date: Tue, 28 Jul 2026 08:31:00 +0200 Subject: [PATCH 05/12] fix(calendar): restore CI typecheck and ICS test --- templates/calendar/app/components/calendar/CommandPalette.tsx | 2 +- templates/calendar/server/lib/ical-fetcher.spec.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/calendar/app/components/calendar/CommandPalette.tsx b/templates/calendar/app/components/calendar/CommandPalette.tsx index 2176e69f4d..c1df1c06e8 100644 --- a/templates/calendar/app/components/calendar/CommandPalette.tsx +++ b/templates/calendar/app/components/calendar/CommandPalette.tsx @@ -196,7 +196,7 @@ export function CommandPalette({ {event.title} {format( - event.allDay + event.allDay || !timezone ? parseISO(event.start) : toZonedTime(event.start, timezone), "MMM d", diff --git a/templates/calendar/server/lib/ical-fetcher.spec.ts b/templates/calendar/server/lib/ical-fetcher.spec.ts index 446bcccd1d..31ff7561dd 100644 --- a/templates/calendar/server/lib/ical-fetcher.spec.ts +++ b/templates/calendar/server/lib/ical-fetcher.spec.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const ssrfSafeFetchMock = vi.hoisted(() => vi.fn()); vi.mock("@agent-native/core/extensions/url-safety", () => ({ + isBlockedToolUrl: () => false, ssrfSafeFetch: ssrfSafeFetchMock, })); From 26759467e0e233335a1467e8c49bcb7110dc0baa Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Tue, 28 Jul 2026 11:17:16 +0200 Subject: [PATCH 06/12] fix(calendar): use configured timezone for selected date --- templates/calendar/app/pages/CalendarView.tsx | 75 +++++++++++-------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/templates/calendar/app/pages/CalendarView.tsx b/templates/calendar/app/pages/CalendarView.tsx index 2131eca5dd..075d988439 100644 --- a/templates/calendar/app/pages/CalendarView.tsx +++ b/templates/calendar/app/pages/CalendarView.tsx @@ -338,6 +338,16 @@ export default function CalendarView() { const settingsQuery = useSettings(); const { data: settings } = settingsQuery; const calendarTimezone = settings?.timezone || getLocalTimezone(); + const selectedCalendarDate = useMemo( + () => toZonedTime(selectedDate, calendarTimezone), + [selectedDate, calendarTimezone], + ); + const selectCalendarDate = useCallback( + (date: Date) => { + setSelectedDate(fromZonedTime(date, calendarTimezone)); + }, + [calendarTimezone, setSelectedDate], + ); const { data: rawOverlayPeople } = useOverlayPeople(); const overlayPeople = Array.isArray(rawOverlayPeople) ? rawOverlayPeople : []; const overlayEmails = useMemo( @@ -360,8 +370,8 @@ export default function CalendarView() { const { from, to } = useMemo(() => { switch (viewMode) { case "month": { - const ms = startOfMonth(selectedDate); - const me = endOfMonth(selectedDate); + const ms = startOfMonth(selectedCalendarDate); + const me = endOfMonth(selectedCalendarDate); return { from: fromZonedTime(startOfWeek(ms), calendarTimezone).toISOString(), to: fromZonedTime(endOfWeek(me), calendarTimezone).toISOString(), @@ -370,19 +380,19 @@ export default function CalendarView() { case "week": { return { from: fromZonedTime( - startOfWeek(selectedDate), + startOfWeek(selectedCalendarDate), calendarTimezone, ).toISOString(), to: fromZonedTime( - endOfWeek(selectedDate), + endOfWeek(selectedCalendarDate), calendarTimezone, ).toISOString(), }; } case "day": { - const dayStart = new Date(selectedDate); + const dayStart = new Date(selectedCalendarDate); dayStart.setHours(0, 0, 0, 0); - const dayEnd = new Date(selectedDate); + const dayEnd = new Date(selectedCalendarDate); dayEnd.setHours(23, 59, 59, 999); return { from: fromZonedTime(dayStart, calendarTimezone).toISOString(), @@ -390,7 +400,7 @@ export default function CalendarView() { }; } } - }, [viewMode, selectedDate, calendarTimezone]); + }, [viewMode, selectedCalendarDate, calendarTimezone]); const { data: rawEventsData, @@ -556,12 +566,12 @@ export default function CalendarView() { const evEnd = e.allDay ? parseISO(e.end) : toZonedTime(e.end, calendarTimezone); - const dayStart = startOfDay(selectedDate); + const dayStart = startOfDay(selectedCalendarDate); const dayEnd = addDays(dayStart, 1); return evStart < dayEnd && evEnd > dayStart; }) : events, - [events, viewMode, selectedDate, calendarTimezone], + [events, viewMode, selectedCalendarDate, calendarTimezone], ); const openNotificationEvent = useCallback( (event: CalendarEvent) => { @@ -824,7 +834,7 @@ export default function CalendarView() { direction === "next" ? { month: addMonths, week: addWeeks, day: addDays } : { month: subMonths, week: subWeeks, day: subDays }; - setSelectedDate(fns[viewMode](selectedDate, 1)); + selectCalendarDate(fns[viewMode](selectedCalendarDate, 1)); } function handleToday() { @@ -833,16 +843,16 @@ export default function CalendarView() { const handleDateSelect = useCallback( (date: Date) => { - setSelectedDate(date); + selectCalendarDate(date); if (viewMode === "month") { setViewMode("day"); } }, - [viewMode, setSelectedDate, setViewMode], + [viewMode, selectCalendarDate, setViewMode], ); function handleGoToDate(date: Date) { - setSelectedDate(date); + selectCalendarDate(date); setViewMode("day"); } @@ -1196,7 +1206,7 @@ export default function CalendarView() { return; } - setSelectedDate(clickedDate); + selectCalendarDate(clickedDate); const defaultDuration = Math.max( 5, activeSettings.defaultEventDuration ?? 30, @@ -1241,7 +1251,7 @@ export default function CalendarView() { settings, settingsQuery, t, - setSelectedDate, + selectCalendarDate, setEventDraft, ], ); @@ -1479,18 +1489,18 @@ export default function CalendarView() { break; case "ArrowDown": e.preventDefault(); - setSelectedDate( + selectCalendarDate( viewMode === "month" - ? addWeeks(selectedDate, 1) - : addDays(selectedDate, 1), + ? addWeeks(selectedCalendarDate, 1) + : addDays(selectedCalendarDate, 1), ); break; case "ArrowUp": e.preventDefault(); - setSelectedDate( + selectCalendarDate( viewMode === "month" - ? subWeeks(selectedDate, 1) - : subDays(selectedDate, 1), + ? subWeeks(selectedCalendarDate, 1) + : subDays(selectedCalendarDate, 1), ); break; case "p": @@ -1530,7 +1540,8 @@ export default function CalendarView() { deleteDialogEvent, isTypingInInput, viewMode, - selectedDate, + selectedCalendarDate, + selectCalendarDate, sidebarEvent, focusedEvent, events, @@ -1543,19 +1554,19 @@ export default function CalendarView() { switch (viewMode) { case "month": return isMobile - ? format(selectedDate, "MMM yyyy") - : format(selectedDate, "MMMM yyyy"); + ? format(selectedCalendarDate, "MMM yyyy") + : format(selectedCalendarDate, "MMMM yyyy"); case "week": { - const ws = startOfWeek(selectedDate); - const we = endOfWeek(selectedDate); + const ws = startOfWeek(selectedCalendarDate); + const we = endOfWeek(selectedCalendarDate); return isMobile ? `${format(ws, "MMM d")} – ${format(we, "d")}` : `${format(ws, "MMM d")} – ${format(we, "d, yyyy")}`; } case "day": return isMobile - ? format(selectedDate, "EEE, MMM d") - : format(selectedDate, "EEEE, MMMM d, yyyy"); + ? format(selectedCalendarDate, "EEE, MMM d") + : format(selectedCalendarDate, "EEEE, MMMM d, yyyy"); } })(); @@ -1731,7 +1742,7 @@ export default function CalendarView() { setCreateDefaultEnd(undefined); } }} - defaultDate={selectedDate} + defaultDate={selectedCalendarDate} defaultStartTime={createDefaultStart} defaultEndTime={createDefaultEnd} /> @@ -1746,7 +1757,7 @@ export default function CalendarView() { Date: Tue, 4 Aug 2026 13:33:17 +0200 Subject: [PATCH 07/12] fix(calendar): preserve drag duration across DST --- .../calendar/app/hooks/use-event-drag.test.ts | 33 +++++++++++++++ .../calendar/app/hooks/use-event-drag.ts | 42 +++++++++++++++---- 2 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 templates/calendar/app/hooks/use-event-drag.test.ts diff --git a/templates/calendar/app/hooks/use-event-drag.test.ts b/templates/calendar/app/hooks/use-event-drag.test.ts new file mode 100644 index 0000000000..8561feebc7 --- /dev/null +++ b/templates/calendar/app/hooks/use-event-drag.test.ts @@ -0,0 +1,33 @@ +import type { CalendarEvent } from "@shared/api"; +import { describe, expect, it } from "vitest"; + +import { resolveDraggedEventTimes } from "./use-event-drag"; + +const event: CalendarEvent = { + id: "event-1", + title: "DST event", + description: "", + location: "", + start: "2026-03-08T06:30:00.000Z", + end: "2026-03-08T07:30:00.000Z", + allDay: false, + source: "local", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("resolveDraggedEventTimes", () => { + it("preserves elapsed duration when a move crosses a spring-forward gap", () => { + const times = resolveDraggedEventTimes({ + event, + mode: "move", + start: new Date(2026, 2, 8, 1, 30), + heightMinutes: 60, + timezone: "America/New_York", + }); + + expect(times.start.toISOString()).toBe("2026-03-08T06:30:00.000Z"); + expect(times.end.toISOString()).toBe("2026-03-08T07:30:00.000Z"); + expect(times.end.getTime() - times.start.getTime()).toBe(60 * 60_000); + }); +}); diff --git a/templates/calendar/app/hooks/use-event-drag.ts b/templates/calendar/app/hooks/use-event-drag.ts index 4ffe535f67..44ae98ef78 100644 --- a/templates/calendar/app/hooks/use-event-drag.ts +++ b/templates/calendar/app/hooks/use-event-drag.ts @@ -1,5 +1,5 @@ import type { CalendarEvent } from "@shared/api"; -import { startOfDay, set, addMinutes } from "date-fns"; +import { startOfDay, set, addMinutes, parseISO } from "date-fns"; import { fromZonedTime, toZonedTime } from "date-fns-tz"; import { useState, useRef, useCallback, useEffect } from "react"; @@ -52,6 +52,32 @@ export interface UseEventDragOptions { timezone: string; } +export function resolveDraggedEventTimes({ + event, + mode, + start, + heightMinutes, + timezone, +}: { + event: CalendarEvent; + mode: DragState["mode"]; + start: Date; + heightMinutes: number; + timezone: string; +}) { + const newStart = fromZonedTime(start, timezone); + const durationMinutes = + mode === "move" + ? (parseISO(event.end).getTime() - parseISO(event.start).getTime()) / + 60_000 + : heightMinutes; + + return { + start: newStart, + end: addMinutes(newStart, durationMinutes), + }; +} + export function useEventDrag({ hourHeight, startHour, @@ -304,13 +330,15 @@ export function useEventDrag({ set(baseDay, { hours: startHour, minutes: 0, seconds: 0 }), topMinutes, ); - const newEnd = addMinutes(newStart, heightMinutes); + const times = resolveDraggedEventTimes({ + event: state.event, + mode: state.mode, + start: newStart, + heightMinutes, + timezone, + }); - onEventTimeChange( - state.eventId, - fromZonedTime(newStart, timezone), - fromZonedTime(newEnd, timezone), - ); + onEventTimeChange(state.eventId, times.start, times.end); } dragStateRef.current = null; From c39ec98850116754e1809d48411fcc1c1f6fbc6e Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Tue, 4 Aug 2026 14:29:01 +0200 Subject: [PATCH 08/12] fix(calendar): use saved timezone for inventory cursors --- .../calendar/actions/list-events.test.ts | 62 ++++++++++++++++++- templates/calendar/actions/list-events.ts | 8 ++- 2 files changed, 68 insertions(+), 2 deletions(-) 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 f4ca601f4e..353e0496d2 100644 --- a/templates/calendar/actions/list-events.ts +++ b/templates/calendar/actions/list-events.ts @@ -75,6 +75,7 @@ interface ListCalendarEventsArgs { interface ListCalendarEventsOptions { ownedAccounts?: string[]; range?: CalendarEventRange; + timezone?: string; } type CalendarInventorySource = "google" | "bookings" | "ics" | "overlays"; @@ -579,7 +580,7 @@ export async function listCalendarEvents( ): Promise { const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); - const timezone = await getCalendarTimezone(email); + const timezone = options.timezone ?? (await getCalendarTimezone(email)); const range = options.range ?? resolveCalendarEventRange({ @@ -832,6 +833,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 @@ -845,6 +849,7 @@ export default defineAction({ preparedRange = resolveCalendarEventRange({ from: args.from, to: args.to, + timezone: calendarTimezone, }); preparedOwnedAccounts = args.accountEmails ? undefined @@ -871,6 +876,7 @@ export default defineAction({ { ownedAccounts: preparedOwnedAccounts, range: preparedRange, + timezone: calendarTimezone, }, ); From 6de951688478830ff8cd936df8b98f24bb56c965 Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Tue, 18 Aug 2026 17:20:37 +0200 Subject: [PATCH 09/12] fix(calendar): preserve local time when moving events across DST handleEventDrop rewrote the date on the raw UTC instant with browser-local setFullYear, so dragging an event across a DST boundary shifted its displayed time in the configured calendar timezone. Move the date in the calendar zone and convert back with fromZonedTime. --- templates/calendar/app/pages/CalendarView.tsx | 96 ++++++++++++------- 1 file changed, 63 insertions(+), 33 deletions(-) diff --git a/templates/calendar/app/pages/CalendarView.tsx b/templates/calendar/app/pages/CalendarView.tsx index 075d988439..5ef17f5c09 100644 --- a/templates/calendar/app/pages/CalendarView.tsx +++ b/templates/calendar/app/pages/CalendarView.tsx @@ -139,6 +139,49 @@ function isRecurringCalendarEvent(event: CalendarEvent): boolean { return Boolean(event.recurringEventId || event.recurrence?.length); } +function moveEventTimes({ + start, + end, + targetDate, + allDay, + timezone, +}: { + start: string; + end: string; + targetDate: Date; + allDay: boolean; + timezone: string; +}) { + const originalStart = parseISO(start); + const originalEnd = parseISO(end); + const newStart = allDay + ? new Date(originalStart) + : toZonedTime(originalStart, timezone); + const newEnd = allDay + ? new Date(originalEnd) + : toZonedTime(originalEnd, timezone); + + newStart.setFullYear( + targetDate.getFullYear(), + targetDate.getMonth(), + targetDate.getDate(), + ); + newEnd.setFullYear( + targetDate.getFullYear(), + targetDate.getMonth(), + targetDate.getDate(), + ); + + return { + start: allDay + ? newStart.toISOString() + : fromZonedTime(newStart, timezone).toISOString(), + end: allDay + ? newEnd.toISOString() + : fromZonedTime(newEnd, timezone).toISOString(), + }; +} + function updateScopePayload(scope: UpdateEventScope | undefined): { scope?: UpdateEventScope; } { @@ -1004,52 +1047,39 @@ export default function CalendarView() { if (!event) return; if (calendarDraftIdFromEventId(eventId)) { - const originalStart = parseISO(event.start); - const originalEnd = parseISO(event.end); - const newStart = new Date(originalStart); - const newEnd = new Date(originalEnd); - newStart.setFullYear( - newDate.getFullYear(), - newDate.getMonth(), - newDate.getDate(), - ); - newEnd.setFullYear( - newDate.getFullYear(), - newDate.getMonth(), - newDate.getDate(), - ); + const times = moveEventTimes({ + start: event.start, + end: event.end, + targetDate: newDate, + allDay: event.allDay, + timezone: calendarTimezone, + }); updateDraftEvent(eventId, { - start: newStart.toISOString(), - end: newEnd.toISOString(), + start: times.start, + end: times.end, }); return; } const oldStartISO = event.start; const oldEndISO = event.end; - const originalStart = parseISO(event.start); - const originalEnd = parseISO(event.end); - const newStart = new Date(originalStart); - const newEnd = new Date(originalEnd); - - newStart.setFullYear( - newDate.getFullYear(), - newDate.getMonth(), - newDate.getDate(), - ); - newEnd.setFullYear( - newDate.getFullYear(), - newDate.getMonth(), - newDate.getDate(), - ); + const times = moveEventTimes({ + start: event.start, + end: event.end, + targetDate: newDate, + allDay: event.allDay, + timezone: calendarTimezone, + }); + const newStart = parseISO(times.start); + const newEnd = parseISO(times.end); // Guard against a zero/negative duration reaching the server (e.g. a // DST transition collapsing a short event's start/end onto each other). if (newEnd.getTime() <= newStart.getTime()) return; const updates = { - start: newStart.toISOString(), - end: newEnd.toISOString(), + start: times.start, + end: times.end, }; const isRecurring = isRecurringCalendarEvent(event); const guestNotification = await promptGuestNotification({ From f0fadfbfb72234bdf9f89937bb404893ef269e2a Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Tue, 18 Aug 2026 17:21:32 +0200 Subject: [PATCH 10/12] fix(calendar): refresh drag callbacks when the timezone changes startDrag and onPointerUp read the configured timezone but omitted it from their dependency arrays, so a drag started after a live settings change could keep the previous zone's closure and save the event in the old timezone. --- templates/calendar/app/hooks/use-event-drag.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/templates/calendar/app/hooks/use-event-drag.ts b/templates/calendar/app/hooks/use-event-drag.ts index 44ae98ef78..6a8765dd16 100644 --- a/templates/calendar/app/hooks/use-event-drag.ts +++ b/templates/calendar/app/hooks/use-event-drag.ts @@ -201,6 +201,7 @@ export function useEventDrag({ getScrollTop, startHour, hourHeight, + timezone, ], ); @@ -349,6 +350,7 @@ export function useEventDrag({ days, startHour, onEventTimeChange, + timezone, ]); const cancelDrag = useCallback(() => { From a5734953903027fa50940008fc6f5de914d73db4 Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Wed, 19 Aug 2026 07:12:57 +0200 Subject: [PATCH 11/12] fix(calendar): recover from a legacy invalid saved timezone getCalendarTimezone threw whenever the stored timezone was not a valid IANA zone, so an account saved by an older build that accepted arbitrary strings could not load events at all. normalizeCalendarSettings now validates the zone and takes the fallbacks a caller wants, so every read and write resolves to a usable one. Settings reads and writes move behind readCalendarSettings / readPublicCalendarSettings / saveCalendarSettings, leaving one place that normalizes: the grid and the settings page can no longer resolve an account to different zones, and saving an unrelated field no longer resets the timezone of an account that had none. Consolidates the IANA check into shared/timezone.ts, which also stops a non-RangeError fault from being reported as an invalid zone. --- templates/calendar/actions/get-settings.ts | 12 +- templates/calendar/actions/update-settings.ts | 19 +-- .../calendar/app/lib/calendar-timezone.ts | 9 +- ...ain-for-accounts-whose-saved-timezone-w.md | 6 + .../calendar/server/handlers/settings.ts | 35 ++--- .../server/lib/calendar-settings.spec.ts | 132 ++++++++++++++++++ .../calendar/server/lib/calendar-settings.ts | 73 ++++++---- .../server/lib/update-settings-action.spec.ts | 5 +- templates/calendar/shared/settings.test.ts | 40 ++++++ templates/calendar/shared/settings.ts | 24 ++-- templates/calendar/shared/timezone.test.ts | 36 +++++ templates/calendar/shared/timezone.ts | 15 ++ 12 files changed, 308 insertions(+), 98 deletions(-) create mode 100644 templates/calendar/changelog/2026-08-19-the-calendar-loads-again-for-accounts-whose-saved-timezone-w.md create mode 100644 templates/calendar/server/lib/calendar-settings.spec.ts create mode 100644 templates/calendar/shared/timezone.test.ts create mode 100644 templates/calendar/shared/timezone.ts diff --git a/templates/calendar/actions/get-settings.ts b/templates/calendar/actions/get-settings.ts index 4a4a73f603..46902983ca 100644 --- a/templates/calendar/actions/get-settings.ts +++ b/templates/calendar/actions/get-settings.ts @@ -1,10 +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 { getDefaultSettings } from "../server/lib/calendar-settings.js"; -import { normalizeCalendarSettings } from "../shared/settings.js"; +import { readCalendarSettings } from "../server/lib/calendar-settings.js"; export default defineAction({ description: "Get calendar settings", @@ -13,12 +11,6 @@ export default defineAction({ run: async () => { const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); - const stored = await getUserSetting(email, "calendar-settings"); - // Seed with the caller's request timezone so a first-time account starts in - // its own zone rather than the fixed default; saved values still win. - return normalizeCalendarSettings({ - ...getDefaultSettings(), - ...(stored && typeof stored === "object" ? stored : {}), - }); + return readCalendarSettings(email); }, }); diff --git a/templates/calendar/actions/update-settings.ts b/templates/calendar/actions/update-settings.ts index a95fb256f6..609c628780 100644 --- a/templates/calendar/actions/update-settings.ts +++ b/templates/calendar/actions/update-settings.ts @@ -1,14 +1,9 @@ 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 { isCalendarTimezone } from "../server/lib/calendar-settings.js"; -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", @@ -40,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/app/lib/calendar-timezone.ts b/templates/calendar/app/lib/calendar-timezone.ts index 1c7dd576cc..1cf47a6ad2 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 { isCalendarTimezone } from "@shared/timezone"; import { addDays, endOfMonth, @@ -43,13 +44,7 @@ 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; - } + return isCalendarTimezone(timezone); } export function normalizeTimezone(timezone?: string): string { 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..484a2e976f --- /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 loads 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 c042783d9f..e44baa529a 100644 --- a/templates/calendar/server/handlers/settings.ts +++ b/templates/calendar/server/handlers/settings.ts @@ -1,14 +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 { getDefaultSettings } from "../lib/calendar-settings.js"; +import { + readCalendarSettings, + readPublicCalendarSettings, + saveCalendarSettings, +} from "../lib/calendar-settings.js"; async function uEmail(event: H3Event): Promise { const session = await getSession(event); @@ -21,12 +18,7 @@ async function uEmail(event: H3Event): Promise { export const getSettings = defineEventHandler(async (event: H3Event) => { try { - const email = await uEmail(event); - const stored = await getUserSetting(email, "calendar-settings"); - return normalizeCalendarSettings({ - ...getDefaultSettings(), - ...(stored && typeof stored === "object" ? stored : {}), - }); + return await readCalendarSettings(await uEmail(event)); } catch (error: any) { setResponseStatus(event, 500); return { error: error.message }; @@ -34,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 index cfb33f0e1a..5521128ca6 100644 --- a/templates/calendar/server/lib/calendar-settings.ts +++ b/templates/calendar/server/lib/calendar-settings.ts @@ -1,42 +1,59 @@ import { getRequestTimezone } from "@agent-native/core/server"; -import { getUserSetting } from "@agent-native/core/settings"; +import { + getSetting, + getUserSetting, + putSetting, + putUserSetting, +} from "@agent-native/core/settings"; import type { Settings } from "../../shared/api.js"; -import { DEFAULT_SETTINGS } from "../../shared/settings.js"; +import { + DEFAULT_SETTINGS, + normalizeCalendarSettings, +} from "../../shared/settings.js"; +import { isCalendarTimezone } from "../../shared/timezone.js"; -function defaultTimezone() { +const SETTINGS_KEY = "calendar-settings"; + +function callerTimezone(): string { const timezone = getRequestTimezone(); - if (!timezone) return "America/New_York"; + return isCalendarTimezone(timezone) ? timezone : DEFAULT_SETTINGS.timezone; +} - try { - new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(); - return timezone; - } catch { - return "America/New_York"; - } +export async function readCalendarSettings(email: string): Promise { + return normalizeCalendarSettings(await getUserSetting(email, SETTINGS_KEY), { + timezone: callerTimezone(), + }); } -export function getDefaultSettings(): Settings { - return { ...DEFAULT_SETTINGS, timezone: defaultTimezone() }; +/** + * 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)); } -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 { - return false; - } +/** 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 : {}), + }, + { timezone: callerTimezone() }, + ); + const record = settings as unknown as Record; + await putUserSetting(email, SETTINGS_KEY, record); + // Also write the global key so the public booking page can read it. + await 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 { - const settings = (await getUserSetting(email, "calendar-settings")) as { - timezone?: unknown; - } | null; - if (settings?.timezone === undefined) return getDefaultSettings().timezone; - if (!isCalendarTimezone(settings.timezone)) { - throw new Error("Saved calendar timezone must be a valid IANA timezone."); - } - return settings.timezone; + return (await readCalendarSettings(email)).timezone; } diff --git a/templates/calendar/server/lib/update-settings-action.spec.ts b/templates/calendar/server/lib/update-settings-action.spec.ts index 0ef0cff506..d49fb1d64d 100644 --- a/templates/calendar/server/lib/update-settings-action.spec.ts +++ b/templates/calendar/server/lib/update-settings-action.spec.ts @@ -1,5 +1,6 @@ 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 putSettingMock = vi.hoisted(() => vi.fn()); @@ -9,6 +10,7 @@ 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", () => ({ @@ -18,11 +20,12 @@ vi.mock("@agent-native/core/settings", () => ({ })); import action from "../../actions/update-settings"; -import { isCalendarTimezone } from "./calendar-settings"; +import { isCalendarTimezone } from "../../shared/timezone"; describe("update-settings timezone validation", () => { beforeEach(() => { vi.clearAllMocks(); + getRequestTimezoneMock.mockReturnValue("America/New_York"); getRequestUserEmailMock.mockReturnValue("owner@example.com"); getUserSettingMock.mockResolvedValue(null); putSettingMock.mockResolvedValue(undefined); 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..eb34efcc4e --- /dev/null +++ b/templates/calendar/shared/timezone.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { 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; + } + }); +}); diff --git a/templates/calendar/shared/timezone.ts b/templates/calendar/shared/timezone.ts new file mode 100644 index 0000000000..5c78fdc6d0 --- /dev/null +++ b/templates/calendar/shared/timezone.ts @@ -0,0 +1,15 @@ +/** + * The one check for "is this a usable IANA zone", shared by client, server, and + * actions. Only a `RangeError` means Intl rejected the zone; any other failure + * is a real fault and must surface instead of being reported as "invalid". + */ +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; + } +} From 6be28f9765275ac10cf8adc10b44df5eaf265f8b Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Wed, 19 Aug 2026 12:07:48 +0200 Subject: [PATCH 12/12] refactor(calendar): resolve every timezone conversion through one helper The template carried the same wall-clock-to-instant algorithm in three places and the same IANA validity check in seven. Each copy handled DST edges a little differently: list-events put a skipped midnight (Santiago) an hour into the previous day, and every private copy of the validity check reported a broken Intl as "invalid timezone". shared/timezone.ts now owns the conversion, the date-key helpers, and the check; find-time.ts and list-events.ts delegate to it under their existing names, so callers are unchanged. Formatters are cached per zone and option set because constructing one costs ~45us and resolving a wall clock probes the zone a dozen times: dateTimeInTimezoneToIso drops from ~813us to ~99us per call, and the two event labels that render once per event are faster than the date-fns-tz code they replaced. Also drops an unreachable fallback in saveCalendarSettings, parallelizes its two independent writes, and makes the update-settings spec exercise the action schema rather than the predicate directly. --- pnpm-lock.yaml | 12 -- templates/calendar/actions/list-events.ts | 92 ++------------ templates/calendar/actions/view-screen.ts | 30 +++-- .../components/calendar/CommandPalette.tsx | 12 +- .../app/components/calendar/EventCard.tsx | 12 +- .../calendar/app/lib/calendar-timezone.ts | 16 +-- .../calendar/app/lib/event-form-utils.test.ts | 19 --- .../calendar/app/lib/event-form-utils.ts | 65 +--------- ...ain-for-accounts-whose-saved-timezone-w.md | 2 +- templates/calendar/package.json | 1 - .../calendar/server/lib/calendar-settings.ts | 19 ++- templates/calendar/server/lib/find-time.ts | 108 +++------------- .../server/lib/update-settings-action.spec.ts | 12 +- templates/calendar/shared/timezone.test.ts | 59 ++++++++- templates/calendar/shared/timezone.ts | 118 +++++++++++++++++- 15 files changed, 256 insertions(+), 321 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77856128be..52b23ca390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2846,9 +2846,6 @@ importers: chrono-node: specifier: 2.9.1 version: 2.9.1 - date-fns-tz: - specifier: 3.2.0 - version: 3.2.0(date-fns@4.4.0) dotenv: specifier: ^17.2.1 version: 17.4.2 @@ -16023,11 +16020,6 @@ packages: date-fns-jalali@4.1.0-0: resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} - date-fns-tz@3.2.0: - resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} - peerDependencies: - date-fns: ^3.0.0 || ^4.0.0 - date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} @@ -32517,10 +32509,6 @@ snapshots: date-fns-jalali@4.1.0-0: {} - date-fns-tz@3.2.0(date-fns@4.4.0): - dependencies: - date-fns: 4.4.0 - date-fns@4.4.0: {} dayjs@1.11.21: {} diff --git a/templates/calendar/actions/list-events.ts b/templates/calendar/actions/list-events.ts index 353e0496d2..55a40e29bd 100644 --- a/templates/calendar/actions/list-events.ts +++ b/templates/calendar/actions/list-events.ts @@ -17,6 +17,12 @@ 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}$/; @@ -348,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())) { @@ -439,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())) { diff --git a/templates/calendar/actions/view-screen.ts b/templates/calendar/actions/view-screen.ts index 48fcd958e8..19f43cd081 100644 --- a/templates/calendar/actions/view-screen.ts +++ b/templates/calendar/actions/view-screen.ts @@ -2,8 +2,6 @@ import { defineAction } from "@agent-native/core"; import { readAppState } from "@agent-native/core/application-state"; import { getRequestUserEmail } from "@agent-native/core/server"; import { accessFilter } from "@agent-native/core/sharing"; -import { addDays, parseISO, startOfWeek } from "date-fns"; -import { fromZonedTime, toZonedTime } from "date-fns-tz"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -14,6 +12,11 @@ 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"; @@ -52,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.", @@ -73,15 +81,19 @@ export default defineAction({ const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); const timezone = await getCalendarTimezone(email); - const viewDate = nav?.date - ? parseISO(nav.date) - : toZonedTime(new Date(), timezone); - const from = startOfWeek(viewDate); - const to = addDays(from, 7); + // 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( - fromZonedTime(from, timezone).toISOString(), - fromZonedTime(to, timezone).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 c1df1c06e8..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, @@ -13,7 +14,6 @@ import { } from "@tabler/icons-react"; import * as chrono from "chrono-node"; import { format, parseISO, parse, isValid } from "date-fns"; -import { toZonedTime } from "date-fns-tz"; import { cn } from "@/lib/utils"; @@ -195,12 +195,10 @@ export function CommandPalette({ /> {event.title} - {format( - event.allDay || !timezone - ? parseISO(event.start) - : toZonedTime(event.start, timezone), - "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 62ef047e74..683a6bd32d 100644 --- a/templates/calendar/app/components/calendar/EventCard.tsx +++ b/templates/calendar/app/components/calendar/EventCard.tsx @@ -1,7 +1,7 @@ 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 { formatInTimeZone } from "date-fns-tz"; import { getEventDisplayColor, @@ -167,12 +167,10 @@ export function EventCard({ )} {!event.allDay && ( - {timezone - ? formatInTimeZone(event.start, timezone, "h:mm a") - : new Date(event.start).toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", - })} + {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 1cf47a6ad2..76d1685c87 100644 --- a/templates/calendar/app/lib/calendar-timezone.ts +++ b/templates/calendar/app/lib/calendar-timezone.ts @@ -1,5 +1,5 @@ import type { CalendarEvent } from "@shared/api"; -import { isCalendarTimezone } from "@shared/timezone"; +import { addDaysToDateKey, isCalendarTimezone } from "@shared/timezone"; import { addDays, endOfMonth, @@ -43,14 +43,8 @@ export function getBrowserTimezone(): string { } } -export function isValidTimezone(timezone: string): boolean { - return isCalendarTimezone(timezone); -} - 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. */ @@ -63,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 209ac7e1b3..cec37309a4 100644 --- a/templates/calendar/app/lib/event-form-utils.test.ts +++ b/templates/calendar/app/lib/event-form-utils.test.ts @@ -38,25 +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"); - }); - - it("keeps an event's elapsed duration when its end lands in a spring-forward gap", () => { - const timezone = "America/New_York"; - const start = dateTimeInTimezoneToIso("2026-03-08", "01:30", timezone); - const end = dateTimeInTimezoneToIso("2026-03-08", "02:30", timezone); - - expect(start).toBe("2026-03-08T06:30:00.000Z"); - expect(new Date(end).getTime() - new Date(start).getTime()).toBe( - 60 * 60_000, - ); - }); -}); - 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-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 index 484a2e976f..c4eb7be853 100644 --- 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 @@ -3,4 +3,4 @@ type: fixed date: 2026-08-19 --- -The calendar loads again for accounts whose saved timezone was stored in a format the calendar no longer understands +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/package.json b/templates/calendar/package.json index f9a7263e89..42ab3981b9 100644 --- a/templates/calendar/package.json +++ b/templates/calendar/package.json @@ -25,7 +25,6 @@ "@resvg/resvg-js": "^2.6.2", "@tabler/icons-react": "catalog:", "chrono-node": "2.9.1", - "date-fns-tz": "3.2.0", "dotenv": "^17.2.1", "drizzle-orm": "^0.45.2", "h3": "catalog:", diff --git a/templates/calendar/server/lib/calendar-settings.ts b/templates/calendar/server/lib/calendar-settings.ts index 5521128ca6..22ba07307d 100644 --- a/templates/calendar/server/lib/calendar-settings.ts +++ b/templates/calendar/server/lib/calendar-settings.ts @@ -39,17 +39,16 @@ export async function saveCalendarSettings( email: string, patch: unknown, ): Promise { - const settings = normalizeCalendarSettings( - { - ...(await readCalendarSettings(email)), - ...(patch && typeof patch === "object" ? patch : {}), - }, - { timezone: callerTimezone() }, - ); + const settings = normalizeCalendarSettings({ + ...(await readCalendarSettings(email)), + ...(patch && typeof patch === "object" ? patch : {}), + }); const record = settings as unknown as Record; - await putUserSetting(email, SETTINGS_KEY, record); - // Also write the global key so the public booking page can read it. - await putSetting(SETTINGS_KEY, 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; } 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/update-settings-action.spec.ts b/templates/calendar/server/lib/update-settings-action.spec.ts index d49fb1d64d..61e6488d6e 100644 --- a/templates/calendar/server/lib/update-settings-action.spec.ts +++ b/templates/calendar/server/lib/update-settings-action.spec.ts @@ -1,4 +1,5 @@ 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()); @@ -20,7 +21,6 @@ vi.mock("@agent-native/core/settings", () => ({ })); import action from "../../actions/update-settings"; -import { isCalendarTimezone } from "../../shared/timezone"; describe("update-settings timezone validation", () => { beforeEach(() => { @@ -32,8 +32,14 @@ describe("update-settings timezone validation", () => { putUserSettingMock.mockResolvedValue(undefined); }); - it("rejects invalid IANA timezones", () => { - expect(isCalendarTimezone("not-a-timezone")).toBe(false); + 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 () => { diff --git a/templates/calendar/shared/timezone.test.ts b/templates/calendar/shared/timezone.test.ts index eb34efcc4e..7b848053e8 100644 --- a/templates/calendar/shared/timezone.test.ts +++ b/templates/calendar/shared/timezone.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { isCalendarTimezone } from "./timezone"; +import { + addDaysToDateKey, + dateKeyInTimezone, + dateTimeInTimezoneToIso, + isCalendarTimezone, +} from "./timezone"; describe("isCalendarTimezone", () => { it("accepts a valid IANA zone", () => { @@ -34,3 +39,55 @@ describe("isCalendarTimezone", () => { } }); }); + +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 index 5c78fdc6d0..71fe22e223 100644 --- a/templates/calendar/shared/timezone.ts +++ b/templates/calendar/shared/timezone.ts @@ -1,7 +1,35 @@ /** - * The one check for "is this a usable IANA zone", shared by client, server, and - * actions. Only a `RangeError` means Intl rejected the zone; any other failure - * is a real fault and must surface instead of being reported as "invalid". + * 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; @@ -13,3 +41,87 @@ export function isCalendarTimezone(value: unknown): value is string { 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); +}