Skip to content

Commit 5efc7f3

Browse files
perf: optimize O(n²) algorithms in slot generation to O(n) or O(n log n) (calcom#21373)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
1 parent 0afecec commit 5efc7f3

1 file changed

Lines changed: 51 additions & 35 deletions

File tree

  • packages/trpc/server/routers/viewer/slots

packages/trpc/server/routers/viewer/slots/util.ts

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// eslint-disable-next-line no-restricted-imports
2-
import { countBy } from "lodash";
32
import type { Logger } from "tslog";
43
import { v4 as uuid } from "uuid";
54

@@ -248,14 +247,21 @@ export const getRegularOrDynamicEventType = withReporting(
248247
);
249248

250249
function applyOccupiedSeatsToCurrentSeats(currentSeats: CurrentSeats, occupiedSeats: SelectedSlots[]) {
251-
const occupiedSeatsCount = countBy(occupiedSeats, (item) => item.slotUtcStartDate.toISOString());
252-
Object.keys(occupiedSeatsCount).forEach((date) => {
250+
const occupiedSeatsMap = new Map<string, number>();
251+
252+
occupiedSeats.forEach((item) => {
253+
const dateKey = item.slotUtcStartDate.toISOString();
254+
occupiedSeatsMap.set(dateKey, (occupiedSeatsMap.get(dateKey) || 0) + 1);
255+
});
256+
257+
occupiedSeatsMap.forEach((count, date) => {
253258
currentSeats.push({
254259
uid: uuid(),
255260
startTime: dayjs(date).toDate(),
256-
_count: { attendees: occupiedSeatsCount[date] },
261+
_count: { attendees: count },
257262
});
258263
});
264+
259265
return currentSeats;
260266
}
261267

@@ -542,15 +548,15 @@ const _getAvailableSlots = async ({ input, ctx }: GetScheduleOptions): Promise<I
542548

543549
currentSeats = availabilityCheckProps.currentSeats;
544550
}
551+
const busySlotsFromReservedSlots = reservedSlots.reduce<EventBusyDate[]>((r, c) => {
552+
if (!c.isSeat) {
553+
r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate });
554+
}
555+
return r;
556+
}, []);
557+
545558
availableTimeSlots = availableTimeSlots
546559
.map((slot) => {
547-
const busySlotsFromReservedSlots = reservedSlots.reduce<EventBusyDate[]>((r, c) => {
548-
if (!c.isSeat) {
549-
r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate });
550-
}
551-
return r;
552-
}, []);
553-
554560
if (
555561
!checkForConflicts({
556562
time: slot.time,
@@ -588,34 +594,41 @@ const _getAvailableSlots = async ({ input, ctx }: GetScheduleOptions): Promise<I
588594
});
589595

590596
function _mapSlotsToDate() {
597+
const currentSeatsMap = new Map();
598+
599+
if (currentSeats && currentSeats.length > 0) {
600+
currentSeats.forEach((booking) => {
601+
const timeKey = booking.startTime.toISOString();
602+
currentSeatsMap.set(timeKey, {
603+
attendees: booking._count.attendees,
604+
uid: booking.uid,
605+
});
606+
});
607+
}
608+
591609
return availableTimeSlots.reduce(
592610
(
593611
r: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>,
594612
{ time, ...passThroughProps }
595613
) => {
596-
// TODO: Adds unit tests to prevent regressions in getSchedule (try multiple timezones)
597-
598614
// This used to be _time.tz(input.timeZone) but Dayjs tz() is slow.
599615
// toLocaleDateString slugish, using Intl.DateTimeFormat we get the desired speed results.
600616
const dateString = formatter.format(time.toDate());
617+
const timeISO = time.toISOString();
601618

602619
r[dateString] = r[dateString] || [];
603620
if (eventType?.onlyShowFirstAvailableSlot && r[dateString].length > 0) {
604621
return r;
605622
}
623+
624+
const existingBooking = currentSeatsMap.get(timeISO);
625+
606626
r[dateString].push({
607627
...passThroughProps,
608-
time: time.toISOString(),
609-
// Conditionally add the attendees and booking id to slots object if there is already a booking during that time
610-
...(currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString()) && {
611-
attendees:
612-
currentSeats[
613-
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
614-
]._count.attendees,
615-
bookingUid:
616-
currentSeats[
617-
currentSeats.findIndex((booking) => booking.startTime.toISOString() === time.toISOString())
618-
].uid,
628+
time: timeISO,
629+
...(existingBooking && {
630+
attendees: existingBooking.attendees,
631+
bookingUid: existingBooking.uid,
619632
}),
620633
});
621634
return r;
@@ -654,35 +667,38 @@ const _getAvailableSlots = async ({ input, ctx }: GetScheduleOptions): Promise<I
654667
function _mapWithinBoundsSlotsToDate() {
655668
// This should never happen. Just for type safety, we already check in the upper scope
656669
if (!eventType) throw new TRPCError({ code: "NOT_FOUND" });
657-
return Object.entries(slotsMappedToDate).reduce((withinBoundsSlotsMappedToDate, [date, slots]) => {
658-
// Computation Optimization: If a future limit violation has been found, we just consider all slots to be out of bounds beyond that slot.
659-
// We can't do the same for periodType=RANGE because it can start from a day other than today and today will hit the violation then.
660-
if (foundAFutureLimitViolation && doesRangeStartFromToday(eventType.periodType)) {
661-
return withinBoundsSlotsMappedToDate;
670+
671+
const withinBoundsSlotsMappedToDate = {} as typeof slotsMappedToDate;
672+
const doesStartFromToday = doesRangeStartFromToday(eventType.periodType);
673+
674+
for (const [date, slots] of Object.entries(slotsMappedToDate)) {
675+
if (foundAFutureLimitViolation && doesStartFromToday) {
676+
break; // Instead of continuing the loop, we can break since all future dates will be skipped
662677
}
678+
663679
const filteredSlots = slots.filter((slot) => {
664680
const isFutureLimitViolationForTheSlot = isTimeViolatingFutureLimit({
665681
time: slot.time,
666682
periodLimits,
667683
});
684+
668685
if (isFutureLimitViolationForTheSlot) {
669686
foundAFutureLimitViolation = true;
670687
}
688+
671689
return (
672690
!isFutureLimitViolationForTheSlot &&
673691
// TODO: Perf Optimization: Slots calculation logic already seems to consider the minimum booking notice and past booking time and thus there shouldn't be need to filter out slots here.
674692
!isTimeOutOfBounds({ time: slot.time, minimumBookingNotice: eventType.minimumBookingNotice })
675693
);
676694
});
677695

678-
if (!filteredSlots.length) {
679-
// If there are no slots available, we don't set that date, otherwise having an empty slots array makes frontend consider it as an all day OOO case
680-
return withinBoundsSlotsMappedToDate;
696+
if (filteredSlots.length) {
697+
withinBoundsSlotsMappedToDate[date] = filteredSlots;
681698
}
699+
}
682700

683-
withinBoundsSlotsMappedToDate[date] = filteredSlots;
684-
return withinBoundsSlotsMappedToDate;
685-
}, {} as typeof slotsMappedToDate);
701+
return withinBoundsSlotsMappedToDate;
686702
}
687703
const mapWithinBoundsSlotsToDate = withReporting(_mapWithinBoundsSlotsToDate, "mapWithinBoundsSlotsToDate");
688704
const withinBoundsSlotsMappedToDate = mapWithinBoundsSlotsToDate();

0 commit comments

Comments
 (0)