diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2a448618f..c9125301da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2724,6 +2724,9 @@ 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 @@ -15077,6 +15080,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.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} @@ -30696,6 +30704,10 @@ 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/get-settings.ts b/templates/calendar/actions/get-settings.ts index 374bfe812c..f0aa82b271 100644 --- a/templates/calendar/actions/get-settings.ts +++ b/templates/calendar/actions/get-settings.ts @@ -3,15 +3,9 @@ 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 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, -}; - export default defineAction({ description: "Get calendar settings", schema: z.object({}), @@ -19,8 +13,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/actions/list-events.test.ts b/templates/calendar/actions/list-events.test.ts index b5fd8bb7b6..5e12f3ebc6 100644 --- a/templates/calendar/actions/list-events.test.ts +++ b/templates/calendar/actions/list-events.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const getRequestTimezoneMock = vi.hoisted(() => vi.fn()); const getRequestUserEmailMock = vi.hoisted(() => vi.fn()); @@ -252,6 +252,10 @@ describe("list-events inventory contract", () => { verifyShortLivedTokenMock.mockReturnValue({ ok: true }); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("keeps legacy callers on CalendarEvent arrays", async () => { const result = await (listEventsAction as any).run( { from: "2026-06-17", to: "2026-06-18" }, @@ -704,6 +708,62 @@ describe("list-events inventory contract", () => { expect(listGoogleEventsMock).not.toHaveBeenCalled(); }); + it("uses the saved timezone for omitted-range inventory cursors", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-17T12:00:00.000Z")); + getRequestTimezoneMock.mockReturnValue("UTC"); + getUserSettingMock.mockResolvedValue({ timezone: "America/New_York" }); + listGoogleEventsMock.mockResolvedValue({ + events: [ + { + id: "google-event-1", + googleEventId: "event-1", + title: "First", + description: "", + start: "2026-06-17T16:00:00.000Z", + end: "2026-06-17T16:30:00.000Z", + location: "", + allDay: false, + source: "google", + accountEmail: "steve@example.com", + createdAt: "2026-06-12T10:13:39.746Z", + updatedAt: "2026-06-12T10:13:39.746Z", + }, + { + id: "google-event-2", + googleEventId: "event-2", + title: "Second", + description: "", + start: "2026-06-17T17:00:00.000Z", + end: "2026-06-17T17:30:00.000Z", + location: "", + allDay: false, + source: "google", + accountEmail: "steve@example.com", + createdAt: "2026-06-12T10:13:39.746Z", + updatedAt: "2026-06-12T10:13:39.746Z", + }, + ], + errors: [], + }); + + const first = await (listEventsAction as any).run( + { format: "inventory", pageSize: 1, sources: ["google"] }, + { caller: "mcp" }, + ); + const second = await (listEventsAction as any).run( + { + format: "inventory", + pageSize: 1, + sources: ["google"], + cursor: first.page.nextCursor, + }, + { caller: "mcp" }, + ); + + expect(second.items.map((item: any) => item.id)).toEqual(["event-2"]); + }); + it("rejects a malformed inventory cursor before provider reads", async () => { await expect( (listEventsAction as any).run( diff --git a/templates/calendar/actions/list-events.ts b/templates/calendar/actions/list-events.ts index 995e4d0dee..353e0496d2 100644 --- a/templates/calendar/actions/list-events.ts +++ b/templates/calendar/actions/list-events.ts @@ -13,6 +13,7 @@ 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"; @@ -74,6 +75,7 @@ interface ListCalendarEventsArgs { interface ListCalendarEventsOptions { ownedAccounts?: string[]; range?: CalendarEventRange; + timezone?: string; } type CalendarInventorySource = "google" | "bookings" | "ics" | "overlays"; @@ -578,11 +580,13 @@ export async function listCalendarEvents( ): Promise { const email = getRequestUserEmail(); if (!email) throw new Error("no authenticated user"); + const timezone = options.timezone ?? (await getCalendarTimezone(email)); const range = options.range ?? resolveCalendarEventRange({ from: args.from, to: args.to, + timezone, }); const sources = resolveInventorySources(args.sources); @@ -829,6 +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 @@ -842,6 +849,7 @@ export default defineAction({ preparedRange = resolveCalendarEventRange({ from: args.from, to: args.to, + timezone: calendarTimezone, }); preparedOwnedAccounts = args.accountEmails ? undefined @@ -868,6 +876,7 @@ export default defineAction({ { ownedAccounts: preparedOwnedAccounts, range: preparedRange, + timezone: calendarTimezone, }, ); diff --git a/templates/calendar/actions/update-settings.ts b/templates/calendar/actions/update-settings.ts index f1ebc86f03..de7bf96f37 100644 --- a/templates/calendar/actions/update-settings.ts +++ b/templates/calendar/actions/update-settings.ts @@ -3,12 +3,20 @@ import { getRequestUserEmail } from "@agent-native/core/server"; import { putUserSetting, putSetting } from "@agent-native/core/settings"; import { z } from "zod"; +import { isCalendarTimezone } from "../server/lib/calendar-settings.js"; import type { Settings } from "../shared/api.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 28c4c61cc4..48fcd958e8 100644 --- a/templates/calendar/actions/view-screen.ts +++ b/templates/calendar/actions/view-screen.ts @@ -2,10 +2,13 @@ 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"; import { rowToBookingLink } from "../server/lib/booking-link-utils.js"; +import { getCalendarTimezone } from "../server/lib/calendar-settings.js"; import type { CalendarEvent, CalendarEventDraft } from "../shared/api.js"; import { CALENDAR_VIEW_PREFERENCES_KEY, @@ -67,18 +70,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/app/components/calendar/CommandPalette.tsx b/templates/calendar/app/components/calendar/CommandPalette.tsx index 319af9c8d9..c1df1c06e8 100644 --- a/templates/calendar/app/components/calendar/CommandPalette.tsx +++ b/templates/calendar/app/components/calendar/CommandPalette.tsx @@ -13,6 +13,7 @@ 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"; @@ -28,6 +29,7 @@ interface CommandPaletteProps { open: boolean; onClose: () => void; events: CalendarEvent[]; + timezone?: string; onGoToDate: (date: Date) => void; onEventClick: (event: CalendarEvent) => void; onCreateEvent: () => void; @@ -80,6 +82,7 @@ export function CommandPalette({ open, onClose, events, + timezone, onGoToDate, onEventClick, onCreateEvent, @@ -192,7 +195,12 @@ export function CommandPalette({ /> {event.title} - {format(parseISO(event.start), "MMM d")} + {format( + event.allDay || !timezone + ? 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 c0bdaabf4e..999fce4dea 100644 --- a/templates/calendar/app/components/calendar/DayView.tsx +++ b/templates/calendar/app/components/calendar/DayView.tsx @@ -14,6 +14,7 @@ import { addDays, min, } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; import { useState, useEffect, useRef, useMemo, useCallback, memo } from "react"; import { useCalendarSetters } from "@/components/layout/AppLayout"; @@ -49,6 +50,7 @@ import { OutOfOfficeEvent } from "./OutOfOfficeEvent"; interface DayViewProps { events: CalendarEvent[]; + timezone: string; date: Date; onDeleteEvent: (eventId: string) => void; onEventTimeChange?: (eventId: string, newStart: Date, newEnd: Date) => void; @@ -182,9 +184,13 @@ function computeLayout(dayEvents: CalendarEvent[]): Map { return result; } -function getEventStyleForDate(event: CalendarEvent, date: Date) { - const start = parseISO(event.start); - const end = parseISO(event.end); +function getEventStyleForDate( + event: CalendarEvent, + date: Date, + timezone: string, +) { + 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]); @@ -202,6 +208,7 @@ function getEventStyleForDate(event: CalendarEvent, date: Date) { interface DayEventCardProps { event: CalendarEvent; + timezone: string; date: Date; layout: Map; now: Date; @@ -243,6 +250,7 @@ interface DayEventCardProps { */ const DayEventCard = memo(function DayEventCard({ event, + timezone, date, layout, now, @@ -284,7 +292,7 @@ const DayEventCard = memo(function DayEventCard({ top: `${overrides.top}px`, height: `${overrides.height}px`, } - : getEventStyleForDate(event, date); + : getEventStyleForDate(event, date, timezone); const color = getEventDisplayColor(event, prefs); const evStart = parseISO(event.start); const rawEnd = parseISO(event.end); @@ -529,6 +537,7 @@ const DayCreateGhost = memo(function DayCreateGhost({ export const DayView = memo(function DayView({ events, + timezone, date, onDeleteEvent, onEventTimeChange, @@ -618,7 +627,10 @@ export const DayView = memo(function DayView({ function nameForToken(token: "shortGeneric" | "longGeneric" | "short") { try { return ( - new Intl.DateTimeFormat("en-US", { timeZoneName: token }) + new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + timeZoneName: token, + }) .formatToParts(now) .find((p) => p.type === "timeZoneName")?.value ?? "" ); @@ -629,7 +641,7 @@ export const DayView = memo(function DayView({ let iana = ""; try { - iana = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; + iana = timezone; } catch {} const longGeneric = nameForToken("longGeneric"); @@ -648,10 +660,12 @@ export const DayView = memo(function DayView({ tzLong: longGeneric || iana, tzIana: iana, }; - }, []); + }, [now, timezone]); + const calendarNow = toZonedTime(now, timezone); const today = isToday(date); - 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 = today && nowMinutes >= 0 && nowMinutes <= (END_HOUR - START_HOUR) * 60; @@ -676,6 +690,7 @@ export const DayView = memo(function DayView({ scrollContainerRef, onEventTimeChange: handleEventTimeChange, events, + timezone, }); const canDrag = !!onEventTimeChange; @@ -1109,6 +1124,7 @@ export const DayView = memo(function DayView({ void; dimmed?: boolean; colorPreferences?: CalendarColorPreferences; + timezone?: string; } export function EventCard({ @@ -37,6 +39,7 @@ export function EventCard({ onDragEnd, dimmed = false, colorPreferences, + timezone, }: EventCardProps) { const t = useT(); const workingLocationLabels = createWorkingLocationDisplayLabels(t); @@ -164,10 +167,12 @@ export function EventCard({ )} {!event.allDay && ( - {new Date(event.start).toLocaleTimeString([], { - hour: "numeric", - minute: "2-digit", - })} + {timezone + ? formatInTimeZone(event.start, timezone, "h:mm a") + : new Date(event.start).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + })} )} {event.ownerColor && ( diff --git a/templates/calendar/app/components/calendar/MonthView.tsx b/templates/calendar/app/components/calendar/MonthView.tsx index a1b3993679..6e2a1fc841 100644 --- a/templates/calendar/app/components/calendar/MonthView.tsx +++ b/templates/calendar/app/components/calendar/MonthView.tsx @@ -13,6 +13,7 @@ import { format, parseISO, } from "date-fns"; +import { toZonedTime } from "date-fns-tz"; import { memo, useState, useMemo } from "react"; import { useIsMobile } from "@/hooks/use-mobile"; @@ -25,6 +26,7 @@ import { EventDetailPopover } from "./EventDetailPopover"; interface MonthViewProps { events: CalendarEvent[]; + timezone: string; selectedDate: Date; onDateSelect: (date: Date) => void; onDeleteEvent?: (eventId: string) => void; @@ -75,6 +77,7 @@ interface DayOccurrence { export const MonthView = memo(function MonthView({ events, + timezone, selectedDate, onDateSelect, onDeleteEvent, @@ -115,8 +118,14 @@ export const MonthView = memo(function MonthView({ const eventsByDay = useMemo(() => { const map = new Map(); for (const e of events) { - const evStart = parseISO(e.start); - const evEnd = e.end ? parseISO(e.end) : addDays(evStart, 1); + const evStart = e.allDay + ? parseISO(e.start) + : toZonedTime(e.start, timezone); + const evEnd = e.end + ? e.allDay + ? parseISO(e.end) + : toZonedTime(e.end, timezone) + : addDays(evStart, 1); for (const day of days) { const dayStart = startOfDay(day); const dayEnd = addDays(dayStart, 1); @@ -134,7 +143,7 @@ export const MonthView = memo(function MonthView({ } } return map; - }, [events, days]); + }, [events, days, timezone]); function handleDragOver(e: React.DragEvent, dayKey: string) { e.preventDefault(); @@ -275,6 +284,7 @@ export const MonthView = memo(function MonthView({ > void; onDeleteEvent: (eventId: string) => void; @@ -231,9 +233,9 @@ function computeLayout( return result; } -function getSegmentStyle(event: CalendarEvent, day: Date) { - const evStart = parseISO(event.start); - const evEnd = parseISO(event.end); +function getSegmentStyle(event: CalendarEvent, day: Date, timezone: string) { + 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; @@ -248,6 +250,7 @@ function getSegmentStyle(event: CalendarEvent, day: Date) { interface WeekEventCardProps { event: CalendarEvent; + timezone: string; day: Date; dayIndex: number; layout: Map; @@ -302,6 +305,7 @@ interface WeekEventCardProps { */ const WeekEventCard = memo(function WeekEventCard({ event, + timezone, day, dayIndex, layout, @@ -340,8 +344,8 @@ const WeekEventCard = memo(function WeekEventCard({ overrideTop !== null && overrideHeight !== null && overrideDayIndex !== null ? { top: overrideTop, height: overrideHeight, dayIndex: overrideDayIndex } : null; - 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); @@ -377,7 +381,7 @@ const WeekEventCard = memo(function WeekEventCard({ top: `${overrides.top}px`, height: `${overrides.height}px`, } - : getSegmentStyle(event, day); + : getSegmentStyle(event, day, timezone); const color = getEventDisplayColor(event, prefs); const segStart = isStart ? start : dayBase; const segEnd = min([end, segDayEnd]); @@ -603,6 +607,7 @@ const WeekCreateGhost = memo(function WeekCreateGhost({ export const WeekView = memo(function WeekView({ events, + timezone, selectedDate, onDateSelect, onDeleteEvent, @@ -811,7 +816,10 @@ export const WeekView = memo(function WeekView({ function nameForToken(token: "shortGeneric" | "longGeneric" | "short") { try { return ( - new Intl.DateTimeFormat("en-US", { timeZoneName: token }) + new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + timeZoneName: token, + }) .formatToParts(now) .find((p) => p.type === "timeZoneName")?.value ?? "" ); @@ -822,7 +830,7 @@ export const WeekView = memo(function WeekView({ let iana = ""; try { - iana = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; + iana = timezone; } catch {} const longGeneric = nameForToken("longGeneric"); @@ -841,7 +849,7 @@ export const WeekView = memo(function WeekView({ tzLong: longGeneric || iana, tzIana: iana, }; - }, []); + }, [now, timezone]); // Drag-to-move and drag-to-resize const handleEventTimeChange = useCallback( @@ -864,6 +872,7 @@ export const WeekView = memo(function WeekView({ days, onEventTimeChange: handleEventTimeChange, events, + timezone, }); const canDrag = !!onEventTimeChange; @@ -1486,6 +1495,7 @@ export const WeekView = memo(function WeekView({ { + 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 36f258194b..44ae98ef78 100644 --- a/templates/calendar/app/hooks/use-event-drag.ts +++ b/templates/calendar/app/hooks/use-event-drag.ts @@ -1,5 +1,6 @@ import type { CalendarEvent } from "@shared/api"; -import { parseISO, 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"; const SNAP_MINUTES = 15; @@ -48,6 +49,33 @@ export interface UseEventDragOptions { onEventTimeChange: (eventId: string, newStart: Date, newEnd: Date) => void; /** All events (to find the event being dragged) */ events: CalendarEvent[]; + 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({ @@ -57,6 +85,7 @@ export function useEventDrag({ days, onEventTimeChange, events, + timezone, }: UseEventDragOptions) { const [dragState, setDragState] = useState(null); const dragStateRef = useRef(null); @@ -125,8 +154,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, }); @@ -289,7 +318,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]; @@ -301,9 +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, newStart, newEnd); + onEventTimeChange(state.eventId, times.start, times.end); } dragStateRef.current = null; diff --git a/templates/calendar/app/pages/CalendarView.tsx b/templates/calendar/app/pages/CalendarView.tsx index 8b4805f9e9..075d988439 100644 --- a/templates/calendar/app/pages/CalendarView.tsx +++ b/templates/calendar/app/pages/CalendarView.tsx @@ -30,6 +30,7 @@ import { parseISO, startOfDay, } from "date-fns"; +import { fromZonedTime, toZonedTime } from "date-fns-tz"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; @@ -336,6 +337,17 @@ export default function CalendarView() { const defaultAccountEmail = googleStatus.data?.accounts?.[0]?.email; 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( @@ -358,28 +370,37 @@ 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: 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(selectedCalendarDate), + calendarTimezone, + ).toISOString(), + to: fromZonedTime( + 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: dayStart.toISOString(), to: dayEnd.toISOString() }; + return { + from: fromZonedTime(dayStart, calendarTimezone).toISOString(), + to: fromZonedTime(dayEnd, calendarTimezone).toISOString(), + }; } } - }, [viewMode, selectedDate]); + }, [viewMode, selectedCalendarDate, calendarTimezone]); const { data: rawEventsData, @@ -539,14 +560,18 @@ export default function CalendarView() { () => viewMode === "day" ? events.filter((e) => { - const evStart = parseISO(e.start); - const evEnd = parseISO(e.end); - const dayStart = startOfDay(selectedDate); + 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(selectedCalendarDate); const dayEnd = addDays(dayStart, 1); return evStart < dayEnd && evEnd > dayStart; }) : events, - [events, viewMode, selectedDate], + [events, viewMode, selectedCalendarDate, calendarTimezone], ); const openNotificationEvent = useCallback( (event: CalendarEvent) => { @@ -809,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() { @@ -818,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"); } @@ -1181,7 +1206,7 @@ export default function CalendarView() { return; } - setSelectedDate(clickedDate); + selectCalendarDate(clickedDate); const defaultDuration = Math.max( 5, activeSettings.defaultEventDuration ?? 30, @@ -1226,7 +1251,7 @@ export default function CalendarView() { settings, settingsQuery, t, - setSelectedDate, + selectCalendarDate, setEventDraft, ], ); @@ -1464,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": @@ -1515,7 +1540,8 @@ export default function CalendarView() { deleteDialogEvent, isTypingInInput, viewMode, - selectedDate, + selectedCalendarDate, + selectCalendarDate, sidebarEvent, focusedEvent, events, @@ -1528,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"); } })(); @@ -1716,7 +1742,7 @@ export default function CalendarView() { setCreateDefaultEnd(undefined); } }} - defaultDate={selectedDate} + defaultDate={selectedCalendarDate} defaultStartTime={createDefaultStart} defaultEndTime={createDefaultEnd} /> @@ -1730,7 +1756,8 @@ export default function CalendarView() { {viewMode === "month" && ( setCommandPaletteOpen(false)} events={events} + timezone={calendarTimezone} onGoToDate={handleGoToDate} onEventClick={(event) => { setCommandPaletteOpen(false); 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 86f8dc0db6..7b47f664c1 100644 --- a/templates/calendar/package.json +++ b/templates/calendar/package.json @@ -24,6 +24,7 @@ "@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/handlers/settings.ts b/templates/calendar/server/handlers/settings.ts index d43d84c454..4f15763e7c 100644 --- a/templates/calendar/server/handlers/settings.ts +++ b/templates/calendar/server/handlers/settings.ts @@ -8,13 +8,7 @@ import { import { defineEventHandler, setResponseStatus, type H3Event } from "h3"; 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 "../lib/calendar-settings.js"; async function uEmail(event: H3Event): Promise { const session = await getSession(event); @@ -29,7 +23,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); @@ -40,7 +35,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..a23250264f --- /dev/null +++ b/templates/calendar/server/lib/calendar-settings.ts @@ -0,0 +1,46 @@ +import { getRequestTimezone } from "@agent-native/core/server"; +import { getUserSetting } from "@agent-native/core/settings"; + +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, + }; +} + +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/get-settings-action.spec.ts b/templates/calendar/server/lib/get-settings-action.spec.ts new file mode 100644 index 0000000000..4cfb7dcd2c --- /dev/null +++ b/templates/calendar/server/lib/get-settings-action.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getRequestTimezoneMock = vi.hoisted(() => vi.fn()); +const getRequestUserEmailMock = vi.hoisted(() => vi.fn()); +const getUserSettingMock = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core", () => ({ + defineAction: (action: T) => action, +})); +vi.mock("@agent-native/core/server", () => ({ + getRequestTimezone: getRequestTimezoneMock, + getRequestUserEmail: getRequestUserEmailMock, +})); +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: getUserSettingMock, +})); + +import action from "../../actions/get-settings"; + +describe("get-settings timezone default", () => { + beforeEach(() => { + vi.clearAllMocks(); + getRequestUserEmailMock.mockReturnValue("owner@example.com"); + getRequestTimezoneMock.mockReturnValue("Pacific/Auckland"); + }); + + it("uses the caller timezone for an account without saved settings", async () => { + getUserSettingMock.mockResolvedValue(null); + + await expect(action.run({})).resolves.toMatchObject({ + timezone: "Pacific/Auckland", + bookingPageTitle: "Book a Meeting", + defaultEventDuration: 30, + }); + }); + + it("keeps saved settings instead of replacing their timezone", async () => { + getUserSettingMock.mockResolvedValue({ + timezone: "America/New_York", + bookingPageTitle: "Saved title", + bookingPageDescription: "Saved description", + defaultEventDuration: 45, + }); + + await expect(action.run({})).resolves.toMatchObject({ + timezone: "America/New_York", + bookingPageTitle: "Saved title", + }); + }); +}); diff --git a/templates/calendar/server/lib/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, })); diff --git a/templates/calendar/server/lib/list-events-action.spec.ts b/templates/calendar/server/lib/list-events-action.spec.ts new file mode 100644 index 0000000000..2e0cc5ad74 --- /dev/null +++ b/templates/calendar/server/lib/list-events-action.spec.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getRequestTimezoneMock = vi.hoisted(() => vi.fn()); +const getRequestUserEmailMock = vi.hoisted(() => vi.fn()); +const getUserSettingMock = vi.hoisted(() => vi.fn()); +const isConnectedMock = vi.hoisted(() => vi.fn()); +const getOwnedAccountEmailsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core/server", () => ({ + getRequestTimezone: getRequestTimezoneMock, + getRequestUserEmail: getRequestUserEmailMock, +})); +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: getUserSettingMock, +})); +vi.mock("@agent-native/core/sharing", () => ({ + accessFilter: vi.fn(() => ({})), +})); +vi.mock("./google-calendar.js", () => ({ + getOwnedAccountEmails: getOwnedAccountEmailsMock, + isConnected: isConnectedMock, +})); +vi.mock("./ical-fetcher.js", () => ({ + fetchICalEvents: vi.fn(), +})); +vi.mock("../db/index.js", () => ({ + schema: { + bookingLinks: { slug: {}, title: {}, color: {} }, + bookingLinkShares: {}, + }, + getDb: () => ({ + select: () => ({ + from: () => ({ + where: async () => [], + }), + }), + }), +})); + +import { + listCalendarEvents, + resolveCalendarEventRange, +} from "../../actions/list-events"; + +describe("calendar event ranges", () => { + beforeEach(() => { + vi.clearAllMocks(); + getRequestUserEmailMock.mockReturnValue("owner@example.com"); + getUserSettingMock + .mockResolvedValueOnce({ timezone: "Europe/Warsaw" }) + .mockResolvedValue([]); + isConnectedMock.mockResolvedValue(false); + getOwnedAccountEmailsMock.mockResolvedValue([]); + }); + + it("uses Calendar settings for date-only list ranges", async () => { + const result = await listCalendarEvents({ + from: "2026-07-23", + to: "2026-07-24", + }); + + expect(result.range).toMatchObject({ + from: "2026-07-22T22:00:00.000Z", + to: "2026-07-23T22:00:00.000Z", + timezone: "Europe/Warsaw", + }); + }); + + it("handles a 23-hour spring-forward calendar day", () => { + expect( + resolveCalendarEventRange({ + from: "2026-03-08", + to: "2026-03-09", + timezone: "America/New_York", + }), + ).toMatchObject({ + from: "2026-03-08T05:00:00.000Z", + to: "2026-03-09T04:00:00.000Z", + }); + }); +}); diff --git a/templates/calendar/server/lib/update-settings-action.spec.ts b/templates/calendar/server/lib/update-settings-action.spec.ts new file mode 100644 index 0000000000..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, + ); + }); +});