import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { useLocation } from 'react-router-dom'; import { format } from 'date-fns'; 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 enAuLocale from '@fullcalendar/core/locales/en-au'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core'; import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat, Users } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import axios from 'axios'; 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 { Select } from '@/components/ui/select'; import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet'; import CalendarSidebar from './CalendarSidebar'; import EventDetailPanel from './EventDetailPanel'; import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; import type { CreateDefaults } from './EventDetailPanel'; type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; const viewLabels: Record = { dayGridMonth: 'Month', timeGridWeek: 'Week', timeGridDay: 'Day', }; const UMBRA_EVENT_CLASSES = ['umbra-event']; 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); // Resizable sidebar const SIDEBAR_STORAGE_KEY = 'umbra-calendar-sidebar-width'; const SIDEBAR_MIN = 180; const SIDEBAR_MAX = 400; const SIDEBAR_DEFAULT = 224; // w-56 const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY); if (saved) { const n = parseInt(saved, 10); if (!isNaN(n) && n >= SIDEBAR_MIN && n <= SIDEBAR_MAX) return n; } return SIDEBAR_DEFAULT; }); const isResizingRef = useRef(false); const sidebarRef = useRef(null); const handleSidebarMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); isResizingRef.current = true; const startX = e.clientX; const startWidth = sidebarWidth; let latestWidth = startWidth; const onMouseMove = (ev: MouseEvent) => { latestWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + (ev.clientX - startX))); // Direct DOM mutation — bypasses React entirely during drag, zero re-renders if (sidebarRef.current) { sidebarRef.current.style.width = latestWidth + 'px'; } }; const onMouseUp = () => { isResizingRef.current = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; // Single React state commit on release — triggers localStorage persist + final reconciliation setSidebarWidth(latestWidth); }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }, [sidebarWidth]); // Persist sidebar width on change useEffect(() => { localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth)); }, [sidebarWidth]); // 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]); // Set of calendar IDs that are shared (owned or membership) const sharedCalendarIds = useMemo(() => { const ids = new Set(); calendars.forEach((cal) => { if (cal.is_shared) ids.add(cal.id); }); sharedData.forEach((m) => ids.add(m.calendar_id)); return ids; }, [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 = useMediaQuery(DESKTOP); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); // 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) => { // Skip wheel navigation on touch devices (let them scroll normally) if ('ontouchstart' in window) return; 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); }, []); // AW-2: Track visible date range for scoped event fetching // W-02 fix: Initialize from current month to avoid unscoped first fetch const [visibleRange, setVisibleRange] = useState<{ start: string; end: string }>(() => { const now = new Date(); const y = now.getFullYear(); const m = now.getMonth(); // FullCalendar month view typically fetches prev month to next month const start = format(new Date(y, m - 1, 1), 'yyyy-MM-dd'); const end = format(new Date(y, m + 2, 0), 'yyyy-MM-dd'); return { start, end }; }); const { data: events = [] } = useQuery({ queryKey: ['calendar-events', visibleRange.start, visibleRange.end], queryFn: async () => { const { data } = await api.get('/events', { params: { start: visibleRange.start, end: visibleRange.end }, }); return data; }, // AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min refetchInterval: 30_000, staleTime: 30_000, }); 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 ? sharedCalendarIds.has(selectedEvent.calendar_id) : false; // Close panel if shared calendar was removed while viewing (skip for invited events) useEffect(() => { if (!selectedEvent || allCalendarIds.size === 0) return; if (selectedEvent.is_invited) 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, ) => { // C-01 fix: match active query key which includes date range queryClient.setQueryData( ['calendar-events', visibleRange.start, visibleRange.end], (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'] }); if (axios.isAxiosError(error) && error.response?.status === 423) { toast.error('Event is locked by another user'); } else { toast.error(getErrorMessage(error, 'Failed to update event')); } }, }); const filteredEvents = useMemo(() => { if (calendars.length === 0) return events; // Invited events: if display_calendar_id is set, respect that calendar's visibility; // otherwise (pending) always show return events.filter((e) => { if (e.is_invited) { return e.display_calendar_id ? visibleCalendarIds.has(e.display_calendar_id) : true; } return 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 // Exclude declined invited events from calendar display .filter((event) => !(event.is_invited && event.invitation_status === 'declined')) .map((event) => ({ id: String(event.id), title: event.title, start: event.start_datetime, end: event.end_datetime || undefined, allDay: event.all_day, color: 'transparent', editable: !event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only', extendedProps: { is_virtual: event.is_virtual, is_recurring: event.is_recurring, parent_event_id: event.parent_event_id, calendar_id: event.calendar_id, calendarColor: event.calendar_color || 'hsl(var(--accent-color))', is_invited: event.is_invited, }, })); 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; } if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') { info.revert(); toast.error('You have read-only access to this calendar'); 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; } if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') { info.revert(); toast.error('You have read-only access to this calendar'); 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); // AW-2: Capture visible range for scoped event fetching // C-02 fix: use format() not toISOString() to avoid UTC date shift const start = format(arg.start, 'yyyy-MM-dd'); const end = format(arg.end, 'yyyy-MM-dd'); setVisibleRange((prev) => prev.start === start && prev.end === end ? prev : { start, end } ); }; 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); // Set --event-color CSS var via eventDidMount (write-only, no reads) const handleEventDidMount = useCallback((info: { el: HTMLElement; event: { extendedProps: Record } }) => { const color = info.event.extendedProps.calendarColor as string; if (color) { info.el.style.setProperty('--event-color', color); } }, []); const renderEventContent = useCallback((arg: EventContentArg) => { const isMonth = arg.view.type === 'dayGridMonth'; const isAllDay = arg.event.allDay; const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id; const isInvited = arg.event.extendedProps.is_invited; const calColor = arg.event.extendedProps.calendarColor as string; // Sync --event-color on the parent FC element so CSS rules (background, hover) // pick up color changes without requiring a full remount (eventDidMount only fires once). const syncColor = (el: HTMLElement | null) => { if (el && calColor) { const fcEl = el.closest('.umbra-event'); if (fcEl) (fcEl as HTMLElement).style.setProperty('--event-color', calColor); } }; const icons = ( <> {isInvited && } {isRecurring && } ); if (isMonth) { if (isAllDay) { return (
{arg.event.title} {icons}
); } // Timed events in month: dot + title + time right-aligned return (
{arg.event.title} {icons} {arg.timeText}
); } // Week/day view — title on top, time underneath return (
{arg.event.title} {icons}
{arg.timeText}
); }, []); return (
{!isDesktop && ( setMobileSidebarOpen(false)} /> { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} /> )}
{/* 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-32 sm: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 && ( )}
); }