Skip to content

Commit eefd6bc

Browse files
enhance: Month Calendar Performance (#740)
1 parent f1006bd commit eefd6bc

3 files changed

Lines changed: 184 additions & 76 deletions

File tree

packages/frontend/src/config/widgets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const WIDGETS_CONFIG = {
1414
},
1515
[ETemplateNameRegistry.Calendar]: {
1616
TAG_ITEM_TYPE: "event",
17-
QUERY_EVENTS_HARD_LIMIT: 200,
17+
QUERY_EVENTS_HARD_LIMIT: 500,
1818
WIDGET_HEIGHT: 604, // gotten from cal-month
1919
POLLING_INTERVAL: 180 * 60, // 3h
2020
ADJUSTABLE: true,

packages/frontend/src/containers/calendar/CalendarContainer.tsx

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -174,29 +174,88 @@ const CalendarContainer: FC<IModuleContainer<TCategoryData[][]>> = ({
174174
const pollingInterval =
175175
(moduleData.widget.refresh_interval || POLLING_INTERVAL) * 1000;
176176

177+
const tagsParam = tags ? filteringListToStr(tags) : undefined;
178+
const queryOpts = { skip: !selectedDate, pollingInterval };
179+
180+
// Compute month boundaries using native Date (immutable — each is a new object)
181+
const monthStartDate = new Date(
182+
selectedDate.getFullYear(),
183+
selectedDate.getMonth(),
184+
1
185+
);
186+
const formatDate = (d: Date) => d.toISOString().slice(0, 10);
187+
const addMonths = (d: Date, n: number) =>
188+
new Date(d.getFullYear(), d.getMonth() + n, 1);
189+
190+
const prevStart = formatDate(addMonths(monthStartDate, -1));
191+
const currStart = formatDate(monthStartDate);
192+
const nextStart = formatDate(addMonths(monthStartDate, 1));
193+
const nextEnd = formatDate(addMonths(monthStartDate, 2));
194+
195+
const {
196+
data: prevMonthData,
197+
isLoading: isLoadingPrev,
198+
isFetching: isFetchingPrev,
199+
} = useGetEventsQuery(
200+
{
201+
period_after: prevStart,
202+
period_before: currStart,
203+
limit: QUERY_EVENTS_HARD_LIMIT,
204+
tags: tagsParam,
205+
},
206+
queryOpts
207+
);
208+
177209
const {
178-
data: eventsData,
179-
isLoading: isLoadingEvents,
180-
isFetching: isFetchingEvents,
210+
data: currMonthData,
211+
isLoading: isLoadingCurr,
212+
isFetching: isFetchingCurr,
181213
} = useGetEventsQuery(
182214
{
183-
period_after: moment(selectedDate)
184-
.startOf("month")
185-
.subtract(1, "month")
186-
.format("YYYY-MM-DD"),
187-
period_before: moment(selectedDate)
188-
.startOf("month")
189-
.add(1, "month")
190-
.format("YYYY-MM-DD"),
215+
period_after: currStart,
216+
period_before: nextStart,
191217
limit: QUERY_EVENTS_HARD_LIMIT,
192-
tags: tags ? filteringListToStr(tags) : undefined,
218+
tags: tagsParam,
193219
},
194-
{ skip: !selectedDate, pollingInterval }
220+
queryOpts
195221
);
196222

223+
const {
224+
data: nextMonthData,
225+
isLoading: isLoadingNext,
226+
isFetching: isFetchingNext,
227+
} = useGetEventsQuery(
228+
{
229+
period_after: nextStart,
230+
period_before: nextEnd,
231+
limit: QUERY_EVENTS_HARD_LIMIT,
232+
tags: tagsParam,
233+
},
234+
queryOpts
235+
);
236+
237+
const mergedEvents = useMemo(() => {
238+
const prev = prevMonthData?.results ?? [];
239+
const curr = currMonthData?.results ?? [];
240+
const next = nextMonthData?.results ?? [];
241+
const seen = new Set<string>();
242+
return [...prev, ...curr, ...next].filter((e) => {
243+
if (seen.has(e.id)) return false;
244+
seen.add(e.id);
245+
return true;
246+
});
247+
}, [
248+
prevMonthData?.results,
249+
currMonthData?.results,
250+
nextMonthData?.results,
251+
]);
252+
253+
const isLoadingEvents = isLoadingPrev || isLoadingCurr || isLoadingNext;
254+
const isFetchingEvents = isFetchingPrev || isFetchingCurr || isFetchingNext;
255+
197256
const closestEvent: TEvent | undefined = useMemo(
198-
() => getClosestEvent(eventsData?.results, selectedDate),
199-
[eventsData?.results, selectedDate]
257+
() => getClosestEvent(mergedEvents, selectedDate),
258+
[mergedEvents, selectedDate]
200259
);
201260

202261
const {
@@ -305,7 +364,7 @@ const CalendarContainer: FC<IModuleContainer<TCategoryData[][]>> = ({
305364

306365
return (
307366
<CalendarModule
308-
events={eventsData?.results}
367+
events={mergedEvents}
309368
fetchEvents={fetchCalendarEvents}
310369
onClickEvent={onClickEvent}
311370
onDatesSet={onDatesSet}

packages/ui-kit/src/components/calendar/CalendarMonth.tsx

Lines changed: 108 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { FC, useRef, useState, useEffect, useCallback } from "react";
2-
import { ViewContentArg, DatesSetArg, EventMountArg } from "@fullcalendar/core";
1+
import { FC, useRef, useEffect, useCallback } from "react";
2+
import { ViewContentArg, DatesSetArg } from "@fullcalendar/core";
33
import dayGridPlugin from "@fullcalendar/daygrid";
44
import interactionPlugin, { DateClickArg } from "@fullcalendar/interaction";
55
import FullCalendar from "@fullcalendar/react";
@@ -56,59 +56,96 @@ export const getCoords = (elem: HTMLElement | null): TElemCoords => {
5656
};
5757

5858
const MAX_DOTS_PER_DAY = 30;
59+
const DOT_CLASS =
60+
"fc-daygrid-event fc-daygrid-block-event fc-h-event fc-event fc-event-start fc-event-end fc-event-today";
5961

60-
// Track which events have been added to each day to avoid race conditions
61-
const dayEventCounts = new Map<string, Set<string>>();
62-
63-
const eventRender = (info: EventMountArg, widgetHash: string) => {
64-
const { event, backgroundColor } = info;
62+
/**
63+
* Build all event dots in a single batch pass instead of per-event callbacks.
64+
* Uses a pre-built day cell map to avoid repeated DOM queries,
65+
* native Date arithmetic instead of moment, and DocumentFragment
66+
* for a single DOM write per day cell.
67+
*/
68+
const renderEventDots = (events: TEvent[], widgetHash: string): void => {
6569
const cal = document.querySelector(`#cal-${widgetHash}`);
66-
const { start } = event;
67-
const end = moment(event.end || start)
68-
.add(1, "days")
69-
.format();
70-
71-
const now = moment(start);
72-
if (cal) {
73-
while (now.isBefore(end, "day")) {
74-
const dateStr = now.format("YYYY-MM-DD");
75-
const daygrid = cal.querySelector(
76-
`.fc-daygrid-day[data-date="${dateStr}"] .fc-daygrid-day-frame .fc-daygrid-day-events`
77-
);
78-
79-
if (daygrid) {
80-
// Initialize tracking for this day if needed
81-
if (!dayEventCounts.has(dateStr)) {
82-
dayEventCounts.set(dateStr, new Set());
83-
}
84-
85-
const eventsForDay = dayEventCounts.get(dateStr);
86-
if (eventsForDay) {
87-
// Check if we haven't already added this event and haven't exceeded limit
88-
if (
89-
!eventsForDay.has(event.id) &&
90-
eventsForDay.size < MAX_DOTS_PER_DAY
91-
) {
92-
const prevDot = daygrid.querySelector(
93-
`[data-id="${event.id}"]`
94-
);
95-
if (!prevDot) {
96-
const dot = document.createElement("span");
97-
dot.style.backgroundColor = backgroundColor;
98-
dot.className =
99-
"fc-daygrid-event fc-daygrid-block-event fc-h-event fc-event fc-event-start fc-event-end fc-event-today";
100-
dot.setAttribute("data-id", event.id);
101-
daygrid.append(dot);
102-
103-
// Track that we added this event
104-
eventsForDay.add(event.id);
105-
}
70+
if (!cal) return;
71+
72+
// Clear any previously rendered dots and "+X more" links before re-rendering
73+
cal.querySelectorAll(
74+
`.${DOT_CLASS.split(" ")[0]}, .fc-daygrid-more-link`
75+
).forEach((el) => el.remove());
76+
77+
// Build day cell lookup map once (max 42 cells in a month grid)
78+
const dayCellMap = new Map<string, Element>();
79+
cal.querySelectorAll(".fc-daygrid-day").forEach((cell) => {
80+
const date = cell.getAttribute("data-date");
81+
const container = cell.querySelector(
82+
".fc-daygrid-day-frame .fc-daygrid-day-events"
83+
);
84+
if (date && container) dayCellMap.set(date, container);
85+
});
86+
87+
// Group dots by day
88+
const dotsByDay = new Map<
89+
string,
90+
{ id: string; color: string | undefined }[]
91+
>();
92+
93+
events
94+
.filter((event) => event.start)
95+
.forEach((event) => {
96+
const startTime = new Date(event.start);
97+
const endTime =
98+
new Date(event.end || event.start).getTime() + 86_400_000;
99+
const current = new Date(startTime);
100+
101+
while (current.getTime() < endTime) {
102+
const dateStr = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}-${String(current.getDate()).padStart(2, "0")}`;
103+
if (dayCellMap.has(dateStr)) {
104+
let dayDots = dotsByDay.get(dateStr);
105+
if (!dayDots) {
106+
dayDots = [];
107+
dotsByDay.set(dateStr, dayDots);
106108
}
109+
dayDots.push({
110+
id: event.id,
111+
color: event.backgroundColor,
112+
});
107113
}
114+
current.setDate(current.getDate() + 1);
115+
}
116+
});
117+
118+
// Render dots using DocumentFragment (single DOM write per day)
119+
Array.from(dotsByDay.entries()).forEach(([dateStr, dots]) => {
120+
const container = dayCellMap.get(dateStr);
121+
if (!container) return;
122+
123+
const fragment = document.createDocumentFragment();
124+
const seen = new Set<string>();
125+
let overflow = 0;
126+
127+
dots.forEach((dot) => {
128+
if (seen.has(dot.id)) return;
129+
seen.add(dot.id);
130+
if (seen.size <= MAX_DOTS_PER_DAY) {
131+
const span = document.createElement("span");
132+
if (dot.color) span.style.backgroundColor = dot.color;
133+
span.className = DOT_CLASS;
134+
fragment.appendChild(span);
135+
} else {
136+
overflow += 1;
108137
}
109-
now.add(1, "days");
138+
});
139+
140+
if (overflow > 0) {
141+
const moreEl = document.createElement("span");
142+
moreEl.className = "fc-daygrid-more-link fc-more-link text-[11px]";
143+
moreEl.textContent = `+${overflow} more`;
144+
fragment.appendChild(moreEl);
110145
}
111-
}
146+
147+
container.appendChild(fragment);
148+
});
112149
};
113150

114151
interface ICalendarMonth extends ICalendarBaseProps {
@@ -140,7 +177,7 @@ export const CalendarMonth: FC<ICalendarMonth> = ({
140177
events,
141178
onDatesSet,
142179
showFullSize,
143-
catFilters,
180+
catFilters: _catFilters,
144181
selectedDate,
145182
widgetHash,
146183
handleHeaderTooltips,
@@ -149,9 +186,9 @@ export const CalendarMonth: FC<ICalendarMonth> = ({
149186
isAlphaModalOpen,
150187
handleIsAlphaModalOpen,
151188
}) => {
152-
const [key, setKey] = useState(0); // to force calendar to rerender
153-
154189
const calendarRef = useRef<FullCalendar>(null);
190+
const eventsRef = useRef(events);
191+
eventsRef.current = events;
155192

156193
const handleSize = (event: ViewContentArg): void => {
157194
const contentAPi = event.view.calendar;
@@ -296,6 +333,10 @@ export const CalendarMonth: FC<ICalendarMonth> = ({
296333
const handleNewMonthView = useCallback(
297334
(info: DatesSetArg) => {
298335
handleHeaderTooltips(info, widgetHash, showFullSize);
336+
// Re-render dots after FC has swapped grid cells for the new month
337+
if (eventsRef.current?.length) {
338+
renderEventDots(eventsRef.current, widgetHash);
339+
}
299340
if (onDatesSet != null) {
300341
onDatesSet(info.view.currentStart.toString());
301342
}
@@ -360,22 +401,31 @@ export const CalendarMonth: FC<ICalendarMonth> = ({
360401
n2?.setAttribute("data-tip", "Next Week");
361402
n2?.setAttribute("data-place", "right");
362403
}
404+
405+
// Render dots on initial mount when events are already available
406+
if (eventsRef.current?.length) {
407+
renderEventDots(eventsRef.current, widgetHash);
408+
}
363409
}
364410
},
365411
[widgetHash]
366412
);
367413

414+
// Re-render dots when event data changes (e.g., new data from queries).
415+
// viewDidMount and datesSet handle lifecycle-driven rendering;
416+
// this effect handles data-driven updates when the grid is already stable.
368417
useEffect(() => {
369-
// Clear the day event counts when calendar re-renders
370-
dayEventCounts.clear();
371-
setKey((prev) => prev + 1);
372-
}, [events, catFilters]);
418+
if (!events?.length) return undefined;
419+
const rafId = requestAnimationFrame(() => {
420+
renderEventDots(events, widgetHash);
421+
});
422+
return () => cancelAnimationFrame(rafId);
423+
}, [events, widgetHash]);
373424

374425
return (
375426
<FullCalendar
376427
initialDate={selectedDate}
377428
locale={locale}
378-
key={key}
379429
plugins={[dayGridPlugin, interactionPlugin]}
380430
dayMaxEvents={MAX_DOTS_PER_DAY}
381431
headerToolbar={{
@@ -394,9 +444,8 @@ export const CalendarMonth: FC<ICalendarMonth> = ({
394444
},
395445
}}
396446
navLinkDayClick={() => {}} // this controls the date number click
397-
eventDisplay="block"
447+
eventDisplay="none"
398448
events={events}
399-
eventDidMount={(...args) => eventRender(...args, widgetHash)}
400449
ref={calendarRef}
401450
windowResize={handleSize}
402451
contentHeight="auto"

0 commit comments

Comments
 (0)