Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: OOO for team events #14503

Merged
merged 4 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
16 changes: 13 additions & 3 deletions packages/core/getAggregatedAvailability/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,28 @@ 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[];
dateRangesWithoutOOO: DateRange[];
user?: { isFixed?: boolean };
}[],
schedulingType: SchedulingType | null
): DateRange[] => {
const isTeamEvent =
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for team events we want to use the date ranges that already take into account OOO days

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.dateRangesWithoutOOO));

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.dateRangesWithoutOOO))
);
}

const availability = intersect(dateRangesToIntersect);
Expand Down
20 changes: 12 additions & 8 deletions packages/core/getUserAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,27 +332,30 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
}
}

const dateRanges = buildDateRanges({
const datesOutOfOffice = await getOutOfOfficeDays({
userId: user.id,
dateFrom,
dateTo,
availability,
});

const { dateRanges, dateRangesWithoutOOO } = buildDateRanges({
dateFrom,
dateTo,
availability,
timeZone,
outOfOffice: datesOutOfOffice,
});

const formattedBusyTimes = detailedBusyTimes.map((busy) => ({
start: dayjs(busy.start),
end: dayjs(busy.end),
}));

const datesOutOfOffice = await getOutOfOfficeDays({
userId: user.id,
dateFrom,
dateTo,
availability,
});

const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);

const dateRangesInWhichUserIsAvailableWithoutOOO = subtract(dateRangesWithoutOOO, formattedBusyTimes);

log.debug(
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
JSON.stringify({
Expand All @@ -368,6 +371,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
busy: detailedBusyTimes,
timeZone,
dateRanges: dateRangesInWhichUserIsAvailable,
dateRangesWithoutOOO: dateRangesInWhichUserIsAvailableWithoutOOO,
workingHours,
dateOverrides,
currentSeats,
Expand Down
58 changes: 53 additions & 5 deletions packages/lib/date-ranges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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");
});
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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, dateRangesWithoutOOO } = 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(dateRangesWithoutOOO.length).toBe(1);
expect(dateRangesWithoutOOO[0]).toEqual({
start: dayjs("2023-06-14T12:00:00Z").tz(timeZone),
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
});
});
});

describe("subtract", () => {
Expand Down
33 changes: 31 additions & 2 deletions packages/lib/date-ranges.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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[]; dateRangesWithoutOOO: DateRange[] } {
const dateFromOrganizerTZ = dateFrom.tz(timeZone);
const groupedWorkingHours = groupByDate(
availability.reduce((processed: DateRange[], item) => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -158,7 +178,16 @@ export function buildDateRanges({
(ranges) => ranges.filter((range) => range.start.valueOf() !== range.end.valueOf())
);

return dateRanges.flat();
const dateRangesWithoutOOO = 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())
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOO always starts and end on the same time (handled the same way as a full-day date override)

);

return { dateRanges: dateRanges.flat(), dateRangesWithoutOOO: dateRangesWithoutOOO.flat() };
}

export function groupByDate(ranges: DateRange[]): { [x: string]: DateRange[] } {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion packages/trpc/server/routers/viewer/slots/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
const {
busy,
dateRanges,
dateRangesWithoutOOO,
currentSeats: _currentSeats,
timeZone,
datesOutOfOffice,
Expand Down Expand Up @@ -496,6 +497,7 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
return {
timeZone,
dateRanges,
dateRangesWithoutOOO,
busy,
user: currentUser,
datesOutOfOffice,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For team events, OOO days are already included aggregatedAvailability

});

let availableTimeSlots: typeof timeSlots = [];
Expand Down