diff --git a/apps/web/modules/bookings/components/AvailableTimeSlots.tsx b/apps/web/modules/bookings/components/AvailableTimeSlots.tsx index cd85a4738a957c..981f2f1b0e6dbb 100644 --- a/apps/web/modules/bookings/components/AvailableTimeSlots.tsx +++ b/apps/web/modules/bookings/components/AvailableTimeSlots.tsx @@ -10,8 +10,10 @@ import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-sche import { useSlotsForAvailableDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate"; import { PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM } from "@calcom/lib/constants"; import { localStorage } from "@calcom/lib/webstorage"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; import { AvailableTimesHeader } from "@calcom/web/modules/bookings/components/AvailableTimesHeader"; import type { useScheduleForEventReturnType } from "@calcom/features/bookings/Booker/utils/event"; @@ -50,6 +52,7 @@ type AvailableTimeSlotsProps = { unavailableTimeSlots: string[]; confirmButtonDisabled?: boolean; onAvailableTimeSlotSelect: (time: string) => void; + onLoadNextRoundRobinChunk?: () => void; }; /** @@ -74,8 +77,10 @@ export const AvailableTimeSlots = ({ confirmButtonDisabled, confirmStepClassNames, onAvailableTimeSlotSelect, + onLoadNextRoundRobinChunk, ...props }: AvailableTimeSlotsProps) => { + const { t } = useLocale(); const selectedDate = useBookerStoreContext((state) => state.selectedDate); const setSeatedEventData = useBookerStoreContext((state) => state.setSeatedEventData); @@ -108,6 +113,7 @@ export const AvailableTimeSlots = ({ }; const scheduleData = schedule?.data; + const roundRobinChunkInfo = scheduleData?.roundRobinChunkInfo; const nonEmptyScheduleDays = useNonEmptyScheduleDays(scheduleData?.slots); const nonEmptyScheduleDaysFromSelectedDate = nonEmptyScheduleDays.filter( @@ -184,15 +190,60 @@ export const AvailableTimeSlots = ({ [overlayCalendarToggled, onTimeSelect, seatsPerTimeSlot, skipConfirmStep, toggleConfirmButton] ); + const showLoadMoreButton = + roundRobinChunkInfo?.hasMoreNonFixedHosts && onLoadNextRoundRobinChunk; + + const renderLoadMoreButton = () => { + if (!showLoadMoreButton) return null; + + const totalLoadedHosts = + roundRobinChunkInfo.chunkOffset * roundRobinChunkInfo.chunkSize + + roundRobinChunkInfo.loadedNonFixedHosts; + const totalHosts = roundRobinChunkInfo.totalNonFixedHosts || roundRobinChunkInfo.totalHosts || 0; + const progressPercentage = + totalHosts > 0 ? Math.min(100, Math.round((totalLoadedHosts / totalHosts) * 100)) : 0; + const circumference = 2 * Math.PI * 8; + const dashArray = (progressPercentage / 100) * circumference; + + const ProgressIcon = ( + + + + + ); + + return ( + + ); + }; + return ( - <> +
{isLoading ? (
) : ( slotsPerDay.length > 0 && slotsPerDay.map((slots) => { - // Check if this day is OOO - since OOO is date-level, just check the first slot const isOOODay = slots.slots.length > 0 && slots.slots[0]?.away; return ( - {isLoading && // Shows exact amount of days as skeleton. + {isLoading && Array.from({ length: 1 + (extraDays ?? 0) }).map((_, i) => )} {!isLoading && slotsPerDay.length > 0 && @@ -247,6 +299,12 @@ export const AvailableTimeSlots = ({
))}
- + + {showLoadMoreButton && ( +
+ {renderLoadMoreButton()} +
+ )} +
); }; diff --git a/apps/web/modules/bookings/components/Booker.tsx b/apps/web/modules/bookings/components/Booker.tsx index e6201fc3845779..bfc256dc235389 100644 --- a/apps/web/modules/bookings/components/Booker.tsx +++ b/apps/web/modules/bookings/components/Booker.tsx @@ -89,6 +89,7 @@ const BookerComponent = ({ eventMetaChildren, roundRobinHideOrgAndTeam, showNoAvailabilityDialog, + onLoadNextRoundRobinChunk, }: BookerProps & WrappedBookerProps) => { const searchParams = useCompatSearchParams(); const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); @@ -540,6 +541,7 @@ const BookerComponent = ({ watchedCfToken={watchedCfToken} confirmButtonDisabled={confirmButtonDisabled} confirmStepClassNames={customClassNames?.confirmStep} + onLoadNextRoundRobinChunk={onLoadNextRoundRobinChunk} /> diff --git a/apps/web/modules/bookings/components/BookerWebWrapper.tsx b/apps/web/modules/bookings/components/BookerWebWrapper.tsx index 95b74f43e386f1..413634de66010b 100644 --- a/apps/web/modules/bookings/components/BookerWebWrapper.tsx +++ b/apps/web/modules/bookings/components/BookerWebWrapper.tsx @@ -21,6 +21,7 @@ import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hoo import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; import { useBookings } from "@calcom/features/bookings/Booker/components/hooks/useBookings"; import { useCalendars } from "@calcom/features/bookings/Booker/components/hooks/useCalendars"; +import { useRoundRobinChunking } from "@calcom/features/bookings/Booker/components/hooks/useRoundRobinChunking"; import { useSlots } from "@calcom/features/bookings/Booker/components/hooks/useSlots"; import { useVerifyCode } from "@calcom/features/bookings/Booker/components/hooks/useVerifyCode"; import { useVerifyEmail } from "@calcom/features/bookings/Booker/components/hooks/useVerifyEmail"; @@ -93,7 +94,10 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { }); const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow); - + const [roundRobinChunkSettings, setRoundRobinChunkSettings] = useBookerStoreContext( + (state) => [state.roundRobinChunkSettings, state.setRoundRobinChunkSettings], + shallow + ); const { data: session } = useSession(); const routerQuery = useRouterQuery(); const hasSession = !!session; @@ -155,6 +159,21 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { useApiV2: props.useApiV2, bookerLayout, ...(props.entity.orgSlug ? { orgSlug: props.entity.orgSlug } : {}), + roundRobinChunkSettings: roundRobinChunkSettings ?? undefined, + }); + const { roundRobinChunkInfo, handleLoadNextRoundRobinChunk } = + useRoundRobinChunking({ + roundRobinChunkInfo: schedule.data?.roundRobinChunkInfo, + isFetching: schedule.isFetching, + roundRobinChunkSettings, + setRoundRobinChunkSettings, + resetDeps: [ + props.username, + props.eventSlug, + props.entity.orgSlug, + props.entity.eventTypeId, + event.data?.id, + ], }); const bookings = useBookings({ event, @@ -256,6 +275,9 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { event={event} bookerLayout={bookerLayout} schedule={schedule} + onLoadNextRoundRobinChunk={ + roundRobinChunkInfo?.hasMoreNonFixedHosts ? handleLoadNextRoundRobinChunk : undefined + } verifyCode={verifyCode} isPlatform={false} areInstantMeetingParametersSet={areInstantMeetingParametersSet} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c700e413d63aa4..025a29b943ec51 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4357,5 +4357,7 @@ "error_enabling_feature": "Error enabling feature. Please try again.", "set_organizer_as_contact_owner": "Set booking organizer as contact owner", "overwrite_existing_contact_owner": "Overwrite existing contact owner", - "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS":"↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" + "round_robin_load_next_hosts": "Load more availability", + "round_robin_reset_hosts": "Reset host selection", + "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/bookings/Booker/__tests__/test-utils.tsx b/packages/features/bookings/Booker/__tests__/test-utils.tsx index a22cd24a4ba7de..94ac276f90a8d1 100644 --- a/packages/features/bookings/Booker/__tests__/test-utils.tsx +++ b/packages/features/bookings/Booker/__tests__/test-utils.tsx @@ -72,6 +72,10 @@ const createMockStore = (initialState?: Partial): StoreApi void; +}; + +export const useRoundRobinChunking = ({ + roundRobinChunkInfo, + isFetching, + resetDeps = [], + roundRobinChunkSettings, + setRoundRobinChunkSettings, +}: UseRoundRobinChunkingOptions) => { + const setRoundRobinChunkInfo = useBookerStoreContext((state) => state.setRoundRobinChunkInfo); + + useEffect(() => { + setRoundRobinChunkInfo(roundRobinChunkInfo ?? null); + }, [roundRobinChunkInfo, setRoundRobinChunkInfo]); + + useEffect(() => { + setRoundRobinChunkSettings(null); + setRoundRobinChunkInfo(null); + }, [setRoundRobinChunkInfo, setRoundRobinChunkSettings, ...resetDeps]); + + const handleLoadNextRoundRobinChunk = useCallback(() => { + if (!roundRobinChunkInfo?.hasMoreNonFixedHosts || isFetching) return; + const currentOffset = roundRobinChunkSettings?.chunkOffset ?? roundRobinChunkInfo.chunkOffset ?? 0; + setRoundRobinChunkSettings({ + manual: true, + chunkOffset: currentOffset + 1, + }); + }, [roundRobinChunkInfo, roundRobinChunkSettings, isFetching, setRoundRobinChunkSettings]); + + return { + roundRobinChunkSettings, + roundRobinChunkInfo: roundRobinChunkInfo ?? null, + isManualRoundRobinChunking: roundRobinChunkSettings?.manual ?? false, + handleLoadNextRoundRobinChunk, + }; +}; diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index c6f2627a582bee..20bf05c389a670 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -3,14 +3,11 @@ import { useEffect } from "react"; import { createWithEqualityFn } from "zustand/traditional"; - - import dayjs from "@calcom/dayjs"; import { BOOKER_NUMBER_OF_DAYS_TO_LOAD } from "@calcom/lib/constants"; +import type { RoundRobinChunkInfo } from "@calcom/lib/types/roundRobinChunkInfo"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; - - import type { GetBookingType } from "../lib/get-booking"; import type { BookerState, BookerLayout } from "./types"; import { updateQueryParam, getQueryParam, removeQueryParam } from "./utils/query-param"; @@ -84,6 +81,11 @@ type SeatedEventData = { showAvailableSeatsCount?: boolean | null; }; +export type RoundRobinChunkSettings = { + manual: boolean; + chunkOffset: number; +}; + export type BookerStore = { /** * Event details. These are stored in store for easier @@ -215,6 +217,10 @@ export type BookerStore = { isPlatform?: boolean; allowUpdatingUrlParams?: boolean; defaultPhoneCountry?: CountryCode | null; + roundRobinChunkSettings: RoundRobinChunkSettings | null; + setRoundRobinChunkSettings: (settings: RoundRobinChunkSettings | null) => void; + roundRobinChunkInfo: RoundRobinChunkInfo | null; + setRoundRobinChunkInfo: (info: RoundRobinChunkInfo | null) => void; }; /** @@ -495,6 +501,11 @@ export const createBookerStore = () => }, isPlatform: false, allowUpdatingUrlParams: true, + roundRobinChunkSettings: null, + setRoundRobinChunkSettings: (settings: RoundRobinChunkSettings | null) => + set({ roundRobinChunkSettings: settings }), + roundRobinChunkInfo: null, + setRoundRobinChunkInfo: (info: RoundRobinChunkInfo | null) => set({ roundRobinChunkInfo: info }), defaultPhoneCountry: null, })); diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index 2458ac8291995f..57977d7b807cb1 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -133,6 +133,7 @@ export type WrappedBookerPropsMain = { isBookingDryRun?: boolean; renderCaptcha?: boolean; confirmButtonDisabled?: boolean; + onLoadNextRoundRobinChunk?: () => void; }; export type WrappedBookerPropsForPlatform = WrappedBookerPropsMain & { diff --git a/packages/features/bookings/Booker/utils/event.ts b/packages/features/bookings/Booker/utils/event.ts index e4b1b988c5f095..513b894fe1974c 100644 --- a/packages/features/bookings/Booker/utils/event.ts +++ b/packages/features/bookings/Booker/utils/event.ts @@ -71,6 +71,7 @@ export const useScheduleForEvent = ({ isTeamEvent, useApiV2 = true, bookerLayout, + roundRobinChunkSettings, }: { username?: string | null; eventSlug?: string | null; @@ -92,6 +93,10 @@ export const useScheduleForEvent = ({ extraDays: number; columnViewExtraDays: { current: number }; }; + roundRobinChunkSettings?: { + manual: boolean; + chunkOffset: number; + }; }) => { const { timezone } = useBookerTime(); const [usernameFromStore, eventSlugFromStore, monthFromStore, durationFromStore] = useBookerStoreContext( @@ -117,6 +122,8 @@ export const useScheduleForEvent = ({ teamMemberEmail, useApiV2: useApiV2, bookerLayout, + roundRobinManualChunking: roundRobinChunkSettings?.manual, + roundRobinChunkOffset: roundRobinChunkSettings?.chunkOffset, }); return { @@ -125,6 +132,7 @@ export const useScheduleForEvent = ({ isError: schedule?.isError, isSuccess: schedule?.isSuccess, isLoading: schedule?.isLoading, + isFetching: schedule?.isFetching, invalidate: schedule?.invalidate, dataUpdatedAt: schedule?.dataUpdatedAt, }; diff --git a/packages/features/bookings/lib/bookingCreateBodySchema.ts b/packages/features/bookings/lib/bookingCreateBodySchema.ts index fa789db5b1c3b4..bc4fdbb0d989aa 100644 --- a/packages/features/bookings/lib/bookingCreateBodySchema.ts +++ b/packages/features/bookings/lib/bookingCreateBodySchema.ts @@ -31,7 +31,6 @@ export const bookingCreateBodySchema = z.object({ rrHostSubsetIds: z.array(z.number()).nullish(), crmAppSlug: z.string().nullish().optional(), cfToken: z.string().nullish().optional(), - /** * Holds the corrected responses of the Form for a booking, provided during rerouting */ diff --git a/packages/features/schedules/lib/use-schedule/useApiV2AvailableSlots.ts b/packages/features/schedules/lib/use-schedule/useApiV2AvailableSlots.ts index 50b7a3f8fe90ad..bdaf962e650682 100644 --- a/packages/features/schedules/lib/use-schedule/useApiV2AvailableSlots.ts +++ b/packages/features/schedules/lib/use-schedule/useApiV2AvailableSlots.ts @@ -10,10 +10,15 @@ import type { GetAvailableSlotsResponse } from "@calcom/trpc/server/routers/view export const QUERY_KEY = "get-available-slots"; +type GetAvailableSlotsInputWithChunks = GetAvailableSlotsInput_2024_04_15 & { + roundRobinManualChunking?: boolean; + roundRobinChunkOffset?: number; +}; + export const useApiV2AvailableSlots = ({ enabled, ...rest -}: GetAvailableSlotsInput_2024_04_15 & { enabled: boolean }) => { +}: GetAvailableSlotsInputWithChunks & { enabled: boolean }) => { const availableSlots = useQuery({ queryKey: [ QUERY_KEY, @@ -28,6 +33,8 @@ export const useApiV2AvailableSlots = ({ rest.skipContactOwner, rest.teamMemberEmail, rest.embedConnectVersion ?? false, + rest.roundRobinManualChunking ?? false, + rest.roundRobinChunkOffset ?? 0, ], queryFn: () => { return axios diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index ef1958838d4c69..6035261a8931aa 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -35,6 +35,8 @@ export type UseScheduleWithCacheArgs = { extraDays: number; columnViewExtraDays: { current: number }; }; + roundRobinManualChunking?: boolean; + roundRobinChunkOffset?: number; }; const getAvailabilityLoadedEventPayload = ({ @@ -66,6 +68,8 @@ export const useSchedule = ({ useApiV2 = false, enabled: enabledProp = true, bookerLayout, + roundRobinManualChunking, + roundRobinChunkOffset, }: UseScheduleWithCacheArgs) => { const bookerState = useBookerStore((state) => state.state); @@ -117,6 +121,8 @@ export const useSchedule = ({ // Ensures that connectVersion causes a refresh of the data ...(embedConnectVersion ? { embedConnectVersion } : {}), _isDryRun: searchParams ? isBookingDryRun(searchParams) : false, + ...(roundRobinManualChunking ? { roundRobinManualChunking: true } : {}), + ...(typeof roundRobinChunkOffset === "number" ? { roundRobinChunkOffset } : {}), }; const options = { @@ -151,6 +157,8 @@ export const useSchedule = ({ routedTeamMemberIds: input.routedTeamMemberIds ?? undefined, teamMemberEmail: input.teamMemberEmail ?? undefined, eventTypeId: eventId ?? undefined, + roundRobinManualChunking: roundRobinManualChunking ? true : undefined, + roundRobinChunkOffset, }); const schedule = trpc.viewer.slots.getSchedule.useQuery(input, { diff --git a/packages/lib/types/roundRobinChunkInfo.ts b/packages/lib/types/roundRobinChunkInfo.ts new file mode 100644 index 00000000000000..0672e21df2da35 --- /dev/null +++ b/packages/lib/types/roundRobinChunkInfo.ts @@ -0,0 +1,9 @@ +export type RoundRobinChunkInfo = { + totalHosts: number; + totalNonFixedHosts: number; + chunkSize: number; + chunkOffset: number; + loadedNonFixedHosts: number; + hasMoreNonFixedHosts: boolean; + manualChunking: boolean; +}; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index de826773ddf5e7..b2b1238735a24c 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -21,6 +21,7 @@ import { } from "@calcom/features/bookings/Booker/BookerStoreProvider"; import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout"; import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm"; +import { useRoundRobinChunking } from "@calcom/features/bookings/Booker/components/hooks/useRoundRobinChunking"; import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/useLocalSet"; import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store"; import { useTimePreferences } from "@calcom/features/bookings/lib"; @@ -111,9 +112,13 @@ const BookerPlatformWrapperComponent = ( const [isOverlayCalendarEnabled, setIsOverlayCalendarEnabled] = useState( Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault")) ); + const [roundRobinChunkSettings, setRoundRobinChunkSettings] = useBookerStoreContext( + (state) => [state.roundRobinChunkSettings, state.setRoundRobinChunkSettings], + shallow + ); const prevStateRef = useRef(null); const bookerStoreContext = useContext(BookerStoreContext); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + const getStateValues = useCallback((state: any): BookerStoreValues => { return Object.fromEntries( Object.entries(state).filter(([_, value]) => typeof value !== "function") @@ -336,6 +341,12 @@ const BookerPlatformWrapperComponent = ( rrHostSubsetIds: rrHostSubsetIds, } : {}), + ...(roundRobinChunkSettings?.manual + ? { + roundRobinManualChunking: true, + roundRobinChunkOffset: roundRobinChunkSettings.chunkOffset, + } + : {}), enabled: Boolean(teamId || username) && Boolean(month) && @@ -347,6 +358,18 @@ const BookerPlatformWrapperComponent = ( _silentCalendarFailures: silentlyHandleCalendarFailures, ...routingParams, }); + const { roundRobinChunkInfo, handleLoadNextRoundRobinChunk } = useRoundRobinChunking({ + roundRobinChunkInfo: schedule.data?.roundRobinChunkInfo, + isFetching: schedule.isFetching, + roundRobinChunkSettings, + setRoundRobinChunkSettings, + resetDeps: [ + props.entity?.orgSlug, + props.username, + props.isTeamEvent ? props.teamId : null, + event?.data?.id, + ], + }); useEffect(() => { if ( @@ -645,6 +668,9 @@ const BookerPlatformWrapperComponent = ( isBookingDryRun={isBookingDryRun ?? routingParams?.isBookingDryRun} eventMetaChildren={props.eventMetaChildren} roundRobinHideOrgAndTeam={props.roundRobinHideOrgAndTeam} + onLoadNextRoundRobinChunk={ + roundRobinChunkInfo?.hasMoreNonFixedHosts ? handleLoadNextRoundRobinChunk : undefined + } /> ); diff --git a/packages/platform/atoms/hooks/useAvailableSlots.ts b/packages/platform/atoms/hooks/useAvailableSlots.ts index 11f93960ed0252..d44ef1eb344f9e 100644 --- a/packages/platform/atoms/hooks/useAvailableSlots.ts +++ b/packages/platform/atoms/hooks/useAvailableSlots.ts @@ -12,10 +12,15 @@ import type { GetAvailableSlotsResponse } from "../booker/types"; export const QUERY_KEY = "get-available-slots"; +type GetAvailableSlotsInputWithChunks = GetAvailableSlotsInput_2024_04_15 & { + roundRobinManualChunking?: boolean; + roundRobinChunkOffset?: number; +}; + export const useAvailableSlots = ({ enabled, ...rest -}: GetAvailableSlotsInput_2024_04_15 & { enabled: boolean }) => { +}: GetAvailableSlotsInputWithChunks & { enabled: boolean }) => { const availableSlots = useQuery({ queryKey: [ QUERY_KEY, @@ -30,6 +35,8 @@ export const useAvailableSlots = ({ rest.skipContactOwner, rest.teamMemberEmail, rest.rrHostSubsetIds, + rest.roundRobinManualChunking ?? false, + rest.roundRobinChunkOffset ?? 0, ], queryFn: () => { return http diff --git a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts index 8c041852febda3..1a097151c0d7f5 100644 --- a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts +++ b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts @@ -226,6 +226,24 @@ export class GetAvailableSlotsInput_2024_04_15 { }) */ @ApiHideProperty() rrHostSubsetIds?: number[]; + + @Transform(({ value }) => (value ? value.toLowerCase() === "true" : false)) + @IsBoolean() + @IsOptional() + @ApiHideProperty() + roundRobinManualChunking?: boolean; + + @Transform(({ value }) => { + if (value === undefined || value === null || value === "") { + return undefined; + } + const parsedValue = typeof value === "string" ? parseInt(value, 10) : value; + return Number.isNaN(parsedValue) ? undefined : parsedValue; + }) + @IsNumber() + @IsOptional() + @ApiHideProperty() + roundRobinChunkOffset?: number; } export class RemoveSelectedSlotInput_2024_04_15 { diff --git a/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts b/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts index 46d8e578f8790b..5e766ecf859df8 100644 --- a/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts +++ b/packages/platform/types/slots/slots-2024-09-04/inputs/get-slots.input.ts @@ -9,6 +9,7 @@ import { IsArray, ArrayMinSize, IsEnum, + IsBoolean, } from "class-validator"; import { SlotFormat } from "@calcom/platform-enums"; @@ -109,6 +110,24 @@ export class GetAvailableSlotsInput_2024_09_04 { }) */ @ApiHideProperty() rrHostSubsetIds?: number[]; + + @Transform(({ value }) => (value ? value.toLowerCase() === "true" : false)) + @IsBoolean() + @IsOptional() + @ApiHideProperty() + roundRobinManualChunking?: boolean; + + @Transform(({ value }) => { + if (value === undefined || value === null || value === "") { + return undefined; + } + const parsedValue = typeof value === "string" ? parseInt(value, 10) : value; + return Number.isNaN(parsedValue) ? undefined : parsedValue; + }) + @IsNumber() + @IsOptional() + @ApiHideProperty() + roundRobinChunkOffset?: number; } export const ById_2024_09_04_type = "byEventTypeId"; diff --git a/packages/trpc/server/routers/viewer/slots/types.ts b/packages/trpc/server/routers/viewer/slots/types.ts index 01320ea4f4a193..60b4a34ea1a345 100644 --- a/packages/trpc/server/routers/viewer/slots/types.ts +++ b/packages/trpc/server/routers/viewer/slots/types.ts @@ -34,6 +34,8 @@ export const getScheduleSchemaObject = z.object({ routedTeamMemberIds: z.array(z.number()).nullish(), skipContactOwner: z.boolean().nullish(), rrHostSubsetIds: z.array(z.number()).nullish(), + roundRobinManualChunking: z.boolean().optional(), + roundRobinChunkOffset: z.coerce.number().int().min(0).optional(), _enableTroubleshooter: z.boolean().optional(), _bypassCalendarBusyTimes: z.boolean().optional(), _silentCalendarFailures: z.boolean().optional(), diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts index 1112a02f51bd2a..d39ad8b47771aa 100644 --- a/packages/trpc/server/routers/viewer/slots/util.test.ts +++ b/packages/trpc/server/routers/viewer/slots/util.test.ts @@ -1,9 +1,23 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import dayjs from "@calcom/dayjs"; + +import { AvailableSlotsService } from "./util"; +import type { IAvailableSlotsService } from "./util"; +import type { TGetScheduleInputSchema } from "./types"; import { BookingDateInPastError, isTimeOutOfBounds } from "@calcom/lib/isOutOfBounds"; +import { SchedulingType } from "@calcom/prisma/enums"; +import { getAggregatedAvailability } from "@calcom/features/availability/lib/getAggregatedAvailability/getAggregatedAvailability"; import { TRPCError } from "@trpc/server"; +vi.mock("@calcom/features/availability/lib/getAggregatedAvailability/getAggregatedAvailability", () => { + return { + getAggregatedAvailability: vi.fn(), + }; +}); +const getAggregatedAvailabilityMock = vi.mocked(getAggregatedAvailability); + describe("BookingDateInPastError handling", () => { it("should convert BookingDateInPastError to TRPCError with BAD_REQUEST code", () => { const testFilteringLogic = () => { @@ -43,3 +57,259 @@ describe("BookingDateInPastError handling", () => { expect(() => testFilteringLogic()).toThrow("Attempting to book a meeting in the past."); }); }); + +describe("round robin chunking", () => { + const dependencyStub = {} as unknown as IAvailableSlotsService; + let consoleSpy: ReturnType; + + const baseInput = {} as TGetScheduleInputSchema; + const loggerStub = { + info: vi.fn(), + debug: vi.fn(), + }; + + const computeExpectedChunkSize = (count: number) => { + const dynamic = Math.ceil(count * 0.2); + return Math.min(50, dynamic); + }; + + const createHosts = (count: number, weights?: number[]) => + Array.from({ length: count }).map((_, index) => ({ + isFixed: false, + groupId: null, + user: { + id: index + 1, + credentials: [], + }, + weight: weights?.[index] ?? 100, + })); + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + const invokeChunking = async ({ + hosts, + isRRWeightsEnabled, + manualChunking = false, + chunkOffset, + }: { + hosts: ReturnType; + isRRWeightsEnabled: boolean; + manualChunking?: boolean; + chunkOffset?: number; + }) => { + const service = new AvailableSlotsService(dependencyStub); + const calculateSpy = vi + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(service as any, "calculateHostsAndAvailabilities") + .mockResolvedValue({ + allUsersAvailability: [], + usersWithCredentials: [], + currentSeats: undefined, + }); + + const startTime = dayjs(); + const endTime = dayjs().add(1, "day"); + + const result = await ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service as any + ).calculateAvailabilityWithRoundRobinChunks({ + hosts, + eventType: { + id: 123, + schedulingType: SchedulingType.ROUND_ROBIN, + isRRWeightsEnabled, + team: null, + }, + input: { + ...baseInput, + ...(manualChunking ? { roundRobinManualChunking: true } : {}), + ...(chunkOffset !== undefined ? { roundRobinChunkOffset: chunkOffset } : {}), + }, + loggerWithEventDetails: loggerStub, + startTime, + endTime, + bypassBusyCalendarTimes: false, + silentCalendarFailures: false, + mode: "slots", + }); + + return { result, calculateSpy }; + }; + + it("processes host chunks sequentially until availability is found", async () => { + const hosts = createHosts(105); + const firstChunkAvailability: [] = []; + const secondChunkAvailability = [ + { + start: dayjs(), + end: dayjs().add(30, "minutes"), + }, + ]; + + getAggregatedAvailabilityMock.mockReturnValueOnce(firstChunkAvailability).mockReturnValueOnce(secondChunkAvailability); + + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: false }); + + expect(calculateSpy).toHaveBeenCalledTimes(2); + expect(getAggregatedAvailabilityMock).toHaveBeenCalledTimes(2); + const expectedChunkSize = computeExpectedChunkSize(hosts.length); + expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(expectedChunkSize); + expect(calculateSpy.mock.calls[1][0].hosts).toHaveLength(expectedChunkSize); + expect(result.aggregatedAvailability).toEqual(secondChunkAvailability); + expect(result.roundRobinChunkInfo).toEqual({ + totalHosts: hosts.length, + totalNonFixedHosts: hosts.length, + chunkSize: expectedChunkSize, + chunkOffset: 1, + loadedNonFixedHosts: expectedChunkSize, + hasMoreNonFixedHosts: true, + manualChunking: false, + }); + }); + + it("allows fetching a specific chunk when manual chunking is enabled", async () => { + const hosts = createHosts(105); + const manualChunkOffset = 3; + getAggregatedAvailabilityMock.mockReturnValue([]); + + const { result, calculateSpy } = await invokeChunking({ + hosts, + isRRWeightsEnabled: false, + manualChunking: true, + chunkOffset: manualChunkOffset, + }); + + expect(calculateSpy).toHaveBeenCalledTimes(1); + const expectedChunkSize = computeExpectedChunkSize(hosts.length); + expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(expectedChunkSize); + expect(result.roundRobinChunkInfo).toEqual({ + totalHosts: hosts.length, + totalNonFixedHosts: hosts.length, + chunkSize: expectedChunkSize, + chunkOffset: manualChunkOffset, + loadedNonFixedHosts: expectedChunkSize, + hasMoreNonFixedHosts: true, + manualChunking: true, + }); + }); + + it("increases chunk size proportionally for very large teams", async () => { + const hosts = createHosts(250); + getAggregatedAvailabilityMock.mockReturnValue([]); + + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: false }); + + const expectedChunkSize = computeExpectedChunkSize(hosts.length); + expect(expectedChunkSize).toBeGreaterThan(20); + expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(expectedChunkSize); + const chunkCount = Math.ceil(hosts.length / expectedChunkSize); + const lastChunkSize = hosts.length - expectedChunkSize * (chunkCount - 1); + expect(result.roundRobinChunkInfo).toEqual({ + totalHosts: hosts.length, + totalNonFixedHosts: hosts.length, + chunkSize: expectedChunkSize, + chunkOffset: chunkCount - 1, + loadedNonFixedHosts: lastChunkSize, + hasMoreNonFixedHosts: false, + manualChunking: false, + }); + }); + + it("caps chunk size at the defined maximum", async () => { + const hosts = createHosts(1000); + getAggregatedAvailabilityMock.mockReturnValue([]); + + const { calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: false }); + + const expectedChunkSize = computeExpectedChunkSize(hosts.length); + expect(expectedChunkSize).toBe(50); + expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(expectedChunkSize); + }); + + it("skips chunking when weights are enabled", async () => { + const hosts = createHosts(150); + const finalAvailability = [ + { + start: dayjs(), + end: dayjs().add(1, "hour"), + }, + ]; + getAggregatedAvailabilityMock.mockReturnValue(finalAvailability); + + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: true }); + + expect(calculateSpy).toHaveBeenCalledTimes(1); + expect(getAggregatedAvailabilityMock).toHaveBeenCalledTimes(1); + expect(result.aggregatedAvailability).toEqual(finalAvailability); + }); + + it("preserves host ordering (weight-based) when chunking", async () => { + const weights = Array.from({ length: 120 }).map((_, idx) => 2000 - idx * 10); + const hosts = createHosts(weights.length, weights); + + getAggregatedAvailabilityMock.mockReturnValue([]); + + const service = new AvailableSlotsService(dependencyStub); + const calculateSpy = vi + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(service as any, "calculateHostsAndAvailabilities") + .mockResolvedValue({ + allUsersAvailability: [], + usersWithCredentials: [], + currentSeats: undefined, + }); + + await ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + service as any + ).calculateAvailabilityWithRoundRobinChunks({ + hosts, + eventType: { + id: 456, + schedulingType: SchedulingType.ROUND_ROBIN, + isRRWeightsEnabled: false, + team: null, + }, + input: baseInput, + loggerWithEventDetails: loggerStub, + startTime: dayjs(), + endTime: dayjs().add(1, "day"), + bypassBusyCalendarTimes: false, + silentCalendarFailures: false, + mode: "slots", + }); + + const expectedChunkSize = computeExpectedChunkSize(weights.length); + const firstChunkWeights = calculateSpy.mock.calls[0][0].hosts.map((host: { weight?: number | null }) => host.weight); + expect(firstChunkWeights).toEqual(weights.slice(0, expectedChunkSize)); + }); + + it("returns empty availability when all chunks have no slots", async () => { + const hosts = createHosts(120); + getAggregatedAvailabilityMock.mockReturnValue([]); + + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: false }); + + const expectedChunkSize = computeExpectedChunkSize(hosts.length); + const expectedChunkCount = Math.ceil(hosts.length / expectedChunkSize); + expect(calculateSpy).toHaveBeenCalledTimes(expectedChunkCount); + expect(result.aggregatedAvailability).toEqual([]); + expect(result.roundRobinChunkInfo).toEqual({ + totalHosts: hosts.length, + totalNonFixedHosts: hosts.length, + chunkSize: expectedChunkSize, + chunkOffset: expectedChunkCount - 1, + loadedNonFixedHosts: hosts.length - expectedChunkSize * (expectedChunkCount - 1), + hasMoreNonFixedHosts: false, + manualChunking: false, + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 775043eaa95a01..e126bf058075a5 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -11,6 +11,7 @@ import type { IToUser, UserAvailabilityService, } from "@calcom/features/availability/lib/getUserAvailability"; +import type { IOutOfOfficeData } from "@calcom/features/availability/lib/getUserAvailability"; import type { CheckBookingLimitsService } from "@calcom/features/bookings/lib/checkBookingLimits"; import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts"; import type { QualifiedHostsService } from "@calcom/features/bookings/lib/host-filtering/findQualifiedHostsWithDelegationCredentials"; @@ -40,6 +41,7 @@ import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSche import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits"; import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits"; import LimitManager from "@calcom/lib/intervalLimits/limitManager"; +import type { RoundRobinChunkInfo } from "@calcom/lib/types/roundRobinChunkInfo"; import { isBookingWithinPeriod } from "@calcom/lib/intervalLimits/utils"; import { BookingDateInPastError, @@ -63,6 +65,18 @@ import type { GetScheduleOptions } from "./types"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const DEFAULT_SLOTS_CACHE_TTL = 2000; +const ROUND_ROBIN_DYNAMIC_CHUNK_PERCENT = 0.2; +const ROUND_ROBIN_MAX_CHUNK_SIZE = 50; +const ROUND_ROBIN_CHUNK_THRESHOLD = 100; + +const chunkArray = (items: T[], size: number) => { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +}; + type GetAvailabilityUserWithDelegationCredentials = Omit, "credentials"> & { credentials: CredentialForCalendarService[]; }; @@ -84,11 +98,33 @@ export interface IGetAvailableSlots { >; // eslint-disable-next-line @typescript-eslint/no-explicit-any troubleshooter?: any; + roundRobinChunkInfo?: RoundRobinChunkInfo; } -export type GetAvailableSlotsResponse = Awaited< - ReturnType<(typeof AvailableSlotsService)["prototype"]["_getAvailableSlots"]> ->; +type AvailableSlotsServiceInstance = InstanceType; +type BusyTimesServiceInstance = ReturnType; + +export type GetAvailableSlotsResponse = Awaited>; + +type AggregatedAvailabilityInput = Parameters[0]; +type AggregatedAvailabilityEntry = AggregatedAvailabilityInput[number] & { + datesOutOfOffice?: IOutOfOfficeData; + timeZone: string; +}; + +type HostsAvailabilityResult = { + allUsersAvailability: AggregatedAvailabilityEntry[]; + usersWithCredentials: GetAvailabilityUserWithDelegationCredentials[]; + currentSeats: CurrentSeats | undefined; +}; + +type AggregatedHostsAvailability = HostsAvailabilityResult & { + aggregatedAvailability: ReturnType; +}; + +type ChunkedAvailabilityResult = AggregatedHostsAvailability & { + roundRobinChunkInfo?: RoundRobinChunkInfo; +}; /** * Minimal capability interface for looking up a user's organization membership. @@ -798,17 +834,14 @@ export class AvailableSlotsService { mode, }: { input: TGetScheduleInputSchema; - eventType: Exclude< - Awaited>, - null - >; + eventType: NonNullable>>; hosts: { isFixed?: boolean; groupId?: string | null; user: GetAvailabilityUserWithDelegationCredentials; }[]; loggerWithEventDetails: Logger; - startTime: ReturnType<(typeof AvailableSlotsService)["prototype"]["getStartTime"]>; + startTime: ReturnType; endTime: Dayjs; bypassBusyCalendarTimes: boolean; silentCalendarFailures: boolean; @@ -863,7 +896,7 @@ export class AvailableSlotsService { : null; let busyTimesFromLimitsBookingsAllUsers: Awaited< - ReturnType + ReturnType > = []; if (eventType && (bookingLimits || durationLimits)) { @@ -988,6 +1021,151 @@ export class AvailableSlotsService { }; } + private async calculateAvailabilityWithRoundRobinChunks({ + hosts, + eventType, + chunkSize, + ...rest + }: { + hosts: { + isFixed?: boolean; + groupId?: string | null; + user: GetAvailabilityUserWithDelegationCredentials; + }[]; + eventType: NonNullable>>; + chunkSize?: number; + input: TGetScheduleInputSchema; + loggerWithEventDetails: Logger; + startTime: ReturnType; + endTime: Dayjs; + bypassBusyCalendarTimes: boolean; + silentCalendarFailures: boolean; + mode?: CalendarFetchMode; + }): Promise { + const rrLog = rest.loggerWithEventDetails ?? log; + + const calculateForHosts = async (currentHosts: typeof hosts) => { + const result = await this.calculateHostsAndAvailabilities({ + ...rest, + hosts: currentHosts, + eventType, + }); + + return { + ...result, + aggregatedAvailability: getAggregatedAvailability(result.allUsersAvailability, eventType.schedulingType), + }; + }; + + const nonFixedHosts = hosts.filter((host) => host.isFixed !== true); + const { roundRobinManualChunking = false, roundRobinChunkOffset = 0 } = rest.input; + rrLog.info( + `RR chunking check for eventType=${eventType.id}: totalHosts=${hosts.length}, nonFixedHosts=${nonFixedHosts.length}, weightsEnabled=${eventType.isRRWeightsEnabled}` + ); + + const shouldChunk = + eventType.schedulingType === SchedulingType.ROUND_ROBIN && + !eventType.isRRWeightsEnabled && + nonFixedHosts.length > ROUND_ROBIN_CHUNK_THRESHOLD; + + if (!shouldChunk) { + return await calculateForHosts(hosts); + } + + const fixedHosts = hosts.filter((host) => host.isFixed); + const manualChunkingEnabled = roundRobinManualChunking === true; + const manualChunkOffset = Math.max(0, roundRobinChunkOffset); + const resolvedChunkSize = + chunkSize ?? + Math.min( + ROUND_ROBIN_MAX_CHUNK_SIZE, + Math.ceil(nonFixedHosts.length * ROUND_ROBIN_DYNAMIC_CHUNK_PERCENT) + ); + const effectiveChunkSize = Math.max(1, resolvedChunkSize); + const hostChunks = chunkArray(nonFixedHosts, effectiveChunkSize); + + const buildChunkInfo = ({ + chunkOffset, + loadedNonFixedHosts, + hasMoreNonFixedHosts, + manualChunking, + }: Omit & { + manualChunking: boolean; + }): RoundRobinChunkInfo => ({ + totalHosts: hosts.length, + totalNonFixedHosts: nonFixedHosts.length, + chunkSize: effectiveChunkSize, + chunkOffset, + loadedNonFixedHosts, + hasMoreNonFixedHosts, + manualChunking, + }); + + if (manualChunkingEnabled) { + const chunkCount = hostChunks.length || 1; + const safeChunkOffset = chunkCount > 0 ? Math.min(manualChunkOffset, chunkCount - 1) : 0; + const hostChunk = hostChunks[safeChunkOffset] ?? []; + const hostsForChunk = [...hostChunk, ...fixedHosts]; + const chunkResult = await calculateForHosts(hostsForChunk); + const chunkInfo = buildChunkInfo({ + chunkOffset: safeChunkOffset, + loadedNonFixedHosts: hostChunk.length, + hasMoreNonFixedHosts: chunkCount > 0 ? safeChunkOffset < chunkCount - 1 : false, + manualChunking: true, + }); + rrLog.info( + `RR manual chunk ${safeChunkOffset + 1} checked: hosts=${hostChunk.length}, slotsFound=${chunkResult.aggregatedAvailability.length}` + ); + return { + ...chunkResult, + roundRobinChunkInfo: chunkInfo, + }; + } + + rrLog.info( + `RR chunking enabled for eventType=${eventType.id} (team=${eventType.team?.id ?? "N/A"}): processing ${nonFixedHosts.length} hosts in batches of ${effectiveChunkSize}` + ); + + let lastResult: ChunkedAvailabilityResult | null = null; + for (let index = 0; index < hostChunks.length; index += 1) { + const hostChunk = hostChunks[index]; + const hostsForChunk = [...hostChunk, ...fixedHosts]; + const chunkResult = await calculateForHosts(hostsForChunk); + const chunkInfo = buildChunkInfo({ + chunkOffset: index, + loadedNonFixedHosts: hostChunk.length, + hasMoreNonFixedHosts: index < hostChunks.length - 1, + manualChunking: false, + }); + const chunkResultWithInfo: ChunkedAvailabilityResult = { + ...chunkResult, + roundRobinChunkInfo: chunkInfo, + }; + rrLog.info( + `RR chunk ${index + 1} checked: hosts=${hostChunk.length}, slotsFound=${chunkResult.aggregatedAvailability.length}` + ); + if (chunkResult.aggregatedAvailability.length > 0) { + return chunkResultWithInfo; + } + lastResult = chunkResultWithInfo; + } + + if (lastResult) { + return lastResult; + } + + const fallbackResult = await calculateForHosts(fixedHosts); + return { + ...fallbackResult, + roundRobinChunkInfo: buildChunkInfo({ + chunkOffset: 0, + loadedNonFixedHosts: 0, + hasMoreNonFixedHosts: false, + manualChunking: false, + }), + }; + } + private async checkRestrictionScheduleEnabled(teamId?: number): Promise { if (!teamId) { return false; @@ -1186,8 +1364,13 @@ export class AvailableSlotsService { const hasFallbackRRHosts = eligibleFallbackRRHosts.length > 0 && eligibleFallbackRRHosts.length > eligibleQualifiedRRHosts.length; - let { allUsersAvailability, usersWithCredentials, currentSeats } = - await this.calculateHostsAndAvailabilities({ + let { + allUsersAvailability, + usersWithCredentials, + currentSeats, + aggregatedAvailability, + roundRobinChunkInfo, + } = await this.calculateAvailabilityWithRoundRobinChunks({ input, eventType, hosts: allHosts, @@ -1207,8 +1390,6 @@ export class AvailableSlotsService { mode, }); - let aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); - // Fairness and Contact Owner have fallbacks because we check for within 2 weeks if (hasFallbackRRHosts) { let diff = 0; @@ -1222,7 +1403,7 @@ export class AvailableSlotsService { // if start time is not within first two weeks, check if there are any available slots if (!aggregatedAvailability.length) { // if no available slots check if first two weeks are available, otherwise fallback - const firstTwoWeeksAvailabilities = await this.calculateHostsAndAvailabilities({ + const firstTwoWeeksAvailabilities = await this.calculateAvailabilityWithRoundRobinChunks({ input, eventType, hosts: [...eligibleQualifiedRRHosts, ...eligibleFixedHosts], @@ -1233,12 +1414,7 @@ export class AvailableSlotsService { silentCalendarFailures, mode, }); - if ( - !getAggregatedAvailability( - firstTwoWeeksAvailabilities.allUsersAvailability, - eventType.schedulingType - ).length - ) { + if (!firstTwoWeeksAvailabilities.aggregatedAvailability.length) { diff = 1; } } @@ -1257,8 +1433,13 @@ export class AvailableSlotsService { if (diff > 0) { // if the first available slot is more than 2 weeks from now, round robin as normal - ({ allUsersAvailability, usersWithCredentials, currentSeats } = - await this.calculateHostsAndAvailabilities({ + ({ + allUsersAvailability, + usersWithCredentials, + currentSeats, + aggregatedAvailability, + roundRobinChunkInfo, + } = await this.calculateAvailabilityWithRoundRobinChunks({ input, eventType, hosts: [...eligibleFallbackRRHosts, ...eligibleFixedHosts], @@ -1269,7 +1450,6 @@ export class AvailableSlotsService { silentCalendarFailures, mode, })); - aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); } } @@ -1619,6 +1799,7 @@ export class AvailableSlotsService { return { slots: filteredSlotsMappedToDate, + roundRobinChunkInfo, ...troubleshooterData, }; }