Files
calendar/packages/lib/date-ranges.ts
T
Alex van AndelGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
21add18ad8 perf: Remove isAfter/isBefore in date-ranges:subtract (#22549)
* perf: Remove isAfter/isBefore in date-ranges:subtract

* Small amend to also use valueOf here

* Addressed issue with utc offset in non-utc mode

* test: add failing test demonstrating timezone offset bug in subtract function

This test shows that when mixing UTC and timezone-aware dayjs objects,
the timezone offset adjustment (date.valueOf() + date.utcOffset() * 60000)
creates incorrect chronological comparisons that prevent proper exclusion.

The test expects 0 results but gets 6, reproducing the issue causing
futureLimit.timezone.test.ts to fail with extra time slots.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Fixed failing tests that proved the logic wasn't working correctly

* test: add scheduling pipeline reproduction test for futureLimit.timezone.test.ts failures

This test reproduces the exact scenario from getBusyTimes.ts that causes
the 6 extra slots (12:30-17:30 UTC) to appear in futureLimit.timezone.test.ts.

The test simulates:
- calendarBusyTimes from external calendar sources (converted to dayjs objects)
- openSeatsDateRanges from booking data with mixed timezone contexts
- The specific timezone mixing pattern that causes exclusion to fail

This demonstrates the remaining issue in the subtract function after
the timezone offset bug was fixed.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* test: add getUserAvailability reproduction test showing subtract extends ranges instead of excluding busy times

This test reproduces the exact scenario from futureLimit.timezone.test.ts where the subtract function incorrectly extends available range end times to match busy time start times instead of properly subtracting the busy times from the available ranges.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* test: fix getUserAvailability reproduction test to expect actual buggy behavior

The test now expects the current buggy behavior (5 ranges with 2024-06-02 missing)
instead of correct behavior, so it passes with current logic and would fail once
the subtract function is fixed to properly handle non-overlapping busy times.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* test: fix getUserAvailability reproduction test to expect correct behavior and fail with buggy logic

The test now expects the correct behavior (ranges should remain at 12:30:00.000Z and all 6 ranges should be present) so it fails with the current buggy subtract function logic that incorrectly extends ranges to 18:30:00.000Z. Once the subtract function is fixed, this test will pass.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Push the valid fix for the futureLimits test failure

* test: update getUserAvailability test console output to match corrected behavior

The test now correctly expects 5 ranges (with June 2 properly excluded due to overlapping busy time) and the console output reflects this corrected behavior.

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* Remove console.log calls

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-16 12:50:35 -04:00

356 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 = [];
const sortedExcludedRanges = [...excludedRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf());
for (const { start: sourceStart, end: sourceEnd, ...passThrough } of sourceRanges) {
let currentStart = sourceStart;
for (const excludedRange of sortedExcludedRanges) {
if (excludedRange.start.valueOf() >= sourceEnd.valueOf()) break;
if (excludedRange.end.valueOf() <= currentStart.valueOf()) continue;
if (excludedRange.start.valueOf() > currentStart.valueOf()) {
result.push({ start: currentStart, end: excludedRange.start, ...passThrough });
}
if (excludedRange.end.valueOf() > currentStart.valueOf()) {
currentStart = excludedRange.end;
}
}
if (sourceEnd.valueOf() > currentStart.valueOf()) {
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;
}