@@ -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(<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(() => {
|
||||
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(
|
||||
<CalendarApp calendarRef={{ current: null }} />,
|
||||
preloadedState
|
||||
);
|
||||
renderWithProviders(<CalendarApp calendarRef={{ current: null }} />, {
|
||||
...preloadedState,
|
||||
calendars: { ...preloadedState.calendars, pending: false },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
|
||||
// Auto-select personal calendars when first loaded
|
||||
const initialLoadRef = useRef(true);
|
||||
const [eventErrors, setEventErrors] = useState<string[]>([]);
|
||||
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<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 [openEventDisplay, setOpenEventDisplay] = useState(false);
|
||||
const [eventDisplayedId, setEventDisplayedId] = useState("");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user