Files
calendar/packages/lib/date-ranges.ts
T
Alex van AndelGitHubcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
63df3d9c14 fix: merge working hours when adjacent (#21912)
* fix: Adjacency issue when working hours connect over multiple days

* Add tests to validate the new merging of day end logic

* Update to correct annotation.

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Implement subsequent date ranges for date overrides also

* The map needs to be updated on successful resolve.

* test: add failing test for overlapping ranges with same end time

Demonstrates bug where overlapping working hour ranges (6:00-10:00 and 8:00-10:00)
lose the earlier portion (6:00-8:00), showing only 8:00 and 9:00 slots
instead of all 4 slots (6:00, 7:00, 8:00, 9:00).

Related to Carina's comment on PR #21912.

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

* fix: properly merge overlapping ranges with same end time

Fixes bug where overlapping working hour ranges with the same end time
(e.g., 6:00-10:00 and 8:00-10:00) would lose the earlier portion of the
first range. The merging logic now correctly preserves the earliest
start time when ranges overlap and share the same end time.

This ensures all expected time slots are available (6:00, 7:00, 8:00, 9:00)
instead of losing the earlier slots (6:00, 7:00).

Resolves the issue identified in Carina's comment on PR #21912.

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

* perf: optimize overlapping range detection from O(n²) to O(n)

Replaces Object.keys().find() with Map-based lookup for ranges with same end time.
This optimization handles 2000+ date ranges efficiently, reducing complexity from
4M operations to linear time while maintaining the same merging behavior.

Performance improvement for high-volume event types with many availability ranges.

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-30 16:15:40 +00:00

468 lines
14 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;
}
// reducer
export function processWorkingHours(
results: Record<number, DateRange>,
{
item,
timeZone,
dateFrom,
dateTo,
travelSchedules,
}: {
item: WorkingHours;
timeZone: string;
dateFrom: Dayjs;
dateTo: Dayjs;
travelSchedules: TravelSchedule[];
}
) {
const utcDateTo = dateTo.utc();
let endTimeToKeyMap: Map<number, number[]> | undefined;
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;
}
const endTimeKey = endResult.valueOf();
// Create a map of end times to range keys for O(1) lookup
if (!endTimeToKeyMap) {
endTimeToKeyMap = new Map<number, number[]>();
for (const [key, range] of Object.entries(results)) {
const endTime = range.end.valueOf();
if (!endTimeToKeyMap.has(endTime)) {
endTimeToKeyMap.set(endTime, []);
}
endTimeToKeyMap.get(endTime)!.push(Number(key));
}
}
// Check for overlapping ranges with the same end time using O(1) lookup
const keysWithSameEndTime = endTimeToKeyMap.get(endTimeKey) || [];
let foundOverlapping = false;
for (const key of keysWithSameEndTime) {
const existingRange = results[key];
if (
startResult.valueOf() <= existingRange.end.valueOf() &&
endResult.valueOf() >= existingRange.start.valueOf()
) {
// Merge by taking the earliest start time and keeping the same end time
results[key] = {
start: dayjs.min(existingRange.start, startResult),
end: endResult,
};
foundOverlapping = true;
break;
}
}
if (foundOverlapping) {
continue;
}
if (results[startResult.valueOf()]) {
// if a result already exists, we merge the end time
const oldKey = startResult.valueOf();
const newKey = endResult.valueOf();
results[newKey] = {
start: results[oldKey].start,
end: dayjs.max(results[oldKey].end, endResult),
};
if (endTimeToKeyMap) {
const oldEndTime = results[oldKey].end.valueOf();
const oldKeys = endTimeToKeyMap.get(oldEndTime) || [];
const filteredKeys = oldKeys.filter((k) => k !== oldKey);
if (filteredKeys.length === 0) {
endTimeToKeyMap.delete(oldEndTime);
} else {
endTimeToKeyMap.set(oldEndTime, filteredKeys);
}
if (!endTimeToKeyMap.has(endTimeKey)) {
endTimeToKeyMap.set(endTimeKey, []);
}
endTimeToKeyMap.get(endTimeKey)!.push(newKey);
}
delete results[oldKey]; // delete the previous end time
continue;
}
// otherwise we create a new result
const newKey = endResult.valueOf();
results[newKey] = {
start: startResult,
end: endResult,
};
if (endTimeToKeyMap) {
if (!endTimeToKeyMap.has(endTimeKey)) {
endTimeToKeyMap.set(endTimeKey, []);
}
endTimeToKeyMap.get(endTimeKey)!.push(newKey);
}
}
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(
Object.values(
availability.reduce((processed: Record<number, DateRange>, item) => {
if (!("days" in item)) {
return processed;
}
processed = processWorkingHours(processed, {
item,
timeZone,
dateFrom: dateFromOrganizerTZ,
dateTo,
travelSchedules,
});
return processed;
}, {})
)
);
const groupedOOO = groupByDate(
outOfOffice
? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs.utc(outOfOffice), timeZone))
: []
);
const groupedDateOverrides = groupByDate(
Object.values(
availability.reduce((processed: Record<number, DateRange>, item) => {
// early return if item is not a date override
if (!("date" in item && !!item.date)) {
return processed;
}
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,
"[]"
)
) {
// unlike working hours, date overrides are always one. No loop per day.
const newProcessedDateOverride = processDateOverride({
item,
itemDateAsUtc,
timeZone,
travelSchedules,
});
if (processed[newProcessedDateOverride.start.valueOf()]) {
// if a result already exists, we merge the end time
processed[newProcessedDateOverride.start.valueOf()].end = dayjs.max(
processed[newProcessedDateOverride.start.valueOf()].end,
newProcessedDateOverride.end
);
return processed;
}
processed[newProcessedDateOverride.end.valueOf()] = newProcessedDateOverride;
}
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;
}