fix: OOO for team events (#14503)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Carina Wollendorfer
2024-04-10 13:47:26 -07:00
committed by GitHub
co-authored by CarinaWolli
parent c4b7062d65
commit 7b0321043d
8 changed files with 126 additions and 26 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import type { WorkingHours } from "@calcom/types/schedule";
export const getAggregateWorkingHours = (
usersWorkingHoursAndBusySlots: (Omit<
Awaited<ReturnType<Awaited<typeof import("./getUserAvailability")>["getUserAvailability"]>>,
"currentSeats" | "dateRanges"
"currentSeats" | "dateRanges" | "oooExcludedDateRanges"
> & { user?: { isFixed?: boolean } })[],
// eslint-disable-next-line @typescript-eslint/no-unused-vars
schedulingType: SchedulingType | null
@@ -5,18 +5,30 @@ import { SchedulingType } from "@calcom/prisma/enums";
import { mergeOverlappingDateRanges } from "./date-range-utils/mergeOverlappingDateRanges";
export const getAggregatedAvailability = (
userAvailability: { dateRanges: DateRange[]; user?: { isFixed?: boolean } }[],
userAvailability: {
dateRanges: DateRange[];
oooExcludedDateRanges: DateRange[];
user?: { isFixed?: boolean };
}[],
schedulingType: SchedulingType | null
): DateRange[] => {
const isTeamEvent =
schedulingType === SchedulingType.COLLECTIVE ||
schedulingType === SchedulingType.ROUND_ROBIN ||
userAvailability.length > 1;
const fixedHosts = userAvailability.filter(
({ user }) => !schedulingType || schedulingType === SchedulingType.COLLECTIVE || user?.isFixed
);
const dateRangesToIntersect = fixedHosts.map((s) => s.dateRanges);
const dateRangesToIntersect = fixedHosts.map((s) =>
!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges
);
const unfixedHosts = userAvailability.filter(({ user }) => user?.isFixed !== true);
if (unfixedHosts.length) {
dateRangesToIntersect.push(unfixedHosts.flatMap((s) => s.dateRanges));
dateRangesToIntersect.push(
unfixedHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges))
);
}
const availability = intersect(dateRangesToIntersect);
+16 -12
View File
@@ -332,18 +332,6 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
}
}
const dateRanges = buildDateRanges({
dateFrom,
dateTo,
availability,
timeZone,
});
const formattedBusyTimes = detailedBusyTimes.map((busy) => ({
start: dayjs(busy.start),
end: dayjs(busy.end),
}));
const datesOutOfOffice = await getOutOfOfficeDays({
userId: user.id,
dateFrom,
@@ -351,8 +339,23 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
availability,
});
const { dateRanges, oooExcludedDateRanges } = buildDateRanges({
dateFrom,
dateTo,
availability,
timeZone,
outOfOffice: datesOutOfOffice,
});
const formattedBusyTimes = detailedBusyTimes.map((busy) => ({
start: dayjs(busy.start),
end: dayjs(busy.end),
}));
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
const dateRangesInWhichUserIsAvailableWithoutOOO = subtract(oooExcludedDateRanges, formattedBusyTimes);
log.debug(
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
JSON.stringify({
@@ -368,6 +371,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
busy: detailedBusyTimes,
timeZone,
dateRanges: dateRangesInWhichUserIsAvailable,
oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO,
workingHours,
dateOverrides,
currentSeats,
@@ -357,7 +357,7 @@ export async function ensureAvailableUsers(
}
for (const user of eventType.users) {
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
const { oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
{
...input,
userId: user.id,
+53 -5
View File
@@ -251,7 +251,7 @@ describe("buildDateRanges", () => {
const timeZone = "America/New_York";
const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
// [
// { s: '2023-06-13T10:00:00-04:00', e: '2023-06-13T15:00:00-04:00' },
// { s: '2023-06-14T08:00:00-04:00', e: '2023-06-14T17:00:00-04:00' }
@@ -288,7 +288,7 @@ describe("buildDateRanges", () => {
const dateFrom = dayjs.tz("2023-08-15", "Europe/Brussels").startOf("day");
const dateTo = dayjs.tz("2023-08-15", "Europe/Brussels").endOf("day");
const result = buildDateRanges({ availability: item, timeZone, dateFrom, dateTo });
const { dateRanges: result } = buildDateRanges({ availability: item, timeZone, dateFrom, dateTo });
// this happened only on Europe/Brussels, Europe/Amsterdam was 2023-08-15T17:00:00-10:00 (as it should be)
expect(result[0].end.format()).not.toBe("2023-08-14T17:00:00-10:00");
});
@@ -310,7 +310,7 @@ describe("buildDateRanges", () => {
const dateFrom = dayjs("2023-06-13T00:00:00Z");
const dateTo = dayjs("2023-06-15T00:00:00Z");
const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
expect(results[0]).toEqual({
start: dayjs("2023-06-14T07:00:00Z").tz(timeZone),
@@ -330,7 +330,7 @@ describe("buildDateRanges", () => {
const dateFrom = dayjs("2023-06-13T10:00:00Z");
const dateTo = dayjs("2023-06-13T10:30:00Z");
const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
expect(results[0]).toEqual({
start: dayjs("2023-06-13T08:00:00Z").tz(timeZone),
@@ -355,7 +355,7 @@ describe("buildDateRanges", () => {
const dateFrom = dayjs("2023-06-13T00:00:00Z");
const dateTo = dayjs("2023-06-15T00:00:00Z");
const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
const { dateRanges: results } = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
expect(results[0]).toEqual({
start: dayjs("2023-06-14T02:00:00Z").tz(timeZone),
@@ -366,6 +366,54 @@ describe("buildDateRanges", () => {
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
});
});
it("should handle OOO correctly", () => {
const items = [
{
date: new Date(Date.UTC(2023, 5, 13)),
startTime: new Date(Date.UTC(0, 0, 0, 10, 0)), // 10 AM
endTime: new Date(Date.UTC(0, 0, 0, 15, 0)), // 3 PM
},
{
days: [1, 2, 3, 4, 5],
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM
},
];
const outOfOffice = {
"2023-06-13": {
fromUser: { id: 1, displayName: "Team Free Example" },
},
};
const dateFrom = dayjs("2023-06-13T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York)
const dateTo = dayjs("2023-06-15T00:00:00Z");
const timeZone = "America/New_York";
const { dateRanges, oooExcludedDateRanges } = buildDateRanges({
availability: items,
timeZone,
dateFrom,
dateTo,
outOfOffice,
});
expect(dateRanges[0]).toEqual({
start: dayjs("2023-06-13T14:00:00Z").tz(timeZone),
end: dayjs("2023-06-13T19:00:00Z").tz(timeZone),
});
expect(dateRanges[1]).toEqual({
start: dayjs("2023-06-14T12:00:00Z").tz(timeZone),
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
});
expect(oooExcludedDateRanges.length).toBe(1);
expect(oooExcludedDateRanges[0]).toEqual({
start: dayjs("2023-06-14T12:00:00Z").tz(timeZone),
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
});
});
});
describe("subtract", () => {
+31 -2
View File
@@ -1,3 +1,4 @@
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";
@@ -103,17 +104,31 @@ export function processDateOverride({
};
}
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 */,
outOfOffice,
}: {
timeZone: string;
availability: (DateOverride | WorkingHours)[];
dateFrom: Dayjs;
dateTo: Dayjs;
}): DateRange[] {
outOfOffice?: IOutOfOfficeData;
}): { dateRanges: DateRange[]; oooExcludedDateRanges: DateRange[] } {
const dateFromOrganizerTZ = dateFrom.tz(timeZone);
const groupedWorkingHours = groupByDate(
availability.reduce((processed: DateRange[], item) => {
@@ -125,6 +140,11 @@ export function buildDateRanges({
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) => {
@@ -158,7 +178,16 @@ export function buildDateRanges({
(ranges) => ranges.filter((range) => range.start.valueOf() !== range.end.valueOf())
);
return dateRanges.flat();
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[] } {
@@ -97,7 +97,7 @@ async function buildMember(member: Member, dateFrom: Dayjs, dateTo: Dayjs) {
});
const timeZone = schedule?.timeZone || member.user.timeZone;
const dateRanges = buildDateRanges({
const { dateRanges } = buildDateRanges({
dateFrom,
dateTo,
timeZone,
@@ -461,6 +461,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
const {
busy,
dateRanges,
oooExcludedDateRanges,
currentSeats: _currentSeats,
timeZone,
datesOutOfOffice,
@@ -496,6 +497,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
return {
timeZone,
dateRanges,
oooExcludedDateRanges,
busy,
user: currentUser,
datesOutOfOffice,
@@ -523,6 +525,11 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
const checkForAvailabilityCount = 0;
const aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
const isTeamEvent =
eventType.schedulingType === SchedulingType.COLLECTIVE ||
eventType.schedulingType === SchedulingType.ROUND_ROBIN ||
allUsersAvailability.length > 1;
const timeSlots = getSlots({
inviteeDate: startTime,
eventLength: input.duration || eventType.length,
@@ -532,7 +539,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
frequency: eventType.slotInterval || input.duration || eventType.length,
organizerTimeZone:
eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone,
datesOutOfOffice: allUsersAvailability[0]?.datesOutOfOffice,
datesOutOfOffice: !isTeamEvent ? allUsersAvailability[0]?.datesOutOfOffice : undefined,
});
let availableTimeSlots: typeof timeSlots = [];