import FullCalendar from "@fullcalendar/react"; import dayGridPlugin from "@fullcalendar/daygrid"; import timeGridPlugin from "@fullcalendar/timegrid"; import interactionPlugin from "@fullcalendar/interaction"; import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; import "./Calendar.styl"; import "./CustomCalendar.styl"; import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import EventPopover from "../../features/Events/EventModal"; import { CalendarEvent } from "../../features/Events/EventsTypes"; import CalendarSelection from "./CalendarSelection"; import { getCalendarDetailAsync, setTimeZone, } from "../../features/Calendars/CalendarSlice"; import ImportAlert from "../../features/Events/ImportAlert"; import { formatDateToYYYYMMDDTHHMMSS, getCalendarRange, } from "../../utils/dateUtils"; import { push } from "redux-first-history"; import EventPreviewModal from "../../features/Events/EventDisplayPreview"; import AddIcon from "@mui/icons-material/Add"; import { TempCalendarsInput } from "./TempCalendarsInput"; import Button from "@mui/material/Button"; import { Box } from "@mui/material"; import { updateSlotLabelVisibility, eventToFullCalendarFormat, extractEvents, } from "./utils/calendarUtils"; import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers"; import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers"; import { EditModeDialog } from "../Event/EditModeDialog"; import { EventErrorHandler } from "../Error/EventErrorHandler"; import { EventErrorSnackbar } from "../Error/ErrorSnackbar"; import momentTimezonePlugin from "@fullcalendar/moment-timezone"; import { TimezoneSelector } from "./TimezoneSelector"; import { MiniCalendar } from "./MiniCalendar"; import { User } from "../Attendees/PeopleSearch"; import { useTheme } from "@mui/material/styles"; import { updateDarkColor } from "./utils/calendarColorsUtils"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; interface CalendarAppProps { calendarRef: MutableRefObject; onDateChange?: (date: Date) => void; onViewChange?: (view: string) => void; } export default function CalendarApp({ calendarRef, onDateChange, onViewChange, }: CalendarAppProps) { const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedMiniDate, setSelectedMiniDate] = useState(new Date()); const tokens = useAppSelector((state) => state.user.tokens); const userId = useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const dispatch = useAppDispatch(); const theme = useTheme(); useEffect(() => { if (!tokens || !userId) { dispatch(push("/")); } }, [dispatch, tokens, userId]); const calendars = useAppSelector((state) => state.calendars.list); const tempcalendars = useAppSelector((state) => state.calendars.templist); const [selectedCalendars, setSelectedCalendars] = useState([]); const calendarLightSignature = useMemo(() => { return Object.values(calendars || {}) .map((cal) => `${cal.id}:${cal.color?.light ?? ""}`) .sort() .join("|"); }, [calendars]); const calendarIdsString = useMemo( () => Object.keys(calendars || {}) .sort() .join(","), [calendars] ); const calendarIds = useMemo( () => (calendarIdsString ? calendarIdsString.split(",") : []), [calendarIdsString] ); const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) => { const calendar = calendars[calId]; if (!calendar?.events) return []; return Object.values(calendar.events); }); const [currentView, setCurrentView] = useState("timeGridWeek"); const timezone = useAppSelector((state) => state.calendars.timeZone); const fetchedRangesRef = useRef>({}); // Auto-select personal calendars when first loaded const initialLoadRef = useRef(true); const [eventErrors, setEventErrors] = useState([]); const errorHandler = useRef(new EventErrorHandler()); useEffect(() => { const handler = errorHandler.current; handler.setErrorCallback(setEventErrors); return () => handler.setErrorCallback(() => {}); }, []); const handleErrorClose = () => { setEventErrors([]); errorHandler.current.clearAll(); }; useEffect(() => { if (initialLoadRef.current && calendarIds.length > 0 && userId) { const cached = localStorage.getItem("selectedCalendars"); if (cached && cached.length > 0) { const parsed = JSON.parse(cached) as string[]; const valid = parsed.filter((id) => calendars[id]); setSelectedCalendars(valid); } else { const personalCalendarIds = calendarIds.filter( (id) => id.split("/")[0] === userId ); setSelectedCalendars(personalCalendarIds); } initialLoadRef.current = false; } }, [calendarIds, calendars, userId]); // Save selected cals to cache useEffect(() => { if (calendarIds.length > 0) { localStorage.setItem( "selectedCalendars", JSON.stringify(selectedCalendars) ); } }, [selectedCalendars, calendarIds.length]); const prevCalendarLightSignature = useRef(null); useEffect(() => { if (!calendarLightSignature) { prevCalendarLightSignature.current = calendarLightSignature; return; } if (prevCalendarLightSignature.current === calendarLightSignature) { return; } prevCalendarLightSignature.current = calendarLightSignature; updateDarkColor(calendars || {}, theme, dispatch); }, [calendarLightSignature, calendars, theme, dispatch]); useEffect(() => { if (calendarIds.length === 0) return; const validCalendarIds = new Set(calendarIds); setSelectedCalendars((prev) => { const filtered = prev.filter((calId) => validCalendarIds.has(calId)); if (filtered.length === prev.length) { const unchanged = filtered.every((id, index) => id === prev[index]); if (unchanged) { return prev; } } return filtered; }); }, [calendarIds]); const calendarRange = useMemo( () => getCalendarRange(selectedDate), [selectedDate] ); const calendarRangeStart = calendarRange.start.getTime(); const calendarRangeEnd = calendarRange.end.getTime(); const rangeStart = useMemo( () => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeStart)), [calendarRangeStart] ); const rangeEnd = useMemo( () => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeEnd)), [calendarRangeEnd] ); // Create a stable string key for the range const rangeKey = useMemo( () => `${rangeStart}_${rangeEnd}`, [rangeStart, rangeEnd] ); let filteredEvents: CalendarEvent[] = extractEvents( selectedCalendars, calendars || {} ); const tempCalendarIds = useMemo( () => Object.keys(tempcalendars || {}).sort(), [tempcalendars] ); let filteredTempEvents: CalendarEvent[] = extractEvents( tempCalendarIds, tempcalendars || {} ); const sortedSelectedCalendars = useMemo( () => [...selectedCalendars].sort(), [selectedCalendars] ); const prefetchedCalendarsRef = useRef>({}); const tempFetchedRangesRef = useRef>({}); const activeLoadCompletedRef = useRef(false); const [activeLoadCompleted, setActiveLoadCompleted] = useState(false); useEffect(() => { activeLoadCompletedRef.current = false; setActiveLoadCompleted(false); }, [rangeKey]); useEffect(() => { if (!rangeKey || sortedSelectedCalendars.length === 0) { activeLoadCompletedRef.current = true; setActiveLoadCompleted(true); return; } let cancelled = false; const ACTIVE_BATCH_SIZE = 5; const loadCalendars = async () => { try { const pendingIds = sortedSelectedCalendars.filter( (id) => fetchedRangesRef.current[id] !== rangeKey ); if (pendingIds.length === 0) { activeLoadCompletedRef.current = true; setActiveLoadCompleted(true); return; } for ( let i = 0; i < pendingIds.length && !cancelled; i += ACTIVE_BATCH_SIZE ) { const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE); chunk.forEach((id) => { fetchedRangesRef.current[id] = rangeKey; prefetchedCalendarsRef.current[id] = "active"; }); const requests = chunk.map(async (id) => { try { await dispatch( getCalendarDetailAsync({ calId: id, match: { start: rangeStart, end: rangeEnd, }, }) ).unwrap(); } catch (error) { console.error(`Failed to load calendar ${id}:`, error); fetchedRangesRef.current[id] = ""; } }); await Promise.all(requests); } } finally { if (!cancelled) { activeLoadCompletedRef.current = true; setActiveLoadCompleted(true); } } }; loadCalendars(); return () => { cancelled = true; }; }, [dispatch, rangeKey, sortedSelectedCalendars, rangeStart, rangeEnd]); useEffect(() => { if (!rangeKey || !activeLoadCompleted) return; const hiddenCalendars = calendarIds .filter((id) => !selectedCalendars.includes(id)) .filter((id) => { const prefetched = prefetchedCalendarsRef.current[id]; return prefetched !== rangeKey && prefetched !== "active"; }); if (hiddenCalendars.length === 0) return; hiddenCalendars.forEach((id) => { prefetchedCalendarsRef.current[id] = rangeKey; dispatch( getCalendarDetailAsync({ calId: id, match: { start: rangeStart, end: rangeEnd, }, }) ) .unwrap() .catch((error) => { console.error(`Prefetch calendar ${id} failed:`, error); prefetchedCalendarsRef.current[id] = ""; }); }); }, [ calendarIdsString, selectedCalendars, rangeKey, dispatch, rangeStart, rangeEnd, activeLoadCompleted, ]); const calendarsWithClearedCache = useMemo(() => { return selectedCalendars .map((id) => { const cleared = calendars[id]?.lastCacheCleared; return cleared ? { id, cleared } : null; }) .filter(Boolean) as { id: string; cleared: number }[]; }, [selectedCalendars, calendars]); const processedCacheClearRef = useRef>({}); useEffect(() => { calendarsWithClearedCache.forEach(({ id, cleared }) => { if (processedCacheClearRef.current[id] === cleared) { return; } processedCacheClearRef.current[id] = cleared; delete fetchedRangesRef.current[id]; prefetchedCalendarsRef.current[id] = ""; dispatch( getCalendarDetailAsync({ calId: id, match: { start: rangeStart, end: rangeEnd, }, }) ) .unwrap() .then(() => { fetchedRangesRef.current[id] = rangeKey; prefetchedCalendarsRef.current[id] = rangeKey; }) .catch(() => { fetchedRangesRef.current[id] = ""; prefetchedCalendarsRef.current[id] = ""; }); }); }, [calendarsWithClearedCache, dispatch, rangeKey, rangeStart, rangeEnd]); const tempCalendarControllersRef = useRef>( new Map() ); useEffect(() => { const currentIds = new Set(tempCalendarIds); Object.keys(tempFetchedRangesRef.current).forEach((id) => { if (!currentIds.has(id)) { delete tempFetchedRangesRef.current[id]; } }); }, [tempCalendarIds]); useEffect(() => { if (!rangeKey || tempCalendarIds.length === 0) return; let cancelled = false; const TEMP_BATCH_SIZE = 5; const loadTempCalendars = async () => { const pendingIds = tempCalendarIds.filter( (id) => tempFetchedRangesRef.current[id] !== rangeKey ); if (pendingIds.length === 0) { return; } for ( let i = 0; i < pendingIds.length && !cancelled; i += TEMP_BATCH_SIZE ) { const chunk = pendingIds.slice(i, i + TEMP_BATCH_SIZE); chunk.forEach((id) => { tempFetchedRangesRef.current[id] = rangeKey; }); const requests = chunk.map(async (id) => { try { await dispatch( getCalendarDetailAsync({ calId: id, match: { start: rangeStart, end: rangeEnd, }, calType: "temp", }) ).unwrap(); } catch (error) { console.error(`Failed to load temp calendar ${id}:`, error); tempFetchedRangesRef.current[id] = ""; } }); await Promise.all(requests); } }; loadTempCalendars(); return () => { cancelled = true; }; }, [dispatch, rangeKey, tempCalendarIds, rangeStart, rangeEnd]); const [anchorEl, setAnchorEl] = useState(null); const [openEventDisplay, setOpenEventDisplay] = useState(false); const [eventDisplayedId, setEventDisplayedId] = useState(""); const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false); const [eventDisplayedCalId, setEventDisplayedCalId] = useState(""); const [openEditModePopup, setOpenEditModePopup] = useState( null ); const [, setTypeOfAction] = useState<"solo" | "all" | undefined>(undefined); const [afterChoiceFunc, setAfterChoiceFunc] = useState(); const [selectedEvent, setSelectedEvent] = useState( {} as CalendarEvent ); const [selectedRange, setSelectedRange] = useState( null ); const [tempUsers, setTempUsers] = useState([]); const [tempEvent, setTempEvent] = useState( {} as CalendarEvent ); // Event handlers const eventHandlers = useCalendarEventHandlers({ setSelectedRange, setAnchorEl, calendarRef, selectedCalendars, tempcalendars, calendarRange, dispatch, setOpenEventDisplay, setEventDisplayedId, setEventDisplayedCalId, setEventDisplayedTemp, calendars, setSelectedEvent, setAfterChoiceFunc, setOpenEditModePopup, tempUsers, setTempEvent, timezone, }); // View handlers const viewHandlers = useCalendarViewHandlers({ calendarRef, setSelectedDate, setSelectedMiniDate, onViewChange, calendars, tempcalendars, errorHandler: errorHandler.current, }); if (process.env.NODE_ENV === "test") { (window as any).__calendarRef = calendarRef; } const { t, lang } = useI18n(); return (
{ eventHandlers.handleDateSelect(null as unknown as DateSelectArg); }} selectedCalendars={selectedCalendars} setSelectedCalendars={setSelectedCalendars} />
{ if (ref) { calendarRef.current = ref.getApi(); } }} plugins={[ dayGridPlugin, timeGridPlugin, interactionPlugin, momentTimezonePlugin, ]} initialView="timeGridWeek" firstDay={1} editable={true} locale={lang} selectable={true} timeZone={timezone} height={"100%"} select={eventHandlers.handleDateSelect} nowIndicator slotLabelClassNames={(arg) => [ updateSlotLabelVisibility(new Date(), arg, timezone), ]} nowIndicatorContent={viewHandlers.handleNowIndicatorContent} headerToolbar={false} views={{ timeGridWeek: { titleFormat: { month: "long", year: "numeric" } }, }} dayMaxEvents={true} events={eventToFullCalendarFormat( filteredEvents, filteredTempEvents, userId )} weekNumbers={ currentView === "timeGridWeek" || currentView === "timeGridDay" } weekNumberFormat={{ week: "long" }} weekNumberContent={(arg) => { return (
{t("menubar.views.week")} {arg.num}
dispatch(setTimeZone(newTimezone)) } />
); }} dayCellContent={(arg) => { const month = arg.date.toLocaleDateString(t("locale"), { month: "short", }); if (arg.view.type === "dayGridMonth") { return ( {arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText} ); } }} slotDuration={"00:30:00"} slotLabelInterval={"01:00:00"} scrollTime="12:00:00" unselectAuto={false} allDayText="" slotLabelFormat={{ hour: "2-digit", minute: "2-digit", hour12: false, }} datesSet={(arg) => { setCurrentView(arg.view.type); // Get the current date from calendar API to ensure consistency const calendarCurrentDate = calendarRef.current?.getDate() || new Date(arg.start); if (arg.view.type === "dayGridMonth") { const start = new Date(arg.start).getTime(); const end = new Date(arg.end).getTime(); const middle = start + (end - start) / 2; setSelectedDate(new Date(middle)); setSelectedMiniDate(calendarCurrentDate); } else { setSelectedDate(new Date(arg.start)); setSelectedMiniDate(new Date(arg.start)); } // Always use the calendar's current date for consistency if (onDateChange) { onDateChange(calendarCurrentDate); } // Notify parent about view change if (onViewChange) { onViewChange(arg.view.type); } // Update slot label visibility when view changes setTimeout(() => { updateSlotLabelVisibility(new Date()); }, 100); }} dayHeaderContent={(arg) => { const date = arg.date.getDate(); const weekDay = arg.date .toLocaleDateString(t("locale"), { weekday: "short" }) .toUpperCase(); return (
{weekDay} {arg.view.type !== "dayGridMonth" && ( {date} )}
); }} dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount} dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount} viewDidMount={viewHandlers.handleViewDidMount} viewWillUnmount={viewHandlers.handleViewWillUnmount} eventClick={eventHandlers.handleEventClick} eventAllow={eventHandlers.handleEventAllow} eventDrop={eventHandlers.handleEventDrop} eventResize={eventHandlers.handleEventResize} eventContent={viewHandlers.handleEventContent} eventDidMount={viewHandlers.handleEventDidMount} /> { setTypeOfAction(type); afterChoiceFunc && afterChoiceFunc(type); }} /> {openEventDisplay && eventDisplayedId && eventDisplayedCalId && ( )}
); }