diff --git a/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts b/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts new file mode 100644 index 00000000000000..5b49c0a96f3b8f --- /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 { 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 have no host available between 11:00 and 11:30 on January 23, 2025", () => { + 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("should only have all fixed hosts available between 11:15 and 11:20 on January 23, 2025", () => { + 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 0f4143a2437d03..145a0903201134 100644 --- a/packages/core/getAggregatedAvailability/index.ts +++ b/packages/core/getAggregatedAvailability/index.ts @@ -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(); + + 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); }; diff --git a/packages/lib/slots.test.ts b/packages/lib/slots.test.ts index c942cdb3dd0d7d..fef7171e79f3ef 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 645c71fa0abdb2..0a5fff885e1de2 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,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(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"); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index ff0a422dbe7dc0..30b018e0db31a2 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -396,11 +396,6 @@ 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, @@ -408,7 +403,6 @@ async function _getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise