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 { useEffect, 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, updateCalsDetails, } 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"; interface CalendarAppProps { calendarRef: React.RefObject; 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("/")); } }, [tokens, userId]); const calendars = useAppSelector((state) => state.calendars.list); const tempcalendars = useAppSelector((state) => state.calendars.templist) ?? {}; const pending = useAppSelector((state) => state.calendars.pending); const [selectedCalendars, setSelectedCalendars] = useState([]); 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 && Object.keys(calendars).length > 0 && userId) { const personalCalendarIds = Object.keys(calendars).filter( (id) => id.split("/")[0] === userId ); setSelectedCalendars(personalCalendarIds); initialLoadRef.current = false; } }, [calendars, userId]); useEffect(() => { updateDarkColor(calendars, theme, dispatch); }, [ theme, Object.values(calendars) .map((c) => c.color?.dark) .join(","), ]); useEffect(() => { const validCalendarIds = new Set(Object.keys(calendars)); setSelectedCalendars((prev) => prev.filter((calId) => validCalendarIds.has(calId)) ); }, [calendars]); const calendarRange = getCalendarRange(selectedDate); // Create a stable string key for the range const rangeKey = `${formatDateToYYYYMMDDTHHMMSS( calendarRange.start )}_${formatDateToYYYYMMDDTHHMMSS(calendarRange.end)}`; let filteredEvents: CalendarEvent[] = extractEvents( selectedCalendars, calendars ); let filteredTempEvents: CalendarEvent[] = extractEvents( Object.keys(tempcalendars), tempcalendars ); useEffect(() => { if (!rangeKey) return; selectedCalendars.forEach((id) => { if (fetchedRangesRef.current[id] === rangeKey) return; fetchedRangesRef.current[id] = rangeKey; dispatch( getCalendarDetailAsync({ calId: id, match: { start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), }, }) ); }); }, [ rangeKey, selectedCalendars, dispatch, calendarRange.start, calendarRange.end, ]); useEffect(() => { selectedCalendars.forEach((calId) => { const calendar = calendars[calId]; if (calendar?.lastCacheCleared) { delete fetchedRangesRef.current[calId]; dispatch( getCalendarDetailAsync({ calId, match: { start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start), end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end), }, }) ); fetchedRangesRef.current[calId] = rangeKey; } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ selectedCalendars.map((id) => calendars[id]?.lastCacheCleared).join(","), ]); const [prevTempCalendars, setPrevTempCalendars] = useState([]); const [prevRangeKey, setPrevRangeKey] = useState(""); useEffect(() => { updateCalsDetails( Object.keys(tempcalendars), prevTempCalendars, pending, rangeKey, prevRangeKey, dispatch, calendarRange, "temp" ); setPrevTempCalendars(Object.keys(tempcalendars)); setPrevRangeKey(rangeKey); }, [rangeKey, Object.keys(tempcalendars).join(","), pending]); 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; } 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} 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 (
{arg.text}
dispatch(setTimeZone(newTimezone)) } />
); }} dayCellContent={(arg) => { const month = arg.date.toLocaleDateString("en-US", { 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("en-US", { 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 && ( )}
); }