diff --git a/webui/src/features/calendar/CalendarPage.tsx b/webui/src/features/calendar/CalendarPage.tsx index b35ca26..52913a9 100644 --- a/webui/src/features/calendar/CalendarPage.tsx +++ b/webui/src/features/calendar/CalendarPage.tsx @@ -66,6 +66,7 @@ import { agendaDateLabel, agendaGroupsForDays, calendarEventColorStyle, + continuousEventWindow, daysBetweenCount, daysForMode, dayKey, @@ -92,6 +93,7 @@ import { } from "./calendarViewModel"; type EventDialogState = {kind: "create";} | {kind: "edit";event: CalendarEvent;}; +const INITIAL_CONTINUOUS_WEEKS = { before: 4, after: 6 }; const modeOptions: {id: CalendarMode;label: string;}[] = [ { id: "continuous", label: "i18n:govoplan-calendar.continuous.04f2ccda" }, @@ -109,9 +111,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; const [events, setEvents] = useState([]); const eventsRef = useRef([]); const eventDeltaRef = useRef<{key: string;watermark: string | null;}>({ key: "", watermark: null }); + const eventRequestRef = useRef(0); const [mode, setMode] = useState(() => loadCalendarMode()); const [focusDate, setFocusDate] = useState(() => startOfDay(new Date())); - const [continuousWeeks, setContinuousWeeks] = useState({ before: 8, after: 12 }); + const [continuousWeeks, setContinuousWeeks] = useState(INITIAL_CONTINUOUS_WEEKS); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); @@ -137,6 +140,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; const targetCalendarId = eventCalendarId || calendars[0]?.id || ""; const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]); const days = useMemo(() => daysForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]); + const eventWindow = useMemo( + () => mode === "continuous" ? continuousEventWindow(days, continuousViewport) : visibleRange, + [continuousViewport.height, continuousViewport.scrollTop, days, mode, visibleRange] + ); const eventsByDay = useMemo(() => groupEventsByDay(visibleEvents), [visibleEvents]); const agendaGroups = useMemo(() => agendaGroupsForDays(days, eventsByDay), [days, eventsByDay]); const canWrite = hasScope(auth, "calendar:event:write"); @@ -158,9 +165,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; if (calendars.length) { void loadEvents(); } else { + eventsRef.current = []; setEvents([]); } - }, [calendars.length, visibleRange.start.getTime(), visibleRange.end.getTime()]); + }, [calendars.length, eventWindow.start.getTime(), eventWindow.end.getTime()]); useEffect(() => { if (mode !== "continuous") return undefined; @@ -209,10 +217,11 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; } async function loadEvents() { + const requestId = ++eventRequestRef.current; setLoading(true); setError(""); - const start = visibleRange.start.toISOString(); - const end = visibleRange.end.toISOString(); + const start = eventWindow.start.toISOString(); + const end = eventWindow.end.toISOString(); const deltaKey = `${start}|${end}`; try { let since = eventDeltaRef.current.key === deltaKey ? eventDeltaRef.current.watermark : null; @@ -227,16 +236,18 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; nextEvents = mergeCalendarEventDelta(nextEvents, response); since = response.watermark || null; } while (response.has_more && response.watermark); + if (requestId !== eventRequestRef.current) return; eventsRef.current = nextEvents; eventDeltaRef.current = { key: deltaKey, watermark: since }; setEvents(nextEvents); } catch (err) { + if (requestId !== eventRequestRef.current) return; setError(errorText(err)); eventsRef.current = []; eventDeltaRef.current = { key: "", watermark: null }; setEvents([]); } finally { - setLoading(false); + if (requestId === eventRequestRef.current) setLoading(false); } } @@ -341,6 +352,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; if (calendars.length > 1 || payload.event_action === "move") { await loadEvents(); } else { + eventsRef.current = []; setEvents([]); } } catch (err) { @@ -403,6 +415,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; return; } pendingContinuousScrollDayRef.current = today; + setContinuousWeeks(INITIAL_CONTINUOUS_WEEKS); setFocusDate(today); window.requestAnimationFrame(() => { if (pendingContinuousScrollDayRef.current) { @@ -704,7 +717,13 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings; size="equal" ariaLabel="i18n:govoplan-calendar.calendar_views.9e6b9c2b" value={mode} - onChange={setMode} + onChange={(nextMode) => { + if (nextMode === "continuous") { + setContinuousWeeks(INITIAL_CONTINUOUS_WEEKS); + setContinuousViewport({ scrollTop: 0, height: 0 }); + } + setMode(nextMode); + }} options={modeOptions} /> diff --git a/webui/src/features/calendar/calendarViewModel.ts b/webui/src/features/calendar/calendarViewModel.ts index 27e1185..3af71c8 100644 --- a/webui/src/features/calendar/calendarViewModel.ts +++ b/webui/src/features/calendar/calendarViewModel.ts @@ -123,6 +123,45 @@ export function daysForMode( return daysBetween(range.start, range.end); } +export function continuousEventWindow( + days: Date[], + viewport: ContinuousViewport, + options: {overscanWeeks?: number;minimumWeeks?: number;stepWeeks?: number;} = {}, +): Range { + if (days.length === 0) { + const now = startOfDay(new Date()); + return { start: now, end: addDays(now, 7) }; + } + const totalWeeks = Math.max(1, Math.ceil(days.length / 7)); + const overscanWeeks = Math.max(0, Math.floor(options.overscanWeeks ?? 2)); + const minimumWeeks = Math.max(1, Math.floor(options.minimumWeeks ?? 10)); + const stepWeeks = Math.max(1, Math.floor(options.stepWeeks ?? 4)); + const firstVisibleWeek = Math.max( + 0, + Math.min(totalWeeks - 1, Math.floor(viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT)), + ); + const visibleWeeks = Math.max( + 1, + Math.ceil(Math.max(viewport.height, CONTINUOUS_WEEK_ROW_HEIGHT) / CONTINUOUS_WEEK_ROW_HEIGHT), + ); + const desiredWeeks = Math.min( + totalWeeks, + Math.max(minimumWeeks, visibleWeeks + overscanWeeks * 2), + ); + let startWeek = Math.max( + 0, + Math.floor(Math.max(0, firstVisibleWeek - overscanWeeks) / stepWeeks) * stepWeeks, + ); + if (startWeek + desiredWeeks > totalWeeks) { + startWeek = Math.max(0, totalWeeks - desiredWeeks); + } + const endWeek = Math.min(totalWeeks, startWeek + desiredWeeks); + return { + start: days[startWeek * 7], + end: addDays(days[Math.min(days.length - 1, endWeek * 7 - 1)], 1), + }; +} + export function groupEventsByDay( events: CalendarEvent[], ): Map { diff --git a/webui/tests/calendar-view-model.test.ts b/webui/tests/calendar-view-model.test.ts index 28a068e..1ad183b 100644 --- a/webui/tests/calendar-view-model.test.ts +++ b/webui/tests/calendar-view-model.test.ts @@ -1,5 +1,7 @@ import { + continuousEventWindow, continuousVirtualWindow, + daysForMode, groupEventsByDay, layoutTimedEventsForDay, moveEventToDay, @@ -49,6 +51,23 @@ assert( "virtualization should preserve offscreen geometry", ); +const continuousDays = daysForMode("continuous", focus, { + before: 50, + after: 50, +}); +const eventWindow = continuousEventWindow( + continuousDays, + { scrollTop: 40 * 92, height: 6 * 92 }, +); +assert( + (eventWindow.end.getTime() - eventWindow.start.getTime()) / 86_400_000 === 10 * 7, + "continuous event loading should stay bounded to a ten-week window", +); +assert( + eventWindow.start > continuousDays[0], + "continuous event loading should follow the virtualized viewport", +); + const day = new Date(2026, 6, 7); const parallel = [0, 1, 2, 3].map((index) => event(