feat: implement parallel calendar loading with batch processing

- Refactor getCalendarsListAsync to batch fetch getUserDetails
  - Deduplicate ownerIds before fetching
  - Process in batches of 20 with Promise.all
  - Add error handling with fallback data

- Implement batch loading for selected calendar events
  - Load events in batches of 5 calendars
  - Use sorted selectedCalendars to prevent infinite loops
  - Add cancellation support for cleanup

- Add prefetch mechanism for hidden calendars
  - Prefetch calendars not currently selected
  - Track prefetched state to avoid duplicate requests
  - Non-blocking background loading

- Apply batch loading to temp calendars
  - Separate cache tracking for temp calendars
  - Batch load temp calendar events
  - Clean up cache when temp calendars are removed
This commit is contained in:
lenhanphung
2025-11-06 17:10:18 +07:00
committed by Benoit TELLIER
parent 2d9ff88c1b
commit 990e290066
2 changed files with 252 additions and 86 deletions
+171 -46
View File
@@ -5,7 +5,7 @@ import interactionPlugin from "@fullcalendar/interaction";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import "./Calendar.styl";
import "./CustomCalendar.styl";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import EventPopover from "../../features/Events/EventModal";
import { CalendarEvent } from "../../features/Events/EventsTypes";
@@ -29,7 +29,6 @@ import {
updateSlotLabelVisibility,
eventToFullCalendarFormat,
extractEvents,
updateCalsDetails,
} from "./utils/calendarUtils";
import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers";
import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
@@ -71,7 +70,6 @@ export default function CalendarApp({
const calendars = useAppSelector((state) => state.calendars.list);
const tempcalendars =
useAppSelector((state) => state.calendars.templist) ?? {};
const pending = useAppSelector((state) => state.calendars.pending);
const [selectedCalendars, setSelectedCalendars] = useState<string[]>([]);
const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) => {
@@ -102,7 +100,7 @@ export default function CalendarApp({
};
useEffect(() => {
if (initialLoadRef.current && Object.keys(calendars).length > 0 && userId) {
if (initialLoadRef.current && Object.keys(calendars || {}).length > 0 && userId) {
const cached = localStorage.getItem("selectedCalendars");
if (cached && cached.length > 0) {
const parsed = JSON.parse(cached) as string[];
@@ -120,7 +118,7 @@ export default function CalendarApp({
// Save selected cals to cache
useEffect(() => {
if (Object.keys(calendars).length > 0) {
if (Object.keys(calendars || {}).length > 0) {
localStorage.setItem(
"selectedCalendars",
JSON.stringify(selectedCalendars)
@@ -129,22 +127,25 @@ export default function CalendarApp({
}, [selectedCalendars]);
useEffect(() => {
updateDarkColor(calendars, theme, dispatch);
updateDarkColor(calendars || {}, theme, dispatch);
}, [
theme,
Object.values(calendars)
Object.values(calendars || {})
.map((c) => c.color?.dark)
.join(","),
]);
useEffect(() => {
const validCalendarIds = new Set(Object.keys(calendars));
const validCalendarIds = new Set(Object.keys(calendars || {}));
setSelectedCalendars((prev) =>
prev.filter((calId) => validCalendarIds.has(calId))
);
}, [calendars]);
const calendarRange = getCalendarRange(selectedDate);
const calendarRange = useMemo(
() => getCalendarRange(selectedDate),
[selectedDate]
);
// Create a stable string key for the range
const rangeKey = `${formatDateToYYYYMMDDTHHMMSS(
@@ -153,35 +154,118 @@ export default function CalendarApp({
let filteredEvents: CalendarEvent[] = extractEvents(
selectedCalendars,
calendars
calendars || {}
);
let filteredTempEvents: CalendarEvent[] = extractEvents(
Object.keys(tempcalendars),
tempcalendars
Object.keys(tempcalendars || {}),
tempcalendars || {}
);
const sortedSelectedCalendars = useMemo(
() => [...selectedCalendars].sort(),
[selectedCalendars]
);
const prefetchedCalendarsRef = useRef<Record<string, string>>({});
const tempFetchedRangesRef = useRef<Record<string, string>>({});
useEffect(() => {
if (!rangeKey || sortedSelectedCalendars.length === 0) return;
let cancelled = false;
const ACTIVE_BATCH_SIZE = 5;
const loadCalendars = async () => {
const pendingIds = sortedSelectedCalendars.filter(
(id) => fetchedRangesRef.current[id] !== rangeKey
);
if (pendingIds.length === 0) {
return;
}
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
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 (error) {
console.error(`Failed to load calendar ${id}:`, error);
fetchedRangesRef.current[id] = "";
}
});
await Promise.all(requests);
}
};
loadCalendars();
return () => {
cancelled = true;
};
}, [
dispatch,
rangeKey,
sortedSelectedCalendars,
calendarRange,
]);
useEffect(() => {
if (!rangeKey) return;
selectedCalendars.forEach((id) => {
if (fetchedRangesRef.current[id] === rangeKey) return;
fetchedRangesRef.current[id] = rangeKey;
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
const hiddenCalendars = Object.keys(calendars || {})
.filter((id) => !selectedCalendars.includes(id))
.filter((id) => prefetchedCalendarsRef.current[id] !== rangeKey);
hiddenCalendars.forEach((id) => {
prefetchedCalendarsRef.current[id] = rangeKey;
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
start: rangeStart,
end: rangeEnd,
},
})
);
)
.unwrap()
.catch((error) => {
console.error(`Prefetch calendar ${id} failed:`, error);
prefetchedCalendarsRef.current[id] = "";
});
});
}, [
rangeKey,
calendars,
selectedCalendars,
rangeKey,
calendarRange,
dispatch,
calendarRange.start,
calendarRange.end,
]);
useEffect(() => {
@@ -189,6 +273,7 @@ export default function CalendarApp({
const calendar = calendars[calId];
if (calendar?.lastCacheCleared) {
delete fetchedRangesRef.current[calId];
prefetchedCalendarsRef.current[calId] = "";
dispatch(
getCalendarDetailAsync({
@@ -201,6 +286,7 @@ export default function CalendarApp({
);
fetchedRangesRef.current[calId] = rangeKey;
prefetchedCalendarsRef.current[calId] = rangeKey;
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -208,40 +294,79 @@ export default function CalendarApp({
selectedCalendars.map((id) => calendars[id]?.lastCacheCleared).join(","),
]);
const [prevTempCalendars, setPrevTempCalendars] = useState<string[]>([]);
const [prevRangeKey, setPrevRangeKey] = useState<string>("");
const tempCalendarIds = useMemo(
() => Object.keys(tempcalendars || {}).sort(),
[tempcalendars]
);
const tempCalendarControllersRef = useRef<Map<string, AbortController>>(
new Map()
);
useEffect(() => {
updateCalsDetails(
Object.keys(tempcalendars),
prevTempCalendars,
pending,
rangeKey,
prevRangeKey,
dispatch,
calendarRange,
"temp",
tempCalendarControllersRef.current
);
prevTempCalendars.forEach((calId) => {
if (!Object.keys(tempcalendars).includes(calId)) {
const controller = tempCalendarControllersRef.current.get(calId);
if (controller) {
controller.abort();
tempCalendarControllersRef.current.delete(calId);
}
delete fetchedRangesRef.current[calId];
const currentIds = new Set(tempCalendarIds);
Object.keys(tempFetchedRangesRef.current).forEach((id) => {
if (!currentIds.has(id)) {
delete tempFetchedRangesRef.current[id];
}
});
}, [tempCalendarIds]);
setPrevTempCalendars(Object.keys(tempcalendars));
setPrevRangeKey(rangeKey);
}, [rangeKey, Object.keys(tempcalendars).join(","), pending]);
useEffect(() => {
if (!rangeKey || tempCalendarIds.length === 0) return;
let cancelled = false;
const TEMP_BATCH_SIZE = 5;
const rangeStart = formatDateToYYYYMMDDTHHMMSS(calendarRange.start);
const rangeEnd = formatDateToYYYYMMDDTHHMMSS(calendarRange.end);
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 (error) {
console.error(`Failed to load temp calendar ${id}:`, error);
tempFetchedRangesRef.current[id] = "";
}
});
await Promise.all(requests);
}
};
loadTempCalendars();
return () => {
cancelled = true;
};
}, [dispatch, rangeKey, tempCalendarIds, calendarRange]);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
+81 -40
View File
@@ -46,9 +46,13 @@ export const getCalendarsListAsync = createAsyncThunk<
const importedCalendars: Record<string, Calendars> = {};
const user = (await getOpenPaasUser()) as Record<string, string>;
const calendars = (await getCalendars(user.id)) as Record<string, any>;
const rawCalendars = calendars._embedded["dav:calendar"];
const errors = [];
for (const cal of rawCalendars) {
const rawCalendars = calendars._embedded["dav:calendar"] as Record<
string,
any
>[];
const errors: string[] = [];
const normalizedCalendars = rawCalendars.map((cal) => {
const description = cal["caldav:description"];
let delegated = false;
let source = cal["calendarserver:source"]
@@ -62,50 +66,87 @@ export const getCalendarsListAsync = createAsyncThunk<
const id = source.replace("/calendars/", "").replace(".json", "");
const ownerId = id.split("/")[0];
const visibility = getCalendarVisibility(cal["acl"]);
return {
cal,
description,
delegated,
source,
link,
id,
ownerId,
visibility,
};
});
// Safely fetch owner data with fallback
let ownerData: any;
const uniqueOwnerIds = Array.from(
new Set(normalizedCalendars.map(({ ownerId }) => ownerId).filter(Boolean))
);
const ownerDataMap = new Map<string, any>();
const OWNER_BATCH_SIZE = 20;
const fetchOwnerData = async (ownerId: string) => {
try {
ownerData = await getUserDetails(ownerId);
} catch (error) {
console.error(
`Failed to fetch user details for ${id.split("/")[0]}:`,
error
);
// Provide fallback data
ownerData = {
const data = await getUserDetails(ownerId);
ownerDataMap.set(ownerId, data);
} catch (error: any) {
console.error(`Failed to fetch user details for ${ownerId}:`, error);
ownerDataMap.set(ownerId, {
firstname: "",
lastname: "Unknown User",
emails: [],
});
errors.push(formatReduxError(error));
}
};
for (let i = 0; i < uniqueOwnerIds.length; i += OWNER_BATCH_SIZE) {
const chunk = uniqueOwnerIds.slice(i, i + OWNER_BATCH_SIZE);
await Promise.all(chunk.map((ownerId) => fetchOwnerData(ownerId)));
}
normalizedCalendars.forEach(
({
cal,
description,
delegated,
link,
id,
ownerId,
visibility,
}) => {
const ownerData = ownerDataMap.get(ownerId) || {
firstname: "",
lastname: "Unknown User",
emails: [],
};
errors.push(error);
}
const name =
ownerId !== user.id && cal["dav:name"] === "#default"
? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
ownerData.lastname
}` + "'s calendar"
: cal["dav:name"];
const name =
ownerId !== user.id && cal["dav:name"] === "#default"
? `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
ownerData.lastname
}` + "'s calendar"
: cal["dav:name"];
const color = {
light: cal["apple:color"] ?? "#006BD8",
dark: cal["X-TWAKE-Dark-theme-color"] ?? "#FFF",
};
importedCalendars[id] = {
id,
name,
link,
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
ownerData.lastname
}`,
ownerEmails: ownerData.emails,
description,
delegated,
color,
visibility,
events: {},
};
}
const color = {
light: cal["apple:color"] ?? "#006BD8",
dark: cal["X-TWAKE-Dark-theme-color"] ?? "#FFF",
};
importedCalendars[id] = {
id,
name,
link,
owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${
ownerData.lastname
}`,
ownerEmails: ownerData.emails,
description,
delegated,
color,
visibility,
events: {},
};
}
);
return { importedCalendars, errors: errors.join("\n") };
} catch (err: any) {