import { useAppDispatch, useAppSelector } from "@/app/hooks"; import { getCalendarDetailAsync } from "@/features/Calendars/services"; import EventPreviewModal from "@/features/Events/EventDisplayPreview"; import EventPopover from "@/features/Events/EventModal"; import { CalendarEvent } from "@/features/Events/EventsTypes"; import ImportAlert from "@/features/Events/ImportAlert"; import SearchResultsPage from "@/features/Search/SearchResultsPage"; import { setTimeZone } from "@/features/Settings/SettingsSlice"; import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager"; import { formatDateToYYYYMMDDTHHMMSS, getCalendarRange, } from "@/utils/dateUtils"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars"; import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars"; import { browserDefaultTimeZone } from "@/utils/timezone"; import type { EventApi, LocaleInput } from "@fullcalendar/core"; import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; import frLocale from "@fullcalendar/core/locales/fr"; import ruLocale from "@fullcalendar/core/locales/ru"; import viLocale from "@fullcalendar/core/locales/vi"; import dayGridPlugin from "@fullcalendar/daygrid"; import interactionPlugin from "@fullcalendar/interaction"; import momentTimezonePlugin from "@fullcalendar/moment-timezone"; import FullCalendar from "@fullcalendar/react"; import timeGridPlugin from "@fullcalendar/timegrid"; import { Box, Button, radius, useTheme } from "@linagora/twake-mui"; import AddIcon from "@mui/icons-material/Add"; import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; import { useI18n } from "twake-i18n"; import { User } from "../Attendees/PeopleSearch"; import { EventErrorSnackbar } from "../Error/ErrorSnackbar"; import { EventErrorHandler } from "../Error/EventErrorHandler"; import { EditModeDialog } from "../Event/EditModeDialog"; import "./Calendar.styl"; import CalendarSelection from "./CalendarSelection"; import "./CustomCalendar.styl"; import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers"; import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers"; import { MiniCalendar } from "./MiniCalendar"; import { TempCalendarsInput } from "./TempCalendarsInput"; import { TimezoneSelector } from "./TimezoneSelector"; import { updateDarkColor } from "./utils/calendarColorsUtils"; import { eventToFullCalendarFormat, extractEvents, updateSlotLabelVisibility, } from "./utils/calendarUtils"; const localeMap: Record = { fr: frLocale, ru: ruLocale, vi: viLocale, en: undefined, }; 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 userId = useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const dispatch = useAppDispatch(); const theme = useTheme(); const view = useAppSelector((state) => state.settings.view); const userData = useAppSelector((state) => state.user.userData); const hideDeclinedEvents = useAppSelector( (state) => state.settings.hideDeclinedEvents ); const calendars = useAppSelector((state) => state.calendars.list); const isPending = useAppSelector((state) => state.calendars.pending); const displayWeekNumbers = useAppSelector( (state) => state.settings.displayWeekNumbers ); const tempcalendars = useAppSelector((state) => state.calendars.templist); const storedCalendars = useSelectedCalendars(); const [selectedCalendars, setSelectedCalendars] = useState(storedCalendars); 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.settings.timeZone) ?? browserDefaultTimeZone; const fetchedRangesRef = useRef>({}); // Auto-select personal calendars when first loaded const initialLoadRef = useRef(true); const [eventErrors, setEventErrors] = useState([]); const errorHandler = useRef(new EventErrorHandler()); // useEffect(() => { // if (view === "search") { // document.body.classList.add("dialog-expanded"); // } else { // document.body.classList.remove("dialog-expanded"); // } // }, [view]); 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) => extractEventBaseUuid(id) === userId ); setSelectedCalendars(personalCalendarIds); } initialLoadRef.current = false; } }, [calendarIds, calendars, userId]); // Save selected cals to cache useEffect(() => { if (calendarIds.length > 0) { setSelectedCalendarsToStorage(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] ); const filteredEvents: CalendarEvent[] = extractEvents( selectedCalendars, calendars || {}, userData?.email, hideDeclinedEvents ); const tempCalendarIds = useMemo( () => Object.keys(tempcalendars || {}).sort(), [tempcalendars] ); const filteredTempEvents: CalendarEvent[] = extractEvents( tempCalendarIds, tempcalendars || {}, userData?.email, hideDeclinedEvents ); 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 (isPending) return; 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 { fetchedRangesRef.current[id] = ""; } }); await Promise.all(requests); } } finally { if (!cancelled) { activeLoadCompletedRef.current = true; setActiveLoadCompleted(true); } } }; loadCalendars(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, rangeKey, sortedSelectedCalendars, rangeStart, rangeEnd]); useEffect(() => { if (!rangeKey || !activeLoadCompleted || isPending) 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(() => { prefetchedCalendarsRef.current[id] = ""; }); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ calendarIdsString, selectedCalendars, rangeKey, dispatch, rangeStart, rangeEnd, activeLoadCompleted, isPending, ]); 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(() => { if (isPending) return; 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, isPending, ]); 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 { 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(""); // Listen for eventModalError event to reopen modal on API failure useEffect(() => { const handleEventModalError = (event: CustomEvent) => { if (event.detail?.type === "create") { // Reopen create event modal setAnchorEl(document.body); } else if (event.detail?.type === "update") { // Store update modal info to sessionStorage for EventDisplayPreview to pick up try { sessionStorage.setItem( "eventUpdateModalReopen", JSON.stringify({ eventId: event.detail.eventId, calId: event.detail.calId, typeOfAction: event.detail.typeOfAction, timestamp: Date.now(), }) ); // Open EventDisplayPreview if it's not already open with matching event, so it can pick up the sessionStorage if ( !openEventDisplay || eventDisplayedId !== event.detail.eventId || eventDisplayedCalId !== event.detail.calId ) { setEventDisplayedId(event.detail.eventId); setEventDisplayedCalId(event.detail.calId); setEventDisplayedTemp(false); setOpenEventDisplay(true); } else { // If EventDisplayPreview is already open, trigger reopen by dispatching a custom event window.dispatchEvent( new CustomEvent("eventUpdateModalReopen", { detail: { eventId: event.detail.eventId, calId: event.detail.calId, typeOfAction: event.detail.typeOfAction, }, }) ); } } catch { // Ignore sessionStorage errors } } }; window.addEventListener( "eventModalError", handleEventModalError as EventListener ); return () => { window.removeEventListener( "eventModalError", handleEventModalError as EventListener ); }; }, [openEventDisplay, eventDisplayedId, eventDisplayedCalId]); const [openEditModePopup, setOpenEditModePopup] = useState( null ); const [, setTypeOfAction] = useState<"solo" | "all" | undefined>(undefined); const [afterChoiceFunc, setAfterChoiceFunc] = useState< ((type: "solo" | "all" | undefined) => void) | undefined >(); const [, 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, 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.__calendarRef = calendarRef; } const { t, lang } = useI18n(); return (
{ eventHandlers.handleDateSelect(null as unknown as DateSelectArg); }} selectedCalendars={selectedCalendars} setSelectedCalendars={setSelectedCalendars} />
{view === "calendar" && ( { if (ref) { calendarRef.current = ref.getApi(); } }} plugins={[ dayGridPlugin, timeGridPlugin, interactionPlugin, momentTimezonePlugin, ]} initialView="timeGridWeek" firstDay={1} editable={true} locale={localeMap[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, userData?.email, isPending, calendars )} eventOrder={(a: EventApi, b: EventApi) => a.extendedProps.priority - b.extendedProps.priority } weekNumbers={ currentView === "timeGridWeek" || currentView === "timeGridDay" } weekNumberFormat={{ week: "long" }} weekNumberContent={(arg) => { return (
{displayWeekNumbers && (
{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); const calendarCurrentDate = calendarRef.current?.getDate() || new Date(arg.start); setDisplayedDateAndRange(calendarCurrentDate); 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(calendarCurrentDate); setSelectedMiniDate(calendarCurrentDate); } // 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} /> )} {view === "search" && } { setTypeOfAction(type); if (afterChoiceFunc) { afterChoiceFunc(type); } }} /> {openEventDisplay && eventDisplayedId && eventDisplayedCalId && ( )}
); }