import { useState, useRef, useEffect, useMemo } from 'react'; 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 } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import type { CalendarEvent, EventTemplate } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useSettings } from '@/hooks/useSettings'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import CalendarSidebar from './CalendarSidebar'; import EventForm from './EventForm'; type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; const viewLabels: Record = { dayGridMonth: 'Month', timeGridWeek: 'Week', timeGridDay: 'Day', }; type ScopeAction = 'edit' | 'delete'; export default function CalendarPage() { const queryClient = useQueryClient(); const calendarRef = useRef(null); const [showForm, setShowForm] = useState(false); const [editingEvent, setEditingEvent] = useState(null); const [selectedStart, setSelectedStart] = useState(null); const [selectedEnd, setSelectedEnd] = useState(null); const [selectedAllDay, setSelectedAllDay] = useState(false); const [currentView, setCurrentView] = useState('dayGridMonth'); const [calendarTitle, setCalendarTitle] = useState(''); const [templateEvent, setTemplateEvent] = useState | null>(null); const [templateName, setTemplateName] = useState(null); // Scope dialog state const [scopeDialogOpen, setScopeDialogOpen] = useState(false); const [scopeAction, setScopeAction] = useState('edit'); const [scopeEvent, setScopeEvent] = useState(null); const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null); const { settings } = useSettings(); const { data: calendars = [] } = useCalendars(); const calendarContainerRef = useRef(null); // 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(); }, []); // 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 visibleCalendarIds = useMemo( () => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)), [calendars], ); 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 scopeDeleteMutation = useMutation({ mutationFn: async ({ id, scope }: { id: number; scope: string }) => { await api.delete(`/events/${id}?scope=${scope}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success('Event(s) deleted'); setScopeDialogOpen(false); setScopeEvent(null); }, onError: (error) => { toast.error(getErrorMessage(error, 'Failed to delete event')); }, }); const filteredEvents = useMemo(() => { if (calendars.length === 0) return events; return events.filter((e) => visibleCalendarIds.has(e.calendar_id)); }, [events, visibleCalendarIds, calendars.length]); 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 isRecurring = (event: CalendarEvent): boolean => { return !!(event.is_recurring || 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; } if (isRecurring(event)) { setScopeEvent(event); setScopeAction('edit'); setScopeDialogOpen(true); } else { setEditingEvent(event); setShowForm(true); } }; const handleScopeChoice = (scope: 'this' | 'this_and_future') => { if (!scopeEvent) return; if (scopeAction === 'edit') { setEditingEvent(scopeEvent); setActiveEditScope(scope); setShowForm(true); setScopeDialogOpen(false); } else if (scopeAction === 'delete') { scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope }); } }; const handleEventDrop = (info: EventDropArg) => { if (info.event.extendedProps.is_virtual) { info.revert(); return; } // Prevent drag-drop on recurring events — user must use scope dialog via click 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; } // Prevent resize on recurring events — user must use scope dialog via click 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) => { setSelectedStart(selectInfo.startStr); setSelectedEnd(selectInfo.endStr); setSelectedAllDay(selectInfo.allDay); setShowForm(true); }; const handleCloseForm = () => { calendarRef.current?.getApi().unselect(); setShowForm(false); setEditingEvent(null); setTemplateEvent(null); setTemplateName(null); setActiveEditScope(null); setSelectedStart(null); setSelectedEnd(null); setSelectedAllDay(false); }; const handleUseTemplate = (template: EventTemplate) => { setTemplateEvent({ 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); setTemplateName(template.name); setEditingEvent(null); setShowForm(true); }; 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 — h-16 matches sidebar header */}

{calendarTitle}

{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( ))}
{/* Calendar grid */}
{showForm && ( )} {/* Recurring event scope dialog */} {scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}

This is a recurring event. How would you like to proceed?

); }