import { AgentToggleButton } from "@agent-native/core/client/agent-chat"; import { agentNativePath } from "@agent-native/core/client/api-path"; import { useT } from "@agent-native/core/client/i18n"; import type { CalendarEvent, CalendarEventDraft, UpdateEventScope, } from "@shared/api"; import { IconCheck, IconChevronLeft, IconChevronRight, IconChevronDown, IconMenu2, IconSearch, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, parseISO, startOfDay, } from "date-fns"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import type { QuickCreateEvent } from "@/components/calendar/CommandPalette"; import { CommandPalette } from "@/components/calendar/CommandPalette"; import { CreateEventPopover } from "@/components/calendar/CreateEventDialog"; import { DayView } from "@/components/calendar/DayView"; import { DeleteEventDialog } from "@/components/calendar/DeleteEventDialog"; import { EventDetailPanel } from "@/components/calendar/EventDetailPanel"; import { GoogleConnectBanner } from "@/components/calendar/GoogleConnectBanner"; import { shouldPromptGuests, useGuestNotificationPrompt, } from "@/components/calendar/GuestNotificationDialog"; import { MonthView } from "@/components/calendar/MonthView"; import { PeopleSearchDialog } from "@/components/calendar/PeopleSearchDialog"; import { WeekView } from "@/components/calendar/WeekView"; import { useCalendarContext } from "@/components/layout/AppLayout"; import type { ViewMode } from "@/components/layout/AppLayout"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { useEvents, useCreateEvent, useUpdateEvent, useDeleteEvent, useRsvpEvent, findEventByCurrentOrReplacedId, prefetchEvents, shouldShowEventsSkeleton, } from "@/hooks/use-events"; import { useGoogleAuthStatus } from "@/hooks/use-google-auth"; import { useMeetingStartNotifications } from "@/hooks/use-meeting-start-notifications"; import { useIsMobile } from "@/hooks/use-mobile"; import { useOverlayPeople } from "@/hooks/use-overlay-people"; import { useSettings } from "@/hooks/use-settings"; import { setUndoAction, runUndo } from "@/hooks/use-undo"; import { useViewPreferences } from "@/hooks/use-view-preferences"; import { resolveEventAccountEmail } from "@/lib/event-account-selection"; import { getGoogleEventColorHex } from "@/lib/event-colors"; import { dateTimeInTimezoneToIso, getLocalTimezone, } from "@/lib/event-form-utils"; import { buildDeleteEventMutationInput } from "@/lib/event-mutation-inputs"; import { isMcpEmbedSurface } from "@/lib/mcp-embed"; import { cn } from "@/lib/utils"; const CALENDAR_DRAFT_EVENT_PREFIX = "calendar-draft-event:"; type DraftEventPatch = Partial & { addGoogleMeet?: boolean; addZoom?: boolean; workingLocationType?: "homeOffice" | "officeLocation" | "customLocation"; workingLocationLabel?: string; }; function safeCalendarDraftId(id: string | undefined): string | null { return id && /^[a-zA-Z0-9_-]{1,64}$/.test(id) ? id : null; } function calendarDraftEventId(id: string) { return `${CALENDAR_DRAFT_EVENT_PREFIX}${id}`; } function calendarDraftIdFromEventId(eventId: string) { return eventId.startsWith(CALENDAR_DRAFT_EVENT_PREFIX) ? eventId.slice(CALENDAR_DRAFT_EVENT_PREFIX.length) : null; } function isSlotDraftId(id: string) { return id.startsWith("slot-"); } function parseValidDate(value: string | undefined): Date | null { if (!value) return null; const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } function fallbackDraftRange(fallbackDate: Date) { const start = new Date(fallbackDate); start.setHours(9, 0, 0, 0); const end = new Date(start); end.setHours(10, 0, 0, 0); return { start, end }; } function isRecurringCalendarEvent(event: CalendarEvent): boolean { return Boolean(event.recurringEventId || event.recurrence?.length); } function updateScopePayload(scope: UpdateEventScope | undefined): { scope?: UpdateEventScope; } { return scope ? { scope } : {}; } function addMinutesToDateTimeParts( date: string, time: string, minutes: number, ) { const [hour, minute] = time.split(":").map(Number); const safeHour = Number.isFinite(hour) ? hour : 9; const safeMinute = Number.isFinite(minute) ? minute : 0; const totalMinutes = safeHour * 60 + safeMinute + minutes; const dayOffset = Math.floor(totalMinutes / (24 * 60)); const minuteOfDay = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60); const endDate = new Date(`${date}T00:00:00`); endDate.setDate(endDate.getDate() + dayOffset); const endHour = Math.floor(minuteOfDay / 60); const endMinute = minuteOfDay % 60; const pad = (n: number) => String(n).padStart(2, "0"); return { date: format(endDate, "yyyy-MM-dd"), time: `${pad(endHour)}:${pad(endMinute)}`, }; } function draftRange(draft: CalendarEventDraft, fallbackDate: Date) { const fallback = fallbackDraftRange(fallbackDate); const start = parseValidDate(draft.start) ?? fallback.start; const parsedEnd = parseValidDate(draft.end); const end = parsedEnd && parsedEnd.getTime() > start.getTime() ? parsedEnd : new Date(start.getTime() + 60 * 60 * 1000); return { start, end }; } function draftToCalendarEvent( draft: CalendarEventDraft, fallbackDate: Date, ): CalendarEvent { const { start, end } = draftRange(draft, fallbackDate); return { id: calendarDraftEventId(draft.id), title: draft.title?.trim() || "(No title)", description: draft.description ?? "", start: start.toISOString(), end: end.toISOString(), startTimeZone: draft.startTimeZone, endTimeZone: draft.endTimeZone ?? draft.startTimeZone, location: draft.location ?? draft.workingLocationLabel ?? "", allDay: draft.allDay ?? false, source: "local", accountEmail: draft.accountEmail, colorId: draft.colorId, color: draft.colorId ? getGoogleEventColorHex(draft.colorId) : undefined, transparency: draft.transparency, visibility: draft.visibility, eventType: draft.eventType ?? "default", recurrence: draft.recurrence, attendees: draft.attendees, reminders: draft.reminders, remindersUseDefault: draft.remindersUseDefault, attachments: draft.attachments, pendingConferenceProvider: draft.addZoom ? "zoom" : draft.addGoogleMeet ? "meet" : undefined, createdAt: draft.createdAt ?? new Date().toISOString(), updatedAt: draft.updatedAt ?? draft.createdAt ?? new Date().toISOString(), }; } function applyDraftPatch( draft: CalendarEventDraft, patch: DraftEventPatch, ): CalendarEventDraft { const next: CalendarEventDraft = { ...draft, updatedAt: new Date().toISOString(), }; const copy = (key: K) => { if (patch[key] !== undefined) { next[key] = patch[key] as CalendarEventDraft[K]; } }; copy("title"); copy("description"); copy("start"); copy("end"); copy("startTimeZone"); copy("endTimeZone"); copy("location"); copy("allDay"); copy("eventType"); copy("transparency"); copy("visibility"); copy("colorId"); copy("recurrence"); copy("reminders"); copy("remindersUseDefault"); copy("attachments"); copy("attendees"); copy("accountEmail"); copy("workingLocationType"); copy("workingLocationLabel"); if (patch.addGoogleMeet !== undefined) { next.addGoogleMeet = patch.addGoogleMeet; if (patch.addGoogleMeet) next.addZoom = false; } if (patch.addZoom !== undefined) { next.addZoom = patch.addZoom; if (patch.addZoom) next.addGoogleMeet = false; } return next; } function persistCalendarDraft(draft: CalendarEventDraft) { const safeId = safeCalendarDraftId(draft.id); if (!safeId) return; fetch( agentNativePath( `/_agent-native/application-state/calendar-draft-${safeId}`, ), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(draft), }, ).catch(() => {}); } function deletePersistedCalendarDraft(id: string) { const safeId = safeCalendarDraftId(id); if (!safeId) return; fetch( agentNativePath( `/_agent-native/application-state/calendar-draft-${safeId}`, ), { method: "DELETE", headers: { "X-Agent-Native-CSRF": "1" }, }, ).catch(() => {}); } export default function CalendarView() { const t = useT(); const isMobile = useIsMobile(); const { selectedDate, setSelectedDate, viewMode, setViewMode, peopleSearchOpen, setPeopleSearchOpen, addCalendarOpen, setAddCalendarOpen, setAddCalendarDefaultTab, eventDetailSidebar, setEventDetailSidebar, sidebarEvent, setSidebarEvent, focusedEvent, setFocusedEvent, hiddenCalendars, eventDraft, setEventDraft, openSidebar, } = useCalendarContext(); const { prefs: viewPrefs, update: setViewPrefs } = useViewPreferences(); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDefaultStart, setCreateDefaultStart] = useState(); const [createDefaultEnd, setCreateDefaultEnd] = useState(); const [quickEditEventId, setQuickEditEventId] = useState(null); const [quickEditTempIds, setQuickEditTempIds] = useState< Record >({}); const openedDraftIdRef = useRef(null); const committingDraftIdsRef = useRef>(new Set()); const discardedCommittingDraftsRef = useRef>( new Map(), ); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [deleteDialogEvent, setDeleteDialogEvent] = useState(null); const queryClient = useQueryClient(); const googleStatus = useGoogleAuthStatus(); const defaultAccountEmail = googleStatus.data?.accounts?.[0]?.email; const settingsQuery = useSettings(); const { data: settings } = settingsQuery; const { data: rawOverlayPeople } = useOverlayPeople(); const overlayPeople = Array.isArray(rawOverlayPeople) ? rawOverlayPeople : []; const overlayEmails = useMemo( () => overlayPeople.map((p) => p.email), [overlayPeople], ); const createEvent = useCreateEvent(); const updateEvent = useUpdateEvent(); const deleteEvent = useDeleteEvent(); const rsvpEvent = useRsvpEvent(); const { promptGuestNotification, guestNotificationDialog } = useGuestNotificationPrompt(); const viewModeLabels: Record = { month: t("calendarView.month"), week: t("calendarView.week"), day: t("calendarView.day"), }; // Compute date range for query based on view const { from, to } = useMemo(() => { switch (viewMode) { case "month": { const ms = startOfMonth(selectedDate); const me = endOfMonth(selectedDate); return { from: startOfWeek(ms).toISOString(), to: endOfWeek(me).toISOString(), }; } case "week": { return { from: startOfWeek(selectedDate).toISOString(), to: endOfWeek(selectedDate).toISOString(), }; } case "day": { const dayStart = new Date(selectedDate); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(selectedDate); dayEnd.setHours(23, 59, 59, 999); return { from: dayStart.toISOString(), to: dayEnd.toISOString() }; } } }, [viewMode, selectedDate]); const { data: rawEventsData, error: eventsError, isLoading, isFetching, isPlaceholderData, } = useEvents(from, to, overlayEmails); const rawEvents = Array.isArray(rawEventsData) ? rawEventsData : []; const draftEvent = useMemo( () => (eventDraft ? draftToCalendarEvent(eventDraft, selectedDate) : null), [eventDraft, selectedDate], ); const draftEventIds = useMemo( () => (draftEvent ? [draftEvent.id] : []), [draftEvent], ); useEffect(() => { if (!eventDraft || !defaultAccountEmail) return; const resolvedAccountEmail = resolveEventAccountEmail( googleStatus.data?.accounts ?? [], eventDraft.accountEmail, ); if ( !resolvedAccountEmail || eventDraft.accountEmail === resolvedAccountEmail ) { return; } const nextDraft = { ...eventDraft, accountEmail: resolvedAccountEmail }; setEventDraft(nextDraft); persistCalendarDraft(nextDraft); }, [ defaultAccountEmail, eventDraft, googleStatus.data?.accounts, setEventDraft, ]); // Warm the adjacent ranges so j/k (and the chevron buttons) feel instant. // Borrowed from the mail template's background-warm pattern — fire-and-forget // prefetch that lets React Query dedupe and reuse the response when the user // actually navigates. Only runs once the current range has loaded so we // don't fight the primary fetch for bandwidth. useEffect(() => { if (isLoading) return; const ranges = (() => { switch (viewMode) { case "month": { const next = addMonths(selectedDate, 1); const prev = subMonths(selectedDate, 1); return [next, prev].map((d) => ({ from: startOfWeek(startOfMonth(d)).toISOString(), to: endOfWeek(endOfMonth(d)).toISOString(), })); } case "week": { // Two weeks forward so rapid `j j` stays instant, plus one back. const next = addWeeks(selectedDate, 1); const next2 = addWeeks(selectedDate, 2); const prev = subWeeks(selectedDate, 1); return [next, next2, prev].map((d) => ({ from: startOfWeek(d).toISOString(), to: endOfWeek(d).toISOString(), })); } case "day": { const next = addDays(selectedDate, 1); const prev = subDays(selectedDate, 1); return [next, prev].map((d) => { const start = new Date(d); start.setHours(0, 0, 0, 0); const end = new Date(d); end.setHours(23, 59, 59, 999); return { from: start.toISOString(), to: end.toISOString() }; }); } } })(); for (const range of ranges) { void prefetchEvents(queryClient, range.from, range.to, overlayEmails); } }, [isLoading, viewMode, selectedDate, overlayEmails, queryClient]); // Show the skeleton only when there is genuinely nothing to show for the // current date range — the first load, or navigating to a range we have not // fetched yet. Crucially, do NOT show it when only the *set* of calendars // changes (adding/removing a feed or person overlay). Those swaps change the // query key, so `keepPreviousData` keeps the user's existing events on screen // as placeholder data; flashing a skeleton over them is the bug. Instead we // keep the events visible and let the refreshed set merge in. We track the // last date range we settled real (non-placeholder) data for, so a skeleton // only appears when the range itself differs. const rangeKey = `${from}|${to}`; const settledRangeRef = useRef(null); useEffect(() => { if (!isLoading && !isPlaceholderData) { settledRangeRef.current = rangeKey; } }, [isLoading, isPlaceholderData, rangeKey]); const eventsLoading = shouldShowEventsSkeleton({ isLoading, isPlaceholderData, settledRangeKey: settledRangeRef.current, rangeKey, }); // A quiet background refresh is in flight (e.g. a newly added calendar's // events are still loading) while the existing events stay visible. Drives a // small non-blocking spinner instead of hiding everything behind a skeleton. const eventsRefreshing = isFetching && !eventsLoading; // Apply overlay ownership markers and filter hidden calendars const events = useMemo(() => { const ownerMap = new Map(overlayPeople.map((p) => [p.email, p])); const sourceEvents = draftEvent ? [...rawEvents.filter((e) => e.id !== draftEvent.id), draftEvent] : rawEvents; return sourceEvents .map((e) => { if (e.overlayEmail && ownerMap.has(e.overlayEmail)) { const owner = ownerMap.get(e.overlayEmail); return { ...e, ownerColor: owner?.color, ownerName: owner?.name, }; } const tempId = quickEditTempIds[e.id]; return tempId && !e._tempId ? { ...e, _tempId: tempId } : e; }) .filter((e) => { // Hide events from hidden people overlays if (e.overlayEmail && hiddenCalendars.people.includes(e.overlayEmail)) return false; // Hide events from hidden Google accounts if ( e.accountEmail && !e.overlayEmail && hiddenCalendars.accounts.includes(e.accountEmail) ) return false; // Hide events from hidden external calendars if (e.source === "ical") { const hiddenMatch = hiddenCalendars.external.some((calId) => e.id.startsWith(`ical-${calId}-`), ); if (hiddenMatch) return false; } return true; }); }, [rawEvents, draftEvent, overlayPeople, hiddenCalendars, quickEditTempIds]); // Filter events for day view — use overlap check so multi-day continuation // events (started on a prior day) still appear on the selected day. const dayEvents = useMemo( () => viewMode === "day" ? events.filter((e) => { const evStart = parseISO(e.start); const evEnd = parseISO(e.end); const dayStart = startOfDay(selectedDate); const dayEnd = addDays(dayStart, 1); return evStart < dayEnd && evEnd > dayStart; }) : events, [events, viewMode, selectedDate], ); const openNotificationEvent = useCallback( (event: CalendarEvent) => { setSelectedDate(parseISO(event.start)); setViewMode("day"); setSidebarEvent(event); setFocusedEvent(event); }, [setFocusedEvent, setSelectedDate, setSidebarEvent, setViewMode], ); useMeetingStartNotifications(events, openNotificationEvent); useEffect(() => { if (!eventDraft) { openedDraftIdRef.current = null; return; } if (openedDraftIdRef.current === eventDraft.id) return; openedDraftIdRef.current = eventDraft.id; const { start } = draftRange(eventDraft, selectedDate); setSelectedDate(start); if (isMobile || viewMode === "month") { setViewMode("day"); } setCreateDefaultStart(undefined); setCreateDefaultEnd(undefined); setCreateDialogOpen(false); setEventDetailSidebar(false); setSidebarEvent(null); setQuickEditEventId(calendarDraftEventId(eventDraft.id)); }, [ eventDraft, isMobile, selectedDate, setEventDetailSidebar, setSelectedDate, setSidebarEvent, setViewMode, viewMode, ]); const createDraftEvent = useCallback( (eventId: string, pendingPatch?: DraftEventPatch) => { const draftId = calendarDraftIdFromEventId(eventId); if (!draftId || !eventDraft || eventDraft.id !== draftId) return; if (committingDraftIdsRef.current.has(draftId)) return; committingDraftIdsRef.current.add(draftId); const pendingDraft = pendingPatch ? applyDraftPatch(eventDraft, pendingPatch) : eventDraft; const accountEmail = resolveEventAccountEmail( googleStatus.data?.accounts ?? [], pendingDraft.accountEmail, ); if (!accountEmail) { committingDraftIdsRef.current.delete(draftId); toast.error(t("calendarView.calendarSettingsLoading")); return; } const draft = pendingDraft.accountEmail === accountEmail ? pendingDraft : { ...pendingDraft, accountEmail }; discardedCommittingDraftsRef.current.delete(draftId); if (pendingPatch || draft !== pendingDraft) { setEventDraft(draft); persistCalendarDraft(draft); } const trimmedTitle = draft.title?.trim(); const title = trimmedTitle && trimmedTitle !== "(No title)" ? trimmedTitle : ""; if (!title && !isSlotDraftId(draftId)) { committingDraftIdsRef.current.delete(draftId); toast.error(t("calendarView.addTitleBeforeCreate")); return; } const { start, end } = draftRange(draft, selectedDate); if (end.getTime() <= start.getTime()) { committingDraftIdsRef.current.delete(draftId); toast.error(t("calendarView.endTimeAfterStart")); return; } const eventType = draft.eventType ?? "default"; const location = draft.location ?? draft.workingLocationLabel ?? ""; const timezone = draft.startTimeZone ?? getLocalTimezone(); const statusPatch = eventType === "default" ? {} : { eventType, workingLocationType: draft.workingLocationType ?? "customLocation", workingLocationLabel: (draft.workingLocationType ?? "customLocation") === "customLocation" ? location : draft.workingLocationLabel, }; const payload: Parameters[0] = { _tempId: eventId, title, description: draft.description ?? "", start: start.toISOString(), end: end.toISOString(), startTimeZone: draft.allDay ? undefined : timezone, endTimeZone: draft.allDay ? undefined : (draft.endTimeZone ?? draft.startTimeZone ?? timezone), location, accountEmail, allDay: draft.allDay ?? false, transparency: eventType === "workingLocation" ? "transparent" : eventType === "default" ? draft.transparency : "opaque", visibility: eventType === "workingLocation" ? "public" : draft.visibility, reminders: draft.reminders, remindersUseDefault: draft.remindersUseDefault, ...statusPatch, addGoogleMeet: draft.addGoogleMeet, addZoom: draft.addZoom, color: draft.colorId ? getGoogleEventColorHex(draft.colorId) : undefined, colorId: draft.colorId, recurrence: draft.recurrence, attachments: draft.attachments, attendees: draft.attendees, }; deletePersistedCalendarDraft(draftId); setEventDraft(null); setQuickEditEventId(null); createEvent.mutate(payload, { onSuccess: (result) => { discardedCommittingDraftsRef.current.delete(draftId); const createdEventId = result?.id; if (createdEventId) { const undo = () => { deleteEvent.mutate( buildDeleteEventMutationInput( { id: createdEventId, accountEmail: result.accountEmail ?? draft.accountEmail ?? defaultAccountEmail, }, { scope: "single", sendUpdates: "none" }, ), ); }; setUndoAction(undo); } }, onError: (error) => { const restoreDraft = discardedCommittingDraftsRef.current.get(draftId) ?? draft; if (restoreDraft) { discardedCommittingDraftsRef.current.delete(draftId); persistCalendarDraft(restoreDraft); setEventDraft(restoreDraft); setQuickEditEventId(calendarDraftEventId(draftId)); } toast.error( error instanceof Error ? error.message : t("eventForm.createFailed"), ); }, onSettled: () => { committingDraftIdsRef.current.delete(draftId); }, }); }, [ createEvent, defaultAccountEmail, deleteEvent, eventDraft, googleStatus.data?.accounts, selectedDate, setEventDraft, t, ], ); const updateDraftEvent = useCallback( (eventId: string, patch: DraftEventPatch) => { const draftId = calendarDraftIdFromEventId(eventId); if (!draftId || !eventDraft || eventDraft.id !== draftId) return null; const nextDraft = applyDraftPatch(eventDraft, patch); setEventDraft(nextDraft); persistCalendarDraft(nextDraft); return nextDraft; }, [eventDraft, setEventDraft], ); const discardDraftEvent = useCallback( (eventId: string) => { const draftId = calendarDraftIdFromEventId(eventId); if (!draftId || !eventDraft || eventDraft.id !== draftId) return; if (committingDraftIdsRef.current.has(draftId)) { discardedCommittingDraftsRef.current.set(draftId, eventDraft); committingDraftIdsRef.current.delete(draftId); } else { discardedCommittingDraftsRef.current.delete(draftId); } deletePersistedCalendarDraft(draftId); setEventDraft(null); setQuickEditEventId(null); if (sidebarEvent?.id === eventId) setSidebarEvent(null); if (focusedEvent?.id === eventId) setFocusedEvent(null); }, [ eventDraft, focusedEvent, setEventDraft, setFocusedEvent, setSidebarEvent, sidebarEvent, ], ); useEffect(() => { if (sidebarEvent) { const rebound = findEventByCurrentOrReplacedId(events, sidebarEvent.id); if (rebound && rebound.id !== sidebarEvent.id) setSidebarEvent(rebound); } if (focusedEvent) { const rebound = findEventByCurrentOrReplacedId(events, focusedEvent.id); if (rebound && rebound.id !== focusedEvent.id) setFocusedEvent(rebound); } }, [events, focusedEvent, setFocusedEvent, setSidebarEvent, sidebarEvent]); const selectedEvent = useMemo(() => { const candidate = sidebarEvent ?? focusedEvent; if (!candidate) return null; return findEventByCurrentOrReplacedId(events, candidate.id) ?? candidate; }, [events, sidebarEvent, focusedEvent]); const refreshedSidebarEvent = useMemo(() => { if (!sidebarEvent) return null; return ( findEventByCurrentOrReplacedId(events, sidebarEvent.id) ?? sidebarEvent ); }, [events, sidebarEvent]); function handleNavigate(direction: "prev" | "next") { const fns = direction === "next" ? { month: addMonths, week: addWeeks, day: addDays } : { month: subMonths, week: subWeeks, day: subDays }; setSelectedDate(fns[viewMode](selectedDate, 1)); } function handleToday() { setSelectedDate(new Date()); } const handleDateSelect = useCallback( (date: Date) => { setSelectedDate(date); if (viewMode === "month") { setViewMode("day"); } }, [viewMode, setSelectedDate, setViewMode], ); function handleGoToDate(date: Date) { setSelectedDate(date); setViewMode("day"); } function handleOpenSelectedEventInGoogleCalendar(event: CalendarEvent) { if (!event.htmlLink) { toast.error(t("calendarView.googleCalendarLinkUnavailable")); return; } try { const url = new URL(event.htmlLink); const isGoogleCalendarUrl = url.protocol === "https:" && (url.hostname === "calendar.google.com" || (url.hostname === "www.google.com" && url.pathname.startsWith("/calendar/"))); if (!isGoogleCalendarUrl) { toast.error(t("calendarView.googleCalendarLinkUnavailable")); return; } const opened = window.open( url.toString(), "_blank", "noopener,noreferrer", ); if (!opened) { window.location.assign(url.toString()); } } catch { toast.error(t("calendarView.googleCalendarLinkUnavailable")); } } const handleDirectDelete = useCallback( async ( ev: CalendarEvent, notificationOptions?: { sendUpdates: "all" | "none"; notificationMessage?: string; }, ) => { const isOrganizer = ev.organizer?.self || ev.attendees?.find((a) => a.self)?.organizer || !ev.attendees?.length; const hasOtherAttendees = ev.attendees && ev.attendees.filter((a) => !a.self).length > 0; const removeOnly = !isOrganizer && !!hasOtherAttendees; const shouldAskGuests = !removeOnly && shouldPromptGuests(ev); const guestNotification = notificationOptions ?? (shouldAskGuests ? await promptGuestNotification({ event: ev, action: "cancellation", }) : { sendUpdates: "none" as const }); if (!guestNotification) return; // Snapshot for undo — preserve all event fields so undo recreates faithfully const { id: _id, source: _source, ...snapshot } = ev; // removeOnly means the current user was only an attendee, not the // organizer — the event still exists for everyone else. Undo must // re-accept the existing event rather than fabricate a new one the // user doesn't own. const undo = removeOnly ? () => { rsvpEvent.mutate( { id: ev.id, status: "accepted", accountEmail: ev.accountEmail, sendUpdates: "none", }, { onError: () => toast.error(t("calendarView.failedRestoreAttendance")), }, ); } : () => { createEvent.mutate(snapshot); }; deleteEvent.mutate( buildDeleteEventMutationInput(ev, { scope: "single", ...guestNotification, removeOnly, }), { onSuccess: () => { if (sidebarEvent?.id === ev.id) setSidebarEvent(null); setUndoAction(undo); toast( removeOnly ? t("calendarView.eventRemoved") : t("calendarView.eventDeleted"), { action: { label: t("calendarView.undo"), onClick: undo }, }, ); }, onError: () => toast.error(t("calendarView.failedDeleteEvent")), }, ); }, [ createEvent, deleteEvent, promptGuestNotification, rsvpEvent, setSidebarEvent, sidebarEvent, t, ], ); const handleDeleteEvent = useCallback( (eventId: string) => { if (calendarDraftIdFromEventId(eventId)) { discardDraftEvent(eventId); return; } const ev = events.find((e) => e.id === eventId); if (!ev) return; const isRecurring = !!(ev.recurringEventId || ev.recurrence?.length); const isOrganizer = ev.organizer?.self || ev.attendees?.find((a) => a.self)?.organizer || !ev.attendees?.length; const hasOtherAttendees = ev.attendees && ev.attendees.filter((a) => !a.self).length > 0; const removeOnly = !isOrganizer && !!hasOtherAttendees; if (isRecurring || (!removeOnly && shouldPromptGuests(ev))) { setDeleteDialogEvent(ev); } else { void handleDirectDelete(ev); } }, [discardDraftEvent, events, handleDirectDelete], ); // Move event to a new date (drag-and-drop from MonthView) async function handleEventDrop(eventId: string, newDate: Date) { 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 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(), ); // Guard against a zero/negative duration reaching the server (e.g. a // DST transition collapsing a short event's start/end onto each other). if (newEnd.getTime() <= newStart.getTime()) return; const updates = { start: newStart.toISOString(), end: newEnd.toISOString(), }; const isRecurring = isRecurringCalendarEvent(event); const guestNotification = await promptGuestNotification({ event, action: "update", updates, recurrenceScope: isRecurring, }); if (!guestNotification) return; const undoScope = guestNotification.scope; const undo = () => { updateEvent.mutate({ id: eventId, accountEmail: event.accountEmail, start: oldStartISO, end: oldEndISO, sendUpdates: "none", ...updateScopePayload(undoScope), }); }; const toastId = toast.loading( isRecurring ? t("calendarView.updatingRecurringEvent") : t("calendarView.movingEvent"), ); updateEvent.mutate( { id: eventId, accountEmail: event.accountEmail, ...updates, ...guestNotification, }, { onSuccess: () => { setUndoAction(undo); toast.success(t("calendarView.eventMoved"), { id: toastId, action: { label: t("calendarView.undo"), onClick: undo }, }); }, onError: () => toast.error(t("calendarView.failedMoveEvent"), { id: toastId }), }, ); } // Move/resize event to new start/end times (drag from Week/Day views) const handleEventTimeChange = useCallback( async (eventId: string, newStart: Date, newEnd: Date) => { // Skip no-op drags (dropped back in same spot) const event = events.find((e) => e.id === eventId); if (!event) return; // Guard against a zero/negative duration reaching the server — // gesture math should already prevent this, but never commit it. if (newEnd.getTime() <= newStart.getTime()) return; if (calendarDraftIdFromEventId(eventId)) { const timezone = settings?.timezone || getLocalTimezone(); updateDraftEvent(eventId, { start: newStart.toISOString(), end: newEnd.toISOString(), allDay: false, startTimeZone: timezone, endTimeZone: timezone, }); return; } const oldStart = parseISO(event.start).getTime(); const oldEnd = parseISO(event.end).getTime(); if (oldStart === newStart.getTime() && oldEnd === newEnd.getTime()) { return; } const oldStartISO = event.start; const oldEndISO = event.end; const updates = { start: newStart.toISOString(), end: newEnd.toISOString(), }; const isRecurring = isRecurringCalendarEvent(event); const guestNotification = await promptGuestNotification({ event, action: "update", updates, recurrenceScope: isRecurring, }); if (!guestNotification) return; const undoScope = guestNotification.scope; const undo = () => { updateEvent.mutate({ id: eventId, accountEmail: event.accountEmail, start: oldStartISO, end: oldEndISO, sendUpdates: "none", ...updateScopePayload(undoScope), }); }; const toastId = toast.loading( isRecurring ? t("calendarView.updatingRecurringEvent") : t("calendarView.updatingEvent"), ); updateEvent.mutate( { id: eventId, accountEmail: event.accountEmail, ...updates, ...guestNotification, }, { onSuccess: () => { setUndoAction(undo); toast.success(t("calendarView.eventUpdated"), { id: toastId, action: { label: t("calendarView.undo"), onClick: undo }, }); }, onError: () => toast.error(t("calendarView.failedUpdateEvent"), { id: toastId }), }, ); }, [ events, settings, updateDraftEvent, promptGuestNotification, updateEvent, t, ], ); const handleClickTimeSlot = useCallback( async ( clickedDate: Date, startTime: string, endTime: string, options?: { explicitDuration?: boolean }, ) => { let activeSettings = settings; if (!activeSettings) { const result = await settingsQuery.refetch(); activeSettings = result.data; } if (!activeSettings?.timezone) { toast.error(t("calendarView.calendarSettingsLoading")); return; } setSelectedDate(clickedDate); const defaultDuration = Math.max( 5, activeSettings.defaultEventDuration ?? 30, ); const timezone = activeSettings.timezone; setCreateDefaultStart(startTime); setCreateDialogOpen(false); const dateStr = format(clickedDate, "yyyy-MM-dd"); // A drag-to-create gesture already computed the exact dragged range; // a plain click falls back to the user's configured default duration. const end = options?.explicitDuration ? { date: dateStr, time: endTime } : addMinutesToDateTimeParts(dateStr, startTime, defaultDuration); setCreateDefaultEnd(end.time); const startISO = dateTimeInTimezoneToIso(dateStr, startTime, timezone); const endISO = dateTimeInTimezoneToIso(end.date, end.time, timezone); const now = new Date().toISOString(); const draftId = `slot-${Date.now()}`; const draft: CalendarEventDraft = { id: draftId, title: "", description: "", location: "", start: startISO, end: endISO, startTimeZone: timezone, endTimeZone: timezone, allDay: false, eventType: "default", accountEmail: defaultAccountEmail, createdAt: now, updatedAt: now, }; persistCalendarDraft(draft); setEventDraft(draft); setQuickEditEventId(calendarDraftEventId(draftId)); }, [ defaultAccountEmail, settings, settingsQuery, t, setSelectedDate, setEventDraft, ], ); // Command palette natural-language quick create (e.g. "lunch with Sam // tomorrow 12:30") — builds a prefilled draft and jumps to it, reusing the // same draft/quick-edit flow as clicking a time slot. const handleCreateEventFromText = useCallback( async (quickCreate: QuickCreateEvent) => { let activeSettings = settings; if (!activeSettings) { const result = await settingsQuery.refetch(); activeSettings = result.data; } if (!activeSettings?.timezone) { toast.error(t("calendarView.calendarSettingsLoading")); return; } const timezone = activeSettings.timezone; const defaultDuration = Math.max( 5, activeSettings.defaultEventDuration ?? 30, ); const startTime = quickCreate.hasExplicitTime ? format(quickCreate.start, "HH:mm") : "09:00"; const dateStr = format(quickCreate.start, "yyyy-MM-dd"); const end = addMinutesToDateTimeParts( dateStr, startTime, defaultDuration, ); const startISO = dateTimeInTimezoneToIso(dateStr, startTime, timezone); const endISO = dateTimeInTimezoneToIso(end.date, end.time, timezone); setSelectedDate(quickCreate.start); setViewMode("day"); setCreateDefaultStart(startTime); setCreateDefaultEnd(end.time); setCreateDialogOpen(false); const now = new Date().toISOString(); const draftId = `slot-${Date.now()}`; const draft: CalendarEventDraft = { id: draftId, title: quickCreate.title, description: "", location: "", start: startISO, end: endISO, startTimeZone: timezone, endTimeZone: timezone, allDay: false, eventType: "default", accountEmail: defaultAccountEmail, createdAt: now, updatedAt: now, }; persistCalendarDraft(draft); setEventDraft(draft); setQuickEditEventId(calendarDraftEventId(draftId)); }, [ defaultAccountEmail, settings, settingsQuery, t, setSelectedDate, setViewMode, setEventDraft, ], ); const handleQuickEditSave = useCallback( async (eventId: string, title: string, accountEmail?: string) => { setQuickEditEventId(null); if (calendarDraftIdFromEventId(eventId)) { updateDraftEvent(eventId, { title: title.trim() }); return; } setQuickEditTempIds((current) => { if (!current[eventId]) return current; const { [eventId]: _removed, ...next } = current; return next; }); if (title.trim() && title.trim() !== "(No title)") { const event = events.find((e) => e.id === eventId); const updates = { title: title.trim() }; const guestNotification = event ? await promptGuestNotification({ event, action: "update", updates, }) : { sendUpdates: "none" as const }; if (!guestNotification) return; updateEvent.mutate({ id: eventId, accountEmail: event?.accountEmail ?? accountEmail, ...updates, ...guestNotification, }); } }, [events, updateDraftEvent, promptGuestNotification, updateEvent], ); const handleTitleSave = useCallback( async (eventId: string, title: string, accountEmail?: string) => { if (calendarDraftIdFromEventId(eventId)) { updateDraftEvent(eventId, { title }); return; } const event = events.find((e) => e.id === eventId); const updates = { title }; const guestNotification = event ? await promptGuestNotification({ event, action: "update", updates, }) : { sendUpdates: "none" as const }; if (!guestNotification) return; updateEvent.mutate({ id: eventId, accountEmail: event?.accountEmail ?? accountEmail, ...updates, ...guestNotification, }); }, [events, updateDraftEvent, promptGuestNotification, updateEvent], ); const handleQuickEditCancel = useCallback( (eventId: string, accountEmail?: string) => { setQuickEditEventId(null); if (calendarDraftIdFromEventId(eventId)) { discardDraftEvent(eventId); return; } setQuickEditTempIds((current) => { if (!current[eventId]) return current; const { [eventId]: _removed, ...next } = current; return next; }); // Delete the event if title was never set const ev = events.find((e) => e.id === eventId); if (!ev || ev.title === "(No title)") { deleteEvent.mutate( buildDeleteEventMutationInput( { id: eventId, accountEmail: ev?.accountEmail ?? accountEmail ?? defaultAccountEmail, }, { scope: "single", sendUpdates: "none", }, ), ); } }, [defaultAccountEmail, discardDraftEvent, events, deleteEvent], ); // IconKeyboard shortcuts — don't fire when user is typing in an input const isTypingInInput = useCallback((e: KeyboardEvent) => { const target = e.target as HTMLElement; return ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ); }, []); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { // Cmd+K / Ctrl+K — always open command palette if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setCommandPaletteOpen(true); return; } // Skip all other shortcuts when typing or when a dialog is open if (isTypingInInput(e)) return; if (createDialogOpen || deleteDialogEvent) return; // Delete/Backspace — delete the selected event if (e.key === "Delete" || e.key === "Backspace") { const targetEvent = sidebarEvent || focusedEvent; if (!targetEvent) return; e.preventDefault(); handleDeleteEvent(targetEvent.id); return; } // Don't intercept keyboard shortcuts with modifier keys (Cmd+C, Ctrl+V, etc.) if (e.metaKey || e.ctrlKey || e.altKey) return; // `?` / shift+/ opens the keyboard shortcuts help — that listener now // lives in AppLayout so it works on every tab. Don't double-handle here. // Arrow keys navigate the calendar grid — never steal them from list // navigation inside the command palette or other open dialogs. const isArrowKey = e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown"; if ( isArrowKey && (commandPaletteOpen || peopleSearchOpen || addCalendarOpen) ) { return; } switch (e.key) { case "z": e.preventDefault(); runUndo(); break; case "j": case "ArrowRight": e.preventDefault(); handleNavigate("next"); break; case "k": case "ArrowLeft": e.preventDefault(); handleNavigate("prev"); break; case "ArrowDown": e.preventDefault(); setSelectedDate( viewMode === "month" ? addWeeks(selectedDate, 1) : addDays(selectedDate, 1), ); break; case "ArrowUp": e.preventDefault(); setSelectedDate( viewMode === "month" ? subWeeks(selectedDate, 1) : subDays(selectedDate, 1), ); break; case "p": e.preventDefault(); setPeopleSearchOpen(true); break; case "t": handleToday(); break; case "m": setViewMode("month"); break; case "w": setViewMode("week"); break; case "d": setViewMode("day"); break; case "c": e.preventDefault(); setEventDraft(null); setCreateDefaultStart(undefined); setCreateDefaultEnd(undefined); setCreateDialogOpen(true); break; case "/": e.preventDefault(); setCommandPaletteOpen(true); break; } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ createDialogOpen, deleteDialogEvent, isTypingInInput, viewMode, selectedDate, sidebarEvent, focusedEvent, events, commandPaletteOpen, peopleSearchOpen, addCalendarOpen, ]); const headerLabel = (() => { switch (viewMode) { case "month": return isMobile ? format(selectedDate, "MMM yyyy") : format(selectedDate, "MMMM yyyy"); case "week": { const ws = startOfWeek(selectedDate); const we = endOfWeek(selectedDate); 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"); } })(); return (
{/* Left: calendar area (header + grid) */}
{/* Google Calendar connect banner — show when there's a credentials error */} {eventsError ? : null} {/* Error detail */} {eventsError && (
{eventsError.message}
)} {/* Top bar */}
{/* Left: view mode dropdown */}

