app/src/pages/user/calendarPage.tsx
2025-07-11 13:21:49 +00:00

530 lines
17 KiB
TypeScript

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<Event[]>([]);
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
const { user } = useAuth();
const calendarRef = useRef<FullCalendar>(null);
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
const [hiddenSubjectClassDivs, setHiddenSubjectClassDivs] = useState<string[]>([]);
const [hiddenPeriodCodeDivs, setHiddenPeriodCodeDivs] = useState<string[]>([]);
const [hiddenTimeDivs, setHiddenTimeDivs] = useState<string[]>([]);
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 (
<div className={`custom-event-content ${openDropdownId === event.id ? 'event-with-dropdown' : ''}`} style={eventStyle}>
<div style={titleStyle}>{event.title}</div>
<div style={ellipsisStyle}>
<FaEllipsisV onClick={(e) => {
e.stopPropagation();
toggleDropdown(event.id);
}} />
</div>
{openDropdownId === event.id && (
<div className="event-dropdown" style={{ position: 'absolute'}}>
<div onClick={(e) => {
e.stopPropagation();
hideSubjectClassFromView(subjectClass);
setOpenDropdownId(null);
}}>
Hide this class from view
</div>
<div onClick={(e) => {
e.stopPropagation();
toggleAllDivs(subjectClass, !areAllDivsHidden(subjectClass));
setOpenDropdownId(null);
}}>
{areAllDivsHidden(subjectClass) ? 'Show' : 'Hide'} all divs
</div>
<div onClick={(e) => {
e.stopPropagation();
toggleSubjectClassDivVisibility(subjectClass);
setOpenDropdownId(null);
}}>
{hiddenSubjectClassDivs.includes(subjectClass) ? 'Show' : 'Hide'} subject class
</div>
<div onClick={(e) => {
e.stopPropagation();
togglePeriodCodeDivVisibility(subjectClass);
setOpenDropdownId(null);
}}>
{hiddenPeriodCodeDivs.includes(subjectClass) ? 'Show' : 'Hide'} period code
</div>
<div onClick={(e) => {
e.stopPropagation();
toggleTimeDivVisibility(subjectClass);
setOpenDropdownId(null);
}}>
{hiddenTimeDivs.includes(subjectClass) ? 'Show' : 'Hide'} time
</div>
</div>
)}
{!hiddenSubjectClassDivs.includes(subjectClass) && (
<div style={contentStyle} className="event-subject-class">{subjectClass}</div>
)}
{!hiddenPeriodCodeDivs.includes(subjectClass) && (
<div style={contentStyle} className="event-period">{event.extendedProps?.periodCode || 'Period Code'}</div>
)}
{!hiddenTimeDivs.includes(subjectClass) && (
<div style={contentStyle} className="event-time">
{event.start?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} -
{event.end?.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
)}
</div>
);
}, [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 <div>Please log in to view your calendar.</div>;
}
return (
<div className="calendar-page">
<div className="calendar-container" style={{ height: '100vh', position: 'relative' }}>
<FullCalendar
{...calendarOptions}
ref={calendarRef}
/>
</div>
</div>
);
};
export default CalendarPage;