diff --git a/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts b/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts new file mode 100644 index 00000000000000..bead6fa4b562af --- /dev/null +++ b/packages/core/getAggregatedAvailability/getAggregatedAvailability.test.ts @@ -0,0 +1,179 @@ +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); + }); + // 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..cf5191482a5213 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() * 1e12 + 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..db53c1800a017c 100644 --- a/packages/lib/slots.test.ts +++ b/packages/lib/slots.test.ts @@ -92,6 +92,89 @@ describe("Tests the date-range slot logic", () => { }); 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, + organizerTimeZone: "America/Toronto", + }); + + 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, + organizerTimeZone: "America/Toronto", + }); + + 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, + organizerTimeZone: "America/Toronto", + }); + + expect(result).toHaveLength(2); + }); }); describe("Tests the slot logic", () => { diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index 645c71fa0abdb2..94c113fc2b6ce8 100644 --- a/packages/lib/slots.ts +++ b/packages/lib/slots.ts @@ -141,6 +141,26 @@ function buildSlots({ return slots; } +const adjustDateRanges = (dateRanges: DateRange[], frequency: number) => { + if (dateRanges.length === 0) return; + + const baseStart = dateRanges[0].start.clone(); // Reference start time + const baseEnd = dateRanges[0].end.clone(); + + for (let i = 1; i < dateRanges.length; i++) { + // we skip if the date-range to adjust is outside the base date range day. - this avoids offset on other days. + if (dateRanges[i].start <= baseStart.startOf("day") || dateRanges[i].start >= baseEnd.endOf("day")) { + continue; + } + const timeSinceBase = dateRanges[i].start.diff(baseStart, "minutes"); + const adjustedStart = baseStart.clone().add(Math.ceil(timeSinceBase / frequency) * frequency, "minutes"); + // Modify the start time directly in the original array + dateRanges[i].start = adjustedStart; + } + + return dateRanges; +}; + function buildSlotsWithDateRanges({ dateRanges, frequency, @@ -164,15 +184,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 +208,14 @@ function buildSlotsWithDateRanges({ } } + const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute"); + + // to stay consistent with past behaviour we need to adjust the dateRanges according to frequency. + // this prevents slots appearing as 11:00, 11:15, 11:45, 12:00, .. etc. + adjustDateRanges(dateRanges, frequency); + 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 @@ -233,12 +262,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) {