revert: Reapply "fix: Multiple rr-hosts combine to create erroneous availabil… (#19263)

* Reapply "fix: Multiple rr-hosts combine to create erroneous availability (#18772)"

This reverts commit 5dbc6d9ca6.

* Only thing left is a diff to show which slots are removed

* Add logging around time slot results

* Setup logging through axiom

* .... forgot

* fix: type errors

* Only log when there are differences

* Updated from old which had a failing test to new logic
This commit is contained in:
Alex van Andel
2025-02-14 12:34:56 -03:00
committed by GitHub
parent d5420eda00
commit 5b59cf9d9c
7 changed files with 788 additions and 196 deletions
@@ -0,0 +1,212 @@
import { describe, it, expect } from "vitest";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { getAggregatedAvailabilityNew as getAggregatedAvailability } from ".";
// Helper to check if a time range overlaps with availability
const isAvailable = (availability: { start: Dayjs; end: Dayjs }[], range: { start: Dayjs; end: Dayjs }) => {
return availability.some(({ start, end }) => {
return start <= range.start && end >= range.end;
});
};
describe("getAggregatedAvailability", () => {
// rr-host availability used to combine into erroneous slots, this confirms it no longer happens
it("should not merge RR availability resulting in an unavailable slot due to overlap", () => {
const userAvailability = [
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:20:00.000Z") },
{ start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") },
],
user: { isFixed: false },
},
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
{ start: dayjs("2025-01-23T13:20:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") },
],
user: { isFixed: false },
},
];
const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
const timeRangeToCheckBusy = {
start: dayjs("2025-01-23T11:00:00.000Z"),
end: dayjs("2025-01-23T11:30:00.000Z"),
};
expect(isAvailable(result, timeRangeToCheckBusy)).toBe(false);
const timeRangeToCheckAvailable = {
start: dayjs("2025-01-23T11:00:00.000Z"),
end: dayjs("2025-01-23T11:20:00.000Z"),
};
expect(isAvailable(result, timeRangeToCheckAvailable)).toBe(true);
});
it("it returns the right amount of date ranges even if the end time is before the start time", () => {
const userAvailability = [
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-27T14:00:00.000Z"), end: dayjs("2025-01-27T04:30-05:00") },
],
user: { isFixed: false },
},
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-27T14:00:00.000Z"), end: dayjs("2025-01-27T14:45:00.000Z") },
],
user: { isFixed: false },
},
];
const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
expect(result).toEqual([
{
start: dayjs("2025-01-27T14:00:00.000Z"),
end: dayjs("2025-01-27T09:30:00.000Z"),
},
{
start: dayjs("2025-01-27T14:00:00.000Z"),
end: dayjs("2025-01-27T14:45:00.000Z"),
},
]);
});
// validates fixed host behaviour, they all have to be available
it("correctly joins fixed host availability resulting in one or more combined date ranges", () => {
const userAvailability = [
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:20:00.000Z") },
{ start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") },
],
user: { isFixed: true },
},
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
{ start: dayjs("2025-01-23T13:20:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") },
],
user: { isFixed: true },
},
];
const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
const timeRangeToCheckBusy = {
start: dayjs("2025-01-23T11:00:00.000Z"),
end: dayjs("2025-01-23T11:30:00.000Z"),
};
expect(isAvailable(result, timeRangeToCheckBusy)).toBe(false);
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T11:15:00.000Z").format());
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T11:20:00.000Z").format());
});
// Combines rr hosts and fixed hosts, both fixed and one of the rr hosts has to be available for the whole period
// All fixed user ranges are merged with each rr-host
it("Fixed hosts and at least one rr host available between 11:00-11:30 & 12:30-13:00 on January 23, 2025", () => {
// Both fixed user A and B are available 11:00-11:30 & 12:30-13:00 & 13:15-13:30
// Only user C (rr) is available 11:00-11:30 and only user D (rr) is available 12:30-13:00
// No rr users are available 13:15-13:30 and this date range should not be a result.
const userAvailability = [
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
{ start: dayjs("2025-01-23T12:30:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
{ start: dayjs("2025-01-23T13:15:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") },
{ start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") },
],
user: { isFixed: true },
},
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
{ start: dayjs("2025-01-23T12:30:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
{ start: dayjs("2025-01-23T13:15:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") },
{ start: dayjs("2025-01-23T13:20:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") },
],
user: { isFixed: true },
},
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
],
user: { isFixed: false },
},
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T12:30:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
],
user: { isFixed: false },
},
];
const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
const timeRangeToCheckAvailable = {
start: dayjs("2025-01-23T11:00:00.000Z"),
end: dayjs("2025-01-23T11:30:00.000Z"),
};
expect(isAvailable(result, timeRangeToCheckAvailable)).toBe(true);
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format());
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T11:30:00.000Z").format());
expect(result[1].start.format()).toEqual(dayjs("2025-01-23T12:30:00.000Z").format());
expect(result[1].end.format()).toEqual(dayjs("2025-01-23T13:00:00.000Z").format());
});
it("does not duplicate slots when multiple rr-hosts offer the same availability", () => {
const userAvailability = [
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
{ start: dayjs("2025-01-23T12:30:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") },
{ start: dayjs("2025-01-23T13:15:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") },
{ start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") },
],
user: { isFixed: true },
},
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
],
user: { isFixed: false },
},
{
dateRanges: [],
oooExcludedDateRanges: [
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") },
],
user: { isFixed: false },
},
];
const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN");
const timeRangeToCheckAvailable = {
start: dayjs("2025-01-23T11:00:00.000Z"),
end: dayjs("2025-01-23T11:30:00.000Z"),
};
expect(isAvailable(result, timeRangeToCheckAvailable)).toBe(true);
expect(result.length).toBe(1);
});
});
@@ -0,0 +1,53 @@
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";
function uniqueAndSortedDateRanges(ranges: DateRange[]): DateRange[] {
const seen = new Set<string>();
return ranges
.sort((a, b) => {
const startDiff = a.start.valueOf() - b.start.valueOf();
return startDiff !== 0 ? startDiff : a.end.valueOf() - b.end.valueOf();
})
.filter((range) => {
const key = `${range.start.valueOf()}-${range.end.valueOf()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
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 fixedDateRanges = mergeOverlappingDateRanges(
intersect(fixedHosts.map((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges)))
);
const dateRangesToIntersect = !!fixedDateRanges.length ? [fixedDateRanges] : [];
const roundRobinHosts = userAvailability.filter(({ user }) => user?.isFixed !== true);
if (roundRobinHosts.length) {
dateRangesToIntersect.push(
roundRobinHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges))
);
}
const availability = intersect(dateRangesToIntersect);
// we no longer merge overlapping date ranges, rr-hosts need to be individually available here.
return uniqueAndSortedDateRanges(availability);
};
@@ -0,0 +1,37 @@
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,37 +1,3 @@
import type { DateRange } from "@calcom/lib/date-ranges";
import { intersect } from "@calcom/lib/date-ranges";
import { SchedulingType } from "@calcom/prisma/enums";
export { getAggregatedAvailability as getAggregatedAvailabilityNew } from "./getAggregatedAvailability";
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);
};
export { getAggregatedAvailability as getAggregatedAvailability } from "./getAggregatedAvailabilityOld";
+254 -129
View File
@@ -31,18 +31,18 @@ describe("Tests the date-range slot logic", () => {
frequency: 60,
minimumBookingNotice: 0,
eventLength: 60,
organizerTimeZone: "Etc/GMT",
dateRanges: dateRangesNextDay,
})
).toHaveLength(24);
});
it("can fit 24 hourly slots for an empty day with interval != eventLength", async () => {
expect(
getSlots({
inviteeDate: dayjs.utc().add(1, "day"),
frequency: 60,
minimumBookingNotice: 0,
eventLength: 60,
organizerTimeZone: "America/Toronto",
eventLength: 30,
dateRanges: dateRangesNextDay,
})
).toHaveLength(24);
@@ -59,7 +59,6 @@ describe("Tests the date-range slot logic", () => {
dateRanges: dateRangesMockDay,
eventLength: 60,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(12);
});
@@ -74,7 +73,6 @@ describe("Tests the date-range slot logic", () => {
dateRanges: dateRangesNextDay,
eventLength: 60,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(11);
});
@@ -88,10 +86,260 @@ describe("Tests the date-range slot logic", () => {
dateRanges: dateRangesNextDay,
eventLength: 20,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
});
expect(result).toHaveLength(72);
});
it("can create multiple time slot groups when multiple date ranges are given", async () => {
const nextDay = dayjs.utc().add(1, "day").startOf("day");
const dateRanges = [
// 11:00-11:20,11:20-11:40,11:40-12:00
{
start: nextDay.hour(11),
end: nextDay.hour(12),
},
// 14:00-14:20,14:20-14:40,14:40-15:00
{
start: nextDay.hour(14),
end: nextDay.hour(15),
},
];
const result = getSlots({
inviteeDate: nextDay,
frequency: 20,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 20,
offsetStart: 0,
});
expect(result).toHaveLength(6);
});
it("can merge multiple time slot groups when multiple date ranges are given that overlap", async () => {
const nextDay = dayjs.utc().add(1, "day").startOf("day");
const dateRanges = [
// 11:00-11:20,11:20-11:40,11:40-12:00
{
start: nextDay.hour(11),
end: nextDay.hour(12),
},
// 12:00-12:20,12:20-12:40
{
start: nextDay.hour(11).minute(20),
end: nextDay.hour(12).minute(40),
},
];
const result = getSlots({
inviteeDate: nextDay,
frequency: 20,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 20,
offsetStart: 0,
});
expect(result).toHaveLength(5);
});
// for now, stay consistent with current behaviour and enable the slot 11:00, 11:45
// however, optimal slot allocation is 11:15-12:00,12:00-12:45 (as both hosts can be routed to at this time)
it("finds correct slots when two unequal date ranges are given", async () => {
const nextDay = dayjs.utc().add(1, "day").startOf("day");
const dateRanges = [
// 11:00-13:00
{
start: nextDay.hour(11),
end: nextDay.hour(13),
},
// 11:15-13:00
{
start: nextDay.hour(11).minute(15),
end: nextDay.hour(13),
},
];
const result = getSlots({
inviteeDate: nextDay,
frequency: 45,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 45,
offsetStart: 0,
});
expect(result).toHaveLength(2);
});
it("finds correct slots when two unequal date ranges are given (inverse)", async () => {
const nextDay = dayjs.utc().add(1, "day").startOf("day");
const dateRangesInverseOrder = [
// 11:15-13:00
{
start: nextDay.hour(11).minute(15),
end: nextDay.hour(13),
},
// 11:00-13:00
{
start: nextDay.hour(11),
end: nextDay.hour(13),
},
];
const resultInverseOrder = getSlots({
inviteeDate: nextDay,
frequency: 45,
minimumBookingNotice: 0,
dateRanges: dateRangesInverseOrder,
eventLength: 45,
offsetStart: 0,
});
expect(resultInverseOrder).toHaveLength(2);
});
it("finds correct slots over the span of multiple days", async () => {
const inviteeDate = dayjs.utc().add(1, "day").startOf("day");
const dateRanges = [
// 11:30-14:00
{
start: inviteeDate.hour(11).minute(30),
end: inviteeDate.hour(14),
},
// 9:15-13:00
{
start: inviteeDate.hour(9).minute(15),
end: inviteeDate.hour(11),
},
// 11:30-14:00
{
start: inviteeDate.add(1, "day").hour(11).minute(30),
end: inviteeDate.add(1, "day").hour(14),
},
// 11:15-13:00
{
start: inviteeDate.add(1, "day").hour(9).minute(15),
end: inviteeDate.add(1, "day").hour(11),
},
];
// each day availability should go 10:00, 12:00, 13:00
const result = getSlots({
// right now from the perspective of invitee local time.
inviteeDate,
frequency: 60,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 60,
offsetStart: 0,
});
expect(result).toHaveLength(6);
});
it("shows correct time slots for 20 minutes long events with working hours that do not end at a full hour", async () => {
const result = getSlots({
inviteeDate: dayjs().add(1, "day"),
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
expect(result).toHaveLength(71);
});
it("tests the final slot of the day is included", async () => {
const slots = getSlots({
inviteeDate: dayjs.tz("2023-07-13T00:00:00.000", "Europe/Brussels"),
eventLength: 15,
offsetStart: 0,
dateRanges: [
{ start: dayjs("2023-07-13T07:00:00.000Z"), end: dayjs("2023-07-13T15:00:00.000Z") },
{ start: dayjs("2023-07-13T18:30:00.000Z"), end: dayjs("2023-07-13T20:59:59.000Z") },
],
minimumBookingNotice: 120,
frequency: 15,
organizerTimeZone: "Europe/London",
}).reverse();
expect(slots[0].time.format()).toBe("2023-07-13T22:45:00+02:00");
});
it("tests slots for half hour timezones", async () => {
const slots = getSlots({
inviteeDate: dayjs.tz("2023-07-13T00:00:00.000", "Asia/Kolkata"),
frequency: 60,
minimumBookingNotice: 0,
eventLength: 60,
dateRanges: [
{
start: dayjs.tz("2023-07-13T07:30:00.000", "Asia/Kolkata"),
end: dayjs.tz("2023-07-13T09:30:00.000", "Asia/Kolkata"),
},
],
});
expect(slots).toHaveLength(1);
expect(slots[0].time.format()).toBe("2023-07-13T08:00:00+05:30");
});
it("tests slots for 5 minute events", async () => {
const slots = getSlots({
inviteeDate: dayjs.tz("2023-07-13T00:00:00.000+05:30", "Europe/London"),
frequency: 5,
minimumBookingNotice: 0,
eventLength: 5,
dateRanges: [
// fits 1 slot
{
start: dayjs.tz("2023-07-13T07:00:00.000", "Europe/London"),
end: dayjs.tz("2023-07-13T07:05:00.000", "Europe/London"),
},
// fits 4 slots
{
start: dayjs.tz("2023-07-13T07:10:00.000", "Europe/London"),
end: dayjs.tz("2023-07-13T07:30:00.000", "Europe/London"),
},
],
});
expect(slots).toHaveLength(5);
});
it("tests slots for events with an event length that is not divisible by 5", async () => {
const slots = getSlots({
inviteeDate: dayjs.utc().startOf("day"),
frequency: 8,
minimumBookingNotice: 0,
eventLength: 8,
dateRanges: [
{
start: dayjs.utc("2023-07-13T07:22:00.000"),
end: dayjs.utc("2023-07-13T08:00:00.000"),
},
],
});
/*
* 2023-07-13T07:22:00.000Z
* 2023-07-13T07:30:00.000Z
* 2023-07-13T07:38:00.000Z
* 2023-07-13T07:46:00.000Z
*/
expect(slots).toHaveLength(4);
});
});
describe("Tests the slot logic", () => {
@@ -205,29 +453,6 @@ describe("Tests the slot logic", () => {
).toHaveLength(11);
});
it("shows correct time slots for 20 minutes long events with working hours that do not end at a full hour", async () => {
const result = getSlots({
inviteeDate: dayjs().add(1, "day"),
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
expect(result).toHaveLength(71);
});
it("can fit 48 25 minute slots with a 5 minute offset for an empty day", async () => {
expect(
getSlots({
@@ -248,105 +473,6 @@ describe("Tests the slot logic", () => {
})
).toHaveLength(48);
});
it("tests the final slot of the day is included", async () => {
const slots = getSlots({
inviteeDate: dayjs.tz("2023-07-13T00:00:00.000+02:00", "Europe/Brussels"),
eventLength: 15,
workingHours: [
{
days: [1, 2, 3, 4, 5],
startTime: 480,
endTime: 960,
userId: 9,
},
{
days: [4],
startTime: 1170,
endTime: 1379,
userId: 9,
},
],
dateOverrides: [],
offsetStart: 0,
dateRanges: [
{ start: dayjs("2023-07-13T07:00:00.000Z"), end: dayjs("2023-07-13T15:00:00.000Z") },
{ start: dayjs("2023-07-13T18:30:00.000Z"), end: dayjs("2023-07-13T20:59:59.000Z") },
],
minimumBookingNotice: 120,
frequency: 15,
organizerTimeZone: "Europe/London",
}).reverse();
expect(slots[0].time.format()).toBe("2023-07-13T22:45:00+02:00");
});
it("tests slots for half hour timezones", async () => {
const slots = getSlots({
inviteeDate: dayjs.tz("2023-07-13T00:00:00.000+05:30", "Asia/Kolkata"),
frequency: 60,
minimumBookingNotice: 0,
eventLength: 60,
organizerTimeZone: "Asia/Kolkata",
dateRanges: [
{
start: dayjs.tz("2023-07-13T07:30:00.000", "Asia/Kolkata"),
end: dayjs.tz("2023-07-13T09:30:00.000", "Asia/Kolkata"),
},
],
});
expect(slots).toHaveLength(1);
expect(slots[0].time.format()).toBe("2023-07-13T08:00:00+05:30");
});
it("tests slots for 5 minute events", async () => {
const slots = getSlots({
inviteeDate: dayjs.tz("2023-07-13T00:00:00.000+05:30", "Europe/London"),
frequency: 5,
minimumBookingNotice: 0,
eventLength: 5,
organizerTimeZone: "Europe/London",
dateRanges: [
// fits 1 slot
{
start: dayjs.tz("2023-07-13T07:00:00.000", "Europe/London"),
end: dayjs.tz("2023-07-13T07:05:00.000", "Europe/London"),
},
// fits 4 slots
{
start: dayjs.tz("2023-07-13T07:10:00.000", "Europe/London"),
end: dayjs.tz("2023-07-13T07:30:00.000", "Europe/London"),
},
],
});
expect(slots).toHaveLength(5);
});
it("tests slots for events with an event length that is not divisible by 5", async () => {
const slots = getSlots({
inviteeDate: dayjs.tz("2023-07-13T00:00:00.000+05:30", "Europe/London"),
frequency: 8,
minimumBookingNotice: 0,
eventLength: 8,
organizerTimeZone: "Europe/London",
dateRanges: [
{
start: dayjs.tz("2023-07-13T07:22:00.000", "Europe/London"),
end: dayjs.tz("2023-07-13T08:00:00.000", "Europe/London"),
},
],
});
/*
2023-07-13T06:22:00.000Z
2023-07-13T06:30:00.000Z
2023-07-13T06:38:00.000Z
2023-07-13T06:46:00.000Z
*/
expect(slots).toHaveLength(4);
});
});
describe("Tests the date-range slot logic with custom env variable", () => {
@@ -385,7 +511,6 @@ describe("Tests the date-range slot logic with custom env variable", () => {
minimumBookingNotice: 0,
eventLength: 10,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
dateRanges: [{ start: dayjs("2023-07-13T00:10:00.000Z"), end: dayjs("2023-07-13T02:00:00.000Z") }],
})
).toHaveLength(11);
+149 -6
View File
@@ -16,7 +16,7 @@ export type GetSlots = {
minimumBookingNotice: number;
eventLength: number;
offsetStart?: number;
organizerTimeZone: string;
organizerTimeZone?: string;
datesOutOfOffice?: IOutOfOfficeData;
};
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
@@ -141,7 +141,7 @@ function buildSlots({
return slots;
}
function buildSlotsWithDateRanges({
function buildSlotsWithDateRangesOld({
dateRanges,
frequency,
eventLength,
@@ -241,6 +241,128 @@ function buildSlotsWithDateRanges({
return slots;
}
function buildSlotsWithDateRanges({
dateRanges,
frequency,
eventLength,
timeZone,
minimumBookingNotice,
offsetStart,
datesOutOfOffice,
}: {
dateRanges: DateRange[];
frequency: number;
eventLength: number;
timeZone: string;
minimumBookingNotice: number;
offsetStart?: number;
datesOutOfOffice?: IOutOfOfficeData;
}) {
// keep the old safeguards in; may be needed.
frequency = minimumOfOne(frequency);
eventLength = minimumOfOne(eventLength);
offsetStart = offsetStart ? minimumOfOne(offsetStart) : 0;
// there can only ever be one slot at a given start time, and based on duration also only a single length.
const slots = new Map<
string,
{
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;
}
}
const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute");
const orderedDateRanges = dateRanges.sort((a, b) => a.start.valueOf() - b.start.valueOf());
orderedDateRanges.forEach((range) => {
const dateYYYYMMDD = range.start.format("YYYY-MM-DD");
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;
slotStartTime = slotStartTime.add(offsetStart ?? 0, "minutes").tz(timeZone);
// if the slotStartTime is between an existing slot, we need to adjust to the begin of the existing slot
// but that adjusted startTime must be legal.
const iterator = slots.keys();
let result = iterator.next();
while (!result.done) {
const utcResultValue = dayjs.utc(result.value);
// if the slotStartTime is between an existing slot, we need to adjust to the begin of the existing slot
if (
utcResultValue.isBefore(slotStartTime) &&
utcResultValue.add(frequency + (offsetStart ?? 0), "minutes").isAfter(slotStartTime)
) {
// however, the slot can now be before the start of this date range.
if (!utcResultValue.isBefore(range.start)) {
// it is between, if possible floor down to the start of the existing slot
slotStartTime = utcResultValue;
} else {
// if not possible to floor, we need to ceil up to the next slot.
slotStartTime = utcResultValue.add(frequency + (offsetStart ?? 0), "minutes");
}
// and then convert to the correct timezone - UTC mode is just for performance.
slotStartTime = slotStartTime.tz(timeZone);
}
result = iterator.next();
}
while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").utc().isAfter(range.end)) {
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.set(slotData.time.toISOString(), slotData);
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
}
});
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);
@@ -258,9 +380,27 @@ const getSlots = ({
offsetStart = 0,
organizerTimeZone,
datesOutOfOffice,
}: GetSlots) => {
if (dateRanges) {
const slots = buildSlotsWithDateRanges({
}: GetSlots): {
time: Dayjs;
userIds?: number[];
away?: boolean;
fromUser?: IFromUser;
toUser?: IToUser;
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,
@@ -270,7 +410,10 @@ const getSlots = ({
offsetStart,
datesOutOfOffice,
});
return slots;
}
if (!organizerTimeZone) {
throw new Error("organizerTimeZone is required during getSlots call without dateRanges");
}
// current date in invitee tz
@@ -3,7 +3,10 @@ import { countBy } from "lodash";
import type { Logger } from "tslog";
import { v4 as uuid } from "uuid";
import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability";
import {
getAggregatedAvailability,
getAggregatedAvailabilityNew,
} 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";
@@ -183,6 +186,20 @@ const selectSelectedSlots = Prisma.validator<Prisma.SelectedSlotsDefaultArgs>()(
type SelectedSlots = Prisma.SelectedSlotsGetPayload<typeof selectSelectedSlots>;
const groupTimeSlotsByDay = (timeSlots: { time: Dayjs }[]) => {
return timeSlots.reduce((acc: Record<string, string[]>, { time }) => {
const day = time.format("YYYY-MM-DD"); // Group by date
const formattedTime = time.format("HH:mm"); // Format as HH:mm
if (!acc[day]) {
acc[day] = [];
}
acc[day].push(formattedTime);
return acc;
}, {});
};
function applyOccupiedSeatsToCurrentSeats(currentSeats: CurrentSeats, occupiedSeats: SelectedSlots[]) {
const occupiedSeatsCount = countBy(occupiedSeats, (item) => item.slotUtcStartDate.toISOString());
Object.keys(occupiedSeatsCount).forEach((date) => {
@@ -310,6 +327,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
: dayjs(startTimeAsIsoString).subtract(1, "month").toISOString();
const loggerWithEventDetails = logger.getSubLogger({
type: "json",
prefix: ["getAvailableSlots", `${eventType.id}:${input.usernameList}/${input.eventTypeSlug}`],
});
@@ -350,18 +368,19 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
contactOwnerEmail,
});
let { aggregatedAvailability, allUsersAvailability, usersWithCredentials, currentSeats } =
await calculateHostsAndAvailabilities({
input,
eventType,
hosts: routedHostsWithContactOwnerAndFixedHosts,
contactOwnerEmail,
loggerWithEventDetails,
startTime,
endTime,
bypassBusyCalendarTimes,
shouldServeCache,
});
let { allUsersAvailability, usersWithCredentials, currentSeats } = await calculateHostsAndAvailabilities({
input,
eventType,
hosts: routedHostsWithContactOwnerAndFixedHosts,
contactOwnerEmail,
loggerWithEventDetails,
startTime,
endTime,
bypassBusyCalendarTimes,
shouldServeCache,
});
let aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
// If contact skipping, determine if there's availability within two weeks
if (contactOwnerEmail && aggregatedAvailability.length > 0) {
@@ -375,8 +394,8 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
if (routedHostsAndFixedHosts.length > 0) {
// if the first available slot is more than 2 weeks from now, round robin as normal
({ aggregatedAvailability, allUsersAvailability, usersWithCredentials, currentSeats } =
await calculateHostsAndAvailabilities({
({ allUsersAvailability, usersWithCredentials, currentSeats } = await calculateHostsAndAvailabilities(
{
input,
eventType,
hosts: routedHostsAndFixedHosts,
@@ -386,7 +405,9 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
endTime,
bypassBusyCalendarTimes,
shouldServeCache,
}));
}
));
aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
}
}
}
@@ -401,6 +422,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
// 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,
@@ -412,6 +434,47 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
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 = [];
// Load cached busy slots
const selectedSlots =
@@ -548,11 +611,11 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
);
});
loggerWithEventDetails.debug(safeStringify({ slotsMappedToDate }));
loggerWithEventDetails.debug({ slotsMappedToDate });
const availableDates = Object.keys(slotsMappedToDate);
const allDatesWithBookabilityStatus = monitorCallbackSync(getAllDatesWithBookabilityStatus, availableDates);
loggerWithEventDetails.debug(safeStringify({ availableDates }));
loggerWithEventDetails.debug({ availableDates });
const eventUtcOffset = getUTCOffsetByTimezone(eventTimeZone) ?? 0;
const bookerUtcOffset = input.timeZone ? getUTCOffsetByTimezone(input.timeZone) ?? 0 : 0;
@@ -1024,14 +1087,7 @@ const calculateHostsAndAvailabilities = async ({
}
);
const aggregatedAvailability = monitorCallbackSync(
getAggregatedAvailability,
allUsersAvailability,
eventType.schedulingType
);
return {
aggregatedAvailability,
allUsersAvailability,
usersWithCredentials,
currentSeats,