From 5b59cf9d9cd60db8c5a09570acdef35a3eff095f Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Fri, 14 Feb 2025 15:34:56 +0000 Subject: [PATCH] =?UTF-8?q?revert:=20Reapply=20"fix:=20Multiple=20rr-hosts?= =?UTF-8?q?=20combine=20to=20create=20erroneous=20availabil=E2=80=A6=20(#1?= =?UTF-8?q?9263)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reapply "fix: Multiple rr-hosts combine to create erroneous availability (#18772)" This reverts commit 5dbc6d9ca678afd4423bdc6ec42a3150245fbb90. * 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 --- .../getAggregatedAvailability.test.ts | 212 ++++++++++ .../getAggregatedAvailability.ts | 53 +++ .../getAggregatedAvailabilityOld.ts | 37 ++ .../core/getAggregatedAvailability/index.ts | 38 +- packages/lib/slots.test.ts | 383 ++++++++++++------ packages/lib/slots.ts | 155 ++++++- .../trpc/server/routers/viewer/slots/util.ts | 106 +++-- 7 files changed, 788 insertions(+), 196 deletions(-) create mode 100644 packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts create mode 100644 packages/core/getAggregatedAvailability/getAggregatedAvailability.ts create mode 100644 packages/core/getAggregatedAvailability/getAggregatedAvailabilityOld.ts diff --git a/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts b/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts new file mode 100644 index 0000000000..19c92663a9 --- /dev/null +++ b/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts @@ -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); + }); +}); diff --git a/packages/core/getAggregatedAvailability/getAggregatedAvailability.ts b/packages/core/getAggregatedAvailability/getAggregatedAvailability.ts new file mode 100644 index 0000000000..145a090320 --- /dev/null +++ b/packages/core/getAggregatedAvailability/getAggregatedAvailability.ts @@ -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(); + + 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); +}; diff --git a/packages/core/getAggregatedAvailability/getAggregatedAvailabilityOld.ts b/packages/core/getAggregatedAvailability/getAggregatedAvailabilityOld.ts new file mode 100644 index 0000000000..0f4143a243 --- /dev/null +++ b/packages/core/getAggregatedAvailability/getAggregatedAvailabilityOld.ts @@ -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); +}; diff --git a/packages/core/getAggregatedAvailability/index.ts b/packages/core/getAggregatedAvailability/index.ts index 0f4143a243..1e1f53e1b9 100644 --- a/packages/core/getAggregatedAvailability/index.ts +++ b/packages/core/getAggregatedAvailability/index.ts @@ -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"; diff --git a/packages/lib/slots.test.ts b/packages/lib/slots.test.ts index c942cdb3dd..fef7171e79 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: 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); diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index 645c71fa0a..0d6ad13537 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 }; @@ -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(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 diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index ff0a422dbe..6cb83350a4 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -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()( type SelectedSlots = Prisma.SelectedSlotsGetPayload; +const groupTimeSlotsByDay = (timeSlots: { time: Dayjs }[]) => { + return timeSlots.reduce((acc: Record, { 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 0) { @@ -375,8 +394,8 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise 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, 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