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; export type WorkingHours = Pick; 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; }