import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { EventContentArg, EventClickArg, CalendarOptions } from '@fullcalendar/core'; import FullCalendar from '@fullcalendar/react'; import dayGridPlugin from '@fullcalendar/daygrid'; import timeGridPlugin from '@fullcalendar/timegrid'; import interactionPlugin from '@fullcalendar/interaction'; import multiMonthPlugin from '@fullcalendar/multimonth'; // Import the multiMonth plugin for year view import listPlugin from '@fullcalendar/list'; import { useAuth } from '../../contexts/AuthContext'; import { useNeoUser } from '../../contexts/NeoUserContext'; import { FaEllipsisV } from 'react-icons/fa'; import { logger } from '../../debugConfig'; import { TimetableNeoDBService } from '../../services/graph/timetableNeoDBService'; interface Event { id: string; title: string; start: string; end: string; groupId?: string; extendedProps?: { subjectClass: string; color: string; periodCode: string; tldraw_snapshot?: string; }; } function lightenColor(color: string, amount: number): string { // Remove the '#' if it exists color = color.replace(/^#/, ''); // Parse the color let r = parseInt(color.slice(0, 2), 16); let g = parseInt(color.slice(2, 4), 16); let b = parseInt(color.slice(4, 6), 16); // Convert to HSL const [h, s, l] = rgbToHsl(r, g, b); // Adjust the lightness based on the current lightness const newL = l < 0.5 ? l + (1 - l) * amount : l + (1 - l) * amount * 0.5; // Convert back to RGB [r, g, b] = hslToRgb(h, s, newL); // Convert to hex and return return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; } function rgbToHsl(r: number, g: number, b: number): [number, number, number] { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); let h = 0, s, l = (max + min) / 2; if (max === min) { h = s = 0; // achromatic } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return [h, s, l]; } function hslToRgb(h: number, s: number, l: number): [number, number, number] { let r, g, b; if (s === 0) { r = g = b = l; // achromatic } else { const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) { t += 1; } if (t > 1) { t -= 1; } if (t < 1/6) { return p + (q - p) * 6 * t; } if (t < 1/2) { return q; } if (t < 2/3) { return p + (q - p) * (2/3 - t) * 6; } return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1/3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1/3); } return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; } const CalendarPage: React.FC = () => { const [events, setEvents] = useState([]); const [selectedClasses, setSelectedClasses] = useState([]); const { user } = useAuth(); const calendarRef = useRef(null); const [openDropdownId, setOpenDropdownId] = useState(null); const [hiddenSubjectClassDivs, setHiddenSubjectClassDivs] = useState([]); const [hiddenPeriodCodeDivs, setHiddenPeriodCodeDivs] = useState([]); const [hiddenTimeDivs, setHiddenTimeDivs] = useState([]); const [eventRange, setEventRange] = useState<{ start: Date | null; end: Date | null }>({ start: null, end: null }); const { workerNode, isLoading, error, workerDbName } = useNeoUser(); const getEventRange = useCallback((events: Event[]) => { if (events.length === 0) { return { start: null, end: null }; } let start = new Date(events[0].start); let end = new Date(events[0].end); events.forEach(event => { const eventStart = new Date(event.start); const eventEnd = new Date(event.end); if (eventStart < start) { start = eventStart; } if (eventEnd > end) { end = eventEnd; } }); // Adjust start to the beginning of its month and end to the end of its month start.setDate(1); end.setMonth(end.getMonth() + 1, 0); return { start, end }; }, []); const fetchEvents = useCallback(async () => { if (!user || isLoading || error || !workerNode?.nodeData) { if (error) { logger.error('calendar', 'NeoUser context error', { error }); } return; } try { logger.debug('calendar', 'Fetching events', { unique_id: workerNode.nodeData.unique_id, school_db_name: workerDbName }); const events = await TimetableNeoDBService.fetchTeacherTimetableEvents( workerNode.nodeData.unique_id, workerDbName || '' ); const transformedEvents = events.map(event => ({ ...event, extendedProps: { ...event.extendedProps, tldraw_snapshot: workerNode?.nodeData?.tldraw_snapshot } })); setEvents(transformedEvents); const classes: string[] = []; transformedEvents.forEach((event: Event) => { if (event.extendedProps?.subjectClass && !classes.includes(event.extendedProps.subjectClass)) { classes.push(event.extendedProps.subjectClass); } }); setSelectedClasses(classes); const range = getEventRange(transformedEvents); setEventRange(range); } catch (error) { logger.error('calendar', 'Error fetching events', { error }); } }, [user, workerNode, workerDbName, isLoading, error, getEventRange]); useEffect(() => { fetchEvents(); }, [fetchEvents]); const handleEventClick = useCallback((clickInfo: EventClickArg) => { const tldraw_snapshot = clickInfo.event.extendedProps?.tldraw_snapshot; if (tldraw_snapshot) { // TODO: Implement tldraw_snapshot retrieval from storage API // For now, we'll just log it console.log('TLDraw snapshot:', tldraw_snapshot); } }, []); const filteredEvents = useMemo(() => events.filter(event => selectedClasses.includes(event.extendedProps?.subjectClass || '') ), [events, selectedClasses] ); const handleResize = useCallback(() => { if (calendarRef.current) { calendarRef.current.getApi().updateSize(); } }, []); useEffect(() => { window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, [handleResize]); const toggleDropdown = useCallback((eventId: string) => { setOpenDropdownId(openDropdownId === eventId ? null : eventId); }, [openDropdownId]); const toggleSubjectClassDivVisibility = useCallback((subjectClass: string) => { setHiddenSubjectClassDivs(prev => prev.includes(subjectClass) ? prev.filter(c => c !== subjectClass) : [...prev, subjectClass] ); }, []); const togglePeriodCodeDivVisibility = useCallback((subjectClass: string) => { setHiddenPeriodCodeDivs(prev => prev.includes(subjectClass) ? prev.filter(c => c !== subjectClass) : [...prev, subjectClass] ); }, []); const toggleTimeDivVisibility = useCallback((subjectClass: string) => { setHiddenTimeDivs(prev => prev.includes(subjectClass) ? prev.filter(c => c !== subjectClass) : [...prev, subjectClass] ); }, []); const hideSubjectClassFromView = useCallback((subjectClass: string) => { setSelectedClasses(prev => prev.filter(c => c !== subjectClass)); }, []); const toggleAllDivs = useCallback((subjectClass: string, hide: boolean) => { const updateHiddenDivs = (prev: string[]) => hide ? [...prev, subjectClass] : prev.filter(c => c !== subjectClass); setHiddenSubjectClassDivs(updateHiddenDivs); setHiddenPeriodCodeDivs(updateHiddenDivs); setHiddenTimeDivs(updateHiddenDivs); }, []); const areAllDivsHidden = useCallback((subjectClass: string) => { return hiddenSubjectClassDivs.includes(subjectClass) && hiddenPeriodCodeDivs.includes(subjectClass) && hiddenTimeDivs.includes(subjectClass); }, [hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs]); const renderEventContent = useCallback((eventInfo: EventContentArg) => { const { event } = eventInfo; const subjectClass = event.extendedProps?.subjectClass || 'Subject Class'; const originalColor = event.extendedProps?.color || '#ffffff'; const lightenedColor = lightenColor(originalColor, 0.9); const eventStyle = { backgroundColor: lightenedColor, color: '#000', padding: '4px 6px', borderRadius: '6px', fontSize: '1.0em', overflow: 'visible', display: 'flex', flexDirection: 'column' as const, height: '100%', boxShadow: '0 2px 4px rgba(0,0,0,0.1)', border: `2px solid ${originalColor}`, position: 'relative' as const, }; const titleStyle = { fontWeight: 'bold' as const, whiteSpace: 'nowrap' as const, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px', }; const contentStyle = { fontSize: '0.8em', whiteSpace: 'nowrap' as const, overflow: 'hidden', textOverflow: 'ellipsis' }; const ellipsisStyle = { position: 'absolute' as const, top: '4px', right: '4px', cursor: 'pointer', zIndex: 10, }; return (
{event.title}
{ e.stopPropagation(); toggleDropdown(event.id); }} />
{openDropdownId === event.id && (
{ e.stopPropagation(); hideSubjectClassFromView(subjectClass); setOpenDropdownId(null); }}> Hide this class from view
{ e.stopPropagation(); toggleAllDivs(subjectClass, !areAllDivsHidden(subjectClass)); setOpenDropdownId(null); }}> {areAllDivsHidden(subjectClass) ? 'Show' : 'Hide'} all divs
{ e.stopPropagation(); toggleSubjectClassDivVisibility(subjectClass); setOpenDropdownId(null); }}> {hiddenSubjectClassDivs.includes(subjectClass) ? 'Show' : 'Hide'} subject class
{ e.stopPropagation(); togglePeriodCodeDivVisibility(subjectClass); setOpenDropdownId(null); }}> {hiddenPeriodCodeDivs.includes(subjectClass) ? 'Show' : 'Hide'} period code
{ e.stopPropagation(); toggleTimeDivVisibility(subjectClass); setOpenDropdownId(null); }}> {hiddenTimeDivs.includes(subjectClass) ? 'Show' : 'Hide'} time
)} {!hiddenSubjectClassDivs.includes(subjectClass) && (
{subjectClass}
)} {!hiddenPeriodCodeDivs.includes(subjectClass) && (
{event.extendedProps?.periodCode || 'Period Code'}
)} {!hiddenTimeDivs.includes(subjectClass) && (
{event.start?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} - {event.end?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
)}
); }, [openDropdownId, hiddenSubjectClassDivs, hiddenPeriodCodeDivs, hiddenTimeDivs, toggleDropdown, hideSubjectClassFromView, toggleAllDivs, areAllDivsHidden, toggleSubjectClassDivVisibility, togglePeriodCodeDivVisibility, toggleTimeDivVisibility]); const calendarOptions: CalendarOptions = useMemo(() => ({ plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, multiMonthPlugin, listPlugin], initialView: "timeGridWeek", headerToolbar: { left: 'prev,next today', center: 'title', right: 'viewToggle filterClassesButton' }, customButtons: { filterClassesButton: { text: 'Filter Classes', click: () => {} // We'll implement this differently later }, viewToggle: { text: 'Change View', click: () => {} // We'll implement this differently later } }, views: { dayGridYear: { type: 'dayGrid', duration: { years: 1 }, buttonText: 'Year Grid', visibleRange: (currentDate: Date) => ({ start: eventRange.start || currentDate, end: eventRange.end || currentDate }), }, dayGridMonth: { buttonText: 'Month', visibleRange: (currentDate: Date) => ({ start: eventRange.start || currentDate, end: eventRange.end || currentDate }), }, timeGridWeek: { buttonText: 'Week', visibleRange: (currentDate: Date) => ({ start: eventRange.start || currentDate, end: eventRange.end || currentDate }), }, timeGridDay: { buttonText: 'Day', visibleRange: (currentDate: Date) => ({ start: eventRange.start || currentDate, end: eventRange.end || currentDate }), }, listYear: { buttonText: 'List Year', visibleRange: (currentDate: Date) => ({ start: eventRange.start || currentDate, end: eventRange.end || currentDate }), }, listMonth: { buttonText: 'List Month', visibleRange: (currentDate: Date) => ({ start: eventRange.start || currentDate, end: eventRange.end || currentDate }), }, listWeek: { buttonText: 'List Week', visibleRange: (currentDate: Date) => ({ start: eventRange.start || currentDate, end: eventRange.end || currentDate }), }, listDay: { buttonText: 'List Day', visibleRange: (currentDate: Date) => ({ start: eventRange.start || currentDate, end: eventRange.end || currentDate }), }, }, validRange: eventRange.start && eventRange.end ? { start: eventRange.start, end: eventRange.end } : undefined, events: filteredEvents, height: "100%", slotMinTime: "08:00:00", slotMaxTime: "17:00:00", allDaySlot: false, expandRows: true, slotEventOverlap: false, slotDuration: "00:30:00", slotLabelInterval: "01:00", eventContent: renderEventContent, eventClassNames: (arg: { event: { extendedProps?: { subjectClass?: string } } }) => [arg.event.extendedProps?.subjectClass || ''], eventDidMount: (arg: { event: { extendedProps?: { color?: string }; id: string }; el: HTMLElement }) => { if (arg.event.extendedProps?.color) { const originalColor = arg.event.extendedProps.color; const lightenedColor = lightenColor(originalColor, 0.4); arg.el.style.backgroundColor = lightenedColor; arg.el.style.borderColor = originalColor; } const updateEventContent = () => { const height = arg.el.offsetHeight; const contentElements = arg.el.querySelectorAll('.custom-event-content > div:not(.event-dropdown)'); contentElements.forEach((el, index) => { const element = el as HTMLElement; if (index === 0 || index === 1) { element.style.display = 'block'; } else if (height >= 40 && index === 2) { element.style.display = 'block'; } else if (height >= 60 && index === 3) { element.style.display = 'block'; } else if (height >= 80 && index === 4) { element.style.display = 'block'; } else { element.style.display = 'none'; } }); }; updateEventContent(); const resizeObserver = new ResizeObserver(updateEventContent); resizeObserver.observe(arg.el); return () => resizeObserver.disconnect(); }, eventClick: handleEventClick, }), [eventRange.start, eventRange.end, filteredEvents, renderEventContent, handleEventClick]); if (!user) { console.log('User not logged in'); return
Please log in to view your calendar.
; } return (
); }; export default CalendarPage;