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,
};
}