From 57f969389234dda87cb723309c1e3d22abafd9db Mon Sep 17 00:00:00 2001
From: Camille Moussu <66134347+Eriikah@users.noreply.github.com>
Date: Sat, 14 Mar 2026 06:38:38 +0100
Subject: [PATCH] [#595] Load less data on call to get calendar details (#608)
---
__test__/components/Calendar.test.tsx | 92 +++--
.../components/EventModifications.test.tsx | 18 +-
.../components/MiniCalendarColor.test.tsx | 6 +-
src/components/Calendar/Calendar.tsx | 303 ++-------------
src/components/Calendar/CalendarLayout.tsx | 4 +-
src/components/Calendar/MiniCalendar.tsx | 22 +-
src/features/Calendars/useCalendarLoader.ts | 348 ++++++++++++++++++
src/utils/dateUtils.ts | 35 +-
8 files changed, 495 insertions(+), 333 deletions(-)
create mode 100644 src/features/Calendars/useCalendarLoader.ts
diff --git a/__test__/components/Calendar.test.tsx b/__test__/components/Calendar.test.tsx
index eed8155..9a6019d 100644
--- a/__test__/components/Calendar.test.tsx
+++ b/__test__/components/Calendar.test.tsx
@@ -23,6 +23,9 @@ describe("CalendarSelection", () => {
beforeEach(() => {
localStorage.clear();
});
+ afterEach(() => {
+ jest.useRealTimers();
+ });
const today = new Date();
const start = new Date(today);
start.setHours(10, 0, 0, 0);
@@ -228,6 +231,9 @@ describe("calendar Availability search", () => {
beforeEach(() => {
localStorage.clear();
});
+ afterEach(() => {
+ jest.useRealTimers();
+ });
const preloadedState = {
user: {
userData: {
@@ -440,50 +446,80 @@ describe("calendar Availability search", () => {
expect(dayNumbersWithContent.length).toBe(0);
});
- it("should fetch calendar details with date range matching the displayed month", async () => {
+ it("should fetch calendar details for February after navigating to month view and clicking next", async () => {
const spy = jest
.spyOn(calendarDetailThunks, "getCalendarDetailAsync")
.mockImplementation((payload) => {
- return () => Promise.resolve(payload) as any;
+ const promise = Promise.resolve(payload);
+ const thunk = () => Object.assign(promise, { unwrap: () => promise });
+ return thunk as any;
});
jest.useFakeTimers().setSystemTime(new Date("2025-01-01"));
+
await act(async () =>
- renderWithProviders(, preloadedState)
+ renderWithProviders(, {
+ ...preloadedState,
+ calendars: { ...preloadedState.calendars, pending: false },
+ })
);
+ // Advance past debounce so the initial load fires
+ await act(async () => {
+ jest.advanceTimersByTime(300);
+ });
+
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
+ spy.mockClear();
+
const calendarRef = window.__calendarRef;
const calendarApi = calendarRef.current;
- const view = calendarApi?.view;
+
await act(async () => {
calendarApi.changeView("dayGridMonth");
fireEvent.click(screen.getByTestId("ChevronRightIcon"));
});
- expect(spy).toHaveBeenCalledTimes(2);
- const callArgs = spy.mock.calls[1][0];
+
+ // Advance past debounce so the navigation fetch fires
+ await act(async () => {
+ jest.advanceTimersByTime(300);
+ });
+
+ await waitFor(() => {
+ expect(spy).toHaveBeenCalled();
+ });
+
+ // Find the call that covers February 2025 (the navigated-to month)
+ const februaryCall = spy.mock.calls.find((call) => {
+ const start = call[0].match.start as string;
+ // start should be in January or February 2025 (month view includes padding days)
+ return start.startsWith("2025");
+ });
+
+ expect(februaryCall).toBeDefined();
+ const callArgs = februaryCall![0];
expect(callArgs.calId).toBe("user1/cal1");
- const startDate = new Date(
- callArgs.match.start.replace(
- /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/,
- "$1-$2-$3T$4:$5:$6"
- )
- );
- const endDate = new Date(
- callArgs.match.end.replace(
- /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/,
- "$1-$2-$3T$4:$5:$6"
- )
- );
- // Verify the date range matches the displayed view
- const viewStart = new Date(view.currentStart);
- const viewEnd = new Date(view.currentEnd);
+ const parseDate = (s: string) =>
+ new Date(
+ s.replace(
+ /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/,
+ "$1-$2-$3T$4:$5:$6"
+ )
+ );
- expect(startDate.getTime()).toBeLessThanOrEqual(viewStart.getTime());
- expect(endDate.getTime()).toBeGreaterThanOrEqual(viewEnd.getTime());
+ const startDate = parseDate(callArgs.match.start);
+ const endDate = parseDate(callArgs.match.end);
+
+ // February 2025 month view: range must cover Feb 1 through Mar 1
+ expect(startDate.getTime()).toBeLessThanOrEqual(
+ new Date("2025-02-01").getTime()
+ );
+ expect(endDate.getTime()).toBeGreaterThanOrEqual(
+ new Date("2025-03-01").getTime()
+ );
});
describe("Batch loading and prefetching", () => {
@@ -506,6 +542,7 @@ describe("calendar Availability search", () => {
calendars: {
...preloadedState.calendars,
list: manyCalendars,
+ pending: false,
},
};
@@ -563,6 +600,7 @@ describe("calendar Availability search", () => {
events: {},
},
},
+ pending: false,
},
};
@@ -613,10 +651,10 @@ describe("calendar Availability search", () => {
);
await act(async () => {
- renderWithProviders(
- ,
- preloadedState
- );
+ renderWithProviders(, {
+ ...preloadedState,
+ calendars: { ...preloadedState.calendars, pending: false },
+ });
});
await waitFor(
diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx
index 0cb4b96..57b8f74 100644
--- a/__test__/components/EventModifications.test.tsx
+++ b/__test__/components/EventModifications.test.tsx
@@ -87,7 +87,11 @@ describe("CalendarApp integration", () => {
};
it("renders the event on the calendar and calendarRef works", async () => {
- const dispatch = jest.fn() as AppDispatch;
+ const dispatch = jest.fn().mockReturnValue(
+ Object.assign(Promise.resolve({}), {
+ unwrap: () => Promise.resolve({}),
+ })
+ ) as unknown as AppDispatch;
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
renderCalendar();
@@ -465,7 +469,11 @@ describe("CalendarApp integration", () => {
it("update event attendees on drag", async () => {
// Mock dispatch locally — this test calls createEventHandlers directly
// and does not go through the Redux store or useAppDispatch.
- const mockDispatch = jest.fn();
+ const mockDispatch = jest.fn().mockReturnValue(
+ Object.assign(Promise.resolve({}), {
+ unwrap: () => Promise.resolve({}),
+ })
+ ) as unknown as AppDispatch;
jest
.spyOn(appHooks, "useAppDispatch")
.mockReturnValue(mockDispatch as unknown as AppDispatch);
@@ -539,7 +547,11 @@ describe("CalendarApp integration", () => {
it("update event attendees on resize", async () => {
// Mock dispatch locally — this test calls createEventHandlers directly
// and does not go through the Redux store or useAppDispatch.
- const mockDispatch = jest.fn();
+ const mockDispatch = jest.fn().mockReturnValue(
+ Object.assign(Promise.resolve({}), {
+ unwrap: () => Promise.resolve({}),
+ })
+ ) as unknown as AppDispatch;
jest
.spyOn(appHooks, "useAppDispatch")
.mockReturnValue(mockDispatch as unknown as AppDispatch);
diff --git a/__test__/components/MiniCalendarColor.test.tsx b/__test__/components/MiniCalendarColor.test.tsx
index 476be75..609a973 100644
--- a/__test__/components/MiniCalendarColor.test.tsx
+++ b/__test__/components/MiniCalendarColor.test.tsx
@@ -9,7 +9,11 @@ describe("MiniCalendar", () => {
const day = new Date();
beforeEach(() => {
jest.clearAllMocks();
- const dispatch = jest.fn() as AppDispatch;
+ const dispatch = jest.fn().mockReturnValue(
+ Object.assign(Promise.resolve({}), {
+ unwrap: () => Promise.resolve({}),
+ })
+ ) as unknown as AppDispatch;
jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
jest.useFakeTimers().clearAllTimers();
});
diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx
index beb268d..1e9f585 100644
--- a/src/components/Calendar/Calendar.tsx
+++ b/src/components/Calendar/Calendar.tsx
@@ -1,5 +1,4 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
-import { getCalendarDetailAsync } from "@/features/Calendars/services";
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
import EventPopover from "@/features/Events/EventModal";
import { CalendarEvent } from "@/features/Events/EventsTypes";
@@ -7,10 +6,6 @@ import ImportAlert from "@/features/Events/ImportAlert";
import SearchResultsPage from "@/features/Search/SearchResultsPage";
import { setTimeZone } from "@/features/Settings/SettingsSlice";
import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager";
-import {
- formatDateToYYYYMMDDTHHMMSS,
- getCalendarRange,
-} from "@/utils/dateUtils";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars";
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
@@ -42,6 +37,7 @@ import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { MiniCalendar } from "./MiniCalendar";
import { TempCalendarsInput } from "./TempCalendarsInput";
import { TimezoneSelector } from "./TimezoneSelector";
+import { useCalendarDataLoader } from "../../features/Calendars/useCalendarLoader";
import { updateDarkColor } from "./utils/calendarColorsUtils";
import {
eventToFullCalendarFormat,
@@ -70,6 +66,11 @@ export default function CalendarApp({
menubarProps,
}: CalendarAppProps) {
const [selectedDate, setSelectedDate] = useState(new Date());
+ const [debouncedDate, setDebouncedDate] = useState(new Date());
+ useEffect(() => {
+ const t = setTimeout(() => setDebouncedDate(selectedDate), 300);
+ return () => clearTimeout(t);
+ }, [selectedDate]);
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date());
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
@@ -114,21 +115,11 @@ export default function CalendarApp({
useAppSelector((state) => state.settings.timeZone) ??
browserDefaultTimeZone;
- const fetchedRangesRef = useRef>({});
-
// Auto-select personal calendars when first loaded
const initialLoadRef = useRef(true);
const [eventErrors, setEventErrors] = useState([]);
const errorHandler = useRef(new EventErrorHandler());
- // useEffect(() => {
- // if (view === "search") {
- // document.body.classList.add("dialog-expanded");
- // } else {
- // document.body.classList.remove("dialog-expanded");
- // }
- // }, [view]);
-
useEffect(() => {
const handler = errorHandler.current;
handler.setErrorCallback(setEventErrors);
@@ -195,29 +186,32 @@ export default function CalendarApp({
});
}, [calendarIds]);
- const calendarRange = useMemo(
- () => getCalendarRange(selectedDate),
- [selectedDate]
+ const sortedSelectedCalendars = useMemo(
+ () => [...selectedCalendars].sort(),
+ [selectedCalendars]
);
- const calendarRangeStart = calendarRange.start.getTime();
- const calendarRangeEnd = calendarRange.end.getTime();
-
- const rangeStart = useMemo(
- () => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeStart)),
- [calendarRangeStart]
+ const tempCalendarIdsString = useMemo(
+ () =>
+ Object.keys(tempcalendars || {})
+ .sort()
+ .join(","),
+ [tempcalendars]
+ );
+ const tempCalendarIds = useMemo(
+ () => (tempCalendarIdsString ? tempCalendarIdsString.split(",") : []),
+ [tempCalendarIdsString]
);
- const rangeEnd = useMemo(
- () => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeEnd)),
- [calendarRangeEnd]
- );
-
- // Create a stable string key for the range
- const rangeKey = useMemo(
- () => `${rangeStart}_${rangeEnd}`,
- [rangeStart, rangeEnd]
- );
+ useCalendarDataLoader({
+ selectedDate: debouncedDate,
+ currentView,
+ selectedCalendars,
+ sortedSelectedCalendars,
+ calendarIds,
+ calendarIdsString,
+ tempCalendarIds,
+ });
const filteredEvents: CalendarEvent[] = extractEvents(
selectedCalendars,
@@ -226,11 +220,6 @@ export default function CalendarApp({
hideDeclinedEvents
);
- const tempCalendarIds = useMemo(
- () => Object.keys(tempcalendars || {}).sort(),
- [tempcalendars]
- );
-
const filteredTempEvents: CalendarEvent[] = extractEvents(
tempCalendarIds,
tempcalendars || {},
@@ -238,242 +227,6 @@ export default function CalendarApp({
hideDeclinedEvents
);
- const sortedSelectedCalendars = useMemo(
- () => [...selectedCalendars].sort(),
- [selectedCalendars]
- );
-
- const prefetchedCalendarsRef = useRef>({});
- const tempFetchedRangesRef = useRef>({});
- const activeLoadCompletedRef = useRef(false);
- const [activeLoadCompleted, setActiveLoadCompleted] =
- useState(false);
-
- useEffect(() => {
- activeLoadCompletedRef.current = false;
- setActiveLoadCompleted(false);
- }, [rangeKey]);
-
- useEffect(() => {
- if (isPending) return;
-
- if (!rangeKey || sortedSelectedCalendars.length === 0) {
- activeLoadCompletedRef.current = true;
- setActiveLoadCompleted(true);
- return;
- }
-
- let cancelled = false;
- const ACTIVE_BATCH_SIZE = 5;
-
- const loadCalendars = async () => {
- try {
- const pendingIds = sortedSelectedCalendars.filter(
- (id) => fetchedRangesRef.current[id] !== rangeKey
- );
-
- if (pendingIds.length === 0) {
- activeLoadCompletedRef.current = true;
- setActiveLoadCompleted(true);
- return;
- }
-
- for (
- let i = 0;
- i < pendingIds.length && !cancelled;
- i += ACTIVE_BATCH_SIZE
- ) {
- const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE);
-
- chunk.forEach((id) => {
- fetchedRangesRef.current[id] = rangeKey;
- prefetchedCalendarsRef.current[id] = "active";
- });
-
- const requests = chunk.map(async (id) => {
- try {
- await dispatch(
- getCalendarDetailAsync({
- calId: id,
- match: {
- start: rangeStart,
- end: rangeEnd,
- },
- })
- ).unwrap();
- } catch {
- fetchedRangesRef.current[id] = "";
- }
- });
-
- await Promise.all(requests);
- }
- } finally {
- if (!cancelled) {
- activeLoadCompletedRef.current = true;
- setActiveLoadCompleted(true);
- }
- }
- };
-
- loadCalendars();
-
- return () => {
- cancelled = true;
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [dispatch, rangeKey, sortedSelectedCalendars, rangeStart, rangeEnd]);
-
- useEffect(() => {
- if (!rangeKey || !activeLoadCompleted || isPending) return;
- const hiddenCalendars = calendarIds
- .filter((id) => !selectedCalendars.includes(id))
- .filter((id) => {
- const prefetched = prefetchedCalendarsRef.current[id];
- return prefetched !== rangeKey && prefetched !== "active";
- });
-
- if (hiddenCalendars.length === 0) return;
-
- hiddenCalendars.forEach((id) => {
- prefetchedCalendarsRef.current[id] = rangeKey;
- dispatch(
- getCalendarDetailAsync({
- calId: id,
- match: {
- start: rangeStart,
- end: rangeEnd,
- },
- })
- )
- .unwrap()
- .catch(() => {
- prefetchedCalendarsRef.current[id] = "";
- });
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [
- calendarIdsString,
- selectedCalendars,
- rangeKey,
- dispatch,
- rangeStart,
- rangeEnd,
- activeLoadCompleted,
- isPending,
- ]);
-
- const calendarsWithClearedCache = useMemo(() => {
- return selectedCalendars
- .map((id) => {
- const cleared = calendars[id]?.lastCacheCleared;
- return cleared ? { id, cleared } : null;
- })
- .filter(Boolean) as { id: string; cleared: number }[];
- }, [selectedCalendars, calendars]);
-
- const processedCacheClearRef = useRef>({});
-
- useEffect(() => {
- if (isPending) return;
- calendarsWithClearedCache.forEach(({ id, cleared }) => {
- if (processedCacheClearRef.current[id] === cleared) {
- return;
- }
-
- processedCacheClearRef.current[id] = cleared;
- delete fetchedRangesRef.current[id];
- prefetchedCalendarsRef.current[id] = "";
-
- dispatch(
- getCalendarDetailAsync({
- calId: id,
- match: {
- start: rangeStart,
- end: rangeEnd,
- },
- })
- )
- .unwrap()
- .then(() => {
- fetchedRangesRef.current[id] = rangeKey;
- prefetchedCalendarsRef.current[id] = rangeKey;
- })
- .catch(() => {
- fetchedRangesRef.current[id] = "";
- prefetchedCalendarsRef.current[id] = "";
- });
- });
- }, [
- calendarsWithClearedCache,
- dispatch,
- rangeKey,
- rangeStart,
- rangeEnd,
- isPending,
- ]);
-
- useEffect(() => {
- const currentIds = new Set(tempCalendarIds);
- Object.keys(tempFetchedRangesRef.current).forEach((id) => {
- if (!currentIds.has(id)) {
- delete tempFetchedRangesRef.current[id];
- }
- });
- }, [tempCalendarIds]);
-
- useEffect(() => {
- if (!rangeKey || tempCalendarIds.length === 0) return;
-
- let cancelled = false;
- const TEMP_BATCH_SIZE = 5;
- const loadTempCalendars = async () => {
- const pendingIds = tempCalendarIds.filter(
- (id) => tempFetchedRangesRef.current[id] !== rangeKey
- );
-
- if (pendingIds.length === 0) {
- return;
- }
-
- for (
- let i = 0;
- i < pendingIds.length && !cancelled;
- i += TEMP_BATCH_SIZE
- ) {
- const chunk = pendingIds.slice(i, i + TEMP_BATCH_SIZE);
- chunk.forEach((id) => {
- tempFetchedRangesRef.current[id] = rangeKey;
- });
-
- const requests = chunk.map(async (id) => {
- try {
- await dispatch(
- getCalendarDetailAsync({
- calId: id,
- match: {
- start: rangeStart,
- end: rangeEnd,
- },
- calType: "temp",
- })
- ).unwrap();
- } catch {
- tempFetchedRangesRef.current[id] = "";
- }
- });
-
- await Promise.all(requests);
- }
- };
-
- loadTempCalendars();
-
- return () => {
- cancelled = true;
- };
- }, [dispatch, rangeKey, tempCalendarIds, rangeStart, rangeEnd]);
-
const [anchorEl, setAnchorEl] = useState(null);
const [openEventDisplay, setOpenEventDisplay] = useState(false);
const [eventDisplayedId, setEventDisplayedId] = useState("");
diff --git a/src/components/Calendar/CalendarLayout.tsx b/src/components/Calendar/CalendarLayout.tsx
index 3ec4b41..e36ee72 100644
--- a/src/components/Calendar/CalendarLayout.tsx
+++ b/src/components/Calendar/CalendarLayout.tsx
@@ -1,6 +1,6 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import SettingsPage from "@/features/Settings/SettingsPage";
-import { getCalendarRange } from "@/utils/dateUtils";
+import { getViewRange } from "@/utils/dateUtils";
import type { CalendarApi } from "@fullcalendar/core";
import CozyBridge from "cozy-external-bridge";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -24,7 +24,7 @@ export default function CalendarLayout() {
// Get current calendar range
if (calendarRef.current) {
const view = calendarRef.current.view;
- const calendarRange = getCalendarRange(view.activeStart);
+ const calendarRange = getViewRange(view.activeStart, view.type);
// Refresh events for selected calendars
await refreshCalendars(
diff --git a/src/components/Calendar/MiniCalendar.tsx b/src/components/Calendar/MiniCalendar.tsx
index 66c72af..e9a0013 100644
--- a/src/components/Calendar/MiniCalendar.tsx
+++ b/src/components/Calendar/MiniCalendar.tsx
@@ -1,11 +1,6 @@
-import { useAppDispatch, useAppSelector } from "@/app/hooks";
-import { getCalendarDetailAsync } from "@/features/Calendars/services";
+import { useAppDispatch } from "@/app/hooks";
import { setView } from "@/features/Settings/SettingsSlice";
-import {
- computeStartOfTheWeek,
- formatDateToYYYYMMDDTHHMMSS,
- getCalendarRange,
-} from "@/utils/dateUtils";
+import { computeStartOfTheWeek } from "@/utils/dateUtils";
import type { CalendarApi } from "@fullcalendar/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { DateCalendar } from "@mui/x-date-pickers";
@@ -25,7 +20,6 @@ export function MiniCalendar({
setSelectedMiniDate: (d: Date) => void;
}) {
const dispatch = useAppDispatch();
- const calendars = useAppSelector((state) => state.calendars);
const [visibleDate, setVisibleDate] = useState(selectedDate);
const { t } = useI18n();
@@ -48,19 +42,7 @@ export function MiniCalendar({
}}
showDaysOutsideCurrentMonth
onMonthChange={(month) => {
- const calendarRange = getCalendarRange(month.toDate());
setVisibleDate(month.toDate());
- Object.values(calendars.list || {}).forEach((cal) =>
- dispatch(
- getCalendarDetailAsync({
- calId: cal.id,
- match: {
- start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
- end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
- },
- })
- )
- );
}}
views={["month", "day"]}
slots={{
diff --git a/src/features/Calendars/useCalendarLoader.ts b/src/features/Calendars/useCalendarLoader.ts
new file mode 100644
index 0000000..b5bcf61
--- /dev/null
+++ b/src/features/Calendars/useCalendarLoader.ts
@@ -0,0 +1,348 @@
+import { useAppDispatch, useAppSelector } from "@/app/hooks";
+import { getCalendarDetailAsync } from "@/features/Calendars/services";
+import {
+ formatDateToYYYYMMDDTHHMMSS,
+ getAdjacentWeekRange,
+ getViewRange,
+} from "@/utils/dateUtils";
+import { useEffect, useMemo, useRef } from "react";
+
+export interface Interval {
+ start: number;
+ end: number;
+}
+
+export function mergeInterval(
+ intervals: Interval[],
+ next: Interval
+): Interval[] {
+ const result: Interval[] = [];
+ let merged = { ...next };
+ for (const iv of intervals) {
+ if (iv.end < merged.start) result.push(iv);
+ else if (iv.start > merged.end) {
+ result.push(merged);
+ merged = iv;
+ } else
+ merged = {
+ start: Math.min(iv.start, merged.start),
+ end: Math.max(iv.end, merged.end),
+ };
+ }
+ result.push(merged);
+ return result;
+}
+
+export function subtractIntervals(
+ start: number,
+ end: number,
+ fetched: Interval[]
+): Interval[] {
+ let remaining: Interval[] = [{ start, end }];
+ for (const iv of fetched) {
+ const next: Interval[] = [];
+ for (const r of remaining) {
+ if (iv.end <= r.start || iv.start >= r.end) next.push(r);
+ else {
+ if (iv.start > r.start) next.push({ start: r.start, end: iv.start });
+ if (iv.end < r.end) next.push({ start: iv.end, end: r.end });
+ }
+ }
+ remaining = next;
+ }
+ return remaining;
+}
+
+interface UseCalendarDataLoaderParams {
+ selectedDate: Date;
+ currentView: string;
+ selectedCalendars: string[];
+ sortedSelectedCalendars: string[];
+ calendarIds: string[];
+ calendarIdsString: string;
+ tempCalendarIds: string[];
+}
+
+export function useCalendarDataLoader({
+ selectedDate,
+ currentView,
+ selectedCalendars,
+ sortedSelectedCalendars,
+ calendarIds,
+ calendarIdsString,
+ tempCalendarIds,
+}: UseCalendarDataLoaderParams) {
+ const dispatch = useAppDispatch();
+ const calendars = useAppSelector((state) => state.calendars.list);
+
+ const { visibleStart, visibleEnd, prefetchEnd } = useMemo(() => {
+ const { start, end } = getViewRange(selectedDate, currentView);
+ const prefetchEnd =
+ currentView === "dayGridMonth"
+ ? end.getTime()
+ : getAdjacentWeekRange(selectedDate).end.getTime();
+ return {
+ visibleStart: start.getTime(),
+ visibleEnd: end.getTime(),
+ prefetchEnd,
+ };
+ }, [selectedDate, currentView]);
+
+ const fetchedIntervalsRef = useRef>({});
+ const inFlightRef = useRef>({});
+ const tempFetchedIntervalsRef = useRef>({});
+ const processedCacheClearRef = useRef>({});
+
+ useEffect(() => {
+ let cancelled = false;
+ const BATCH_SIZE = 5;
+ const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms));
+
+ const run = async () => {
+ // Active load: selected calendars, visible range, gaps only
+ // Exclude intervals already fetched OR currently in-flight to avoid duplicates.
+ const activeUnits = sortedSelectedCalendars.flatMap((id) => {
+ const fetched = fetchedIntervalsRef.current[id] ?? [];
+ const inFlight = inFlightRef.current[id] ?? [];
+ // Build the union of fetched + inFlight intervals to subtract against.
+ const covered = inFlight.reduce(
+ (acc, iv) => mergeInterval(acc, iv),
+ fetched
+ );
+ return subtractIntervals(visibleStart, visibleEnd, covered).map(
+ (gap) => ({ id, gap })
+ );
+ });
+
+ activeUnits.forEach(({ id, gap }) => {
+ inFlightRef.current[id] = mergeInterval(
+ inFlightRef.current[id] ?? [],
+ gap
+ );
+ });
+
+ for (let i = 0; i < activeUnits.length; i += BATCH_SIZE) {
+ if (cancelled) return;
+ await Promise.all(
+ activeUnits.slice(i, i + BATCH_SIZE).map(async ({ id, gap }) => {
+ try {
+ await dispatch(
+ getCalendarDetailAsync({
+ calId: id,
+ match: {
+ start: toApiDate(gap.start),
+ end: toApiDate(gap.end),
+ },
+ })
+ ).unwrap();
+ if (!cancelled) {
+ // Promote gap from in-flight → fetched.
+ fetchedIntervalsRef.current[id] = mergeInterval(
+ fetchedIntervalsRef.current[id] ?? [],
+ gap
+ );
+ }
+ } catch {
+ // Remove gap from in-flight so it can be retried.
+ inFlightRef.current[id] = (inFlightRef.current[id] ?? []).flatMap(
+ (iv) => {
+ if (iv.end <= gap.start || iv.start >= gap.end) return [iv];
+ const pieces: Interval[] = [];
+ if (iv.start < gap.start)
+ pieces.push({ start: iv.start, end: gap.start });
+ if (iv.end > gap.end)
+ pieces.push({ start: gap.end, end: iv.end });
+ return pieces;
+ }
+ );
+ }
+ })
+ );
+ }
+
+ if (cancelled) return;
+
+ // Prefetch: hidden calendars (visible range) + adjacent week for selected
+ const hiddenCalendars = calendarIds.filter(
+ (id) => !selectedCalendars.includes(id)
+ );
+
+ const prefetchUnits = [
+ ...hiddenCalendars.flatMap((id) =>
+ subtractIntervals(
+ visibleStart,
+ visibleEnd,
+ fetchedIntervalsRef.current[id] ?? []
+ ).map((gap) => ({ id, gap }))
+ ),
+ ...sortedSelectedCalendars.flatMap((id) =>
+ subtractIntervals(
+ visibleEnd,
+ prefetchEnd,
+ fetchedIntervalsRef.current[id] ?? []
+ )
+ .filter((gap) => gap.start < gap.end)
+ .map((gap) => ({ id, gap }))
+ ),
+ ];
+
+ const savedGaps = new Map();
+ prefetchUnits.forEach(({ gap }, index) => {
+ savedGaps.set(index, { ...gap });
+ });
+
+ // Optimistically mark before firing to avoid duplicate kicks
+ prefetchUnits.forEach(({ id, gap }) => {
+ fetchedIntervalsRef.current[id] = mergeInterval(
+ fetchedIntervalsRef.current[id] ?? [],
+ gap
+ );
+ });
+
+ prefetchUnits.forEach(({ id }, index) => {
+ const originalGap = savedGaps.get(index)!;
+ void dispatch(
+ getCalendarDetailAsync({
+ calId: id,
+ match: {
+ start: toApiDate(originalGap.start),
+ end: toApiDate(originalGap.end),
+ },
+ })
+ )
+ .unwrap()
+ .catch(() => {
+ // Roll back by subtracting the original gap
+ fetchedIntervalsRef.current[id] = (
+ fetchedIntervalsRef.current[id] ?? []
+ ).flatMap((iv) => {
+ if (iv.end <= originalGap.start || iv.start >= originalGap.end) {
+ return [iv]; // no overlap, keep as-is
+ }
+ const pieces: Interval[] = [];
+ if (iv.start < originalGap.start)
+ pieces.push({ start: iv.start, end: originalGap.start });
+ if (iv.end > originalGap.end)
+ pieces.push({ start: originalGap.end, end: iv.end });
+ return pieces;
+ });
+ });
+ });
+ };
+
+ run();
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ dispatch,
+ visibleStart,
+ visibleEnd,
+ sortedSelectedCalendars,
+ calendarIdsString,
+ selectedCalendars,
+ prefetchEnd,
+ ]);
+
+ // Cache-clear
+ const calendarsWithClearedCache = useMemo(
+ () =>
+ selectedCalendars
+ .map((id) => {
+ const cleared = calendars[id]?.lastCacheCleared;
+ return cleared ? { id, cleared } : null;
+ })
+ .filter(Boolean) as { id: string; cleared: number }[],
+ [selectedCalendars, calendars]
+ );
+
+ useEffect(() => {
+ const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms));
+ calendarsWithClearedCache.forEach(({ id, cleared }) => {
+ if (processedCacheClearRef.current[id] === cleared) return;
+ delete fetchedIntervalsRef.current[id];
+
+ void dispatch(
+ getCalendarDetailAsync({
+ calId: id,
+ match: {
+ start: toApiDate(visibleStart),
+ end: toApiDate(visibleEnd),
+ },
+ })
+ )
+ .unwrap()
+ .then(() => {
+ processedCacheClearRef.current[id] = cleared;
+ fetchedIntervalsRef.current[id] = mergeInterval(
+ fetchedIntervalsRef.current[id] ?? [],
+ { start: visibleStart, end: visibleEnd }
+ );
+ })
+ .catch(() => {
+ /* leave unrecorded for retry */
+ });
+ });
+ }, [calendarsWithClearedCache, dispatch, visibleStart, visibleEnd]);
+
+ // Temp calendars cleanup
+ useEffect(() => {
+ const currentIds = new Set(tempCalendarIds);
+ Object.keys(tempFetchedIntervalsRef.current).forEach((id) => {
+ if (!currentIds.has(id)) delete tempFetchedIntervalsRef.current[id];
+ });
+ }, [tempCalendarIds]);
+
+ // Temp calendars load
+ useEffect(() => {
+ if (tempCalendarIds.length === 0) return;
+
+ let cancelled = false;
+ const BATCH_SIZE = 5;
+ const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms));
+
+ const run = async () => {
+ const fetchUnits = tempCalendarIds.flatMap((id) =>
+ subtractIntervals(
+ visibleStart,
+ visibleEnd,
+ tempFetchedIntervalsRef.current[id] ?? []
+ ).map((gap) => ({ id, gap }))
+ );
+
+ for (let i = 0; i < fetchUnits.length; i += BATCH_SIZE) {
+ if (cancelled) return;
+ await Promise.all(
+ fetchUnits.slice(i, i + BATCH_SIZE).map(async ({ id, gap }) => {
+ try {
+ await dispatch(
+ getCalendarDetailAsync({
+ calId: id,
+ match: {
+ start: toApiDate(gap.start),
+ end: toApiDate(gap.end),
+ },
+ calType: "temp",
+ })
+ ).unwrap();
+ if (!cancelled) {
+ tempFetchedIntervalsRef.current[id] = mergeInterval(
+ tempFetchedIntervalsRef.current[id] ?? [],
+ gap
+ );
+ }
+ } catch {
+ /* leave unrecorded for retry */
+ }
+ })
+ );
+ }
+ };
+
+ run();
+ return () => {
+ cancelled = true;
+ };
+ }, [dispatch, visibleStart, visibleEnd, tempCalendarIds]);
+}
diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts
index 86512e5..4ee8edf 100644
--- a/src/utils/dateUtils.ts
+++ b/src/utils/dateUtils.ts
@@ -17,15 +17,12 @@ export function getCalendarRange(date = new Date()) {
const lastOfMonth = new Date(year, month + 1, 0);
- // Calculate how many days from startDate to lastOfMonth
const daysFromStart = Math.floor(
(lastOfMonth.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)
);
- // Calculate number of weeks needed to cover all days (round up)
const weeksNeeded = Math.ceil((daysFromStart + 1) / 7);
- // endDate is the Sunday of the last week (startDate + weeks * 7 - 1)
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + weeksNeeded * 7 - 1);
endDate.setHours(23, 59, 59, 999);
@@ -43,8 +40,8 @@ export function getDeltaInMilliseconds(delta: {
milliseconds: number;
}) {
const MS_PER_DAY = 24 * 60 * 60 * 1000;
- const AVG_MS_PER_MONTH = 30.44 * MS_PER_DAY; // approx
- const AVG_MS_PER_YEAR = 365.25 * MS_PER_DAY; // approx
+ const AVG_MS_PER_MONTH = 30.44 * MS_PER_DAY;
+ const AVG_MS_PER_YEAR = 365.25 * MS_PER_DAY;
return (
(delta.years || 0) * AVG_MS_PER_YEAR +
@@ -67,3 +64,31 @@ export const computeWeekRange = (date: Date): { start: Date; end: Date } => {
weekEnd.setDate(weekStart.getDate() + 7);
return { start: weekStart, end: weekEnd };
};
+
+export function getViewRange(
+ date = new Date(),
+ view: string
+): { start: Date; end: Date } {
+ if (view === "dayGridMonth") {
+ return getCalendarRange(date);
+ }
+ return computeWeekRange(date);
+}
+
+export function getAdjacentWeekRange(date = new Date()): {
+ start: Date;
+ end: Date;
+} {
+ const nextWeekDate = new Date(date);
+ nextWeekDate.setDate(date.getDate() + 7);
+
+ return computeWeekRange(nextWeekDate);
+}
+
+export function getTwoWeekRange(date = new Date()): { start: Date; end: Date } {
+ const start = computeStartOfTheWeek(date);
+ const end = new Date(start);
+ end.setDate(start.getDate() + 14);
+ end.setHours(23, 59, 59, 999);
+ return { start, end };
+}