{t("calendarView.openNavigation")}

setViewMode("day")}> {t("calendarView.day")} D setViewMode("week")}> {t("calendarView.week")} W setViewMode("month")}> {t("calendarView.month")} M {t("calendarView.display")} { e.preventDefault(); setViewPrefs({ hideWeekends: !viewPrefs.hideWeekends }); }} > {t("calendarView.hideWeekends")} {viewPrefs.hideWeekends && ( )}
{/* Center: today, nav arrows, date label */}

{t("eventForm.goToToday")}{" "} T

{headerLabel} {eventsRefreshing && ( )}
{/* Right: search, new event */}

{t("root.commandSearch")}{" "} /

{ setCreateDialogOpen(open); if (open) { setEventDraft(null); } if (!open) { setCreateDefaultStart(undefined); setCreateDefaultEnd(undefined); } }} defaultDate={selectedDate} defaultStartTime={createDefaultStart} defaultEndTime={createDefaultEnd} />
{/* Calendar grid */}
{viewMode === "month" && ( )} {viewMode === "week" && ( )} {viewMode === "day" && ( )}
{/* Event detail sidebar — full height, outside the calendar column */} {eventDetailSidebar && ( setSidebarEvent(null)} onDelete={handleDeleteEvent} onTitleSave={handleTitleSave} /> )} {/* Dialogs */} setCommandPaletteOpen(false)} events={events} onGoToDate={handleGoToDate} onEventClick={(event) => { setCommandPaletteOpen(false); handleGoToDate(parseISO(event.start)); }} onCreateEvent={() => { setCommandPaletteOpen(false); setEventDraft(null); setCreateDialogOpen(true); }} onCreateEventFromText={(quickCreate) => { setCommandPaletteOpen(false); void handleCreateEventFromText(quickCreate); }} onViewChange={setViewMode} onToday={handleToday} selectedEvent={selectedEvent} onOpenSelectedEventInGoogleCalendar={ handleOpenSelectedEventInGoogleCalendar } onAddPeopleCalendar={() => { setCommandPaletteOpen(false); setAddCalendarDefaultTab("people"); setAddCalendarOpen(true); }} onAddUrlCalendar={() => { setCommandPaletteOpen(false); setAddCalendarDefaultTab("url"); setAddCalendarOpen(true); }} /> setDeleteDialogEvent(null)} onConfirm={(options) => { if (!deleteDialogEvent) return; const snapshot = { ...deleteDialogEvent }; const eventId = deleteDialogEvent.id; const undo = () => { createEvent.mutate({ title: snapshot.title, description: snapshot.description ?? "", location: snapshot.location ?? "", start: snapshot.start, end: snapshot.end, startTimeZone: snapshot.startTimeZone, endTimeZone: snapshot.endTimeZone, allDay: snapshot.allDay ?? false, color: snapshot.color, colorId: snapshot.colorId, attachments: snapshot.attachments, eventType: snapshot.eventType, transparency: snapshot.transparency, visibility: snapshot.visibility, reminders: snapshot.reminders, remindersUseDefault: snapshot.remindersUseDefault, accountEmail: snapshot.accountEmail, outOfOfficeProperties: snapshot.outOfOfficeProperties, focusTimeProperties: snapshot.focusTimeProperties, workingLocationProperties: snapshot.workingLocationProperties, }); }; // Optimistic: close dialog immediately setDeleteDialogEvent(null); if (sidebarEvent?.id === eventId) { setSidebarEvent(null); } deleteEvent.mutate( buildDeleteEventMutationInput(snapshot, options), { onSuccess: () => { setUndoAction(undo); toast( options.removeOnly ? t("calendarView.eventRemoved") : t("calendarView.eventDeleted"), { action: { label: t("calendarView.undo"), onClick: undo }, }, ); }, onError: () => toast.error(t("calendarView.failedDeleteEvent")), }, ); }} /> {guestNotificationDialog}
); } function AccountAvatars() { const t = useT(); const googleStatus = useGoogleAuthStatus(); const accounts = googleStatus.data?.accounts ?? []; if (accounts.length === 0) return null; return (
{accounts.map((account, i) => (
{account.photoUrl && !isMcpEmbedSurface() ? ( ) : ( // MCP host iframes (ChatGPT / Claude) ship strict COEP/CORP // headers that block cross-origin googleusercontent.com // avatars and produce noisy console errors. Fall back to a // same-origin initial chip when embedded. See // `templates/calendar/app/lib/mcp-embed.ts`.
{account.email[0]?.toUpperCase()}
)}
))}
{accounts.map((a) => (
{a.email}
))}
); }