* feat: optimize date range intersection algorithm from O(n²) to O(n log n) - Replace nested forEach loops with two-pointer approach - Sort arrays once and traverse efficiently - Maintains exact same functionality and API - Improves performance for team scheduling scenarios Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * feat: add comprehensive stress test for intersect function performance - Add performance comparison test showing 22x improvement from O(n²) to O(n log n) - Include edge cases testing for correctness validation - Test with realistic data sizes (50 date ranges per user) - Demonstrate identical results between old and new algorithms - Provide timing measurements and performance logging Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * refactor: update stress test to focus on optimized algorithm performance - Remove old O(n²) algorithm comparison logic from test - Focus stress test on current optimized intersect function performance - Test with 400 total date ranges (4 users × 100 ranges each) - Maintain realistic data sizes for team scheduling scenarios - Execution time: 10.33ms for 400 ranges with 97 intersections found Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * docs: restore explanatory comment in intersect function - Add back comment explaining intersection logic as requested in PR review - Comment clarifies when intersected time ranges are added to results array - Addresses GitHub feedback from hbjORbj to revert comment removal Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * refactor: remove unnecessary intersectNew variable in edge cases test - Use intersect function directly instead of intersectNew variable assignment - Addresses GitHub feedback from keithwillcode to clean up test code - No functional changes to test logic or assertions Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * test: add comprehensive test coverage for intersect function - Add 27 comprehensive test cases covering all edge cases and scenarios - Test empty inputs, single arrays, overlapping ranges, containment - Add team scheduling scenarios and performance testing - Test unsorted input handling, cross-day scenarios, time precision - Ensure comprehensive coverage without knowing algorithm implementation - All tests pass with optimized O(n log n) algorithm - Performance test shows 8.50ms execution time for 400 date ranges Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * perf: improve intersect function in slots (#22087) --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Benny Joo <sldisek783@gmail.com>
355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
import type { Dayjs } from "@calcom/dayjs";
|
|
import dayjs from "@calcom/dayjs";
|
|
import type { IOutOfOfficeData } from "@calcom/lib/getUserAvailability";
|
|
import type { Availability } from "@calcom/prisma/client";
|
|
|
|
export type DateRange = {
|
|
start: Dayjs;
|
|
end: Dayjs;
|
|
};
|
|
|
|
export type DateOverride = Pick<Availability, "date" | "startTime" | "endTime">;
|
|
export type WorkingHours = Pick<Availability, "days" | "startTime" | "endTime">;
|
|
|
|
type TravelSchedule = { startDate: Dayjs; endDate?: Dayjs; timeZone: string };
|
|
|
|
function getAdjustedTimezone(date: Dayjs, timeZone: string, travelSchedules: TravelSchedule[]) {
|
|
let adjustedTimezone = timeZone;
|
|
|
|
for (const travelSchedule of travelSchedules) {
|
|
if (
|
|
!date.isBefore(travelSchedule.startDate) &&
|
|
(!travelSchedule.endDate || !date.isAfter(travelSchedule.endDate))
|
|
) {
|
|
adjustedTimezone = travelSchedule.timeZone;
|
|
break;
|
|
}
|
|
}
|
|
return adjustedTimezone;
|
|
}
|
|
|
|
export function processWorkingHours({
|
|
item,
|
|
timeZone,
|
|
dateFrom,
|
|
dateTo,
|
|
travelSchedules,
|
|
}: {
|
|
item: WorkingHours;
|
|
timeZone: string;
|
|
dateFrom: Dayjs;
|
|
dateTo: Dayjs;
|
|
travelSchedules: TravelSchedule[];
|
|
}) {
|
|
const utcDateTo = dateTo.utc();
|
|
const results = [];
|
|
for (let date = dateFrom.startOf("day"); utcDateTo.isAfter(date); date = date.add(1, "day")) {
|
|
const fromOffset = dateFrom.startOf("day").utcOffset();
|
|
|
|
const adjustedTimezone = getAdjustedTimezone(date, timeZone, travelSchedules);
|
|
|
|
const offset = date.tz(adjustedTimezone).utcOffset();
|
|
|
|
// it always has to be start of the day (midnight) even when DST changes
|
|
const dateInTz = date.add(fromOffset - offset, "minutes").tz(adjustedTimezone);
|
|
if (!item.days.includes(dateInTz.day())) {
|
|
continue;
|
|
}
|
|
|
|
let start = dateInTz
|
|
.add(item.startTime.getUTCHours(), "hours")
|
|
.add(item.startTime.getUTCMinutes(), "minutes");
|
|
|
|
let end = dateInTz.add(item.endTime.getUTCHours(), "hours").add(item.endTime.getUTCMinutes(), "minutes");
|
|
|
|
const offsetBeginningOfDay = dayjs(start.format("YYYY-MM-DD hh:mm")).tz(adjustedTimezone).utcOffset();
|
|
const offsetDiff = start.utcOffset() - offsetBeginningOfDay; // there will be 60 min offset on the day day of DST change
|
|
|
|
start = start.add(offsetDiff, "minute");
|
|
end = end.add(offsetDiff, "minute");
|
|
|
|
const startResult = dayjs.max(start, dateFrom);
|
|
let endResult = dayjs.min(end, dateTo.tz(adjustedTimezone));
|
|
|
|
// INFO: We only allow users to set availability up to 11:59PM which ends up not making them available
|
|
// up to midnight.
|
|
if (endResult.hour() === 23 && endResult.minute() === 59) {
|
|
endResult = endResult.add(1, "minute");
|
|
}
|
|
|
|
if (endResult.isBefore(startResult)) {
|
|
// if an event ends before start, it's not a result.
|
|
continue;
|
|
}
|
|
|
|
results.push({
|
|
start: startResult,
|
|
end: endResult,
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export function processDateOverride({
|
|
item,
|
|
itemDateAsUtc,
|
|
timeZone,
|
|
travelSchedules,
|
|
}: {
|
|
item: DateOverride;
|
|
itemDateAsUtc: Dayjs;
|
|
timeZone: string;
|
|
travelSchedules: TravelSchedule[];
|
|
}) {
|
|
const overrideDate = dayjs(item.date);
|
|
|
|
const adjustedTimezone = getAdjustedTimezone(overrideDate, timeZone, travelSchedules);
|
|
|
|
const itemDateStartOfDay = itemDateAsUtc.startOf("day");
|
|
const startDate = itemDateStartOfDay
|
|
.add(item.startTime.getUTCHours(), "hours")
|
|
.add(item.startTime.getUTCMinutes(), "minutes")
|
|
.second(0)
|
|
.tz(adjustedTimezone, true);
|
|
|
|
let endDate = itemDateStartOfDay;
|
|
const endTimeHours = item.endTime.getUTCHours();
|
|
const endTimeMinutes = item.endTime.getUTCMinutes();
|
|
|
|
if (endTimeHours === 23 && endTimeMinutes === 59) {
|
|
endDate = endDate.add(1, "day").tz(timeZone, true);
|
|
} else {
|
|
endDate = itemDateStartOfDay
|
|
.add(endTimeHours, "hours")
|
|
.add(endTimeMinutes, "minutes")
|
|
.second(0)
|
|
.tz(adjustedTimezone, true);
|
|
}
|
|
|
|
return {
|
|
start: startDate,
|
|
end: endDate,
|
|
};
|
|
}
|
|
|
|
// This function processes out-of-office dates and returns a date range for each OOO date.
|
|
function processOOO(outOfOffice: Dayjs, timeZone: string) {
|
|
const OOOdate = outOfOffice.tz(timeZone, true);
|
|
return {
|
|
start: OOOdate,
|
|
end: OOOdate,
|
|
};
|
|
}
|
|
|
|
export function buildDateRanges({
|
|
availability,
|
|
timeZone /* Organizer timeZone */,
|
|
dateFrom /* Attendee dateFrom */,
|
|
dateTo /* `` dateTo */,
|
|
travelSchedules,
|
|
outOfOffice,
|
|
}: {
|
|
timeZone: string;
|
|
availability: (DateOverride | WorkingHours)[];
|
|
dateFrom: Dayjs;
|
|
dateTo: Dayjs;
|
|
travelSchedules: TravelSchedule[];
|
|
outOfOffice?: IOutOfOfficeData;
|
|
}): { dateRanges: DateRange[]; oooExcludedDateRanges: DateRange[] } {
|
|
const dateFromOrganizerTZ = dateFrom.tz(timeZone);
|
|
const groupedWorkingHours = groupByDate(
|
|
availability.reduce((processed: DateRange[], item) => {
|
|
if ("days" in item) {
|
|
processed = processed.concat(
|
|
processWorkingHours({ item, timeZone, dateFrom: dateFromOrganizerTZ, dateTo, travelSchedules })
|
|
);
|
|
}
|
|
return processed;
|
|
}, [])
|
|
);
|
|
const OOOdates = outOfOffice
|
|
? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs.utc(outOfOffice), timeZone))
|
|
: [];
|
|
|
|
const groupedOOO = groupByDate(OOOdates);
|
|
|
|
const groupedDateOverrides = groupByDate(
|
|
availability.reduce((processed: DateRange[], item) => {
|
|
if ("date" in item && !!item.date) {
|
|
const itemDateAsUtc = dayjs.utc(item.date);
|
|
// TODO: Remove the .subtract(1, "day") and .add(1, "day") part and
|
|
// refactor this to actually work with correct dates.
|
|
// As of 2024-02-20, there are mismatches between local and UTC dates for overrides
|
|
// and the dateFrom and dateTo fields, resulting in this if not returning true, which
|
|
// results in "no available users found" errors.
|
|
if (
|
|
itemDateAsUtc.isBetween(
|
|
dateFrom.subtract(1, "day").startOf("day"),
|
|
dateTo.add(1, "day").endOf("day"),
|
|
null,
|
|
"[]"
|
|
)
|
|
) {
|
|
processed.push(processDateOverride({ item, itemDateAsUtc, timeZone, travelSchedules }));
|
|
}
|
|
}
|
|
return processed;
|
|
}, [])
|
|
);
|
|
|
|
const dateRanges = Object.values({
|
|
...groupedWorkingHours,
|
|
...groupedDateOverrides,
|
|
}).map(
|
|
// remove 0-length overrides that were kept to cancel out working dates until now.
|
|
(ranges) => ranges.filter((range) => range.start.valueOf() !== range.end.valueOf())
|
|
);
|
|
|
|
const oooExcludedDateRanges = Object.values({
|
|
...groupedWorkingHours,
|
|
...groupedDateOverrides,
|
|
...groupedOOO,
|
|
}).map(
|
|
// remove 0-length overrides && OOO dates that were kept to cancel out working dates until now.
|
|
(ranges) => ranges.filter((range) => range.start.valueOf() !== range.end.valueOf())
|
|
);
|
|
|
|
return { dateRanges: dateRanges.flat(), oooExcludedDateRanges: oooExcludedDateRanges.flat() };
|
|
}
|
|
|
|
export function groupByDate(ranges: DateRange[]): { [x: string]: DateRange[] } {
|
|
const results = ranges.reduce(
|
|
(
|
|
previousValue: {
|
|
[date: string]: DateRange[];
|
|
},
|
|
currentValue
|
|
) => {
|
|
const dateString = dayjs(currentValue.start).format("YYYY-MM-DD");
|
|
|
|
previousValue[dateString] =
|
|
typeof previousValue[dateString] === "undefined"
|
|
? [currentValue]
|
|
: [...previousValue[dateString], currentValue];
|
|
return previousValue;
|
|
},
|
|
{}
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
export function intersect(ranges: DateRange[][]): DateRange[] {
|
|
if (!ranges.length) {
|
|
return [];
|
|
}
|
|
|
|
type ProcessedDateRange = DateRange & { startValue: number; endValue: number };
|
|
|
|
// Pre-sort all user ranges and cache timestamp values.
|
|
const sortedRanges: ProcessedDateRange[][] = ranges.map((userRanges) =>
|
|
userRanges
|
|
.map((r) => ({
|
|
...r,
|
|
startValue: r.start.valueOf(),
|
|
endValue: r.end.valueOf(),
|
|
}))
|
|
.sort((a, b) => a.startValue - b.startValue)
|
|
);
|
|
|
|
let commonAvailability: ProcessedDateRange[] = sortedRanges[0];
|
|
|
|
for (let i = 1; i < sortedRanges.length; i++) {
|
|
// Early exit if no common availability is left.
|
|
if (commonAvailability.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const userRanges = sortedRanges[i];
|
|
const intersectedRanges: ProcessedDateRange[] = [];
|
|
|
|
let commonIndex = 0;
|
|
let userIndex = 0;
|
|
|
|
while (commonIndex < commonAvailability.length && userIndex < userRanges.length) {
|
|
const commonRange = commonAvailability[commonIndex];
|
|
const userRange = userRanges[userIndex];
|
|
|
|
const intersectStartValue = Math.max(commonRange.startValue, userRange.startValue);
|
|
const intersectEndValue = Math.min(commonRange.endValue, userRange.endValue);
|
|
|
|
if (intersectStartValue < intersectEndValue) {
|
|
const intersectStart =
|
|
commonRange.startValue > userRange.startValue ? commonRange.start : userRange.start;
|
|
const intersectEnd = commonRange.endValue < userRange.endValue ? commonRange.end : userRange.end;
|
|
intersectedRanges.push({
|
|
start: intersectStart,
|
|
end: intersectEnd,
|
|
startValue: intersectStartValue,
|
|
endValue: intersectEndValue,
|
|
});
|
|
}
|
|
|
|
if (commonRange.endValue <= userRange.endValue) {
|
|
commonIndex++;
|
|
} else {
|
|
userIndex++;
|
|
}
|
|
}
|
|
commonAvailability = intersectedRanges;
|
|
}
|
|
|
|
// Strip the cached values before returning to match the expected DateRange[] type.
|
|
return commonAvailability.map(({ start, end }) => ({ start, end }));
|
|
}
|
|
|
|
export function subtract(
|
|
sourceRanges: (DateRange & { [x: string]: unknown })[],
|
|
excludedRanges: DateRange[]
|
|
) {
|
|
const result: DateRange[] = [];
|
|
|
|
for (const { start: sourceStart, end: sourceEnd, ...passThrough } of sourceRanges) {
|
|
let currentStart = sourceStart;
|
|
|
|
const overlappingRanges = excludedRanges.filter(
|
|
({ start, end }) => start.isBefore(sourceEnd) && end.isAfter(sourceStart)
|
|
);
|
|
|
|
overlappingRanges.sort((a, b) => (a.start.isAfter(b.start) ? 1 : -1));
|
|
|
|
for (const { start: excludedStart, end: excludedEnd } of overlappingRanges) {
|
|
if (excludedStart.isAfter(currentStart)) {
|
|
result.push({ start: currentStart, end: excludedStart });
|
|
}
|
|
currentStart = excludedEnd.isAfter(currentStart) ? excludedEnd : currentStart;
|
|
}
|
|
|
|
if (sourceEnd.isAfter(currentStart)) {
|
|
result.push({ start: currentStart, end: sourceEnd, ...passThrough });
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export function mergeOverlappingRanges(ranges: { start: Date; end: Date }[]): { start: Date; end: Date }[] {
|
|
if (ranges.length === 0) return [];
|
|
|
|
const sortedRanges = ranges.sort((a, b) => a.start.valueOf() - b.start.valueOf());
|
|
|
|
const mergedRanges: { start: Date; end: Date }[] = [sortedRanges[0]];
|
|
|
|
for (let i = 1; i < sortedRanges.length; i++) {
|
|
const lastMergedRange = mergedRanges[mergedRanges.length - 1];
|
|
const currentRange = sortedRanges[i];
|
|
|
|
if (currentRange.start.getTime() <= lastMergedRange.end.getTime()) {
|
|
lastMergedRange.end = new Date(Math.max(lastMergedRange.end.getTime(), currentRange.end.getTime()));
|
|
} else {
|
|
mergedRanges.push(currentRange);
|
|
}
|
|
}
|
|
return mergedRanges;
|
|
}
|