(front) improve Scheduler UX and performance

- Enable sticky header by setting height: 100% on .ec
- Scroll to current time on calendar initialization
- Parallelize CalDAV event fetching with Promise.all
- Use event-handler-refs pattern for stable callbacks
- Import shared CalendarApi interface from types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-28 15:40:04 +01:00
parent 1c8fd116df
commit ffda528a00
2 changed files with 56 additions and 48 deletions

View File

@@ -18,18 +18,10 @@ import type { EventCalendarAdapter, CalDavExtendedProps } from "../../../service
import type { CalDavService } from "../../../services/dav/CalDavService"; import type { CalDavService } from "../../../services/dav/CalDavService";
import type { CalDavCalendar } from "../../../services/dav/types/caldav-service"; import type { CalDavCalendar } from "../../../services/dav/types/caldav-service";
import type { EventCalendarEvent, EventCalendarFetchInfo } from "../../../services/dav/types/event-calendar"; import type { EventCalendarEvent, EventCalendarFetchInfo } from "../../../services/dav/types/event-calendar";
import type { CalendarApi } from "../types";
type ECEvent = EventCalendarEvent; type ECEvent = EventCalendarEvent;
// Calendar API interface
interface CalendarApi {
updateEvent: (event: ECEvent) => void;
addEvent: (event: ECEvent) => void;
unselect: () => void;
refetchEvents: () => void;
$destroy?: () => void;
}
interface UseSchedulerInitProps { interface UseSchedulerInitProps {
containerRef: MutableRefObject<HTMLDivElement | null>; containerRef: MutableRefObject<HTMLDivElement | null>;
calendarRef: MutableRefObject<CalendarApi | null>; calendarRef: MutableRefObject<CalendarApi | null>;
@@ -47,6 +39,12 @@ interface UseSchedulerInitProps {
handleSelect: (info: unknown) => void; handleSelect: (info: unknown) => void;
} }
// Helper to get current time as HH:MM string
const getCurrentTimeString = (): string => {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
};
export const useSchedulerInit = ({ export const useSchedulerInit = ({
containerRef, containerRef,
calendarRef, calendarRef,
@@ -66,6 +64,32 @@ export const useSchedulerInit = ({
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { calendarLocale, firstDayOfWeek, formatDayHeader } = useCalendarLocale(); const { calendarLocale, firstDayOfWeek, formatDayHeader } = useCalendarLocale();
// Capture initial scroll time only once on first render
const initialScrollTimeRef = useRef<string>(getCurrentTimeString());
// Store event handlers in refs for stable references (advanced-event-handler-refs pattern)
// This prevents calendar recreation when handlers change (e.g., when modalState changes)
const handlersRef = useRef({
handleEventClick,
handleEventDrop,
handleEventResize,
handleDateClick,
handleSelect,
setCurrentDate,
});
// Update refs when handlers change (no effect dependencies = no calendar recreation)
useEffect(() => {
handlersRef.current = {
handleEventClick,
handleEventDrop,
handleEventResize,
handleDateClick,
handleSelect,
setCurrentDate,
};
});
useEffect(() => { useEffect(() => {
if (!containerRef.current || calendarRef.current || !isConnected) return; if (!containerRef.current || calendarRef.current || !isConnected) return;
@@ -82,7 +106,7 @@ export const useSchedulerInit = ({
locale: calendarLocale, locale: calendarLocale,
firstDay: firstDayOfWeek, firstDay: firstDayOfWeek,
slotDuration: "00:30", slotDuration: "00:30",
scrollTime: "08:00", scrollTime: initialScrollTimeRef.current,
displayEventEnd: true, displayEventEnd: true,
// Interactive features // Interactive features
@@ -96,16 +120,16 @@ export const useSchedulerInit = ({
selectBackgroundColor: '#ffcdd2', // Light red color for selection selectBackgroundColor: '#ffcdd2', // Light red color for selection
// Event handlers - ALL INTERACTIONS // Event handlers - ALL INTERACTIONS
// Cast handlers to bypass library type differences (DomEvent vs MouseEvent) // Use ref wrappers for stable references (prevents calendar recreation on handler changes)
eventClick: handleEventClick as (info: unknown) => void, eventClick: (info: unknown) => handlersRef.current.handleEventClick(info),
eventDrop: handleEventDrop as (info: unknown) => void, eventDrop: (info: unknown) => handlersRef.current.handleEventDrop(info),
eventResize: handleEventResize as (info: unknown) => void, eventResize: (info: unknown) => handlersRef.current.handleEventResize(info),
dateClick: handleDateClick as (info: unknown) => void, dateClick: (info: unknown) => handlersRef.current.handleDateClick(info),
select: handleSelect as (info: unknown) => void, select: (info: unknown) => handlersRef.current.handleSelect(info),
// Sync current date with MiniCalendar when navigating // Sync current date with MiniCalendar when navigating
datesSet: (info: { start: Date; end: Date }) => { datesSet: (info: { start: Date; end: Date }) => {
setCurrentDate(info); handlersRef.current.setCurrentDate(info);
}, },
// Event display // Event display
@@ -133,29 +157,16 @@ export const useSchedulerInit = ({
try { try {
// Fetch events from ALL calendars in parallel // Fetch events from ALL calendars in parallel
const allEventsPromises = calendars.map(async (calendar) => { const allEventsPromises = calendars.map(async (calendar) => {
// Fetch source events (with recurrence rules) without expansion const timeRange = {
const sourceEventsResult = await caldavService.fetchEvents(
calendar.url,
{
timeRange: {
start: fetchInfo.start, start: fetchInfo.start,
end: fetchInfo.end, end: fetchInfo.end,
}, };
expand: false,
}
);
// Fetch expanded instances // Fetch source events and expanded instances in parallel
const expandedEventsResult = await caldavService.fetchEvents( const [sourceEventsResult, expandedEventsResult] = await Promise.all([
calendar.url, caldavService.fetchEvents(calendar.url, { timeRange, expand: false }),
{ caldavService.fetchEvents(calendar.url, { timeRange, expand: true }),
timeRange: { ]);
start: fetchInfo.start,
end: fetchInfo.end,
},
expand: true,
}
);
if (!expandedEventsResult.success || !expandedEventsResult.data) { if (!expandedEventsResult.success || !expandedEventsResult.data) {
console.error( console.error(
@@ -241,23 +252,17 @@ export const useSchedulerInit = ({
containerRef.current.innerHTML = ''; containerRef.current.innerHTML = '';
} }
}; };
// Note: refs (containerRef, calendarRef, visibleCalendarUrlsRef, davCalendarsRef) are excluded // Note: refs (containerRef, calendarRef, visibleCalendarUrlsRef, davCalendarsRef, initialScrollTimeRef, handlersRef)
// from dependencies as they are stable references that don't trigger re-renders // are excluded from dependencies as they are stable references that don't trigger re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps // Event handlers are accessed via handlersRef to prevent calendar recreation on handler changes.
}, [ }, [
isConnected, isConnected,
calendarUrl, calendarUrl,
calendarLocale, calendarLocale,
firstDayOfWeek, firstDayOfWeek,
formatDayHeader, formatDayHeader,
handleEventClick,
handleEventDrop,
handleEventResize,
handleDateClick,
handleSelect,
caldavService, caldavService,
adapter, adapter,
setCurrentDate,
t, t,
i18n.language, i18n.language,
]); ]);

View File

@@ -7,6 +7,9 @@
// 1. CSS Variables - Map --ec-* to Cunningham tokens // 1. CSS Variables - Map --ec-* to Cunningham tokens
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.ec { .ec {
// Fill parent container to enable proper scrolling and sticky header
height: 100%;
// Base colors // Base colors
--ec-bg-color: var(--c--globals--colors--gray-000); --ec-bg-color: var(--c--globals--colors--gray-000);
--ec-day-bg-color: var(--c--contextuals--background--surface--secondary); --ec-day-bg-color: var(--c--contextuals--background--surface--secondary);