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

* wip: Open draft PR with passing test (should fail)

* Fix aggregatedAvailability by not merging rr host availability

* Added test for validating fixed host behaviour

* Add test for combined rr and fixed hosts

* Check date-ranges arent returned multiple times when offered by multiple rr hosts

* Also sort date ranges

* Adding failing test that should result in 5 slots, but returns 7 as it returns the same slots twice.

* Slots returns duplicate slots for overlapping date ranges

* As mergeOverlappingDateRanges enabled the unified start time, we need to reproduce this

* Do not 'prettify' slots to the same degree if they fall on different days

* Added test to prove an end date before start also works

* Use a string instead of number as key

* chore: Deprecate organizerTimeZone, effectively unused

* Adjust all dateRanges similarly

* Bring back eventTimeZone

* Remove adjustDateRanges in favour of slot generation changes

* Convert to tz after all logic is finished.

* clearer code flow

* Remove console.log + add failing test due to inverse order

* Address issue with inversion when date ranges are given out of order
This commit is contained in:
Alex van Andel
2025-02-11 19:35:56 +00:00
committed by GitHub
parent 4fd31db6ee
commit f5dc22f145
5 changed files with 544 additions and 169 deletions
@@ -0,0 +1,212 @@
import { describe, it, expect } from "vitest";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { 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);
});
});
@@ -4,6 +4,22 @@ 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[];
@@ -16,22 +32,22 @@ export const getAggregatedAvailability = (
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 fixedDateRanges = mergeOverlappingDateRanges(
intersect(fixedHosts.map((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges)))
);
const unfixedHosts = userAvailability.filter(({ user }) => user?.isFixed !== true);
if (unfixedHosts.length) {
const dateRangesToIntersect = !!fixedDateRanges.length ? [fixedDateRanges] : [];
const roundRobinHosts = userAvailability.filter(({ user }) => user?.isFixed !== true);
if (roundRobinHosts.length) {
dateRangesToIntersect.push(
unfixedHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges))
roundRobinHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges))
);
}
const availability = intersect(dateRangesToIntersect);
return mergeOverlappingDateRanges(availability);
// we no longer merge overlapping date ranges, rr-hosts need to be individually available here.
return uniqueAndSortedDateRanges(availability);
};
+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);
+50 -25
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 };
@@ -147,7 +147,6 @@ function buildSlotsWithDateRanges({
eventLength,
timeZone,
minimumBookingNotice,
organizerTimeZone,
offsetStart,
datesOutOfOffice,
}: {
@@ -156,7 +155,6 @@ function buildSlotsWithDateRanges({
eventLength: number;
timeZone: string;
minimumBookingNotice: number;
organizerTimeZone: string;
offsetStart?: number;
datesOutOfOffice?: IOutOfOfficeData;
}) {
@@ -164,15 +162,19 @@ function buildSlotsWithDateRanges({
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;
}[] = [];
// 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];
@@ -184,9 +186,11 @@ function buildSlotsWithDateRanges({
}
}
dateRanges.forEach((range) => {
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");
const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute");
let slotStartTime = range.start.utc().isAfter(startTimeWithMinNotice)
? range.start
@@ -197,16 +201,34 @@ function buildSlotsWithDateRanges({
? 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)) {
// 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;
@@ -233,12 +255,12 @@ function buildSlotsWithDateRanges({
};
}
slots.push(slotData);
slots.set(slotData.time.toISOString(), slotData);
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
}
});
return slots;
return Array.from(slots.values());
}
function fromIndex<T>(cb: (val: T, i: number, a: T[]) => boolean, index: number) {
@@ -266,13 +288,16 @@ const getSlots = ({
eventLength,
timeZone: getTimeZone(inviteeDate),
minimumBookingNotice,
organizerTimeZone,
offsetStart,
datesOutOfOffice,
});
return slots;
}
if (!organizerTimeZone) {
throw new Error("organizerTimeZone is required during getSlots call without dateRanges");
}
// current date in invitee tz
const startDate = dayjs().utcOffset(inviteeDate.utcOffset()).add(minimumBookingNotice, "minute");
@@ -396,11 +396,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,
@@ -408,7 +403,6 @@ 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,
});
@@ -554,6 +548,9 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<I
const allDatesWithBookabilityStatus = monitorCallbackSync(getAllDatesWithBookabilityStatus, availableDates);
loggerWithEventDetails.debug(safeStringify({ availableDates }));
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({