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 }; +}