fix: Re-instate new slot logic now it's A/B tested in production (#19605)
* fix: Re-instate new slot logic now it's well tested * Fixup getAggregatedAvailability * Changes to code-owners to reflect removed/moved code * Move more files around. * Fix failed type --------- Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
co-authored by
Omar López
parent
4bab8c2c32
commit
1cfd94d293
+1
-2
@@ -12,8 +12,7 @@
|
||||
/packages/app-store/larkcalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/office365calendar/**/* @calcom/Foundation
|
||||
/packages/app-store/zohocalendar/**/* @calcom/Foundation
|
||||
/packages/core/getAggregatedAvailability/index.ts @calcom/Foundation
|
||||
/packages/core/getAggregateWorkingHours.ts @calcom/Foundation
|
||||
/packages/core/getAggregatedAvailability.ts @calcom/Foundation
|
||||
/packages/core/getUserAvailability.ts @calcom/Foundation
|
||||
/packages/features/bookings/lib/**/* @calcom/Foundation
|
||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { expect, it, beforeAll, vi } from "vitest";
|
||||
|
||||
import { getAggregateWorkingHours } from "@calcom/core/getAggregateWorkingHours";
|
||||
|
||||
beforeAll(() => {
|
||||
vi.setSystemTime(new Date("2021-06-20T11:59:59Z"));
|
||||
});
|
||||
|
||||
const HAWAII_AND_NEWYORK_TEAM = [
|
||||
{
|
||||
timeZone: "America/Detroit", // GMT -4 per 22th of Aug, 2022
|
||||
workingHours: [{ userId: 1, days: [1, 2, 3, 4, 5], startTime: 780, endTime: 1260 }],
|
||||
busy: [],
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
},
|
||||
{
|
||||
timeZone: "Pacific/Honolulu", // GMT -10 per 22th of Aug, 2022
|
||||
workingHours: [
|
||||
{ userId: 1, days: [3, 4, 5], startTime: 0, endTime: 360 },
|
||||
{ userId: 2, days: [6], startTime: 0, endTime: 180 },
|
||||
{ userId: 3, days: [2, 3, 4], startTime: 780, endTime: 1439 },
|
||||
{ userId: 4, days: [5], startTime: 780, endTime: 1439 },
|
||||
],
|
||||
busy: [],
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
},
|
||||
];
|
||||
|
||||
/* TODO: Make this test more "professional" */
|
||||
it("Sydney and Shiraz can live in harmony 🙏", async () => {
|
||||
expect(getAggregateWorkingHours(HAWAII_AND_NEWYORK_TEAM, "COLLECTIVE")).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"days": [
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
],
|
||||
"endTime": 360,
|
||||
"startTime": 780,
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
6,
|
||||
],
|
||||
"endTime": 180,
|
||||
"startTime": 0,
|
||||
"userId": 2,
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
],
|
||||
"endTime": 1260,
|
||||
"startTime": 780,
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
5,
|
||||
],
|
||||
"endTime": 1260,
|
||||
"startTime": 780,
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
expect(getAggregateWorkingHours(HAWAII_AND_NEWYORK_TEAM, "ROUND_ROBIN")).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"days": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
],
|
||||
"endTime": 1260,
|
||||
"startTime": 780,
|
||||
"userId": 1,
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
],
|
||||
"endTime": 360,
|
||||
"startTime": 0,
|
||||
"userId": 1,
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
6,
|
||||
],
|
||||
"endTime": 180,
|
||||
"startTime": 0,
|
||||
"userId": 2,
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
],
|
||||
"endTime": 1439,
|
||||
"startTime": 780,
|
||||
"userId": 3,
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
5,
|
||||
],
|
||||
"endTime": 1439,
|
||||
"startTime": 780,
|
||||
"userId": 4,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { WorkingHours } from "@calcom/types/schedule";
|
||||
|
||||
import { getAggregateWorkingHours } from "./getAggregateWorkingHours";
|
||||
|
||||
describe("getAggregateWorkingHours", () => {
|
||||
it("should return all schedules if no scheduling type", () => {
|
||||
const workingHours: WorkingHours[] = [
|
||||
{
|
||||
days: [1, 2, 3],
|
||||
startTime: 0,
|
||||
endTime: 720,
|
||||
},
|
||||
];
|
||||
const result = getAggregateWorkingHours(
|
||||
[
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
},
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
},
|
||||
],
|
||||
null
|
||||
);
|
||||
|
||||
expect(result).toEqual([...workingHours, ...workingHours]);
|
||||
});
|
||||
|
||||
it("should return all schedules if no fixed users exist", () => {
|
||||
const workingHours: WorkingHours[] = [
|
||||
{
|
||||
days: [1, 2, 3],
|
||||
startTime: 0,
|
||||
endTime: 720,
|
||||
},
|
||||
];
|
||||
const result = getAggregateWorkingHours(
|
||||
[
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
},
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
},
|
||||
],
|
||||
"MANAGED"
|
||||
);
|
||||
|
||||
expect(result).toEqual([...workingHours, ...workingHours]);
|
||||
});
|
||||
|
||||
it("should consider all schedules fixed if collective", () => {
|
||||
const workingHoursA: WorkingHours[] = [
|
||||
{
|
||||
days: [1, 2],
|
||||
startTime: 0,
|
||||
endTime: 200,
|
||||
},
|
||||
];
|
||||
const workingHoursB: WorkingHours[] = [
|
||||
{
|
||||
days: [2, 3],
|
||||
startTime: 100,
|
||||
endTime: 300,
|
||||
},
|
||||
];
|
||||
const result = getAggregateWorkingHours(
|
||||
[
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursA,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursB,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
"COLLECTIVE"
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
days: [2],
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should include loose host hours", () => {
|
||||
const workingHoursA: WorkingHours[] = [
|
||||
{
|
||||
days: [1, 2],
|
||||
startTime: 0,
|
||||
endTime: 200,
|
||||
},
|
||||
];
|
||||
const workingHoursB: WorkingHours[] = [
|
||||
{
|
||||
days: [2, 3],
|
||||
startTime: 100,
|
||||
endTime: 300,
|
||||
},
|
||||
];
|
||||
const result = getAggregateWorkingHours(
|
||||
[
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursA,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursB,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
"COLLECTIVE"
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
days: [2],
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
userId: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return last user's hours if no intersection", () => {
|
||||
const workingHoursA: WorkingHours[] = [
|
||||
{
|
||||
days: [1],
|
||||
startTime: 0,
|
||||
endTime: 200,
|
||||
},
|
||||
];
|
||||
const workingHoursB: WorkingHours[] = [
|
||||
{
|
||||
days: [2],
|
||||
startTime: 100,
|
||||
endTime: 300,
|
||||
},
|
||||
];
|
||||
const result = getAggregateWorkingHours(
|
||||
[
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursA,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursB,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
"COLLECTIVE"
|
||||
);
|
||||
|
||||
expect(result).toEqual([...workingHoursB]);
|
||||
});
|
||||
|
||||
it("should include user IDs when not collective", () => {
|
||||
const workingHoursA: WorkingHours[] = [
|
||||
{
|
||||
days: [1, 2],
|
||||
startTime: 0,
|
||||
endTime: 200,
|
||||
userId: 1,
|
||||
},
|
||||
];
|
||||
const workingHoursB: WorkingHours[] = [
|
||||
{
|
||||
days: [2, 3],
|
||||
startTime: 100,
|
||||
endTime: 300,
|
||||
userId: 2,
|
||||
},
|
||||
];
|
||||
const result = getAggregateWorkingHours(
|
||||
[
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursA,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursB,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
"MANAGED"
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
userId: 1,
|
||||
days: [2],
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle multiple intersections", () => {
|
||||
const workingHoursA: WorkingHours[] = [
|
||||
{
|
||||
days: [1, 2],
|
||||
startTime: 0,
|
||||
endTime: 200,
|
||||
},
|
||||
{
|
||||
days: [3, 4],
|
||||
startTime: 100,
|
||||
endTime: 300,
|
||||
},
|
||||
];
|
||||
const workingHoursB: WorkingHours[] = [
|
||||
{
|
||||
days: [2, 3],
|
||||
startTime: 100,
|
||||
endTime: 300,
|
||||
},
|
||||
{
|
||||
days: [4, 5],
|
||||
startTime: 0,
|
||||
endTime: 200,
|
||||
},
|
||||
];
|
||||
const result = getAggregateWorkingHours(
|
||||
[
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursA,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
busy: [],
|
||||
timeZone: "Europe/London",
|
||||
workingHours: workingHoursB,
|
||||
dateOverrides: [],
|
||||
datesOutOfOffice: {},
|
||||
user: {
|
||||
isFixed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
"COLLECTIVE"
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
days: [2],
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
userId: undefined,
|
||||
},
|
||||
{
|
||||
days: [3],
|
||||
startTime: 100,
|
||||
endTime: 300,
|
||||
userId: undefined,
|
||||
},
|
||||
{
|
||||
days: [4],
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
userId: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { WorkingHours } from "@calcom/types/schedule";
|
||||
|
||||
/**
|
||||
* This function gets team members working hours and busy slots,
|
||||
* offsets them to UTC and intersects them for collective events.
|
||||
**/
|
||||
export const getAggregateWorkingHours = (
|
||||
usersWorkingHoursAndBusySlots: (Omit<
|
||||
Awaited<ReturnType<Awaited<typeof import("./getUserAvailability")>["getUserAvailability"]>>,
|
||||
"currentSeats" | "dateRanges" | "oooExcludedDateRanges"
|
||||
> & { user?: { isFixed?: boolean } })[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
schedulingType: SchedulingType | null
|
||||
): WorkingHours[] => {
|
||||
// during personal events, just flatMap.
|
||||
if (!schedulingType) {
|
||||
return usersWorkingHoursAndBusySlots.flatMap((s) => s.workingHours);
|
||||
}
|
||||
const looseHostWorkingHours = usersWorkingHoursAndBusySlots
|
||||
.filter(({ user }) => schedulingType !== SchedulingType.COLLECTIVE && user?.isFixed !== true)
|
||||
.flatMap((s) => s.workingHours);
|
||||
|
||||
const fixedHostSchedules = usersWorkingHoursAndBusySlots.filter(
|
||||
({ user }) => schedulingType === SchedulingType.COLLECTIVE || user?.isFixed
|
||||
);
|
||||
// return early when there are no fixed hosts.
|
||||
if (!fixedHostSchedules.length) {
|
||||
return looseHostWorkingHours;
|
||||
}
|
||||
return fixedHostSchedules.reduce((currentWorkingHours: WorkingHours[], s) => {
|
||||
const updatedWorkingHours: typeof currentWorkingHours = [];
|
||||
|
||||
s.workingHours.forEach((workingHour) => {
|
||||
const sameDayWorkingHours = currentWorkingHours.filter((compare) =>
|
||||
compare.days.find((day) => workingHour.days.includes(day))
|
||||
);
|
||||
if (!sameDayWorkingHours.length) {
|
||||
updatedWorkingHours.push(workingHour); // the first day is always added.
|
||||
return;
|
||||
}
|
||||
// days are overlapping when different users are involved, instead of adding we now need to subtract
|
||||
updatedWorkingHours.push(
|
||||
...sameDayWorkingHours.map((compare) => {
|
||||
const intersect = workingHour.days.filter((day) => compare.days.includes(day));
|
||||
const retVal: WorkingHours & { userId?: number | null } = {
|
||||
days: intersect,
|
||||
startTime: Math.max(workingHour.startTime, compare.startTime),
|
||||
endTime: Math.min(workingHour.endTime, compare.endTime),
|
||||
};
|
||||
if (schedulingType !== SchedulingType.COLLECTIVE) {
|
||||
retVal.userId = compare.userId;
|
||||
}
|
||||
return retVal;
|
||||
})
|
||||
);
|
||||
});
|
||||
return updatedWorkingHours;
|
||||
}, looseHostWorkingHours);
|
||||
};
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import { getAggregatedAvailabilityNew as getAggregatedAvailability } from ".";
|
||||
import { getAggregatedAvailability } from "./getAggregatedAvailability";
|
||||
|
||||
// Helper to check if a time range overlaps with availability
|
||||
const isAvailable = (availability: { start: Dayjs; end: Dayjs }[], range: { start: Dayjs; end: Dayjs }) => {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import type { DateRange } from "@calcom/lib/date-ranges";
|
||||
import { intersect } from "@calcom/lib/date-ranges";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { mergeOverlappingDateRanges } from "./date-range-utils/mergeOverlappingDateRanges";
|
||||
import { mergeOverlappingDateRanges } from "./getAggregatedAvailability/date-range-utils/mergeOverlappingDateRanges";
|
||||
|
||||
function uniqueAndSortedDateRanges(ranges: DateRange[]): DateRange[] {
|
||||
const seen = new Set<string>();
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { DateRange } from "@calcom/lib/date-ranges";
|
||||
|
||||
import { mergeOverlappingDateRanges } from ".";
|
||||
import { mergeOverlappingDateRanges } from "./mergeOverlappingDateRanges";
|
||||
|
||||
const november2 = "2023-11-02";
|
||||
const november3 = "2023-11-03";
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { DateRange } from "@calcom/lib/date-ranges";
|
||||
import { intersect } from "@calcom/lib/date-ranges";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { mergeOverlappingDateRanges } from "./date-range-utils/mergeOverlappingDateRanges";
|
||||
|
||||
export const getAggregatedAvailability = (
|
||||
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) =>
|
||||
!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges
|
||||
);
|
||||
|
||||
const unfixedHosts = userAvailability.filter(({ user }) => user?.isFixed !== true);
|
||||
if (unfixedHosts.length) {
|
||||
dateRangesToIntersect.push(
|
||||
unfixedHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges))
|
||||
);
|
||||
}
|
||||
|
||||
const availability = intersect(dateRangesToIntersect);
|
||||
|
||||
return mergeOverlappingDateRanges(availability);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export { getAggregatedAvailability as getAggregatedAvailabilityNew } from "./getAggregatedAvailability";
|
||||
|
||||
export { getAggregatedAvailability as getAggregatedAvailability } from "./getAggregatedAvailabilityOld";
|
||||
@@ -243,17 +243,8 @@ describe("Tests the slot logic", () => {
|
||||
frequency: 20,
|
||||
minimumBookingNotice: 0,
|
||||
dateRanges: [{ start: dayjs("2021-06-21T00:00:00.000Z"), end: dayjs("2021-06-21T23:45:00.000Z") }],
|
||||
/*workingHours: [
|
||||
{
|
||||
userId: 1,
|
||||
days: Array.from(Array(7).keys()),
|
||||
startTime: MINUTES_DAY_START,
|
||||
endTime: MINUTES_DAY_END - 14, // 23:45
|
||||
},
|
||||
],*/
|
||||
eventLength: 20,
|
||||
offsetStart: 0,
|
||||
organizerTimeZone: "America/Toronto",
|
||||
});
|
||||
|
||||
// 71 20-minutes events in a 24h - 15m day
|
||||
@@ -271,7 +262,6 @@ describe("Tests the slot logic", () => {
|
||||
],
|
||||
minimumBookingNotice: 120,
|
||||
frequency: 15,
|
||||
organizerTimeZone: "Europe/London",
|
||||
}).reverse();
|
||||
|
||||
expect(slots[0].time.format()).toBe("2023-07-13T22:45:00+02:00");
|
||||
|
||||
+10
-251
@@ -8,234 +8,15 @@ import type { DateRange } from "./date-ranges";
|
||||
export type GetSlots = {
|
||||
inviteeDate: Dayjs;
|
||||
frequency: number;
|
||||
dateRanges?: DateRange[];
|
||||
dateRanges: DateRange[];
|
||||
minimumBookingNotice: number;
|
||||
eventLength: number;
|
||||
offsetStart?: number;
|
||||
organizerTimeZone?: string;
|
||||
datesOutOfOffice?: IOutOfOfficeData;
|
||||
};
|
||||
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
|
||||
|
||||
const minimumOfOne = (input: number) => (input < 1 ? 1 : input);
|
||||
const minimumOfZero = (input: number) => (input < 0 ? 0 : input);
|
||||
|
||||
function buildSlots({
|
||||
startOfInviteeDay,
|
||||
computedLocalAvailability,
|
||||
frequency,
|
||||
eventLength,
|
||||
offsetStart = 0,
|
||||
startDate,
|
||||
organizerTimeZone,
|
||||
inviteeTimeZone,
|
||||
}: {
|
||||
computedLocalAvailability: TimeFrame[];
|
||||
startOfInviteeDay: Dayjs;
|
||||
startDate: Dayjs;
|
||||
frequency: number;
|
||||
eventLength: number;
|
||||
offsetStart?: number;
|
||||
organizerTimeZone: string;
|
||||
inviteeTimeZone: string;
|
||||
}) {
|
||||
// no slots today
|
||||
if (startOfInviteeDay.isBefore(startDate, "day")) {
|
||||
return [];
|
||||
}
|
||||
// keep the old safeguards in; may be needed.
|
||||
frequency = minimumOfOne(frequency);
|
||||
eventLength = minimumOfOne(eventLength);
|
||||
offsetStart = minimumOfZero(offsetStart);
|
||||
|
||||
// A day starts at 00:00 unless the startDate is the same as the current day
|
||||
const dayStart = startOfInviteeDay.isSame(startDate, "day")
|
||||
? Math.ceil((startDate.hour() * 60 + startDate.minute()) / frequency) * frequency
|
||||
: 0;
|
||||
|
||||
// Record type so we can use slotStart as key
|
||||
const slotsTimeFrameAvailable: Record<
|
||||
string,
|
||||
{
|
||||
userIds: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
> = {};
|
||||
// get boundaries sorted by start time.
|
||||
const boundaries = computedLocalAvailability
|
||||
.map((item) => [item.startTime < dayStart ? dayStart : item.startTime, item.endTime])
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const ranges: number[][] = [];
|
||||
let currentRange: number[] = [];
|
||||
for (const [start, end] of boundaries) {
|
||||
// bypass invalid value
|
||||
if (start >= end) continue;
|
||||
// fill first elem
|
||||
if (!currentRange.length) {
|
||||
currentRange = [start, end];
|
||||
continue;
|
||||
}
|
||||
if (currentRange[1] < start) {
|
||||
ranges.push(currentRange);
|
||||
currentRange = [start, end];
|
||||
} else if (currentRange[1] < end) {
|
||||
currentRange[1] = end;
|
||||
}
|
||||
}
|
||||
if (currentRange) {
|
||||
ranges.push(currentRange);
|
||||
}
|
||||
|
||||
for (const [boundaryStart, boundaryEnd] of ranges) {
|
||||
// loop through the day, based on frequency.
|
||||
for (
|
||||
let slotStart = boundaryStart + offsetStart;
|
||||
slotStart < boundaryEnd;
|
||||
slotStart += offsetStart + frequency
|
||||
) {
|
||||
computedLocalAvailability.forEach((item) => {
|
||||
// TODO: This logic does not allow for past-midnight bookings.
|
||||
if (slotStart < item.startTime || slotStart > item.endTime + 1 - eventLength) {
|
||||
return;
|
||||
}
|
||||
slotsTimeFrameAvailable[slotStart.toString()] = {
|
||||
userIds: (slotsTimeFrameAvailable[slotStart]?.userIds || []).concat(item.userIds || []),
|
||||
startTime: slotStart,
|
||||
endTime: slotStart + eventLength,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const organizerDSTDiff =
|
||||
dayjs().tz(organizerTimeZone).utcOffset() - startOfInviteeDay.tz(organizerTimeZone).utcOffset();
|
||||
const inviteeDSTDiff =
|
||||
dayjs().tz(inviteeTimeZone).utcOffset() - startOfInviteeDay.tz(inviteeTimeZone).utcOffset();
|
||||
const slots: { time: Dayjs; userIds?: number[] }[] = [];
|
||||
const getTime = (time: number) => {
|
||||
const minutes = time + organizerDSTDiff - inviteeDSTDiff;
|
||||
|
||||
return startOfInviteeDay.tz(inviteeTimeZone).add(minutes, "minutes");
|
||||
};
|
||||
for (const item of Object.values(slotsTimeFrameAvailable)) {
|
||||
/*
|
||||
* @calcom/web:dev: 2022-11-06T00:00:00-04:00
|
||||
* @calcom/web:dev: 2022-11-06T01:00:00-04:00
|
||||
* @calcom/web:dev: 2022-11-06T01:00:00-04:00 <-- note there is no offset change, but we did lose an hour.
|
||||
* @calcom/web:dev: 2022-11-06T02:00:00-04:00
|
||||
* @calcom/web:dev: 2022-11-06T03:00:00-04:00
|
||||
* ...
|
||||
*/
|
||||
slots.push({
|
||||
userIds: item.userIds,
|
||||
time: getTime(item.startTime),
|
||||
});
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function buildSlotsWithDateRangesOld({
|
||||
dateRanges,
|
||||
frequency,
|
||||
eventLength,
|
||||
timeZone,
|
||||
minimumBookingNotice,
|
||||
organizerTimeZone,
|
||||
offsetStart,
|
||||
datesOutOfOffice,
|
||||
}: {
|
||||
dateRanges: DateRange[];
|
||||
frequency: number;
|
||||
eventLength: number;
|
||||
timeZone: string;
|
||||
minimumBookingNotice: number;
|
||||
organizerTimeZone: string;
|
||||
offsetStart?: number;
|
||||
datesOutOfOffice?: IOutOfOfficeData;
|
||||
}) {
|
||||
// keep the old safeguards in; may be needed.
|
||||
frequency = minimumOfOne(frequency);
|
||||
eventLength = minimumOfOne(eventLength);
|
||||
offsetStart = offsetStart ? minimumOfOne(offsetStart) : 0;
|
||||
const slots: {
|
||||
time: Dayjs;
|
||||
userIds?: number[];
|
||||
away?: boolean;
|
||||
fromUser?: IFromUser;
|
||||
toUser?: IToUser;
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
}[] = [];
|
||||
|
||||
let interval = Number(process.env.NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL) || 1;
|
||||
const intervalsWithDefinedStartTimes = [60, 30, 20, 15, 10, 5];
|
||||
|
||||
for (let i = 0; i < intervalsWithDefinedStartTimes.length; i++) {
|
||||
if (frequency % intervalsWithDefinedStartTimes[i] === 0) {
|
||||
interval = intervalsWithDefinedStartTimes[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dateRanges.forEach((range) => {
|
||||
const dateYYYYMMDD = range.start.format("YYYY-MM-DD");
|
||||
const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute");
|
||||
|
||||
let slotStartTime = range.start.utc().isAfter(startTimeWithMinNotice)
|
||||
? range.start
|
||||
: startTimeWithMinNotice;
|
||||
|
||||
slotStartTime =
|
||||
slotStartTime.minute() % interval !== 0
|
||||
? slotStartTime.startOf("hour").add(Math.ceil(slotStartTime.minute() / interval) * interval, "minute")
|
||||
: slotStartTime;
|
||||
|
||||
// Adding 1 minute to date ranges that end at midnight to ensure that the last slot is included
|
||||
const rangeEnd = range.end
|
||||
.add(dayjs().tz(organizerTimeZone).utcOffset(), "minutes")
|
||||
.isSame(range.end.endOf("day").add(dayjs().tz(organizerTimeZone).utcOffset(), "minutes"), "minute")
|
||||
? range.end.add(1, "minute")
|
||||
: range.end;
|
||||
|
||||
slotStartTime = slotStartTime.add(offsetStart ?? 0, "minutes").tz(timeZone);
|
||||
|
||||
while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").utc().isAfter(rangeEnd)) {
|
||||
const dateOutOfOfficeExists = datesOutOfOffice?.[dateYYYYMMDD];
|
||||
let slotData: {
|
||||
time: Dayjs;
|
||||
userIds?: number[];
|
||||
away?: boolean;
|
||||
fromUser?: IFromUser;
|
||||
toUser?: IToUser;
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
} = {
|
||||
time: slotStartTime,
|
||||
};
|
||||
|
||||
if (dateOutOfOfficeExists) {
|
||||
const { toUser, fromUser, reason, emoji } = dateOutOfOfficeExists;
|
||||
|
||||
slotData = {
|
||||
time: slotStartTime,
|
||||
away: true,
|
||||
...(fromUser && { fromUser }),
|
||||
...(toUser && { toUser }),
|
||||
...(reason && { reason }),
|
||||
...(emoji && { emoji }),
|
||||
};
|
||||
}
|
||||
|
||||
slots.push(slotData);
|
||||
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
|
||||
}
|
||||
});
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function buildSlotsWithDateRanges({
|
||||
dateRanges,
|
||||
@@ -359,12 +140,6 @@ function buildSlotsWithDateRanges({
|
||||
return Array.from(slots.values());
|
||||
}
|
||||
|
||||
function fromIndex<T>(cb: (val: T, i: number, a: T[]) => boolean, index: number) {
|
||||
return function (e: T, i: number, a: T[]) {
|
||||
return i >= index && cb(e, i, a);
|
||||
};
|
||||
}
|
||||
|
||||
const getSlots = ({
|
||||
inviteeDate,
|
||||
frequency,
|
||||
@@ -372,7 +147,6 @@ const getSlots = ({
|
||||
dateRanges,
|
||||
eventLength,
|
||||
offsetStart = 0,
|
||||
organizerTimeZone,
|
||||
datesOutOfOffice,
|
||||
}: GetSlots): {
|
||||
time: Dayjs;
|
||||
@@ -383,30 +157,15 @@ const getSlots = ({
|
||||
reason?: string;
|
||||
emoji?: string;
|
||||
}[] => {
|
||||
if (dateRanges && !organizerTimeZone) {
|
||||
return buildSlotsWithDateRanges({
|
||||
dateRanges,
|
||||
frequency,
|
||||
eventLength,
|
||||
timeZone: getTimeZone(inviteeDate),
|
||||
minimumBookingNotice,
|
||||
offsetStart,
|
||||
datesOutOfOffice,
|
||||
});
|
||||
} else if (dateRanges && organizerTimeZone) {
|
||||
return buildSlotsWithDateRangesOld({
|
||||
dateRanges,
|
||||
frequency,
|
||||
eventLength,
|
||||
timeZone: getTimeZone(inviteeDate),
|
||||
minimumBookingNotice,
|
||||
organizerTimeZone,
|
||||
offsetStart,
|
||||
datesOutOfOffice,
|
||||
});
|
||||
}
|
||||
// just to ensure we don't call this anywhere. APIv1/v2 + webapp use dateRanges.
|
||||
throw new Error("Deprecated invocation of getSlots, use dateRanges instead.");
|
||||
return buildSlotsWithDateRanges({
|
||||
dateRanges,
|
||||
frequency,
|
||||
eventLength,
|
||||
timeZone: getTimeZone(inviteeDate),
|
||||
minimumBookingNotice,
|
||||
offsetStart,
|
||||
datesOutOfOffice,
|
||||
});
|
||||
};
|
||||
|
||||
export default getSlots;
|
||||
|
||||
@@ -3,10 +3,7 @@ import { countBy } from "lodash";
|
||||
import type { Logger } from "tslog";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import {
|
||||
getAggregatedAvailability,
|
||||
getAggregatedAvailabilityNew,
|
||||
} from "@calcom/core/getAggregatedAvailability";
|
||||
import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability";
|
||||
import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes";
|
||||
import type { CurrentSeats, GetAvailabilityUser, IFromUser, IToUser } from "@calcom/core/getUserAvailability";
|
||||
import { getUsersAvailability } from "@calcom/core/getUserAvailability";
|
||||
@@ -484,12 +481,6 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
|
||||
eventType.schedulingType === SchedulingType.ROUND_ROBIN ||
|
||||
allUsersAvailability.length > 1;
|
||||
|
||||
// timeZone isn't directly set on eventType now(So, it is legacy)
|
||||
// schedule is always expected to be set for an eventType now so it must never fallback to allUsersAvailability[0].timeZone(fallback is again legacy behavior)
|
||||
// TODO: Also, handleNewBooking only seems to be using eventType?.schedule?.timeZone which seems to confirm that we should simplify it as well.
|
||||
const eventTimeZone =
|
||||
eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone;
|
||||
|
||||
const timeSlots = monitorCallbackSync(getSlots, {
|
||||
inviteeDate: startTime,
|
||||
eventLength: input.duration || eventType.length,
|
||||
@@ -497,51 +488,9 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
|
||||
dateRanges: aggregatedAvailability,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
frequency: eventType.slotInterval || input.duration || eventType.length,
|
||||
organizerTimeZone: eventTimeZone,
|
||||
datesOutOfOffice: !isTeamEvent ? allUsersAvailability[0]?.datesOutOfOffice : undefined,
|
||||
});
|
||||
|
||||
const aggregatedAvailabilityNew = getAggregatedAvailabilityNew(
|
||||
allUsersAvailability,
|
||||
eventType.schedulingType
|
||||
);
|
||||
|
||||
const timeSlotsNew = monitorCallbackSync(getSlots, {
|
||||
inviteeDate: startTime,
|
||||
eventLength: input.duration || eventType.length,
|
||||
offsetStart: eventType.offsetStart,
|
||||
dateRanges: aggregatedAvailabilityNew,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
frequency: eventType.slotInterval || input.duration || eventType.length,
|
||||
datesOutOfOffice: !isTeamEvent ? allUsersAvailability[0]?.datesOutOfOffice : undefined,
|
||||
});
|
||||
|
||||
const ts = groupTimeSlotsByDay(timeSlots),
|
||||
tsNew = groupTimeSlotsByDay(timeSlotsNew);
|
||||
|
||||
const differences = Object.keys({ ...ts, ...tsNew }).reduce(
|
||||
(acc: Record<string, { onlyInTimeSlots: string[]; onlyInTimeSlotsNew: string[] }>, day) => {
|
||||
const t1 = ts[day] || [],
|
||||
t2 = tsNew[day] || [];
|
||||
const missingInNew = t1.filter((t) => !t2.includes(t));
|
||||
const missingInOld = t2.filter((t) => !t1.includes(t));
|
||||
|
||||
if (missingInNew.length || missingInOld.length) {
|
||||
acc[day] = { onlyInTimeSlots: missingInNew, onlyInTimeSlotsNew: missingInOld };
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
if (Object.keys(differences).length) {
|
||||
loggerWithEventDetails.info({
|
||||
routedTeamMemberIds,
|
||||
differences,
|
||||
});
|
||||
}
|
||||
|
||||
let availableTimeSlots: typeof timeSlots = [];
|
||||
const bookerClientUid = ctx?.req?.cookies?.uid;
|
||||
|
||||
@@ -678,6 +627,12 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
|
||||
const allDatesWithBookabilityStatus = monitorCallbackSync(getAllDatesWithBookabilityStatus, availableDates);
|
||||
loggerWithEventDetails.debug({ availableDates });
|
||||
|
||||
// timeZone isn't directly set on eventType now(So, it is legacy)
|
||||
// schedule is always expected to be set for an eventType now so it must never fallback to allUsersAvailability[0].timeZone(fallback is again legacy behavior)
|
||||
// TODO: Also, handleNewBooking only seems to be using eventType?.schedule?.timeZone which seems to confirm that we should simplify it as well.
|
||||
const eventTimeZone =
|
||||
eventType.timeZone || eventType?.schedule?.timeZone || allUsersAvailability?.[0]?.timeZone;
|
||||
|
||||
const eventUtcOffset = getUTCOffsetByTimezone(eventTimeZone) ?? 0;
|
||||
const bookerUtcOffset = input.timeZone ? getUTCOffsetByTimezone(input.timeZone) ?? 0 : 0;
|
||||
const periodLimits = calculatePeriodLimits({
|
||||
|
||||
Reference in New Issue
Block a user