Bound continuous calendar event loading
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user