[#595] Load less data on call to get calendar details (#608)

This commit is contained in:
Camille Moussu
2026-03-14 06:38:38 +01:00
committed by GitHub
parent 27be9f10ae
commit 57f9693892
8 changed files with 495 additions and 333 deletions
+65 -27
View File
@@ -23,6 +23,9 @@ describe("CalendarSelection", () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
}); });
afterEach(() => {
jest.useRealTimers();
});
const today = new Date(); const today = new Date();
const start = new Date(today); const start = new Date(today);
start.setHours(10, 0, 0, 0); start.setHours(10, 0, 0, 0);
@@ -228,6 +231,9 @@ describe("calendar Availability search", () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
}); });
afterEach(() => {
jest.useRealTimers();
});
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
@@ -440,50 +446,80 @@ describe("calendar Availability search", () => {
expect(dayNumbersWithContent.length).toBe(0); 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 const spy = jest
.spyOn(calendarDetailThunks, "getCalendarDetailAsync") .spyOn(calendarDetailThunks, "getCalendarDetailAsync")
.mockImplementation((payload) => { .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")); jest.useFakeTimers().setSystemTime(new Date("2025-01-01"));
await act(async () => await act(async () =>
renderWithProviders(<CalendarLayout />, preloadedState) renderWithProviders(<CalendarLayout />, {
...preloadedState,
calendars: { ...preloadedState.calendars, pending: false },
})
); );
// Advance past debounce so the initial load fires
await act(async () => {
jest.advanceTimersByTime(300);
});
await waitFor(() => { await waitFor(() => {
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
}); });
spy.mockClear();
const calendarRef = window.__calendarRef; const calendarRef = window.__calendarRef;
const calendarApi = calendarRef.current; const calendarApi = calendarRef.current;
const view = calendarApi?.view;
await act(async () => { await act(async () => {
calendarApi.changeView("dayGridMonth"); calendarApi.changeView("dayGridMonth");
fireEvent.click(screen.getByTestId("ChevronRightIcon")); 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"); expect(callArgs.calId).toBe("user1/cal1");
const startDate = new Date( const parseDate = (s: string) =>
callArgs.match.start.replace( new Date(
/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/, s.replace(
"$1-$2-$3T$4:$5:$6" /(\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);
expect(startDate.getTime()).toBeLessThanOrEqual(viewStart.getTime()); const startDate = parseDate(callArgs.match.start);
expect(endDate.getTime()).toBeGreaterThanOrEqual(viewEnd.getTime()); 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", () => { describe("Batch loading and prefetching", () => {
@@ -506,6 +542,7 @@ describe("calendar Availability search", () => {
calendars: { calendars: {
...preloadedState.calendars, ...preloadedState.calendars,
list: manyCalendars, list: manyCalendars,
pending: false,
}, },
}; };
@@ -563,6 +600,7 @@ describe("calendar Availability search", () => {
events: {}, events: {},
}, },
}, },
pending: false,
}, },
}; };
@@ -613,10 +651,10 @@ describe("calendar Availability search", () => {
); );
await act(async () => { await act(async () => {
renderWithProviders( renderWithProviders(<CalendarApp calendarRef={{ current: null }} />, {
<CalendarApp calendarRef={{ current: null }} />, ...preloadedState,
preloadedState calendars: { ...preloadedState.calendars, pending: false },
); });
}); });
await waitFor( await waitFor(
@@ -87,7 +87,11 @@ describe("CalendarApp integration", () => {
}; };
it("renders the event on the calendar and calendarRef works", async () => { 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); jest.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
renderCalendar(); renderCalendar();
@@ -465,7 +469,11 @@ describe("CalendarApp integration", () => {
it("update event attendees on drag", async () => { it("update event attendees on drag", async () => {
// Mock dispatch locally — this test calls createEventHandlers directly // Mock dispatch locally — this test calls createEventHandlers directly
// and does not go through the Redux store or useAppDispatch. // 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 jest
.spyOn(appHooks, "useAppDispatch") .spyOn(appHooks, "useAppDispatch")
.mockReturnValue(mockDispatch as unknown as AppDispatch); .mockReturnValue(mockDispatch as unknown as AppDispatch);
@@ -539,7 +547,11 @@ describe("CalendarApp integration", () => {
it("update event attendees on resize", async () => { it("update event attendees on resize", async () => {
// Mock dispatch locally — this test calls createEventHandlers directly // Mock dispatch locally — this test calls createEventHandlers directly
// and does not go through the Redux store or useAppDispatch. // 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 jest
.spyOn(appHooks, "useAppDispatch") .spyOn(appHooks, "useAppDispatch")
.mockReturnValue(mockDispatch as unknown as AppDispatch); .mockReturnValue(mockDispatch as unknown as AppDispatch);
@@ -9,7 +9,11 @@ describe("MiniCalendar", () => {
const day = new Date(); const day = new Date();
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); 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.spyOn(appHooks, "useAppDispatch").mockReturnValue(dispatch);
jest.useFakeTimers().clearAllTimers(); jest.useFakeTimers().clearAllTimers();
}); });
+28 -275
View File
@@ -1,5 +1,4 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks"; import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { getCalendarDetailAsync } from "@/features/Calendars/services";
import EventPreviewModal from "@/features/Events/EventDisplayPreview"; import EventPreviewModal from "@/features/Events/EventDisplayPreview";
import EventPopover from "@/features/Events/EventModal"; import EventPopover from "@/features/Events/EventModal";
import { CalendarEvent } from "@/features/Events/EventsTypes"; import { CalendarEvent } from "@/features/Events/EventsTypes";
@@ -7,10 +6,6 @@ import ImportAlert from "@/features/Events/ImportAlert";
import SearchResultsPage from "@/features/Search/SearchResultsPage"; import SearchResultsPage from "@/features/Search/SearchResultsPage";
import { setTimeZone } from "@/features/Settings/SettingsSlice"; import { setTimeZone } from "@/features/Settings/SettingsSlice";
import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager"; import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager";
import {
formatDateToYYYYMMDDTHHMMSS,
getCalendarRange,
} from "@/utils/dateUtils";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars"; import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars";
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars"; import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
@@ -42,6 +37,7 @@ import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { MiniCalendar } from "./MiniCalendar"; import { MiniCalendar } from "./MiniCalendar";
import { TempCalendarsInput } from "./TempCalendarsInput"; import { TempCalendarsInput } from "./TempCalendarsInput";
import { TimezoneSelector } from "./TimezoneSelector"; import { TimezoneSelector } from "./TimezoneSelector";
import { useCalendarDataLoader } from "../../features/Calendars/useCalendarLoader";
import { updateDarkColor } from "./utils/calendarColorsUtils"; import { updateDarkColor } from "./utils/calendarColorsUtils";
import { import {
eventToFullCalendarFormat, eventToFullCalendarFormat,
@@ -70,6 +66,11 @@ export default function CalendarApp({
menubarProps, menubarProps,
}: CalendarAppProps) { }: CalendarAppProps) {
const [selectedDate, setSelectedDate] = useState(new Date()); 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 [selectedMiniDate, setSelectedMiniDate] = useState(new Date());
const userId = const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
@@ -114,21 +115,11 @@ export default function CalendarApp({
useAppSelector((state) => state.settings.timeZone) ?? useAppSelector((state) => state.settings.timeZone) ??
browserDefaultTimeZone; browserDefaultTimeZone;
const fetchedRangesRef = useRef<Record<string, string>>({});
// Auto-select personal calendars when first loaded // Auto-select personal calendars when first loaded
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
const [eventErrors, setEventErrors] = useState<string[]>([]); const [eventErrors, setEventErrors] = useState<string[]>([]);
const errorHandler = useRef(new EventErrorHandler()); const errorHandler = useRef(new EventErrorHandler());
// useEffect(() => {
// if (view === "search") {
// document.body.classList.add("dialog-expanded");
// } else {
// document.body.classList.remove("dialog-expanded");
// }
// }, [view]);
useEffect(() => { useEffect(() => {
const handler = errorHandler.current; const handler = errorHandler.current;
handler.setErrorCallback(setEventErrors); handler.setErrorCallback(setEventErrors);
@@ -195,29 +186,32 @@ export default function CalendarApp({
}); });
}, [calendarIds]); }, [calendarIds]);
const calendarRange = useMemo( const sortedSelectedCalendars = useMemo(
() => getCalendarRange(selectedDate), () => [...selectedCalendars].sort(),
[selectedDate] [selectedCalendars]
); );
const calendarRangeStart = calendarRange.start.getTime(); const tempCalendarIdsString = useMemo(
const calendarRangeEnd = calendarRange.end.getTime(); () =>
Object.keys(tempcalendars || {})
const rangeStart = useMemo( .sort()
() => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeStart)), .join(","),
[calendarRangeStart] [tempcalendars]
);
const tempCalendarIds = useMemo(
() => (tempCalendarIdsString ? tempCalendarIdsString.split(",") : []),
[tempCalendarIdsString]
); );
const rangeEnd = useMemo( useCalendarDataLoader({
() => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeEnd)), selectedDate: debouncedDate,
[calendarRangeEnd] currentView,
); selectedCalendars,
sortedSelectedCalendars,
// Create a stable string key for the range calendarIds,
const rangeKey = useMemo( calendarIdsString,
() => `${rangeStart}_${rangeEnd}`, tempCalendarIds,
[rangeStart, rangeEnd] });
);
const filteredEvents: CalendarEvent[] = extractEvents( const filteredEvents: CalendarEvent[] = extractEvents(
selectedCalendars, selectedCalendars,
@@ -226,11 +220,6 @@ export default function CalendarApp({
hideDeclinedEvents hideDeclinedEvents
); );
const tempCalendarIds = useMemo(
() => Object.keys(tempcalendars || {}).sort(),
[tempcalendars]
);
const filteredTempEvents: CalendarEvent[] = extractEvents( const filteredTempEvents: CalendarEvent[] = extractEvents(
tempCalendarIds, tempCalendarIds,
tempcalendars || {}, tempcalendars || {},
@@ -238,242 +227,6 @@ export default function CalendarApp({
hideDeclinedEvents hideDeclinedEvents
); );
const sortedSelectedCalendars = useMemo(
() => [...selectedCalendars].sort(),
[selectedCalendars]
);
const prefetchedCalendarsRef = useRef<Record<string, string>>({});
const tempFetchedRangesRef = useRef<Record<string, string>>({});
const activeLoadCompletedRef = useRef<boolean>(false);
const [activeLoadCompleted, setActiveLoadCompleted] =
useState<boolean>(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<Record<string, number>>({});
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<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [openEventDisplay, setOpenEventDisplay] = useState(false); const [openEventDisplay, setOpenEventDisplay] = useState(false);
const [eventDisplayedId, setEventDisplayedId] = useState(""); const [eventDisplayedId, setEventDisplayedId] = useState("");
+2 -2
View File
@@ -1,6 +1,6 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks"; import { useAppDispatch, useAppSelector } from "@/app/hooks";
import SettingsPage from "@/features/Settings/SettingsPage"; import SettingsPage from "@/features/Settings/SettingsPage";
import { getCalendarRange } from "@/utils/dateUtils"; import { getViewRange } from "@/utils/dateUtils";
import type { CalendarApi } from "@fullcalendar/core"; import type { CalendarApi } from "@fullcalendar/core";
import CozyBridge from "cozy-external-bridge"; import CozyBridge from "cozy-external-bridge";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
@@ -24,7 +24,7 @@ export default function CalendarLayout() {
// Get current calendar range // Get current calendar range
if (calendarRef.current) { if (calendarRef.current) {
const view = calendarRef.current.view; const view = calendarRef.current.view;
const calendarRange = getCalendarRange(view.activeStart); const calendarRange = getViewRange(view.activeStart, view.type);
// Refresh events for selected calendars // Refresh events for selected calendars
await refreshCalendars( await refreshCalendars(
+2 -20
View File
@@ -1,11 +1,6 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks"; import { useAppDispatch } from "@/app/hooks";
import { getCalendarDetailAsync } from "@/features/Calendars/services";
import { setView } from "@/features/Settings/SettingsSlice"; import { setView } from "@/features/Settings/SettingsSlice";
import { import { computeStartOfTheWeek } from "@/utils/dateUtils";
computeStartOfTheWeek,
formatDateToYYYYMMDDTHHMMSS,
getCalendarRange,
} from "@/utils/dateUtils";
import type { CalendarApi } from "@fullcalendar/core"; import type { CalendarApi } from "@fullcalendar/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { DateCalendar } from "@mui/x-date-pickers"; import { DateCalendar } from "@mui/x-date-pickers";
@@ -25,7 +20,6 @@ export function MiniCalendar({
setSelectedMiniDate: (d: Date) => void; setSelectedMiniDate: (d: Date) => void;
}) { }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
const [visibleDate, setVisibleDate] = useState(selectedDate); const [visibleDate, setVisibleDate] = useState(selectedDate);
const { t } = useI18n(); const { t } = useI18n();
@@ -48,19 +42,7 @@ export function MiniCalendar({
}} }}
showDaysOutsideCurrentMonth showDaysOutsideCurrentMonth
onMonthChange={(month) => { onMonthChange={(month) => {
const calendarRange = getCalendarRange(month.toDate());
setVisibleDate(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"]} views={["month", "day"]}
slots={{ slots={{
+348
View File
@@ -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<Record<string, Interval[]>>({});
const inFlightRef = useRef<Record<string, Interval[]>>({});
const tempFetchedIntervalsRef = useRef<Record<string, Interval[]>>({});
const processedCacheClearRef = useRef<Record<string, number>>({});
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<number, Interval>();
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]);
}
+30 -5
View File
@@ -17,15 +17,12 @@ export function getCalendarRange(date = new Date()) {
const lastOfMonth = new Date(year, month + 1, 0); const lastOfMonth = new Date(year, month + 1, 0);
// Calculate how many days from startDate to lastOfMonth
const daysFromStart = Math.floor( const daysFromStart = Math.floor(
(lastOfMonth.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) (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); const weeksNeeded = Math.ceil((daysFromStart + 1) / 7);
// endDate is the Sunday of the last week (startDate + weeks * 7 - 1)
const endDate = new Date(startDate); const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + weeksNeeded * 7 - 1); endDate.setDate(startDate.getDate() + weeksNeeded * 7 - 1);
endDate.setHours(23, 59, 59, 999); endDate.setHours(23, 59, 59, 999);
@@ -43,8 +40,8 @@ export function getDeltaInMilliseconds(delta: {
milliseconds: number; milliseconds: number;
}) { }) {
const MS_PER_DAY = 24 * 60 * 60 * 1000; const MS_PER_DAY = 24 * 60 * 60 * 1000;
const AVG_MS_PER_MONTH = 30.44 * MS_PER_DAY; // approx const AVG_MS_PER_MONTH = 30.44 * MS_PER_DAY;
const AVG_MS_PER_YEAR = 365.25 * MS_PER_DAY; // approx const AVG_MS_PER_YEAR = 365.25 * MS_PER_DAY;
return ( return (
(delta.years || 0) * AVG_MS_PER_YEAR + (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); weekEnd.setDate(weekStart.getDate() + 7);
return { start: weekStart, end: weekEnd }; 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 };
}