From 7b1fbd285369b2c4e372f67b1c99c8bcf7b4cd50 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 5 Jul 2023 18:47:41 +0200 Subject: [PATCH] feat: date range overhaul (#9802) Co-authored-by: CarinaWolli Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: zomars Co-authored-by: Hariom Balhara Co-authored-by: Peer Richelsen --- apps/web/test/lib/getSchedule.test.ts | 102 ++++----- packages/core/getAggregateWorkingHours.ts | 2 +- packages/core/getAggregatedAvailability.ts | 56 +++++ packages/core/getUserAvailability.ts | 14 ++ .../features/bookings/lib/handleNewBooking.ts | 19 +- packages/lib/date-ranges.test.ts | 201 ++++++++++++++++++ packages/lib/date-ranges.ts | 191 +++++++++++++++++ {apps/web/test => packages}/lib/slots.test.ts | 164 ++++++++++++-- packages/lib/slots.ts | 86 +++++++- .../trpc/server/routers/viewer/slots/util.ts | 137 +++++------- 10 files changed, 793 insertions(+), 179 deletions(-) create mode 100644 packages/core/getAggregatedAvailability.ts create mode 100644 packages/lib/date-ranges.test.ts create mode 100644 packages/lib/date-ranges.ts rename {apps/web/test => packages}/lib/slots.test.ts (52%) diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 906b05b542..44e2231526 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -96,8 +96,8 @@ const TestData = { userId: null, eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], - startTime: "1970-01-01T09:30:00.000Z", - endTime: "1970-01-01T18:00:00.000Z", + startTime: new Date("1970-01-01T09:30:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), date: null, }, ], @@ -111,16 +111,16 @@ const TestData = { userId: null, eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], - startTime: "1970-01-01T09:30:00.000Z", - endTime: "1970-01-01T18:00:00.000Z", + startTime: new Date("1970-01-01T09:30:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), date: null, }, { userId: null, eventTypeId: null, days: [0, 1, 2, 3, 4, 5, 6], - startTime: `1970-01-01T14:00:00.000Z`, - endTime: `1970-01-01T18:00:00.000Z`, + startTime: new Date("1970-01-01T14:00:00.000Z"), + endTime: new Date("1970-01-01T18:00:00.000Z"), date: dateString, }, ], @@ -170,8 +170,8 @@ type InputUser = typeof TestData.users.example & { id: number } & { userId: number | null; eventTypeId: number | null; days: number[]; - startTime: string; - endTime: string; + startTime: Date; + endTime: Date; date: string | null; }[]; timeZone: string; @@ -392,16 +392,17 @@ describe("getSchedule", () => { expect(scheduleForDayWithOneBooking).toHaveTimeSlots( [ // "04:00:00.000Z", - This slot is unavailable because of the booking from 4:00 to 4:15 - "04:45:00.000Z", - "05:30:00.000Z", - "06:15:00.000Z", - "07:00:00.000Z", - "07:45:00.000Z", - "08:30:00.000Z", - "09:15:00.000Z", - "10:00:00.000Z", - "10:45:00.000Z", - "11:30:00.000Z", + `04:15:00.000Z`, + `05:00:00.000Z`, + `05:45:00.000Z`, + `06:30:00.000Z`, + `07:15:00.000Z`, + `08:00:00.000Z`, + `08:45:00.000Z`, + `09:30:00.000Z`, + `10:15:00.000Z`, + `11:00:00.000Z`, + `11:45:00.000Z`, ], { dateString: plus3DateString, @@ -845,6 +846,7 @@ describe("getSchedule", () => { // A default Event Type which this user owns { id: 2, + length: 15, slotInterval: 45, users: [{ id: 101 }], }, @@ -900,17 +902,17 @@ describe("getSchedule", () => { expect(thisUserAvailability).toHaveTimeSlots( [ // `04:00:00.000Z`, // <- This slot should be occupied by the Collective Event - `04:45:00.000Z`, - `05:30:00.000Z`, - `06:15:00.000Z`, - `07:00:00.000Z`, - `07:45:00.000Z`, - `08:30:00.000Z`, - `09:15:00.000Z`, - `10:00:00.000Z`, - `10:45:00.000Z`, - `11:30:00.000Z`, - `12:15:00.000Z`, + `04:15:00.000Z`, + `05:00:00.000Z`, + `05:45:00.000Z`, + `06:30:00.000Z`, + `07:15:00.000Z`, + `08:00:00.000Z`, + `08:45:00.000Z`, + `09:30:00.000Z`, + `10:15:00.000Z`, + `11:00:00.000Z`, + `11:45:00.000Z`, ], { dateString: plus2DateString, @@ -932,6 +934,7 @@ describe("getSchedule", () => { { id: 1, slotInterval: 45, + schedulingType: "COLLECTIVE", length: 45, users: [ { @@ -1018,21 +1021,23 @@ describe("getSchedule", () => { endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: Timezones["+5:30"], }); + // A user with blocked time in another event, still affects Team Event availability // It's a collective availability, so both user 101 and 102 are considered for timeslots expect(scheduleForTeamEventOnADayWithOneBookingForEachUser).toHaveTimeSlots( [ //`04:00:00.000Z`, - Blocked with User 101 - `04:45:00.000Z`, + `04:15:00.000Z`, //`05:30:00.000Z`, - Blocked with User 102 in event 2 - `06:15:00.000Z`, - `07:00:00.000Z`, - `07:45:00.000Z`, - `08:30:00.000Z`, - `09:15:00.000Z`, - `10:00:00.000Z`, - `10:45:00.000Z`, - `11:30:00.000Z`, + `05:45:00.000Z`, + `06:30:00.000Z`, + `07:15:00.000Z`, + `08:00:00.000Z`, + `08:45:00.000Z`, + `09:30:00.000Z`, + `10:15:00.000Z`, + `11:00:00.000Z`, + `11:45:00.000Z`, ], { dateString: plus2DateString } ); @@ -1151,16 +1156,17 @@ describe("getSchedule", () => { expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots( [ //`04:00:00.000Z`, // - Blocked with User 101 as well as User 102, so not available in Round Robin - `04:45:00.000Z`, - `05:30:00.000Z`, - `06:15:00.000Z`, - `07:00:00.000Z`, - `07:45:00.000Z`, - `08:30:00.000Z`, - `09:15:00.000Z`, - `10:00:00.000Z`, - `10:45:00.000Z`, - `11:30:00.000Z`, + `04:15:00.000Z`, + `05:00:00.000Z`, + `05:45:00.000Z`, + `06:30:00.000Z`, + `07:15:00.000Z`, + `08:00:00.000Z`, + `08:45:00.000Z`, + `09:30:00.000Z`, + `10:15:00.000Z`, + `11:00:00.000Z`, + `11:45:00.000Z`, ], { dateString: plus3DateString } ); diff --git a/packages/core/getAggregateWorkingHours.ts b/packages/core/getAggregateWorkingHours.ts index ddb2e6ee19..6751f3a84f 100644 --- a/packages/core/getAggregateWorkingHours.ts +++ b/packages/core/getAggregateWorkingHours.ts @@ -8,7 +8,7 @@ import type { WorkingHours } from "@calcom/types/schedule"; export const getAggregateWorkingHours = ( usersWorkingHoursAndBusySlots: (Omit< Awaited["getUserAvailability"]>>, - "currentSeats" + "currentSeats" | "dateRanges" > & { user?: { isFixed?: boolean } })[], // eslint-disable-next-line @typescript-eslint/no-unused-vars schedulingType: SchedulingType | null diff --git a/packages/core/getAggregatedAvailability.ts b/packages/core/getAggregatedAvailability.ts new file mode 100644 index 0000000000..5311383d56 --- /dev/null +++ b/packages/core/getAggregatedAvailability.ts @@ -0,0 +1,56 @@ +import type { DateRange } from "@calcom/lib/date-ranges"; +import { intersect } from "@calcom/lib/date-ranges"; +import { SchedulingType } from "@calcom/prisma/enums"; + +export const getAggregatedAvailability = ( + userAvailability: (Omit< + Awaited["getUserAvailability"]>>, + "currentSeats" + > & { user?: { isFixed?: boolean } })[], + schedulingType: SchedulingType | null +): DateRange[] => { + const fixedHosts = userAvailability.filter( + ({ user }) => !schedulingType || schedulingType === SchedulingType.COLLECTIVE || user?.isFixed + ); + + const dateRangesToIntersect = fixedHosts.map((s) => s.dateRanges); + + const unfixedHosts = userAvailability.filter(({ user }) => user?.isFixed !== true); + if (unfixedHosts.length) { + dateRangesToIntersect.push(unfixedHosts.flatMap((s) => s.dateRanges)); + } + + const availability = intersect(dateRangesToIntersect); + + return mergeOverlappingDateRanges(availability); +}; + +function mergeOverlappingDateRanges(dateRanges: DateRange[]) { + const sortedDateRanges = dateRanges.sort((a, b) => a.start.diff(b.start)); //is it already sorted before? + + const mergedDateRanges: DateRange[] = []; + + let currentRange = sortedDateRanges[0]; + if (!currentRange) { + return []; + } + + for (let i = 1; i < sortedDateRanges.length; i++) { + const nextRange = sortedDateRanges[i]; + if ( + currentRange.start.utc().format("DD MM YY") === nextRange.start.utc().format("DD MM YY") && + currentRange.end.isAfter(nextRange.start) + ) { + currentRange = { + start: currentRange.start, + end: currentRange.end.isAfter(nextRange.end) ? currentRange.end : nextRange.end, + }; + } else { + mergedDateRanges.push(currentRange); + currentRange = nextRange; + } + } + mergedDateRanges.push(currentRange); + + return mergedDateRanges; +} diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index b9943fa630..574bbe1e37 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -5,6 +5,7 @@ import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; import { parseBookingLimit, parseDurationLimit } from "@calcom/lib"; import { getWorkingHours } from "@calcom/lib/availability"; +import { buildDateRanges, subtract } from "@calcom/lib/date-ranges"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { checkBookingLimit } from "@calcom/lib/server"; @@ -244,9 +245,22 @@ export async function getUserAvailability( }; }); + const dateRanges = buildDateRanges({ + dateFrom, + dateTo, + availability, + timeZone, + }); + + const formattedBusyTimes = bufferedBusyTimes.map((busy) => ({ + start: dayjs(busy.start), + end: dayjs(busy.end), + })); + return { busy: bufferedBusyTimes, timeZone, + dateRanges: subtract(dateRanges, formattedBusyTimes), workingHours, dateOverrides, currentSeats, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 57c58d5b0e..24834c7c6b 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -345,11 +345,7 @@ async function ensureAvailableUsers( const availableUsers: IsFixedAwareUser[] = []; /** Let's start checking for availability */ for (const user of eventType.users) { - const { - busy: bufferedBusyTimes, - workingHours, - dateOverrides, - } = await getUserAvailability( + const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability( { userId: user.id, eventTypeId: eventType.id, @@ -358,18 +354,7 @@ async function ensureAvailableUsers( { user, eventType } ); - // check if time slot is outside of schedule. - if ( - !isWithinAvailableHours( - { start: input.dateFrom, end: input.dateTo }, - { - workingHours, - dateOverrides, - organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || user.timeZone, - inviteeTimeZone: input.timeZone, - } - ) - ) { + if (!dateRanges.length) { // user does not have availability at this time, skip user. continue; } diff --git a/packages/lib/date-ranges.test.ts b/packages/lib/date-ranges.test.ts new file mode 100644 index 0000000000..1973f8e883 --- /dev/null +++ b/packages/lib/date-ranges.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; + +import dayjs from "@calcom/dayjs"; + +import { buildDateRanges, processDateOverride, processWorkingHours, subtract } from "./date-ranges"; + +describe("processWorkingHours", () => { + it("should return the correct working hours given a specific availability, timezone, and date range", () => { + const item = { + days: [1, 2, 3, 4, 5], // Monday to Friday + startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM + endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM + }; + + const timeZone = "America/New_York"; + const dateFrom = dayjs.utc().startOf("day").day(2).add(1, "week"); + const dateTo = dayjs.utc().endOf("day").day(3).add(1, "week"); + + const results = processWorkingHours({ item, timeZone, dateFrom, dateTo }); + + expect(results.length).toBe(2); // There should be two working days between the range + // "America/New_York" day shifts -1, so we need to add a day to correct this shift. + expect(results[0]).toEqual({ + start: dayjs(`${dateFrom.tz(timeZone).add(1, "day").format("YYYY-MM-DD")}T12:00:00Z`).tz(timeZone), + end: dayjs(`${dateFrom.tz(timeZone).add(1, "day").format("YYYY-MM-DD")}T21:00:00Z`).tz(timeZone), + }); + expect(results[1]).toEqual({ + start: dayjs(`${dateTo.tz(timeZone).format("YYYY-MM-DD")}T12:00:00Z`).tz(timeZone), + end: dayjs(`${dateTo.tz(timeZone).format("YYYY-MM-DD")}T21:00:00Z`).tz(timeZone), + }); + }); +}); + +describe("processDateOverrides", () => { + it("should return the correct date override given a specific availability, timezone, and date", () => { + const item = { + date: new Date(Date.UTC(2023, 5, 12, 8, 0)), + startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM + endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM + }; + + // 2023-06-12T20:00:00-04:00 (America/New_York) + const timeZone = "America/New_York"; + + const result = processDateOverride({ item, timeZone }); + + expect(result.start.format()).toEqual(dayjs("2023-06-12T12:00:00Z").tz(timeZone).format()); + expect(result.end.format()).toEqual(dayjs("2023-06-12T21:00:00Z").tz(timeZone).format()); + }); +}); + +describe("buildDateRanges", () => { + it("should return the correct date ranges", () => { + const items = [ + { + date: new Date(Date.UTC(2023, 5, 13)), + startTime: new Date(Date.UTC(0, 0, 0, 10, 0)), // 10 AM + endTime: new Date(Date.UTC(0, 0, 0, 15, 0)), // 3 PM + }, + { + days: [1, 2, 3, 4, 5], + startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM + endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM + }, + ]; + + const dateFrom = dayjs("2023-06-13T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York) + const dateTo = dayjs("2023-06-15T00:00:00Z"); + + const timeZone = "America/New_York"; + + const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); + // [ + // { s: '2023-06-13T10:00:00-04:00', e: '2023-06-13T15:00:00-04:00' }, + // { s: '2023-06-14T08:00:00-04:00', e: '2023-06-14T17:00:00-04:00' } + // ] + + expect(results.length).toBe(2); + + expect(results[0]).toEqual({ + start: dayjs("2023-06-13T14:00:00Z").tz(timeZone), + end: dayjs("2023-06-13T19:00:00Z").tz(timeZone), + }); + + expect(results[1]).toEqual({ + start: dayjs("2023-06-14T12:00:00Z").tz(timeZone), + end: dayjs("2023-06-14T21:00:00Z").tz(timeZone), + }); + }); + it("should return correct date ranges with full day unavailable date override", () => { + const items = [ + { + date: new Date(Date.UTC(2023, 5, 13)), + startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), + endTime: new Date(Date.UTC(0, 0, 0, 0, 0)), + }, + { + days: [1, 2, 3, 4, 5], + startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), + endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), + }, + ]; + const timeZone = "Europe/London"; + + const dateFrom = dayjs("2023-06-13T00:00:00Z"); + const dateTo = dayjs("2023-06-15T00:00:00Z"); + + const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo }); + + expect(results[0]).toEqual({ + start: dayjs("2023-06-14T07:00:00Z").tz(timeZone), + end: dayjs("2023-06-14T16:00:00Z").tz(timeZone), + }); + }); +}); + +describe("subtract", () => { + it("subtracts appropriately when excluded ranges are given in order", () => { + const data = { + sourceRanges: [ + { start: dayjs.utc("2023-07-05T04:00:00.000Z"), end: dayjs.utc("2023-07-05T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-06T04:00:00.000Z"), end: dayjs.utc("2023-07-06T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-07T04:00:00.000Z"), end: dayjs.utc("2023-07-07T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-10T04:00:00.000Z"), end: dayjs.utc("2023-07-10T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-11T04:00:00.000Z"), end: dayjs.utc("2023-07-11T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-12T04:00:00.000Z"), end: dayjs.utc("2023-07-12T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-13T04:00:00.000Z"), end: dayjs.utc("2023-07-13T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-14T04:00:00.000Z"), end: dayjs.utc("2023-07-14T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-17T04:00:00.000Z"), end: dayjs.utc("2023-07-17T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-18T04:00:00.000Z"), end: dayjs.utc("2023-07-18T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-19T04:00:00.000Z"), end: dayjs.utc("2023-07-19T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-20T04:00:00.000Z"), end: dayjs.utc("2023-07-20T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-21T04:00:00.000Z"), end: dayjs.utc("2023-07-21T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-24T04:00:00.000Z"), end: dayjs.utc("2023-07-24T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-25T04:00:00.000Z"), end: dayjs.utc("2023-07-25T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-26T04:00:00.000Z"), end: dayjs.utc("2023-07-26T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-27T04:00:00.000Z"), end: dayjs.utc("2023-07-27T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-28T04:00:00.000Z"), end: dayjs.utc("2023-07-28T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-31T04:00:00.000Z"), end: dayjs.utc("2023-07-31T12:00:00.000Z") }, + ], + excludedRanges: [ + { start: dayjs.utc("2023-07-05T04:00:00.000Z"), end: dayjs.utc("2023-07-05T04:15:00.000Z") }, + { start: dayjs.utc("2023-07-05T04:45:00.000Z"), end: dayjs.utc("2023-07-05T05:00:00.000Z") }, + ], + }; + + const result = subtract(data["sourceRanges"], data["excludedRanges"]).map((range) => ({ + start: range.start.format(), + end: range.end.format(), + })); + + expect(result).toEqual( + expect.arrayContaining([ + { start: "2023-07-05T04:15:00Z", end: "2023-07-05T04:45:00Z" }, + { start: "2023-07-05T05:00:00Z", end: "2023-07-05T12:00:00Z" }, + ]) + ); + }); + + it("subtracts appropriately when excluded ranges are not given in order", () => { + const data = { + sourceRanges: [ + { start: dayjs.utc("2023-07-05T04:00:00.000Z"), end: dayjs.utc("2023-07-05T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-06T04:00:00.000Z"), end: dayjs.utc("2023-07-06T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-07T04:00:00.000Z"), end: dayjs.utc("2023-07-07T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-10T04:00:00.000Z"), end: dayjs.utc("2023-07-10T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-11T04:00:00.000Z"), end: dayjs.utc("2023-07-11T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-12T04:00:00.000Z"), end: dayjs.utc("2023-07-12T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-13T04:00:00.000Z"), end: dayjs.utc("2023-07-13T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-14T04:00:00.000Z"), end: dayjs.utc("2023-07-14T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-17T04:00:00.000Z"), end: dayjs.utc("2023-07-17T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-18T04:00:00.000Z"), end: dayjs.utc("2023-07-18T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-19T04:00:00.000Z"), end: dayjs.utc("2023-07-19T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-20T04:00:00.000Z"), end: dayjs.utc("2023-07-20T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-21T04:00:00.000Z"), end: dayjs.utc("2023-07-21T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-24T04:00:00.000Z"), end: dayjs.utc("2023-07-24T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-25T04:00:00.000Z"), end: dayjs.utc("2023-07-25T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-26T04:00:00.000Z"), end: dayjs.utc("2023-07-26T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-27T04:00:00.000Z"), end: dayjs.utc("2023-07-27T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-28T04:00:00.000Z"), end: dayjs.utc("2023-07-28T12:00:00.000Z") }, + { start: dayjs.utc("2023-07-31T04:00:00.000Z"), end: dayjs.utc("2023-07-31T12:00:00.000Z") }, + ], + excludedRanges: [ + { start: dayjs.utc("2023-07-05T04:45:00.000Z"), end: dayjs.utc("2023-07-05T05:00:00.000Z") }, + { start: dayjs.utc("2023-07-05T04:00:00.000Z"), end: dayjs.utc("2023-07-05T04:15:00.000Z") }, + ], + }; + + const result = subtract(data["sourceRanges"], data["excludedRanges"]).map((range) => ({ + start: range.start.format(), + end: range.end.format(), + })); + + expect(result).toEqual( + expect.arrayContaining([ + { start: "2023-07-05T04:15:00Z", end: "2023-07-05T04:45:00Z" }, + { start: "2023-07-05T05:00:00Z", end: "2023-07-05T12:00:00Z" }, + ]) + ); + }); +}); diff --git a/packages/lib/date-ranges.ts b/packages/lib/date-ranges.ts new file mode 100644 index 0000000000..01de5c478e --- /dev/null +++ b/packages/lib/date-ranges.ts @@ -0,0 +1,191 @@ +import type { Dayjs } from "@calcom/dayjs"; +import dayjs from "@calcom/dayjs"; +import type { Availability } from "@calcom/prisma/client"; + +export type DateRange = { + start: Dayjs; + end: Dayjs; +}; + +export type DateOverride = Pick; +export type WorkingHours = Pick; + +export function processWorkingHours({ + item, + timeZone, + dateFrom, + dateTo, +}: { + item: WorkingHours; + timeZone: string; + dateFrom: Dayjs; + dateTo: Dayjs; +}) { + const results = []; + for (let date = dateFrom.tz(timeZone).startOf("day"); dateTo.isAfter(date); date = date.add(1, "day")) { + if (!item.days.includes(date.day())) { + continue; + } + + const start = date.hour(item.startTime.getUTCHours()).minute(item.startTime.getUTCMinutes()).second(0); + const end = date.hour(item.endTime.getUTCHours()).minute(item.endTime.getUTCMinutes()).second(0); + + const startResult = dayjs.max(start, dateFrom.tz(timeZone)); + const endResult = dayjs.min(end, dateTo.tz(timeZone)); + + if (startResult.isAfter(endResult)) { + // if an event ends before start, it's not a result. + continue; + } + + results.push({ + start: startResult, + end: endResult, + }); + } + return results; +} + +export function processDateOverride({ item, timeZone }: { item: DateOverride; timeZone: string }) { + const date = dayjs.utc(item.date); + + const startTime = dayjs(item.startTime).utc().subtract(dayjs().tz(timeZone).utcOffset(), "minute"); + const endTime = dayjs(item.endTime).utc().subtract(dayjs().tz(timeZone).utcOffset(), "minute"); + + return { + start: date.hour(startTime.hour()).minute(startTime.minute()).second(0).tz(timeZone), + end: date.hour(endTime.hour()).minute(endTime.minute()).second(0).tz(timeZone), + }; +} + +export function buildDateRanges({ + availability, + timeZone /* Organizer timeZone */, + dateFrom /* Attendee dateFrom */, + dateTo /* `` dateTo */, +}: { + timeZone: string; + availability: (DateOverride | WorkingHours)[]; + dateFrom: Dayjs; + dateTo: Dayjs; +}): DateRange[] { + const groupedWorkingHours = groupByDate( + availability.reduce((processed: DateRange[], item) => { + if ("days" in item) { + processed = processed.concat(processWorkingHours({ item, timeZone, dateFrom, dateTo })); + } + return processed; + }, []) + ); + const groupedDateOverrides = groupByDate( + availability.reduce((processed: DateRange[], item) => { + if ("date" in item && !!item.date) { + processed.push(processDateOverride({ item, timeZone })); + } + return processed; + }, []) + ); + + const dateRanges = Object.values({ + ...groupedWorkingHours, + ...groupedDateOverrides, + }).map( + // remove 0-length overrides that were kept to cancel out working dates until now. + (ranges) => ranges.filter((range) => !range.start.isSame(range.end)) + ); + + return dateRanges.flat(); +} + +export function groupByDate(ranges: DateRange[]): { [x: string]: DateRange[] } { + const results = ranges.reduce( + ( + previousValue: { + [date: string]: DateRange[]; + }, + currentValue + ) => { + const dateString = dayjs.utc(currentValue.start).format("YYYY-MM-DD"); + + previousValue[dateString] = + typeof previousValue[dateString] === "undefined" + ? [currentValue] + : [...previousValue[dateString], currentValue]; + return previousValue; + }, + {} + ); + + return results; +} + +export function intersect(ranges: DateRange[][]): DateRange[] { + if (!ranges.length) return []; + // Get the ranges of the first user + let commonAvailability = ranges[0]; + + // For each of the remaining users, find the intersection of their ranges with the current common availability + for (let i = 1; i < ranges.length; i++) { + const userRanges = ranges[i]; + + const intersectedRanges: { + start: Dayjs; + end: Dayjs; + }[] = []; + + commonAvailability.forEach((commonRange) => { + userRanges.forEach((userRange) => { + const intersection = getIntersection(commonRange, userRange); + if (intersection !== null) { + // If the current common range intersects with the user range, add the intersected time range to the new array + intersectedRanges.push(intersection); + } + }); + }); + + commonAvailability = intersectedRanges; + } + + // If the common availability is empty, there is no time when all users are available + if (commonAvailability.length === 0) { + return []; + } + + return commonAvailability; +} + +function getIntersection(range1: DateRange, range2: DateRange) { + const start = range1.start.isAfter(range2.start) ? range1.start : range2.start; + const end = range1.end.isBefore(range2.end) ? range1.end : range2.end; + if (start.isBefore(end)) { + return { start, end }; + } + return null; +} + +export function subtract(sourceRanges: DateRange[], excludedRanges: DateRange[]) { + const result: DateRange[] = []; + + for (const { start: sourceStart, end: sourceEnd } of sourceRanges) { + let currentStart = sourceStart; + + const overlappingRanges = excludedRanges.filter( + ({ start, end }) => start.isBefore(sourceEnd) && end.isAfter(sourceStart) + ); + + overlappingRanges.sort((a, b) => (a.start.isAfter(b.start) ? 1 : -1)); + + for (const { start: excludedStart, end: excludedEnd } of overlappingRanges) { + if (excludedStart.isAfter(currentStart)) { + result.push({ start: currentStart, end: excludedStart }); + } + currentStart = excludedEnd.isAfter(currentStart) ? excludedEnd : currentStart; + } + + if (sourceEnd.isAfter(currentStart)) { + result.push({ start: currentStart, end: sourceEnd }); + } + } + + return result; +} diff --git a/apps/web/test/lib/slots.test.ts b/packages/lib/slots.test.ts similarity index 52% rename from apps/web/test/lib/slots.test.ts rename to packages/lib/slots.test.ts index 63834a8cc2..0afa5ef938 100644 --- a/apps/web/test/lib/slots.test.ts +++ b/packages/lib/slots.test.ts @@ -2,12 +2,97 @@ import { describe, expect, it, beforeAll, vi } from "vitest"; import dayjs from "@calcom/dayjs"; import { MINUTES_DAY_END, MINUTES_DAY_START } from "@calcom/lib/availability"; -import getSlots from "@calcom/lib/slots"; +import type { DateRange } from "./date-ranges"; +import getSlots from "./slots"; + +let dateRangesNextDay: DateRange[]; + +let dateRangesMockDay: DateRange[]; beforeAll(() => { - vi.setSystemTime(new Date("2021-06-20T11:59:59Z")); -}) + vi.setSystemTime(dayjs.utc("2021-06-20T11:59:59Z").toDate()); + + dateRangesMockDay = [{ start: dayjs.utc().startOf("day"), end: dayjs.utc().endOf("day") }]; + + dateRangesNextDay = [ + { + start: dayjs.utc().add(1, "day").startOf("day"), + end: dayjs.utc().add(1, "day").endOf("day"), + }, + ]; +}); + +describe("Tests the date-range slot logic", () => { + it("can fit 24 hourly slots for an empty day", async () => { + expect( + getSlots({ + inviteeDate: dayjs.utc().add(1, "day"), + frequency: 60, + minimumBookingNotice: 0, + eventLength: 60, + organizerTimeZone: "Etc/GMT", + dateRanges: dateRangesNextDay, + }) + ).toHaveLength(24); + + expect( + getSlots({ + inviteeDate: dayjs.utc().add(1, "day"), + frequency: 60, + minimumBookingNotice: 0, + eventLength: 60, + organizerTimeZone: "America/Toronto", + dateRanges: dateRangesNextDay, + }) + ).toHaveLength(24); + }); + + it("only shows future booking slots on the same day", async () => { + // The mock date is 1s to midday, so 12 slots should be open given 0 booking notice. + + expect( + getSlots({ + inviteeDate: dayjs.utc(), + frequency: 60, + minimumBookingNotice: 0, + dateRanges: dateRangesMockDay, + eventLength: 60, + offsetStart: 0, + organizerTimeZone: "America/Toronto", + }) + ).toHaveLength(12); + }); + + it("adds minimum booking notice correctly", async () => { + // 24h in a day. + expect( + getSlots({ + inviteeDate: dayjs.utc().add(1, "day").startOf("day"), + frequency: 60, + minimumBookingNotice: 1500, + dateRanges: dateRangesNextDay, + eventLength: 60, + offsetStart: 0, + organizerTimeZone: "America/Toronto", + }) + ).toHaveLength(11); + }); + + it("shows correct time slots for 20 minutes long events with working hours that do not end at a full hour ", async () => { + // 72 20-minutes events in a 24h day + const result = getSlots({ + inviteeDate: dayjs().add(1, "day"), + frequency: 20, + minimumBookingNotice: 0, + dateRanges: dateRangesNextDay, + eventLength: 20, + offsetStart: 0, + organizerTimeZone: "America/Toronto", + }); + expect(result).toHaveLength(72); + }); +}); describe("Tests the slot logic", () => { it("can fit 24 hourly slots for an empty day", async () => { @@ -120,26 +205,27 @@ 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 () => { - // 72 20-minutes events in a 24h day - expect( - getSlots({ - inviteeDate: dayjs.utc().add(1, "day"), - frequency: 20, - minimumBookingNotice: 0, - 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", - }) - ).toHaveLength(71); + 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 () => { @@ -162,4 +248,36 @@ 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"); + }); }); diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index 1bf034229e..a4ba2c5cf2 100644 --- a/packages/lib/slots.ts +++ b/packages/lib/slots.ts @@ -4,15 +4,17 @@ import type { WorkingHours, TimeRange as DateOverride } from "@calcom/types/sche import { getWorkingHours } from "./availability"; import { getTimeZone } from "./date-fns"; +import type { DateRange } from "./date-ranges"; export type GetSlots = { inviteeDate: Dayjs; frequency: number; - workingHours: WorkingHours[]; + workingHours?: WorkingHours[]; dateOverrides?: DateOverride[]; + dateRanges?: DateRange[]; minimumBookingNotice: number; eventLength: number; - offsetStart: number; + offsetStart?: number; organizerTimeZone: string; }; export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number }; @@ -25,7 +27,7 @@ function buildSlots({ computedLocalAvailability, frequency, eventLength, - offsetStart, + offsetStart = 0, startDate, organizerTimeZone, inviteeTimeZone, @@ -35,7 +37,7 @@ function buildSlots({ startDate: Dayjs; frequency: number; eventLength: number; - offsetStart: number; + offsetStart?: number; organizerTimeZone: string; inviteeTimeZone: string; }) { @@ -133,6 +135,61 @@ function buildSlots({ time: getTime(item.startTime), }); } + + return slots; +} + +function buildSlotsWithDateRanges({ + dateRanges, + frequency, + eventLength, + timeZone, + minimumBookingNotice, + organizerTimeZone, + offsetStart, +}: { + dateRanges: DateRange[]; + frequency: number; + eventLength: number; + timeZone: string; + minimumBookingNotice: number; + organizerTimeZone: string; + offsetStart?: number; +}) { + // keep the old safeguards in; may be needed. + frequency = minimumOfOne(frequency); + eventLength = minimumOfOne(eventLength); + offsetStart = offsetStart ? minimumOfOne(offsetStart) : 0; + + const slots: { time: Dayjs; userIds?: number[] }[] = []; + dateRanges.forEach((range) => { + const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute"); + + let slotStartTime = range.start.isAfter(startTimeWithMinNotice) ? range.start : startTimeWithMinNotice; + + slotStartTime = + slotStartTime.utc().minute() % 15 !== 0 + ? slotStartTime + .startOf("day") + .add(slotStartTime.hour() * 60 + Math.ceil(slotStartTime.minute() / 15) * 15, "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"); + while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").isAfter(rangeEnd)) { + slots.push({ + time: slotStartTime.tz(timeZone), + }); + slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes"); + } + }); + return slots; } @@ -146,14 +203,29 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, - workingHours, + workingHours = [], dateOverrides = [], + dateRanges, eventLength, - offsetStart, + offsetStart = 0, organizerTimeZone, }: GetSlots) => { + if (dateRanges) { + const slots = buildSlotsWithDateRanges({ + dateRanges, + frequency, + eventLength, + timeZone: getTimeZone(inviteeDate), + minimumBookingNotice, + organizerTimeZone, + offsetStart, + }); + return slots; + } + // current date in invitee tz const startDate = dayjs().utcOffset(inviteeDate.utcOffset()).add(minimumBookingNotice, "minute"); + // This code is ran client side, startOf() does some conversions based on the // local tz of the client. Sometimes this shifts the day incorrectly. const startOfDayUTC = dayjs.utc().set("hour", 0).set("minute", 0).set("second", 0); @@ -218,7 +290,7 @@ const getSlots = ({ return dayjs.utc(override.start).isBetween(startOfInviteeDay, startOfInviteeDay.endOf("day"), null, "[)"); }); - if (!!activeOverrides.length) { + if (activeOverrides.length) { const overrides = activeOverrides.flatMap((override) => ({ userIds: override.userId ? [override.userId] : [], startTime: override.start.getUTCHours() * 60 + override.start.getUTCMinutes(), diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 6b853d2aa9..7fbd220eb7 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -2,6 +2,7 @@ import { countBy } from "lodash"; import { v4 as uuid } from "uuid"; import { getAggregateWorkingHours } from "@calcom/core/getAggregateWorkingHours"; +import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability"; import type { CurrentSeats } from "@calcom/core/getUserAvailability"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import type { Dayjs } from "@calcom/dayjs"; @@ -10,7 +11,7 @@ import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; import { performance } from "@calcom/lib/server/perfObserver"; -import getTimeSlots from "@calcom/lib/slots"; +import getSlots from "@calcom/lib/slots"; import prisma, { availabilityUserSelect } from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; @@ -210,10 +211,14 @@ export async function getSchedule(input: TGetScheduleInputSchema) { throw new TRPCError({ code: "NOT_FOUND" }); } - const startTime = - input.timeZone === "Etc/GMT" - ? dayjs.utc(input.startTime) - : dayjs(input.startTime).utc().tz(input.timeZone); + const getStartTime = (startTimeInput: string, timeZone?: string) => { + const startTimeMin = dayjs.utc().add(eventType.minimumBookingNotice, "minutes"); + const startTime = timeZone === "Etc/GMT" ? dayjs.utc(startTimeInput) : dayjs(startTimeInput).tz(timeZone); + + return startTimeMin.isAfter(startTime) ? startTimeMin.tz(timeZone) : startTime; + }; + + const startTime = getStartTime(input.startTime, input.timeZone); const endTime = input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone); @@ -237,6 +242,7 @@ export async function getSchedule(input: TGetScheduleInputSchema) { busy, workingHours, dateOverrides, + dateRanges, currentSeats: _currentSeats, timeZone, } = await getUserAvailability( @@ -258,11 +264,13 @@ export async function getSchedule(input: TGetScheduleInputSchema) { timeZone, workingHours, dateOverrides, + dateRanges, busy, user: currentUser, }; }) ); + // flattens availability of multiple users const dateOverrides = userAvailability.flatMap((availability) => availability.dateOverrides.map((override) => ({ userId: availability.user.id, ...override })) @@ -283,32 +291,21 @@ export async function getSchedule(input: TGetScheduleInputSchema) { }); const getSlotsTime = 0; - let checkForAvailabilityTime = 0; + const checkForAvailabilityTime = 0; const getSlotsCount = 0; - let checkForAvailabilityCount = 0; + const checkForAvailabilityCount = 0; - const timeSlots: ReturnType = []; - - for ( - let currentCheckedTime = startTime; - currentCheckedTime.isBefore(endTime); - currentCheckedTime = currentCheckedTime.add(1, "day") - ) { - // get slots retrieves the available times for a given day - timeSlots.push( - ...getTimeSlots({ - inviteeDate: currentCheckedTime, - eventLength: input.duration || eventType.length, - workingHours, - dateOverrides, - minimumBookingNotice: eventType.minimumBookingNotice, - offsetStart: eventType.offsetStart, - frequency: eventType.slotInterval || input.duration || eventType.length, - organizerTimeZone: - eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone, - }) - ); - } + const timeSlots = getSlots({ + inviteeDate: startTime, + eventLength: input.duration || eventType.length, + workingHours, + dateOverrides, + offsetStart: eventType.offsetStart, + dateRanges: getAggregatedAvailability(userAvailability, eventType.schedulingType), + minimumBookingNotice: eventType.minimumBookingNotice, + frequency: eventType.slotInterval || input.duration || eventType.length, + organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone, + }); let availableTimeSlots: typeof timeSlots = []; // Load cached busy slots @@ -332,44 +329,7 @@ export async function getSchedule(input: TGetScheduleInputSchema) { where: { eventTypeId: { equals: eventType.id }, id: { notIn: selectedSlots.map((item) => item.id) } }, }); - availableTimeSlots = timeSlots.filter((slot) => { - const fixedHosts = userAvailability.filter((availability) => availability.user.isFixed); - return fixedHosts.every((schedule) => { - const startCheckForAvailability = performance.now(); - - const isAvailable = checkIfIsAvailable({ - time: slot.time, - ...schedule, - ...availabilityCheckProps, - }); - const endCheckForAvailability = performance.now(); - checkForAvailabilityCount++; - checkForAvailabilityTime += endCheckForAvailability - startCheckForAvailability; - return isAvailable; - }); - }); - // what else are you going to call it? - const looseHostAvailability = userAvailability.filter(({ user: { isFixed } }) => !isFixed); - if (looseHostAvailability.length > 0) { - availableTimeSlots = availableTimeSlots - .map((slot) => { - slot.userIds = slot.userIds?.filter((slotUserId) => { - const userSchedule = looseHostAvailability.find( - ({ user: { id: userId } }) => userId === slotUserId - ); - if (!userSchedule) { - return false; - } - return checkIfIsAvailable({ - time: slot.time, - ...userSchedule, - ...availabilityCheckProps, - }); - }); - return slot; - }) - .filter((slot) => !!slot.userIds?.length); - } + availableTimeSlots = timeSlots; if (selectedSlots?.length > 0) { let occupiedSeats: typeof selectedSlots = selectedSlots.filter( @@ -410,30 +370,41 @@ export async function getSchedule(input: TGetScheduleInputSchema) { }); currentSeats = availabilityCheckProps.currentSeats; } - availableTimeSlots = availableTimeSlots .map((slot) => { - slot.userIds = slot.userIds?.filter((slotUserId) => { - const busy = selectedSlots.reduce((r, c) => { - if (c.userId === slotUserId && !c.isSeat) { - r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); - } - return r; - }, []); - - if (!busy?.length && eventType.seatsPerTimeSlot === null) { - return false; + const busy = selectedSlots.reduce((r, c) => { + if (!c.isSeat) { + r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); } + return r; + }, []); - return checkIfIsAvailable({ + if ( + checkIfIsAvailable({ time: slot.time, busy, ...availabilityCheckProps, - }); - }); - return slot; + }) + ) { + return slot; + } + return undefined; }) - .filter((slot) => !!slot.userIds?.length); + .filter( + ( + item: + | { + time: dayjs.Dayjs; + userIds?: number[] | undefined; + } + | undefined + ): item is { + time: dayjs.Dayjs; + userIds?: number[] | undefined; + } => { + return !!item; + } + ); } availableTimeSlots = availableTimeSlots.filter((slot) => isTimeWithinBounds(slot.time));