530 lines
17 KiB
TypeScript
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; |