Bound continuous calendar event loading

This commit is contained in:
2026-07-30 05:22:09 +02:00
parent 56b387a784
commit b1cf001452
3 changed files with 83 additions and 6 deletions

View File

@@ -66,6 +66,7 @@ import {
agendaDateLabel, agendaDateLabel,
agendaGroupsForDays, agendaGroupsForDays,
calendarEventColorStyle, calendarEventColorStyle,
continuousEventWindow,
daysBetweenCount, daysBetweenCount,
daysForMode, daysForMode,
dayKey, dayKey,
@@ -92,6 +93,7 @@ import {
} from "./calendarViewModel"; } from "./calendarViewModel";
type EventDialogState = {kind: "create";} | {kind: "edit";event: CalendarEvent;}; type EventDialogState = {kind: "create";} | {kind: "edit";event: CalendarEvent;};
const INITIAL_CONTINUOUS_WEEKS = { before: 4, after: 6 };
const modeOptions: {id: CalendarMode;label: string;}[] = [ const modeOptions: {id: CalendarMode;label: string;}[] = [
{ id: "continuous", label: "i18n:govoplan-calendar.continuous.04f2ccda" }, { id: "continuous", label: "i18n:govoplan-calendar.continuous.04f2ccda" },
@@ -109,9 +111,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const eventsRef = useRef<CalendarEvent[]>([]); const eventsRef = useRef<CalendarEvent[]>([]);
const eventDeltaRef = useRef<{key: string;watermark: string | null;}>({ key: "", watermark: null }); const eventDeltaRef = useRef<{key: string;watermark: string | null;}>({ key: "", watermark: null });
const eventRequestRef = useRef(0);
const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode()); const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date())); 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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -137,6 +140,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const targetCalendarId = eventCalendarId || calendars[0]?.id || ""; const targetCalendarId = eventCalendarId || calendars[0]?.id || "";
const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]); const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
const days = useMemo(() => daysForMode(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 eventsByDay = useMemo(() => groupEventsByDay(visibleEvents), [visibleEvents]);
const agendaGroups = useMemo(() => agendaGroupsForDays(days, eventsByDay), [days, eventsByDay]); const agendaGroups = useMemo(() => agendaGroupsForDays(days, eventsByDay), [days, eventsByDay]);
const canWrite = hasScope(auth, "calendar:event:write"); const canWrite = hasScope(auth, "calendar:event:write");
@@ -158,9 +165,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
if (calendars.length) { if (calendars.length) {
void loadEvents(); void loadEvents();
} else { } else {
eventsRef.current = [];
setEvents([]); setEvents([]);
} }
}, [calendars.length, visibleRange.start.getTime(), visibleRange.end.getTime()]); }, [calendars.length, eventWindow.start.getTime(), eventWindow.end.getTime()]);
useEffect(() => { useEffect(() => {
if (mode !== "continuous") return undefined; if (mode !== "continuous") return undefined;
@@ -209,10 +217,11 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
} }
async function loadEvents() { async function loadEvents() {
const requestId = ++eventRequestRef.current;
setLoading(true); setLoading(true);
setError(""); setError("");
const start = visibleRange.start.toISOString(); const start = eventWindow.start.toISOString();
const end = visibleRange.end.toISOString(); const end = eventWindow.end.toISOString();
const deltaKey = `${start}|${end}`; const deltaKey = `${start}|${end}`;
try { try {
let since = eventDeltaRef.current.key === deltaKey ? eventDeltaRef.current.watermark : null; 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); nextEvents = mergeCalendarEventDelta(nextEvents, response);
since = response.watermark || null; since = response.watermark || null;
} while (response.has_more && response.watermark); } while (response.has_more && response.watermark);
if (requestId !== eventRequestRef.current) return;
eventsRef.current = nextEvents; eventsRef.current = nextEvents;
eventDeltaRef.current = { key: deltaKey, watermark: since }; eventDeltaRef.current = { key: deltaKey, watermark: since };
setEvents(nextEvents); setEvents(nextEvents);
} catch (err) { } catch (err) {
if (requestId !== eventRequestRef.current) return;
setError(errorText(err)); setError(errorText(err));
eventsRef.current = []; eventsRef.current = [];
eventDeltaRef.current = { key: "", watermark: null }; eventDeltaRef.current = { key: "", watermark: null };
setEvents([]); setEvents([]);
} finally { } 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") { if (calendars.length > 1 || payload.event_action === "move") {
await loadEvents(); await loadEvents();
} else { } else {
eventsRef.current = [];
setEvents([]); setEvents([]);
} }
} catch (err) { } catch (err) {
@@ -403,6 +415,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
return; return;
} }
pendingContinuousScrollDayRef.current = today; pendingContinuousScrollDayRef.current = today;
setContinuousWeeks(INITIAL_CONTINUOUS_WEEKS);
setFocusDate(today); setFocusDate(today);
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
if (pendingContinuousScrollDayRef.current) { if (pendingContinuousScrollDayRef.current) {
@@ -704,7 +717,13 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
size="equal" size="equal"
ariaLabel="i18n:govoplan-calendar.calendar_views.9e6b9c2b" ariaLabel="i18n:govoplan-calendar.calendar_views.9e6b9c2b"
value={mode} value={mode}
onChange={setMode} onChange={(nextMode) => {
if (nextMode === "continuous") {
setContinuousWeeks(INITIAL_CONTINUOUS_WEEKS);
setContinuousViewport({ scrollTop: 0, height: 0 });
}
setMode(nextMode);
}}
options={modeOptions} options={modeOptions}
/> />
</div> </div>

View File

@@ -123,6 +123,45 @@ export function daysForMode(
return daysBetween(range.start, range.end); 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( export function groupEventsByDay(
events: CalendarEvent[], events: CalendarEvent[],
): Map<string, CalendarEvent[]> { ): Map<string, CalendarEvent[]> {

View File

@@ -1,5 +1,7 @@
import { import {
continuousEventWindow,
continuousVirtualWindow, continuousVirtualWindow,
daysForMode,
groupEventsByDay, groupEventsByDay,
layoutTimedEventsForDay, layoutTimedEventsForDay,
moveEventToDay, moveEventToDay,
@@ -49,6 +51,23 @@ assert(
"virtualization should preserve offscreen geometry", "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 day = new Date(2026, 6, 7);
const parallel = [0, 1, 2, 3].map((index) => const parallel = [0, 1, 2, 3].map((index) =>
event( event(