@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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);
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user