From 9cf011c29c5e59cfc4f5d88e1568df7408de0fab Mon Sep 17 00:00:00 2001 From: Marcin Piniarski Date: Wed, 22 Jul 2026 18:40:49 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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, }, );