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:
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user