2
0
Files
cal/calcom/packages/lib/date-ranges.ts
2024-08-09 00:39:27 +02:00

318 lines
9.6 KiB
TypeScript

import type { IOutOfOfficeData } from "@calcom/core/getUserAvailability";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
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,
};
}
function processOOO(outOfOffice: Dayjs, timeZone: string) {
const utcOffset = outOfOffice.tz(timeZone).utcOffset();
const utcDate = outOfOffice.subtract(utcOffset, "minute");
const OOOdate = utcDate.tz(timeZone);
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(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 [];
// Get the ranges of the first user
let commonAvailability = ranges[0];
// For each of the remaining users, find the intersection of their ranges with the current common availability
for (let i = 1; i < ranges.length; i++) {
const userRanges = ranges[i];
const intersectedRanges: {
start: Dayjs;
end: Dayjs;
}[] = [];
commonAvailability.forEach((commonRange) => {
userRanges.forEach((userRange) => {
const intersection = getIntersection(commonRange, userRange);
if (intersection !== null) {
// If the current common range intersects with the user range, add the intersected time range to the new array
intersectedRanges.push(intersection);
}
});
});
commonAvailability = intersectedRanges;
}
// If the common availability is empty, there is no time when all users are available
if (commonAvailability.length === 0) {
return [];
}
return commonAvailability;
}
function getIntersection(range1: DateRange, range2: DateRange) {
const start = range1.start.utc().isAfter(range2.start) ? range1.start : range2.start;
const end = range1.end.utc().isBefore(range2.end) ? range1.end : range2.end;
if (start.utc().isBefore(end)) {
return { start, end };
}
return null;
}
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;
}