From 5dbc6d9ca678afd4423bdc6ec42a3150245fbb90 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 12 Feb 2025 13:15:04 +0000 Subject: [PATCH] Revert "fix: Multiple rr-hosts combine to create erroneous availability (#18772)" This reverts commit f5dc22f1454bf0cb778a5ba58de76e91c390416e. --- .../getAggregatedAvailability.test.ts | 212 ---------- .../core/getAggregatedAvailability/index.ts | 34 +- packages/lib/slots.test.ts | 383 ++++++------------ packages/lib/slots.ts | 75 ++-- .../trpc/server/routers/viewer/slots/util.ts | 9 +- 5 files changed, 169 insertions(+), 544 deletions(-) delete mode 100644 packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts diff --git a/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts b/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts deleted file mode 100644 index 1311f45600..0000000000 --- a/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -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); - }); -}); diff --git a/packages/core/getAggregatedAvailability/index.ts b/packages/core/getAggregatedAvailability/index.ts index 145a090320..0f4143a243 100644 --- a/packages/core/getAggregatedAvailability/index.ts +++ b/packages/core/getAggregatedAvailability/index.ts @@ -4,22 +4,6 @@ import { SchedulingType } from "@calcom/prisma/enums"; import { mergeOverlappingDateRanges } from "./date-range-utils/mergeOverlappingDateRanges"; -function uniqueAndSortedDateRanges(ranges: DateRange[]): DateRange[] { - const seen = new Set(); - - 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[]; @@ -32,22 +16,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 fixedDateRanges = mergeOverlappingDateRanges( - intersect(fixedHosts.map((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges))) + const dateRangesToIntersect = fixedHosts.map((s) => + !isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges ); - const dateRangesToIntersect = !!fixedDateRanges.length ? [fixedDateRanges] : []; - const roundRobinHosts = userAvailability.filter(({ user }) => user?.isFixed !== true); - if (roundRobinHosts.length) { + + const unfixedHosts = userAvailability.filter(({ user }) => user?.isFixed !== true); + if (unfixedHosts.length) { dateRangesToIntersect.push( - roundRobinHosts.flatMap((s) => (!isTeamEvent ? s.dateRanges : s.oooExcludedDateRanges)) + unfixedHosts.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); + + return mergeOverlappingDateRanges(availability); }; diff --git a/packages/lib/slots.test.ts b/packages/lib/slots.test.ts index fef7171e79..c942cdb3dd 100644 --- a/packages/lib/slots.test.ts +++ b/packages/lib/slots.test.ts @@ -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: 30, + eventLength: 60, + organizerTimeZone: "America/Toronto", dateRanges: dateRangesNextDay, }) ).toHaveLength(24); @@ -59,6 +59,7 @@ describe("Tests the date-range slot logic", () => { dateRanges: dateRangesMockDay, eventLength: 60, offsetStart: 0, + organizerTimeZone: "America/Toronto", }) ).toHaveLength(12); }); @@ -73,6 +74,7 @@ describe("Tests the date-range slot logic", () => { dateRanges: dateRangesNextDay, eventLength: 60, offsetStart: 0, + organizerTimeZone: "America/Toronto", }) ).toHaveLength(11); }); @@ -86,259 +88,9 @@ describe("Tests the date-range slot logic", () => { dateRanges: dateRangesNextDay, eventLength: 20, offsetStart: 0, - }); - 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); + expect(result).toHaveLength(72); }); }); @@ -453,6 +205,29 @@ 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({ @@ -473,6 +248,105 @@ 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", () => { @@ -511,6 +385,7 @@ 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); diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index 0a5fff885e..645c71fa0a 100644 --- a/packages/lib/slots.ts +++ b/packages/lib/slots.ts @@ -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,6 +147,7 @@ function buildSlotsWithDateRanges({ eventLength, timeZone, minimumBookingNotice, + organizerTimeZone, offsetStart, datesOutOfOffice, }: { @@ -155,6 +156,7 @@ function buildSlotsWithDateRanges({ eventLength: number; timeZone: string; minimumBookingNotice: number; + organizerTimeZone: string; offsetStart?: number; datesOutOfOffice?: IOutOfOfficeData; }) { @@ -162,19 +164,15 @@ function buildSlotsWithDateRanges({ 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; - } - >(); + const slots: { + time: Dayjs; + userIds?: number[]; + away?: boolean; + fromUser?: IFromUser; + toUser?: IToUser; + reason?: string; + emoji?: string; + }[] = []; let interval = Number(process.env.NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL) || 1; const intervalsWithDefinedStartTimes = [60, 30, 20, 15, 10, 5]; @@ -186,11 +184,9 @@ function buildSlotsWithDateRanges({ } } - const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute"); - - const orderedDateRanges = dateRanges.sort((a, b) => a.start.valueOf() - b.start.valueOf()); - orderedDateRanges.forEach((range) => { + dateRanges.forEach((range) => { const dateYYYYMMDD = range.start.format("YYYY-MM-DD"); + const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute"); let slotStartTime = range.start.utc().isAfter(startTimeWithMinNotice) ? range.start @@ -201,34 +197,16 @@ 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); - // 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)) { + while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").utc().isAfter(rangeEnd)) { const dateOutOfOfficeExists = datesOutOfOffice?.[dateYYYYMMDD]; let slotData: { time: Dayjs; @@ -255,12 +233,12 @@ function buildSlotsWithDateRanges({ }; } - slots.set(slotData.time.toISOString(), slotData); + slots.push(slotData); slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes"); } }); - return Array.from(slots.values()); + return slots; } function fromIndex(cb: (val: T, i: number, a: T[]) => boolean, index: number) { @@ -288,16 +266,13 @@ 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"); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 30b018e0db..ff0a422dbe 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -396,6 +396,11 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise 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, @@ -403,6 +408,7 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise