import { useState, useRef, useEffect, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import FullCalendar from '@fullcalendar/react'; import dayGridPlugin from '@fullcalendar/daygrid'; import timeGridPlugin from '@fullcalendar/timegrid'; import interactionPlugin from '@fullcalendar/interaction'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useSettings } from '@/hooks/useSettings'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import CalendarSidebar from './CalendarSidebar'; import EventDetailPanel from './EventDetailPanel'; import type { CreateDefaults } from './EventDetailPanel'; type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; const viewLabels: Record = { dayGridMonth: 'Month', timeGridWeek: 'Week', timeGridDay: 'Day', }; export default function CalendarPage() { const queryClient = useQueryClient(); const location = useLocation(); const calendarRef = useRef(null); const [currentView, setCurrentView] = useState('dayGridMonth'); const [calendarTitle, setCalendarTitle] = useState(''); const [eventSearch, setEventSearch] = useState(''); const [searchFocused, setSearchFocused] = useState(false); // Panel state const [selectedEventId, setSelectedEventId] = useState(null); const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed'); const [createDefaults, setCreateDefaults] = useState(null); const { settings } = useSettings(); const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true }); const [visibleSharedIds, setVisibleSharedIds] = useState>(new Set()); const calendarContainerRef = useRef(null); // Location data for event panel const { data: locations = [] } = useQuery({ queryKey: ['locations'], queryFn: async () => { const { data } = await api.get('/locations'); return data; }, staleTime: 5 * 60 * 1000, }); const locationMap = useMemo(() => { const map = new Map(); locations.forEach((l) => map.set(l.id, l.name)); return map; }, [locations]); // Build permission map: calendar_id -> permission level const permissionMap = useMemo(() => { const map = new Map(); calendars.forEach((cal) => map.set(cal.id, 'owner')); sharedData.forEach((m) => map.set(m.calendar_id, m.permission)); return map; }, [calendars, sharedData]); // Handle navigation state from dashboard useEffect(() => { const state = location.state as { date?: string; view?: string; eventId?: number } | null; if (!state) return; const calApi = calendarRef.current?.getApi(); if (state.date && calApi) { calApi.gotoDate(state.date); if (state.view) calApi.changeView(state.view); } if (state.eventId) { setSelectedEventId(state.eventId); setPanelMode('view'); } window.history.replaceState({}, ''); }, [location.state]); // Resize FullCalendar when container size changes (e.g. sidebar collapse) useEffect(() => { const el = calendarContainerRef.current; if (!el) return; const observer = new ResizeObserver(() => { calendarRef.current?.getApi().updateSize(); }); observer.observe(el); return () => observer.disconnect(); }, []); const panelOpen = panelMode !== 'closed'; // Track desktop breakpoint to prevent dual EventDetailPanel mount const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches); useEffect(() => { const mql = window.matchMedia('(min-width: 1024px)'); const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); }, []); // Continuously resize calendar during panel open/close CSS transition useEffect(() => { let rafId: number; const start = performance.now(); const duration = 350; // slightly longer than the 300ms CSS transition const tick = () => { calendarRef.current?.getApi().updateSize(); if (performance.now() - start < duration) { rafId = requestAnimationFrame(tick); } }; rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); }, [panelOpen]); // Scroll wheel navigation in month view useEffect(() => { const el = calendarContainerRef.current; if (!el) return; let debounceTimer: ReturnType | null = null; const handleWheel = (e: WheelEvent) => { const api = calendarRef.current?.getApi(); if (!api || api.view.type !== 'dayGridMonth') return; e.preventDefault(); if (debounceTimer) return; debounceTimer = setTimeout(() => { debounceTimer = null; }, 300); if (e.deltaY > 0) api.next(); else if (e.deltaY < 0) api.prev(); }; el.addEventListener('wheel', handleWheel, { passive: false }); return () => el.removeEventListener('wheel', handleWheel); }, []); const { data: events = [] } = useQuery({ queryKey: ['calendar-events'], queryFn: async () => { const { data } = await api.get('/events'); return data; }, }); const selectedEvent = useMemo( () => events.find((e) => e.id === selectedEventId) ?? null, [selectedEventId, events], ); const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null; const selectedEventIsShared = selectedEvent ? permissionMap.has(selectedEvent.calendar_id) && permissionMap.get(selectedEvent.calendar_id) !== 'owner' : false; // Close panel if shared calendar was removed while viewing useEffect(() => { if (!selectedEvent || allCalendarIds.size === 0) return; if (!allCalendarIds.has(selectedEvent.calendar_id)) { handlePanelClose(); toast.info('This calendar is no longer available'); } }, [allCalendarIds, selectedEvent]); // Escape key closes detail panel useEffect(() => { if (!panelOpen) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handlePanelClose(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [panelOpen]); const visibleCalendarIds = useMemo( () => { const owned = calendars.filter((c) => c.is_visible).map((c) => c.id); return new Set([...owned, ...visibleSharedIds]); }, [calendars, visibleSharedIds], ); const toLocalDatetime = (d: Date): string => { const pad = (n: number) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }; const updateEventTimes = ( id: number, start: string, end: string, allDay: boolean, revert: () => void, ) => { queryClient.setQueryData(['calendar-events'], (old) => old?.map((e) => e.id === id ? { ...e, start_datetime: start, end_datetime: end, all_day: allDay } : e, ), ); eventMutation.mutate({ id, start, end, allDay, revert }); }; const eventMutation = useMutation({ mutationFn: async ({ id, start, end, allDay, }: { id: number; start: string; end: string; allDay: boolean; revert: () => void; }) => { const response = await api.put(`/events/${id}`, { start_datetime: start, end_datetime: end, all_day: allDay, }); return response.data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); toast.success('Event updated'); }, onError: (error, variables) => { variables.revert(); queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); toast.error(getErrorMessage(error, 'Failed to update event')); }, }); const filteredEvents = useMemo(() => { if (calendars.length === 0) return events; return events.filter((e) => visibleCalendarIds.has(e.calendar_id)); }, [events, visibleCalendarIds, calendars.length]); const searchResults = useMemo(() => { if (!eventSearch.trim()) return []; const q = eventSearch.toLowerCase(); return filteredEvents .filter((e) => e.title.toLowerCase().includes(q)) .slice(0, 8); }, [filteredEvents, eventSearch]); const handleSearchSelect = (event: CalendarEvent) => { const calApi = calendarRef.current?.getApi(); if (!calApi) return; const startDate = new Date(event.start_datetime); calApi.gotoDate(startDate); if (event.all_day) { calApi.changeView('dayGridMonth'); } else { calApi.changeView('timeGridDay'); } setEventSearch(''); setSearchFocused(false); // Also open the event in the panel if (!event.is_virtual) { setSelectedEventId(event.id); setPanelMode('view'); } }; const calendarEvents = filteredEvents.map((event) => ({ id: String(event.id), title: event.title, start: event.start_datetime, end: event.end_datetime || undefined, allDay: event.all_day, backgroundColor: event.calendar_color || 'hsl(var(--accent-color))', borderColor: event.calendar_color || 'hsl(var(--accent-color))', extendedProps: { is_virtual: event.is_virtual, is_recurring: event.is_recurring, parent_event_id: event.parent_event_id, }, })); const handleEventClick = (info: EventClickArg) => { const event = events.find((e) => String(e.id) === info.event.id); if (!event) return; if (event.is_virtual) { toast.info(`${event.title} — from People contacts`); return; } setSelectedEventId(event.id); setPanelMode('view'); }; const handleEventDrop = (info: EventDropArg) => { if (info.event.extendedProps.is_virtual) { info.revert(); return; } if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) { info.revert(); toast.info('Click the event to edit recurring events'); return; } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr : info.event.start ? toLocalDatetime(info.event.start) : info.event.startStr; const end = info.event.allDay ? info.event.endStr || info.event.startStr : info.event.end ? toLocalDatetime(info.event.end) : start; updateEventTimes(id, start, end, info.event.allDay, info.revert); }; const handleEventResize = (info: { event: EventDropArg['event']; revert: () => void }) => { if (info.event.extendedProps.is_virtual) { info.revert(); return; } if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) { info.revert(); toast.info('Click the event to edit recurring events'); return; } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr : info.event.start ? toLocalDatetime(info.event.start) : info.event.startStr; const end = info.event.allDay ? info.event.endStr || info.event.startStr : info.event.end ? toLocalDatetime(info.event.end) : start; updateEventTimes(id, start, end, info.event.allDay, info.revert); }; const handleDateSelect = (selectInfo: DateSelectArg) => { setSelectedEventId(null); setPanelMode('create'); setCreateDefaults({ start: selectInfo.startStr, end: selectInfo.endStr, allDay: selectInfo.allDay, }); }; const handleCreateNew = () => { setSelectedEventId(null); setPanelMode('create'); setCreateDefaults(null); }; const handlePanelClose = () => { calendarRef.current?.getApi().unselect(); setPanelMode('closed'); setSelectedEventId(null); setCreateDefaults(null); }; const handleUseTemplate = (template: EventTemplate) => { setSelectedEventId(null); setPanelMode('create'); setCreateDefaults({ templateData: { title: template.title, description: template.description || '', all_day: template.all_day, calendar_id: template.calendar_id ?? undefined, location_id: template.location_id || undefined, is_starred: template.is_starred, recurrence_rule: template.recurrence_rule || undefined, } as Partial, templateName: template.name, }); }; const handleDatesSet = (arg: DatesSetArg) => { setCalendarTitle(arg.view.title); setCurrentView(arg.view.type as CalendarView); }; const navigatePrev = () => calendarRef.current?.getApi().prev(); const navigateNext = () => calendarRef.current?.getApi().next(); const navigateToday = () => calendarRef.current?.getApi().today(); const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view); return (
{/* Custom toolbar */}
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( ))}

{calendarTitle}

{/* Event search */}
setEventSearch(e.target.value)} onFocus={() => setSearchFocused(true)} onBlur={() => setTimeout(() => setSearchFocused(false), 200)} className="w-52 h-8 pl-8 text-sm ring-inset" /> {searchFocused && searchResults.length > 0 && (
{searchResults.map((event) => ( ))}
)}
{/* Calendar grid + event detail panel */}
{/* Detail panel (desktop) */} {panelOpen && isDesktop && (
)}
{/* Mobile detail panel overlay */} {panelOpen && !isDesktop && (
e.stopPropagation()} >
)}
); }