perf: Optimize getSlots function to handle large dateRanges arrays efficiently (#21371)

* perf: Optimize getSlots function to handle large dateRanges arrays efficiently

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Update packages/lib/slots.test.ts

* test: Update performance test to use 2000 date ranges

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* test: Update performance test to process 2000 date ranges across multiple days

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2025-05-17 04:00:13 +01:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> keith@cal.com <keithwillcode@gmail.com>
parent badd9a5698
commit 816df3704e
2 changed files with 85 additions and 23 deletions
+49
View File
@@ -350,3 +350,52 @@ describe("Tests the slot logic with custom env variable", () => {
).toHaveLength(11);
});
});
describe("Tests the slots function performance", () => {
it("handles hundreds of date ranges efficiently", async () => {
const startTime = process.hrtime();
const startDay = dayjs.utc().add(1, "day").startOf("day");
const dateRanges: DateRange[] = [];
for (let day = 0; day < 7; day++) {
const currentDay = startDay.add(day, "day");
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 5) {
if (dateRanges.length >= 2000) break;
dateRanges.push({
start: currentDay.hour(hour).minute(minute),
end: currentDay
.hour(hour)
.minute(minute + 4)
.second(59),
});
}
if (dateRanges.length >= 2000) break;
}
if (dateRanges.length >= 2000) break;
}
const result = getSlots({
inviteeDate: startDay,
frequency: 5,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 5,
offsetStart: 0,
});
expect(result.length).toBeGreaterThan(0);
const endTime = process.hrtime(startTime);
const executionTimeInMs = endTime[0] * 1000 + endTime[1] / 1000000;
expect(executionTimeInMs).toBeLessThan(2000); // less than 2 seconds for 2000 date ranges
console.log(
`Performance test completed in ${executionTimeInMs}ms with ${result.length} slots generated from ${dateRanges.length} date ranges`
);
});
});
+36 -23
View File
@@ -40,6 +40,9 @@ function buildSlotsWithDateRanges({
frequency = minimumOfOne(frequency);
eventLength = minimumOfOne(eventLength);
offsetStart = offsetStart ? minimumOfOne(offsetStart) : 0;
const orderedDateRanges = dateRanges.sort((a, b) => a.start.valueOf() - b.start.valueOf());
// there can only ever be one slot at a given start time, and based on duration also only a single length.
const slots = new Map<
string,
@@ -66,7 +69,8 @@ function buildSlotsWithDateRanges({
const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute");
const orderedDateRanges = dateRanges.sort((a, b) => a.start.valueOf() - b.start.valueOf());
const slotBoundaries = new Map<string, true>();
orderedDateRanges.forEach((range) => {
const dateYYYYMMDD = range.start.format("YYYY-MM-DD");
@@ -81,32 +85,41 @@ function buildSlotsWithDateRanges({
slotStartTime = slotStartTime.add(offsetStart ?? 0, "minutes").tz(timeZone);
// if the slotStartTime is between an existing slot, we need to adjust to the begin of the existing slot
// but that adjusted startTime must be legal.
const iterator = slots.keys();
let result = iterator.next();
// Find the nearest appropriate slot boundary if this time falls within an existing slot
const slotBoundariesArray = Array.from(slotBoundaries.keys()).map((t) => dayjs(t));
if (slotBoundariesArray.length > 0) {
slotBoundariesArray.sort((a, b) => a.valueOf() - b.valueOf());
while (!result.done) {
const utcResultValue = dayjs.utc(result.value);
// if the slotStartTime is between an existing slot, we need to adjust to the begin of the existing slot
if (
utcResultValue.isBefore(slotStartTime) &&
utcResultValue.add(frequency + (offsetStart ?? 0), "minutes").isAfter(slotStartTime)
) {
// however, the slot can now be before the start of this date range.
if (!utcResultValue.isBefore(range.start)) {
// it is between, if possible floor down to the start of the existing slot
slotStartTime = utcResultValue;
} else {
// if not possible to floor, we need to ceil up to the next slot.
slotStartTime = utcResultValue.add(frequency + (offsetStart ?? 0), "minutes");
let prevBoundary = null;
for (let i = slotBoundariesArray.length - 1; i >= 0; i--) {
if (slotBoundariesArray[i].isBefore(slotStartTime)) {
prevBoundary = slotBoundariesArray[i];
break;
}
}
if (prevBoundary) {
const prevBoundaryEnd = prevBoundary.add(frequency + (offsetStart ?? 0), "minutes");
if (prevBoundaryEnd.isAfter(slotStartTime)) {
if (!prevBoundary.isBefore(range.start)) {
slotStartTime = prevBoundary;
} else {
slotStartTime = prevBoundaryEnd;
}
slotStartTime = slotStartTime.tz(timeZone);
}
// and then convert to the correct timezone - UTC mode is just for performance.
slotStartTime = slotStartTime.tz(timeZone);
}
result = iterator.next();
}
while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").utc().isAfter(range.end)) {
const slotKey = slotStartTime.toISOString();
if (slots.has(slotKey)) {
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
continue;
}
slotBoundaries.set(slotKey, true);
const dateOutOfOfficeExists = datesOutOfOffice?.[dateYYYYMMDD];
let slotData: {
time: Dayjs;
@@ -133,7 +146,7 @@ function buildSlotsWithDateRanges({
};
}
slots.set(slotData.time.toISOString(), slotData);
slots.set(slotKey, slotData);
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
}
});