diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 41007bca..a526546f 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -39,6 +39,7 @@ import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; +import { getEventStartDate } from "@/lib/calendar-utils"; import { useTaskStore } from "@/stores/task-store"; import { cn } from "@/lib/utils"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; @@ -385,12 +386,16 @@ export default function CalendarPage() { } }, [client, fetchEvents]); - const focusCalendarOnEvent = useCallback((event: Pick) => { + const focusCalendarOnEvent = useCallback((event: Pick, "start" | "utcStart" | "showWithoutTime">) => { if (!event.start) { return; } - const eventDate = parseISO(event.start); + const eventDate = getEventStartDate({ + start: event.start, + utcStart: event.utcStart ?? null, + showWithoutTime: event.showWithoutTime ?? false, + }); if (Number.isNaN(eventDate.getTime())) { return; } diff --git a/components/calendar/calendar-agenda-view.tsx b/components/calendar/calendar-agenda-view.tsx index 45f2d585..63ae91f4 100644 --- a/components/calendar/calendar-agenda-view.tsx +++ b/components/calendar/calendar-agenda-view.tsx @@ -2,11 +2,11 @@ import { useMemo, useRef, useEffect, useCallback } from "react"; import { useTranslations, useFormatter } from "next-intl"; -import { format, parseISO, isToday, isTomorrow, startOfDay } from "date-fns"; +import { format, isToday, isTomorrow, startOfDay } from "date-fns"; import { MapPin, Users } from "lucide-react"; import { cn } from "@/lib/utils"; import { parseDuration, getEventColor } from "./event-card"; -import { getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils"; +import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { getParticipantCount } from "@/lib/calendar-participants"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; @@ -49,7 +49,7 @@ export function CalendarAgendaView({ const grouped = useMemo(() => { const sorted = [...events].sort((a, b) => - parseISO(a.start).getTime() - parseISO(b.start).getTime() + getEventStartDate(a).getTime() - getEventStartDate(b).getTime() ); const groups: DayGroup[] = []; @@ -144,9 +144,9 @@ export function CalendarAgendaView({ const calId = getPrimaryCalendarId(ev); const calendar = calId ? calendarMap.get(calId) : undefined; const color = getEventColor(ev, calendar); - const start = parseISO(ev.start); + const start = getEventStartDate(ev); const durMin = parseDuration(ev.duration); - const end = new Date(start.getTime() + durMin * 60000); + const end = getEventEndDate(ev); const locationName = ev.locations ? Object.values(ev.locations)[0]?.name : null; diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index 40908ac1..0f9eed76 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -4,9 +4,10 @@ import { useCallback, useState, type CSSProperties, type DragEvent } from "react import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; -import { format, parseISO } from "date-fns"; +import { format } from "date-fns"; import { Users } from "lucide-react"; import { getParticipantCount } from "@/lib/calendar-participants"; +import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { useSettingsStore } from "@/stores/settings-store"; interface EventCardProps { @@ -68,14 +69,14 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM const t = useTranslations("calendar"); const [isBeingDragged, setIsBeingDragged] = useState(false); const color = getEventColor(event, calendar); - const startDate = parseISO(event.start); + const startDate = getEventStartDate(event); const timeFormat = useSettingsStore((state) => state.timeFormat); const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView); const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const calendarName = calendar?.name || ""; const durationMinutes = parseDuration(event.duration); - const endTime = new Date(startDate.getTime() + durationMinutes * 60000); + const endTime = getEventEndDate(event); const timeString = `${format(startDate, timeFmt)} – ${format(endTime, timeFmt)}`; const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`; diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 0611e983..86f1f19f 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -12,6 +12,7 @@ import { format, parseISO } from "date-fns"; import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import { parseDuration, getEventColor } from "./event-card"; +import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; import { isOrganizer, getUserParticipantId, @@ -138,9 +139,9 @@ export function EventDetailPopover({ const [isSavingNote, setIsSavingNote] = useState(false); const color = getEventColor(event, calendar); - const startDate = parseISO(event.start); + const startDate = getEventStartDate(event); const durationMinutes = parseDuration(event.duration); - const endDate = new Date(startDate.getTime() + durationMinutes * 60000); + const endDate = getEventEndDate(event); const locationName = useMemo(() => { if (!event.locations) return null; diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index d69d5dcb..c6178d06 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -8,7 +8,7 @@ import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Vid import { format, parseISO, addHours, addDays } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import { parseDuration, getEventColor } from "./event-card"; -import { buildAllDayDuration, getEventDisplayEndDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; +import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { ParticipantInput } from "./participant-input"; import { isOrganizer, @@ -159,7 +159,7 @@ export function EventModal({ }, [event, existingParticipants]); const getInitialStart = (): Date => { - if (event?.start) return parseISO(event.start); + if (event?.start) return getEventStartDate(event); if (defaultDate) { const d = new Date(defaultDate); if (defaultEndDate) return d; @@ -177,9 +177,7 @@ export function EventModal({ if (event.showWithoutTime) { return getEventDisplayEndDate(event); } - const s = parseISO(event.start); - const dur = parseDuration(event.duration); - return new Date(s.getTime() + dur * 60000); + return getEventEndDate(event); } if (defaultEndDate) return new Date(defaultEndDate); return addHours(getInitialStart(), 1); @@ -384,7 +382,7 @@ export function EventModal({ const handleDuplicate = useCallback(() => { if (!event || !onDuplicate) return; - const start = parseISO(event.start); + const start = getEventStartDate(event); const newStart = addDays(start, 1); const newUid = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() @@ -456,9 +454,9 @@ export function EventModal({ const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0); if (isAttendeeMode && event) { - const startD = parseISO(event.start); + const startD = getEventStartDate(event); const durMin = parseDuration(event.duration); - const endD = new Date(startD.getTime() + durMin * 60000); + const endD = getEventEndDate(event); const locationName = event.locations ? Object.values(event.locations)[0]?.name : null; const participants = getParticipantList(event); @@ -567,9 +565,9 @@ export function EventModal({ // View mode: read-only display of event details with Edit button if (mode === "view" && event) { - const startD = parseISO(event.start); + const startD = getEventStartDate(event); const durMin = parseDuration(event.duration); - const endD = new Date(startD.getTime() + durMin * 60000); + const endD = getEventEndDate(event); const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null; const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null; const viewParticipants = getParticipantList(event); diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index ddf6d5ea..cf2a640b 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -7,6 +7,7 @@ import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react"; import { format, parseISO } from "date-fns"; import type { CalendarEvent, Calendar } from "@/lib/jmap/types"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { getEventStartDate } from "@/lib/calendar-utils"; import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; @@ -195,7 +196,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP const formatEventDate = (event: Partial): string => { if (!event.start) return ""; try { - const date = parseISO(event.start); + const date = getEventStartDate(event as CalendarEvent); const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; return event.showWithoutTime ? format(date, "MMM d, yyyy") diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx index 90ed0898..fa602afa 100644 --- a/components/calendar/mini-calendar.tsx +++ b/components/calendar/mini-calendar.tsx @@ -7,9 +7,10 @@ import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths, addYears, subYears, setMonth, setYear, eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek, - isSameDay, isSameMonth, isToday, format, parseISO, + isSameDay, isSameMonth, isToday, format, } from "date-fns"; import { cn } from "@/lib/utils"; +import { getEventDayBounds } from "@/lib/calendar-utils"; import type { CalendarEvent } from "@/lib/jmap/types"; type PickerView = "days" | "months" | "years"; @@ -54,7 +55,14 @@ export function MiniCalendar({ const eventDates = useMemo(() => { const set = new Set(); events.forEach(e => { - try { set.add(format(parseISO(e.start), "yyyy-MM-dd")); } catch { /* skip */ } + try { + const { startDay, endDay } = getEventDayBounds(e); + const cursor = new Date(startDay); + while (cursor <= endDay) { + set.add(format(cursor, "yyyy-MM-dd")); + cursor.setDate(cursor.getDate() + 1); + } + } catch { /* skip */ } }); return set; }, [events]); diff --git a/lib/__tests__/calendar-utils.test.ts b/lib/__tests__/calendar-utils.test.ts index f8647e2b..12eea00e 100644 --- a/lib/__tests__/calendar-utils.test.ts +++ b/lib/__tests__/calendar-utils.test.ts @@ -7,6 +7,7 @@ import { getEventDayBounds, getEventDisplayEndDate, getEventEndDate, + getEventStartDate, getTimedEventBoundsForDay, isTimedEventFullDayOnDate, layoutOverlappingEvents, @@ -96,7 +97,21 @@ describe('calendar-utils all-day handling', () => { utcEnd: '2026-03-14T11:00:00Z', }); - expectLocalDateParts(getEventDisplayEndDate(event), 2026, 3, 14, 11); + expect(getEventDisplayEndDate(event).toISOString()).toBe('2026-03-14T11:00:00.000Z'); + }); + + it('prefers utc timestamps for timed events with an event timezone', () => { + const event = makeEvent({ + start: '2026-03-15T09:00:00', + duration: 'PT1H', + timeZone: 'America/New_York', + showWithoutTime: false, + utcStart: '2026-03-15T13:00:00Z', + utcEnd: '2026-03-15T14:00:00Z', + }); + + expect(getEventStartDate(event).toISOString()).toBe('2026-03-15T13:00:00.000Z'); + expect(getEventEndDate(event).toISOString()).toBe('2026-03-15T14:00:00.000Z'); }); it('clips timed multi-day events to the visible day bounds', () => { @@ -109,7 +124,7 @@ describe('calendar-utils all-day handling', () => { }); expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({ - startMinutes: 1320, + startMinutes: 1380, endMinutes: 1440, continuesBefore: false, continuesAfter: true, @@ -117,7 +132,7 @@ describe('calendar-utils all-day handling', () => { expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({ startMinutes: 0, - endMinutes: 120, + endMinutes: 180, continuesBefore: true, continuesAfter: false, }); @@ -137,7 +152,7 @@ describe('calendar-utils all-day handling', () => { expect(layout).toHaveLength(1); expect(layout[0]).toMatchObject({ startMinutes: 0, - endMinutes: 120, + endMinutes: 180, column: 0, totalColumns: 1, continuesBefore: true, diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index f4ae7c19..2423b7a8 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -21,6 +21,13 @@ export interface TimedEventLayout { continuesAfter: boolean; } +export function getEventStartDate( + event: Pick, +): Date { + const source = !event.showWithoutTime && event.utcStart ? event.utcStart : event.start; + return parseISO(source); +} + export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWeekSegment[] { rawSegments.sort((left, right) => { if (left.startIndex !== right.startIndex) return left.startIndex - right.startIndex; @@ -28,7 +35,7 @@ export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWe if (left.event.showWithoutTime !== right.event.showWithoutTime) { return left.event.showWithoutTime ? -1 : 1; } - const timeDiff = parseISO(left.event.start).getTime() - parseISO(right.event.start).getTime(); + const timeDiff = getEventStartDate(left.event).getTime() - getEventStartDate(right.event).getTime(); if (timeDiff !== 0) return timeDiff; return (left.event.title || "").localeCompare(right.event.title || ""); }); @@ -48,14 +55,19 @@ export function packWeekSegments(rawSegments: CalendarWeekSegment[]): CalendarWe } export function getEventEndDate(event: CalendarEvent): Date { - const start = parseISO(event.start); + if (!event.showWithoutTime && event.utcEnd) { + return parseISO(event.utcEnd); + } + + const start = getEventStartDate(event); if (!event.duration) return start; return new Date(start.getTime() + parseDuration(event.duration) * 60000); } export function getEventDisplayEndDate(event: CalendarEvent): Date { const end = getEventEndDate(event); - if (!event.showWithoutTime || end.getTime() <= parseISO(event.start).getTime()) { + const start = getEventStartDate(event); + if (!event.showWithoutTime || end.getTime() <= start.getTime()) { return end; } return subMilliseconds(end, 1); @@ -63,7 +75,7 @@ export function getEventDisplayEndDate(event: CalendarEvent): Date { export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDay: Date } { return { - startDay: startOfDay(parseISO(event.start)), + startDay: startOfDay(getEventStartDate(event)), endDay: startOfDay(getEventDisplayEndDate(event)), }; } @@ -74,7 +86,7 @@ export function getTimedEventBoundsForDay( ): { startMinutes: number; endMinutes: number; continuesBefore: boolean; continuesAfter: boolean } | null { if (event.showWithoutTime) return null; - const eventStart = parseISO(event.start); + const eventStart = getEventStartDate(event); const eventEnd = getEventEndDate(event); const dayStart = startOfDay(day); const nextDayStart = addDays(dayStart, 1);