diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4cebb6a1a5..e2b3a9f147 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3511,6 +3511,8 @@ "file_upload_instructions": "Accepted types: {{acceptedTypes}}; Max file size: {{maxSize}}", "license_key_saved": "License key saved successfully", "timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})", + "show_optimized_slots": "Optimized slots", + "show_optimized_slots_description": "Arrange time slots to optimize availability.", "failed_bookings_by_field": "Failed Bookings By Field", "failed_bookings": "Failed bookings", "event_type_no_hosts": "No hosts are assigned to event type", diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index ef488d7e98..997785645a 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -121,6 +121,7 @@ describe("handleChildrenEventTypes", () => { autoTranslateDescriptionEnabled, includeNoShowInRRCalculation, instantMeetingScheduleId, + showOptimizedSlots, ...evType } = mockFindFirstEventType({ id: 123, @@ -182,6 +183,7 @@ describe("handleChildrenEventTypes", () => { assignRRMembersUsingSegment, includeNoShowInRRCalculation, instantMeetingScheduleId, + showOptimizedSlots, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -292,6 +294,7 @@ describe("handleChildrenEventTypes", () => { includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, + showOptimizedSlots, ...evType } = mockFindFirstEventType({ id: 123, @@ -356,6 +359,7 @@ describe("handleChildrenEventTypes", () => { assignRRMembersUsingSegment, rrSegmentQueryValue, useEventLevelSelectedCalendars, + showOptimizedSlots, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -421,6 +425,7 @@ describe("handleChildrenEventTypes", () => { includeNoShowInRRCalculation, instantMeetingScheduleId, assignRRMembersUsingSegment, + showOptimizedSlots, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -444,6 +449,7 @@ describe("handleChildrenEventTypes", () => { requiresBookerEmailVerification: false, lockTimeZoneToggleOnBookingPage: false, useEventTypeDestinationCalendarEmail: false, + showOptimizedSlots: false, workflows: [], parentId: 1, locations: [], diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index b88bbdaec7..85930f7cfb 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -522,6 +522,8 @@ export const EventAdvancedTab = ({ eventType.allowReschedulingCancelledBookings ?? false ); + const showOptimizedSlotsLocked = shouldLockDisableProps("showOptimizedSlots"); + const closeEventNameTip = () => setShowEventNameTip(false); const [isEventTypeColorChecked, setIsEventTypeColorChecked] = useState(!!eventType.eventTypeColor); @@ -1264,6 +1266,29 @@ export const EventAdvancedTab = ({ )} /> + { + const isChecked = value; + return ( + { + onChange(active ?? false); + }} + switchContainerClassName={classNames( + "border-subtle rounded-lg border py-6 px-4 sm:px-6", + isChecked && "rounded-b-none" + )} + /> + ); + }} + /> {isRoundRobinEventType && ( { ); }); }); + +describe("Tests the date-range slot logic with showOptimizedSlots", () => { + it("should respect start of the hour for 60minutes slots for availabilities like 09:30-17:00, when showOptimizedSlots set to true", async () => { + const slots = getSlots({ + showOptimizedSlots: true, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:09:30, end:17:00 + // with 09:00 - 09:30 busy time + dateRanges: [{ start: dayjs("2024-09-12T16:30:00.000Z"), end: dayjs("2024-09-13T00:00:00.000Z") }], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T10:00:00-07:00", + "2024-09-12T11:00:00-07:00", + "2024-09-12T12:00:00-07:00", + "2024-09-12T13:00:00-07:00", + "2024-09-12T14:00:00-07:00", + "2024-09-12T15:00:00-07:00", + "2024-09-12T16:00:00-07:00", + ]); + }); + + it("shows 4 60minutes slots for availability like 08:00-12:30 with 10:00-10:10 busytime, when showOptimizedSlots set to true", async () => { + const slots = getSlots({ + showOptimizedSlots: true, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:08:00, end:10:00 + // start:10:10, end:12:30 + // with 10:00 - 10:10 busy time + dateRanges: [ + { start: dayjs("2024-09-12T15:00:00.000Z"), end: dayjs("2024-09-12T17:00:00.000Z") }, + { start: dayjs("2024-09-12T17:10:00.000Z"), end: dayjs("2024-09-12T19:30:00.000Z") }, + ], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T10:15:00-07:00", + "2024-09-12T11:15:00-07:00", + ]); + }); + + it("shows 3 60minutes slots for availability like 08:00-12:30 with 10:00-10:10 busytime, when showOptimizedSlots set to false", async () => { + const slots = getSlots({ + showOptimizedSlots: false, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:08:00, end:10:00 + // start:10:10, end:12:30 + // with 10:00 - 10:10 busy time + dateRanges: [ + { start: dayjs("2024-09-12T15:00:00.000Z"), end: dayjs("2024-09-12T17:00:00.000Z") }, + { start: dayjs("2024-09-12T17:10:00.000Z"), end: dayjs("2024-09-12T19:30:00.000Z") }, + ], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T11:00:00-07:00", + ]); + }); + + it("shows 3 60minutes slots for availability like 08:00-12:00 with 10:00-10:10 busytime, when showOptimizedSlots set to true", async () => { + const slots = getSlots({ + showOptimizedSlots: true, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:08:00, end:10:00 + // start:10:10, end:12:00 + // with 10:00 - 10:10 busy time + dateRanges: [ + { start: dayjs("2024-09-12T15:00:00.000Z"), end: dayjs("2024-09-12T17:00:00.000Z") }, + { start: dayjs("2024-09-12T17:10:00.000Z"), end: dayjs("2024-09-12T19:00:00.000Z") }, + ], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T11:00:00-07:00", + ]); + }); + + it("shows 3 60minutes slots for availability like 08:00-12:00 with 10:00-10:10 busytime, when showOptimizedSlots set to false", async () => { + const slots = getSlots({ + showOptimizedSlots: false, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:08:00, end:10:00 + // start:10:10, end:12:00 + // with 10:00 - 10:10 busy time + dateRanges: [ + { start: dayjs("2024-09-12T15:00:00.000Z"), end: dayjs("2024-09-12T17:00:00.000Z") }, + { start: dayjs("2024-09-12T17:10:00.000Z"), end: dayjs("2024-09-12T19:00:00.000Z") }, + ], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T11:00:00-07:00", + ]); + }); + + it("shows 9 30minutes slots for availability like 08:00-12:45 with 10:00-10:10 busytime, when showOptimizedSlots set to true", async () => { + const slots = getSlots({ + showOptimizedSlots: true, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 30, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:08:00, end:10:00 + // start:10:10, end:12:45 + // with 10:00 - 10:10 busy time + dateRanges: [ + { start: dayjs("2024-09-12T15:00:00.000Z"), end: dayjs("2024-09-12T17:00:00.000Z") }, + { start: dayjs("2024-09-12T17:10:00.000Z"), end: dayjs("2024-09-12T19:45:00.000Z") }, + ], + minimumBookingNotice: 0, + frequency: 30, + }); + + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T08:30:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T09:30:00-07:00", + "2024-09-12T10:15:00-07:00", + "2024-09-12T10:45:00-07:00", + "2024-09-12T11:15:00-07:00", + "2024-09-12T11:45:00-07:00", + "2024-09-12T12:15:00-07:00", + ]); + }); + + it("shows 8 30minutes slots for availability like 08:00-12:45 with 10:00-10:10 busytime, when showOptimizedSlots set to false", async () => { + const slots = getSlots({ + showOptimizedSlots: false, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 30, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:08:00, end:10:00 + // start:10:10, end:12:45 + // with 10:00 - 10:10 busy time + dateRanges: [ + { start: dayjs("2024-09-12T15:00:00.000Z"), end: dayjs("2024-09-12T17:00:00.000Z") }, + { start: dayjs("2024-09-12T17:10:00.000Z"), end: dayjs("2024-09-12T19:45:00.000Z") }, + ], + minimumBookingNotice: 0, + frequency: 30, + }); + + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T08:30:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T09:30:00-07:00", + "2024-09-12T10:30:00-07:00", + "2024-09-12T11:00:00-07:00", + "2024-09-12T11:30:00-07:00", + "2024-09-12T12:00:00-07:00", + ]); + }); + + it("shows 13 20minutes slots for availability like 08:00-12:30 with 10:00-10:10 busytime, when showOptimizedSlots set to true", async () => { + const slots = getSlots({ + showOptimizedSlots: true, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 20, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:08:00, end:10:00 + // start:10:10, end:12:30 + // with 10:00 - 10:10 busy time + dateRanges: [ + { start: dayjs("2024-09-12T15:00:00.000Z"), end: dayjs("2024-09-12T17:00:00.000Z") }, + { start: dayjs("2024-09-12T17:10:00.000Z"), end: dayjs("2024-09-12T19:30:00.000Z") }, + ], + minimumBookingNotice: 0, + frequency: 20, + }); + + expect(slots.length).toStrictEqual(13); + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T08:20:00-07:00", + "2024-09-12T08:40:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T09:20:00-07:00", + "2024-09-12T09:40:00-07:00", + "2024-09-12T10:10:00-07:00", + "2024-09-12T10:30:00-07:00", + "2024-09-12T10:50:00-07:00", + "2024-09-12T11:10:00-07:00", + "2024-09-12T11:30:00-07:00", + "2024-09-12T11:50:00-07:00", + "2024-09-12T12:10:00-07:00", + ]); + }); + + it("shows 12 20minutes slots for availability like 08:00-12:30 with 10:00-10:10 busytime, when showOptimizedSlots set to false", async () => { + const slots = getSlots({ + showOptimizedSlots: false, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 20, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:08:00, end:10:00 + // start:10:10, end:12:30 + // with 10:00 - 10:10 busy time + dateRanges: [ + { start: dayjs("2024-09-12T15:00:00.000Z"), end: dayjs("2024-09-12T17:00:00.000Z") }, + { start: dayjs("2024-09-12T17:10:00.000Z"), end: dayjs("2024-09-12T19:30:00.000Z") }, + ], + minimumBookingNotice: 0, + frequency: 20, + }); + + expect(slots.length).toStrictEqual(12); + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T08:20:00-07:00", + "2024-09-12T08:40:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T09:20:00-07:00", + "2024-09-12T09:40:00-07:00", + "2024-09-12T10:20:00-07:00", + "2024-09-12T10:40:00-07:00", + "2024-09-12T11:00:00-07:00", + "2024-09-12T11:20:00-07:00", + "2024-09-12T11:40:00-07:00", + "2024-09-12T12:00:00-07:00", + ]); + }); + + it("shows 8 60minutes slots for availability like 09:30-17:30, when showOptimizedSlots set to true", async () => { + const slots = getSlots({ + showOptimizedSlots: true, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:09:30, end:17:30 + dateRanges: [{ start: dayjs("2024-09-12T16:30:00.000Z"), end: dayjs("2024-09-13T00:30:00.000Z") }], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.length).toStrictEqual(8); + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T09:30:00-07:00", + "2024-09-12T10:30:00-07:00", + "2024-09-12T11:30:00-07:00", + "2024-09-12T12:30:00-07:00", + "2024-09-12T13:30:00-07:00", + "2024-09-12T14:30:00-07:00", + "2024-09-12T15:30:00-07:00", + "2024-09-12T16:30:00-07:00", + ]); + }); + + it("shows 7 60minutes slots for availability like 09:30-17:30, when showOptimizedSlots set to false", async () => { + const slots = getSlots({ + showOptimizedSlots: false, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:09:30, end:17:30 + dateRanges: [{ start: dayjs("2024-09-12T16:30:00.000Z"), end: dayjs("2024-09-13T00:30:00.000Z") }], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.length).toStrictEqual(7); + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T10:00:00-07:00", + "2024-09-12T11:00:00-07:00", + "2024-09-12T12:00:00-07:00", + "2024-09-12T13:00:00-07:00", + "2024-09-12T14:00:00-07:00", + "2024-09-12T15:00:00-07:00", + "2024-09-12T16:00:00-07:00", + ]); + }); + + it("should respect start of the hour for 60minutes slots for availabilities like 07:45-15:30, even when showOptimizedSlots set to true", async () => { + const slots = getSlots({ + showOptimizedSlots: true, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:07:45, end:15:30 + dateRanges: [{ start: dayjs("2024-09-12T14:45:00.000Z"), end: dayjs("2024-09-12T22:30:00.000Z") }], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.length).toStrictEqual(7); + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T10:00:00-07:00", + "2024-09-12T11:00:00-07:00", + "2024-09-12T12:00:00-07:00", + "2024-09-12T13:00:00-07:00", + "2024-09-12T14:00:00-07:00", + ]); + }); + + it("should respect start of the hour for 60minutes slots for availabilities like 07:45-15:30, when showOptimizedSlots set to false", async () => { + const slots = getSlots({ + showOptimizedSlots: false, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:07:45, end:15:30 + dateRanges: [{ start: dayjs("2024-09-12T14:45:00.000Z"), end: dayjs("2024-09-12T22:30:00.000Z") }], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.length).toStrictEqual(7); + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T08:00:00-07:00", + "2024-09-12T09:00:00-07:00", + "2024-09-12T10:00:00-07:00", + "2024-09-12T11:00:00-07:00", + "2024-09-12T12:00:00-07:00", + "2024-09-12T13:00:00-07:00", + "2024-09-12T14:00:00-07:00", + ]); + }); + + it("should respect start of the hour for 60minutes slots for availabilities like 09:05-12:55, even when showOptimizedSlots set to true", async () => { + const slots = getSlots({ + showOptimizedSlots: true, + inviteeDate: dayjs.tz("2024-09-12T00:00:00.000-07:00", "America/Los_Angeles"), + eventLength: 60, + offsetStart: 0, + // equivalent dateRanges in UTC-7 + // start:09:05, end:12:55 + dateRanges: [{ start: dayjs("2024-09-12T16:05:00.000Z"), end: dayjs("2024-09-12T19:55:00.000Z") }], + minimumBookingNotice: 0, + frequency: 60, + }); + + expect(slots.length).toStrictEqual(3); + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2024-09-12T09:15:00-07:00", + "2024-09-12T10:15:00-07:00", + "2024-09-12T11:15:00-07:00", + ]); + }); + + it("adjusts slot start time to next 5-minute boundary for current day bookings with non-aligned start time", async () => { + // Set system time to 2021-06-20 11:20:00 UTC + vi.setSystemTime(dayjs.utc("2021-06-20T11:20:00.000Z").toDate()); + + const slots = getSlots({ + inviteeDate: dayjs.tz("2021-06-20T00:00:00.000Z", "America/Los_Angeles"), + frequency: 15, + minimumBookingNotice: 0, + eventLength: 15, + dateRanges: [ + { + start: dayjs.tz("2021-06-20T11:22:00.000", "America/Los_Angeles"), + end: dayjs.tz("2021-06-20T12:55:00.000", "America/Los_Angeles"), + }, + ], + offsetStart: 0, + showOptimizedSlots: true, + }); + + expect(slots.length).toStrictEqual(6); + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2021-06-20T11:25:00-07:00", + "2021-06-20T11:40:00-07:00", + "2021-06-20T11:55:00-07:00", + "2021-06-20T12:10:00-07:00", + "2021-06-20T12:25:00-07:00", + "2021-06-20T12:40:00-07:00", + ]); + + vi.useRealTimers(); + }); + + it("should respect start of the hour for current day bookings with non-aligned start time", async () => { + // Set system time to 2021-06-20 11:20:00 UTC + vi.setSystemTime(dayjs.utc("2021-06-20T11:20:00.000Z").toDate()); + + const slots = getSlots({ + inviteeDate: dayjs.tz("2021-06-20T00:00:00.000Z", "America/Los_Angeles"), + frequency: 60, + minimumBookingNotice: 0, + eventLength: 60, + dateRanges: [ + { + start: dayjs.tz("2021-06-20T11:22:36.234", "America/Los_Angeles"), //with seconds + end: dayjs.tz("2021-06-20T14:00:00.000", "America/Los_Angeles"), + }, + ], + offsetStart: 0, + showOptimizedSlots: true, + }); + + expect(slots.length).toStrictEqual(2); + expect(slots.map((slot) => slot.time.format())).toStrictEqual([ + "2021-06-20T12:00:00-07:00", + "2021-06-20T13:00:00-07:00", + ]); + + vi.useRealTimers(); + }); +}); diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index 7afb184914..3ae06df1b5 100644 --- a/packages/lib/slots.ts +++ b/packages/lib/slots.ts @@ -14,11 +14,56 @@ export type GetSlots = { eventLength: number; offsetStart?: number; datesOutOfOffice?: IOutOfOfficeData; + showOptimizedSlots?: boolean | null; }; export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number }; const minimumOfOne = (input: number) => (input < 1 ? 1 : input); +function getCorrectedSlotStartTime({ + slotStartTime, + range, + showOptimizedSlots, + interval, +}: { + showOptimizedSlots: boolean | null | undefined; + interval: number; + slotStartTime: Dayjs; + range: DateRange; +}) { + if (showOptimizedSlots) { + let correctedSlotStartTime = slotStartTime; + // if showOptimizedSlots option is selected, the slotStartTime should not be modified, + // so that maximum possible slots are shown. + // The below logic in this entire `if branch` only tries to add an increment if sufficient minutes are available (after max possible slots are consumed), + // so that slots are shown respecting the 'Start of the Hour'. + const minutesRequiredToMoveToNextSlot = interval - (slotStartTime.minute() % interval); + const minutesRequiredToMoveTo15MinSlot = 15 - (slotStartTime.minute() % 15); + const minutesRequiredToMoveTo5MinSlot = 5 - (slotStartTime.minute() % 5); + const extraMinutesAvailable = range.end.diff(slotStartTime, "minutes") % interval; + + if (extraMinutesAvailable >= minutesRequiredToMoveToNextSlot) { + // For cases like, Availability -> 9:05 - 12:00, 60Min EventTypes. + // Total available minutes are 175, so only 2 60Min slots can be provided max + // And still 175-120 = 55mins are available, hence 'slotStartTime' is pushed to 10:00 to respect 'Start of the Hour'. + // Slots will be shown as '10:00, 11:00' instead of '09:05, 10:05' + correctedSlotStartTime = slotStartTime.add(minutesRequiredToMoveToNextSlot, "minute"); + } else if (extraMinutesAvailable >= minutesRequiredToMoveTo15MinSlot) { + // For cases like, Availability -> 9:05 - 11:55, 60Min EventTypes. + // Total available minutes are 170, so only 2 60Min slots can be provided max + // And still 175-120 = 50mins are available, but it is less 55mins which is required to push to 10:00 + // so slotStartTime is pushed to next 15Min slot 09:15, instead of showing slots like 9:05,10:05 now slots will be 9:15,10:15 + correctedSlotStartTime = slotStartTime.add(minutesRequiredToMoveTo15MinSlot, "minute"); + } else if (extraMinutesAvailable >= minutesRequiredToMoveTo5MinSlot) { + // so slotStartTime is pushed to next 5Min, instead of showing slots like 11:22,11:37 now slots will be 11:25,11:40 + correctedSlotStartTime = slotStartTime.add(minutesRequiredToMoveTo5MinSlot, "minute"); + } + return correctedSlotStartTime; + } + + return slotStartTime.startOf("hour").add(Math.ceil(slotStartTime.minute() / interval) * interval, "minute"); +} + function buildSlotsWithDateRanges({ dateRanges, frequency, @@ -27,6 +72,7 @@ function buildSlotsWithDateRanges({ minimumBookingNotice, offsetStart, datesOutOfOffice, + showOptimizedSlots, }: { dateRanges: DateRange[]; frequency: number; @@ -35,6 +81,7 @@ function buildSlotsWithDateRanges({ minimumBookingNotice: number; offsetStart?: number; datesOutOfOffice?: IOutOfOfficeData; + showOptimizedSlots?: boolean | null; }) { // keep the old safeguards in; may be needed. frequency = minimumOfOne(frequency); @@ -78,10 +125,17 @@ function buildSlotsWithDateRanges({ ? range.start : startTimeWithMinNotice; - slotStartTime = - slotStartTime.minute() % interval !== 0 - ? slotStartTime.startOf("hour").add(Math.ceil(slotStartTime.minute() / interval) * interval, "minute") - : slotStartTime; + // For current day bookings, normalizing the seconds to zero to avoid issues with time calculations + slotStartTime = slotStartTime.set("second", 0).set("millisecond", 0); + + if (slotStartTime.minute() % interval !== 0) { + slotStartTime = getCorrectedSlotStartTime({ + showOptimizedSlots, + interval, + slotStartTime, + range, + }); + } slotStartTime = slotStartTime.add(offsetStart ?? 0, "minutes").tz(timeZone); @@ -163,6 +217,7 @@ const getSlots = ({ eventLength, offsetStart = 0, datesOutOfOffice, + showOptimizedSlots, }: GetSlots): { time: Dayjs; userIds?: number[]; @@ -180,6 +235,7 @@ const getSlots = ({ minimumBookingNotice, offsetStart, datesOutOfOffice, + showOptimizedSlots, }); }; diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index c37d0cec07..dee2d8827a 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -123,6 +123,7 @@ export const buildEventType = (eventType?: Partial): EventType => { beforeEventBuffer: 0, afterEventBuffer: 0, onlyShowFirstAvailableSlot: false, + showOptimizedSlots: false, seatsPerTimeSlot: null, seatsShowAttendees: null, disableCancelling: false, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index cd63b4ddde..0da7eadf87 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -145,6 +145,7 @@ export const useEventTypeForm = ({ calVideoSettings: eventType.calVideoSettings, maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker || null, maxActiveBookingPerBookerOfferReschedule: eventType.maxActiveBookingPerBookerOfferReschedule, + showOptimizedSlots: eventType.showOptimizedSlots ?? false, }; }, [eventType, periodDates]); diff --git a/packages/prisma/migrations/20240910195018_add_event_type_show_optimized_slots/migration.sql b/packages/prisma/migrations/20240910195018_add_event_type_show_optimized_slots/migration.sql new file mode 100644 index 0000000000..be94319d2c --- /dev/null +++ b/packages/prisma/migrations/20240910195018_add_event_type_show_optimized_slots/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "showOptimizedSlots" BOOLEAN DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index da9da77119..867264dda2 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -168,6 +168,7 @@ model EventType { afterEventBuffer Int @default(0) seatsPerTimeSlot Int? onlyShowFirstAvailableSlot Boolean @default(false) + showOptimizedSlots Boolean? @default(false) disableCancelling Boolean? @default(false) disableRescheduling Boolean? @default(false) seatsShowAttendees Boolean? @default(false) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 60a986574e..bfed607ab4 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1155,6 +1155,7 @@ export class AvailableSlotsService { minimumBookingNotice: eventType.minimumBookingNotice, frequency: eventType.slotInterval || input.duration || eventType.length, datesOutOfOffice: !isTeamEvent ? allUsersAvailability[0]?.datesOutOfOffice : undefined, + showOptimizedSlots: eventType.showOptimizedSlots, }); let availableTimeSlots: typeof timeSlots = []; diff --git a/turbo.json b/turbo.json index 8dfd208865..344481f7fe 100644 --- a/turbo.json +++ b/turbo.json @@ -129,6 +129,7 @@ "NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW", "NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL", "NEXT_PUBLIC_WEBSITE_TERMS_URL", + "NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL", "NEXT_RUNTIME", "NEXTAUTH_COOKIE_DOMAIN", "NEXTAUTH_SECRET",