From 5881c6817291f957f7f7d93a4a3b23fc5ea18e1c Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 8 Jan 2026 15:45:52 -0300 Subject: [PATCH 01/11] Feat: Add chunk availability check --- packages/testing/package.json | 2 +- .../server/routers/viewer/slots/util.test.ts | 180 +++++++++++++++++- .../trpc/server/routers/viewer/slots/util.ts | 150 ++++++++++++--- yarn.lock | 7 +- 4 files changed, 313 insertions(+), 26 deletions(-) diff --git a/packages/testing/package.json b/packages/testing/package.json index 8e27dc904201ff..de9ad069e7839f 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -24,4 +24,4 @@ "prismock": "1.35.3", "vitest": "4.0.16" } -} \ No newline at end of file +} diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts index 1112a02f51bd2a..93ccb536b55c16 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,167 @@ 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 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, + }: { + hosts: ReturnType; + isRRWeightsEnabled: boolean; + }) => { + 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, + }, + chunkSize: 20, + input: baseInput, + 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: true }); + + expect(calculateSpy).toHaveBeenCalledTimes(2); + expect(getAggregatedAvailabilityMock).toHaveBeenCalledTimes(2); + expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(20); + expect(calculateSpy.mock.calls[1][0].hosts).toHaveLength(20); + expect(result.aggregatedAvailability).toEqual(secondChunkAvailability); + }); + + it("skips chunking when weights are disabled", async () => { + const hosts = createHosts(150); + const finalAvailability = [ + { + start: dayjs(), + end: dayjs().add(1, "hour"), + }, + ]; + getAggregatedAvailabilityMock.mockReturnValue(finalAvailability); + + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: false }); + + 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: true, + team: null, + }, + chunkSize: 20, + input: baseInput, + loggerWithEventDetails: loggerStub, + startTime: dayjs(), + endTime: dayjs().add(1, "day"), + bypassBusyCalendarTimes: false, + silentCalendarFailures: false, + mode: "slots", + }); + + const firstChunkWeights = calculateSpy.mock.calls[0][0].hosts.map((host: { weight?: number | null }) => host.weight); + expect(firstChunkWeights).toEqual(weights.slice(0, 20)); + }); + + it("returns empty availability when all chunks have no slots", async () => { + const hosts = createHosts(120); + getAggregatedAvailabilityMock.mockReturnValue([]); + + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: true }); + + expect(calculateSpy).toHaveBeenCalledTimes(6); // 120 hosts / 20 per chunk + expect(result.aggregatedAvailability).toEqual([]); + }); +}); diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index ac01a404044b55..28b2679405c3ca 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"; @@ -62,6 +63,16 @@ import type { GetScheduleOptions } from "./types"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const DEFAULT_SLOTS_CACHE_TTL = 2000; +const ROUND_ROBIN_USER_BATCH_SIZE = 20; +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: CredentialForCalendarService[]; @@ -86,9 +97,22 @@ export interface IGetAvailableSlots { troubleshooter?: any; } -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; +}; export interface IAvailableSlotsService { oooRepo: PrismaOOORepository; @@ -789,17 +813,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; @@ -854,7 +875,7 @@ export class AvailableSlotsService { : null; let busyTimesFromLimitsBookingsAllUsers: Awaited< - ReturnType + ReturnType > = []; if (eventType && (bookingLimits || durationLimits)) { @@ -979,6 +1000,87 @@ export class AvailableSlotsService { }; } + private async calculateAvailabilityWithRoundRobinChunks({ + hosts, + eventType, + chunkSize = ROUND_ROBIN_USER_BATCH_SIZE, + ...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< + HostsAvailabilityResult & { + aggregatedAvailability: ReturnType; + } + > { + 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); + 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); + } + + rrLog.info( + `RR chunking enabled for eventType=${eventType.id} (team=${eventType.team?.id ?? "N/A"}): processing ${nonFixedHosts.length} hosts in batches of ${chunkSize}` + ); + + const fixedHosts = hosts.filter((host) => host.isFixed); + let lastResult: Awaited> | null = null; + let chunkIndex = 0; + + for (const hostChunk of chunkArray(nonFixedHosts, chunkSize)) { + chunkIndex += 1; + const hostsForChunk = [...hostChunk, ...fixedHosts]; + lastResult = await calculateForHosts(hostsForChunk); + rrLog.info( + `RR chunk ${chunkIndex} checked: hosts=${hostChunk.length}, slotsFound=${lastResult.aggregatedAvailability.length}` + ); + if (lastResult.aggregatedAvailability.length > 0) { + return lastResult; + } + } + + if (lastResult) { + return lastResult; + } + + return await calculateForHosts(fixedHosts); + } + private async checkRestrictionScheduleEnabled(teamId?: number): Promise { if (!teamId) { return false; @@ -1097,6 +1199,8 @@ export class AvailableSlotsService { rrHostSubsetIds: input.rrHostSubsetIds ?? undefined, }); + console.log(qualifiedRRHosts) + // Filter out blocked hosts BEFORE calculating availability (batched - single DB query) const organizationId = eventType.parent?.team?.parentId ?? eventType.team?.parentId ?? null; @@ -1124,8 +1228,12 @@ export class AvailableSlotsService { const hasFallbackRRHosts = eligibleFallbackRRHosts.length > 0 && eligibleFallbackRRHosts.length > eligibleQualifiedRRHosts.length; - let { allUsersAvailability, usersWithCredentials, currentSeats } = - await this.calculateHostsAndAvailabilities({ + let { + allUsersAvailability, + usersWithCredentials, + currentSeats, + aggregatedAvailability, + } = await this.calculateAvailabilityWithRoundRobinChunks({ input, eventType, hosts: allHosts, @@ -1145,8 +1253,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; @@ -1160,7 +1266,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], @@ -1171,12 +1277,7 @@ export class AvailableSlotsService { silentCalendarFailures, mode, }); - if ( - !getAggregatedAvailability( - firstTwoWeeksAvailabilities.allUsersAvailability, - eventType.schedulingType - ).length - ) { + if (!firstTwoWeeksAvailabilities.aggregatedAvailability.length) { diff = 1; } } @@ -1195,8 +1296,12 @@ 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, + } = await this.calculateAvailabilityWithRoundRobinChunks({ input, eventType, hosts: [...eligibleFallbackRRHosts, ...eligibleFixedHosts], @@ -1207,7 +1312,6 @@ export class AvailableSlotsService { silentCalendarFailures, mode, })); - aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType); } } diff --git a/yarn.lock b/yarn.lock index 0ff675d6d89a34..2362cbd2a8a489 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2008,6 +2008,7 @@ __metadata: "@calcom/features": "workspace:*" "@calcom/lib": "workspace:*" "@calcom/prisma": "workspace:*" + "@calcom/testing": "workspace:*" "@calcom/trpc": "workspace:*" "@calcom/tsconfig": "workspace:*" "@calcom/types": "workspace:*" @@ -2057,6 +2058,7 @@ __metadata: "@calcom/features": "workspace:*" "@calcom/lib": "workspace:*" "@calcom/office365video": "workspace:*" + "@calcom/testing": "workspace:*" "@calcom/types": "workspace:*" "@calcom/ui": "workspace:*" "@calcom/zoomvideo": "workspace:*" @@ -2410,6 +2412,7 @@ __metadata: dependencies: "@calcom/dayjs": "workspace:*" "@calcom/lib": "workspace:*" + "@calcom/testing": "workspace:*" "@calcom/tsconfig": "workspace:*" "@calcom/types": "workspace:*" rrule: "npm:2.7.1" @@ -2577,6 +2580,7 @@ __metadata: "@calcom/atoms": "workspace:*" "@calcom/dayjs": "workspace:*" "@calcom/lib": "workspace:*" + "@calcom/testing": "workspace:*" "@calcom/trpc": "workspace:*" "@calcom/ui": "workspace:*" "@evyweb/ioctopus": "npm:1.2.0" @@ -3296,7 +3300,7 @@ __metadata: languageName: unknown linkType: soft -"@calcom/testing@workspace:packages/testing": +"@calcom/testing@workspace:*, @calcom/testing@workspace:packages/testing": version: 0.0.0-use.local resolution: "@calcom/testing@workspace:packages/testing" dependencies: @@ -3478,6 +3482,7 @@ __metadata: "@calcom/platform-enums": "workspace:*" "@calcom/platform-types": "workspace:*" "@calcom/prisma": "workspace:*" + "@calcom/testing": "workspace:*" "@calcom/trpc": "workspace:*" "@calcom/tsconfig": "workspace:*" "@calcom/types": "workspace:*" From ca11f47fbe6a7a10e988a5d0d7f1c8f7f27410a2 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Thu, 8 Jan 2026 16:12:35 -0300 Subject: [PATCH 02/11] Remove console.log --- packages/trpc/server/routers/viewer/slots/util.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index d7a2df1b6ff6af..bcb799d8827002 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1199,8 +1199,6 @@ export class AvailableSlotsService { rrHostSubsetIds: input.rrHostSubsetIds ?? undefined, }); - console.log(qualifiedRRHosts) - // Filter out blocked hosts BEFORE calculating availability (batched - single DB query) const organizationId = eventType.parent?.team?.parentId ?? eventType.team?.parentId ?? null; From af0c4a50b277e2cd33eebb6a8aea3b0c9cce4231 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Mon, 19 Jan 2026 14:32:40 -0300 Subject: [PATCH 03/11] Add ui and api changes to allow partial loading --- .../components/AvailableTimeSlots.tsx | 66 ++++++++++++ .../modules/bookings/components/Booker.tsx | 4 + apps/web/public/static/locales/en/common.json | 2 + packages/features/bookings/Booker/store.ts | 19 +++- packages/features/bookings/Booker/types.ts | 2 + .../features/bookings/Booker/utils/event.ts | 8 ++ .../bookings/lib/bookingCreateBodySchema.ts | 1 - .../use-schedule/useApiV2AvailableSlots.ts | 9 +- .../schedules/lib/use-schedule/useSchedule.ts | 8 ++ packages/lib/types/roundRobinChunkInfo.ts | 9 ++ .../atoms/booker/BookerPlatformWrapper.tsx | 47 ++++++++ .../atoms/booker/BookerWebWrapper.tsx | 44 ++++++++ .../platform/atoms/hooks/useAvailableSlots.ts | 9 +- .../slots/slots-2024-04-15/inputs/index.ts | 18 ++++ .../inputs/get-slots.input.ts | 19 ++++ .../trpc/server/routers/viewer/slots/types.ts | 2 + .../server/routers/viewer/slots/util.test.ts | 53 ++++++++- .../trpc/server/routers/viewer/slots/util.ts | 102 +++++++++++++++--- 18 files changed, 398 insertions(+), 24 deletions(-) create mode 100644 packages/lib/types/roundRobinChunkInfo.ts diff --git a/apps/web/modules/bookings/components/AvailableTimeSlots.tsx b/apps/web/modules/bookings/components/AvailableTimeSlots.tsx index cd85a4738a957c..113be86fec7581 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,8 @@ type AvailableTimeSlotsProps = { unavailableTimeSlots: string[]; confirmButtonDisabled?: boolean; onAvailableTimeSlotSelect: (time: string) => void; + onLoadNextRoundRobinChunk?: () => void; + onResetRoundRobinChunkSelection?: () => void; }; /** @@ -74,8 +78,11 @@ export const AvailableTimeSlots = ({ confirmButtonDisabled, confirmStepClassNames, onAvailableTimeSlotSelect, + onLoadNextRoundRobinChunk, + onResetRoundRobinChunkSelection, ...props }: AvailableTimeSlotsProps) => { + const { t } = useLocale(); const selectedDate = useBookerStoreContext((state) => state.selectedDate); const setSeatedEventData = useBookerStoreContext((state) => state.setSeatedEventData); @@ -108,6 +115,7 @@ export const AvailableTimeSlots = ({ }; const scheduleData = schedule?.data; + const roundRobinChunkInfo = scheduleData?.roundRobinChunkInfo; const nonEmptyScheduleDays = useNonEmptyScheduleDays(scheduleData?.slots); const nonEmptyScheduleDaysFromSelectedDate = nonEmptyScheduleDays.filter( @@ -186,6 +194,64 @@ export const AvailableTimeSlots = ({ return ( <> + {roundRobinChunkInfo && roundRobinChunkInfo.hasMoreNonFixedHosts && onLoadNextRoundRobinChunk && ( +
+ {(() => { + 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 ( + + ); + })()} +
+ )}
{isLoading ? (
diff --git a/apps/web/modules/bookings/components/Booker.tsx b/apps/web/modules/bookings/components/Booker.tsx index b0c21cc39062ca..971c9147f4535c 100644 --- a/apps/web/modules/bookings/components/Booker.tsx +++ b/apps/web/modules/bookings/components/Booker.tsx @@ -83,6 +83,8 @@ const BookerComponent = ({ eventMetaChildren, roundRobinHideOrgAndTeam, showNoAvailabilityDialog, + onLoadNextRoundRobinChunk, + onResetRoundRobinChunkSelection, }: BookerProps & WrappedBookerProps) => { const searchParams = useCompatSearchParams(); const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); @@ -526,6 +528,8 @@ const BookerComponent = ({ watchedCfToken={watchedCfToken} confirmButtonDisabled={confirmButtonDisabled} confirmStepClassNames={customClassNames?.confirmStep} + onLoadNextRoundRobinChunk={onLoadNextRoundRobinChunk} + onResetRoundRobinChunkSelection={onResetRoundRobinChunkSelection} /> diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4b37268f812cd3..f2672fe69ee805 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4186,5 +4186,7 @@ "audit_logs_permission_denied": "You do not have permission to view audit logs for this booking.", "audit_logs_permission_check_error": "An error occurred while checking permissions.", "account_already_exists_please_login": "An account with this email already exists. Please log in to accept the invitation.", + "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/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..fa129cb8dddf7b 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -133,6 +133,8 @@ export type WrappedBookerPropsMain = { isBookingDryRun?: boolean; renderCaptcha?: boolean; confirmButtonDisabled?: boolean; + onLoadNextRoundRobinChunk?: () => void; + onResetRoundRobinChunkSelection?: () => 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 2af5115df399a1..ea23927d6787ee 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -92,6 +92,11 @@ const BookerPlatformWrapperComponent = ( const [isOverlayCalendarEnabled, setIsOverlayCalendarEnabled] = useState( Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault")) ); + const [roundRobinChunkSettings, setRoundRobinChunkSettings] = useBookerStoreContext( + (state) => [state.roundRobinChunkSettings, state.setRoundRobinChunkSettings], + shallow + ); + const setRoundRobinChunkInfo = useBookerStoreContext((state) => state.setRoundRobinChunkInfo); const prevStateRef = useRef(null); const bookerStoreContext = useContext(BookerStoreContext); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -170,6 +175,18 @@ const BookerPlatformWrapperComponent = ( selectedDuration, }); + useEffect(() => { + setRoundRobinChunkSettings(null); + setRoundRobinChunkInfo(null); + }, [ + props.entity?.orgSlug, + props.username, + props.isTeamEvent ? props.teamId : null, + event?.data?.id, + setRoundRobinChunkSettings, + setRoundRobinChunkInfo, + ]); + const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts); useInitializeBookerStore({ ...props, @@ -291,6 +308,12 @@ const BookerPlatformWrapperComponent = ( rrHostSubsetIds: rrHostSubsetIds, } : {}), + ...(roundRobinChunkSettings?.manual + ? { + roundRobinManualChunking: true, + roundRobinChunkOffset: roundRobinChunkSettings.chunkOffset, + } + : {}), enabled: Boolean(teamId || username) && Boolean(month) && @@ -302,6 +325,11 @@ const BookerPlatformWrapperComponent = ( _silentCalendarFailures: silentlyHandleCalendarFailures, ...routingParams, }); + const roundRobinChunkInfo = schedule.data?.roundRobinChunkInfo; + useEffect(() => { + setRoundRobinChunkInfo(roundRobinChunkInfo ?? null); + }, [roundRobinChunkInfo, setRoundRobinChunkInfo]); + const isManualRoundRobinChunking = roundRobinChunkSettings?.manual ?? false; useEffect(() => { if (schedule.data && !schedule.isPending && !schedule.error && onTimeslotsLoaded) { @@ -309,6 +337,19 @@ const BookerPlatformWrapperComponent = ( } }, [schedule.data, schedule.isPending, schedule.error, onTimeslotsLoaded]); + const handleLoadNextRoundRobinChunk = useCallback(() => { + if (!roundRobinChunkInfo?.hasMoreNonFixedHosts || schedule.isFetching) return; + const currentOffset = roundRobinChunkSettings?.chunkOffset ?? roundRobinChunkInfo.chunkOffset ?? 0; + setRoundRobinChunkSettings({ + manual: true, + chunkOffset: currentOffset + 1, + }); + }, [roundRobinChunkInfo, roundRobinChunkSettings, schedule.isFetching]); + + const handleResetRoundRobinChunkSelection = useCallback(() => { + setRoundRobinChunkSettings(null); + }, []); + const bookerForm = useBookingForm({ event: event?.data, sessionEmail: @@ -576,6 +617,12 @@ const BookerPlatformWrapperComponent = ( isBookingDryRun={isBookingDryRun ?? routingParams?.isBookingDryRun} eventMetaChildren={props.eventMetaChildren} roundRobinHideOrgAndTeam={props.roundRobinHideOrgAndTeam} + onLoadNextRoundRobinChunk={ + roundRobinChunkInfo?.hasMoreNonFixedHosts ? handleLoadNextRoundRobinChunk : undefined + } + onResetRoundRobinChunkSelection={ + isManualRoundRobinChunking ? handleResetRoundRobinChunkSelection : undefined + } /> ); diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index e8cd69411d2287..69ae893c6a80b2 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -92,6 +92,11 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { }); const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow); + const [roundRobinChunkSettings, setRoundRobinChunkSettings] = useBookerStoreContext( + (state) => [state.roundRobinChunkSettings, state.setRoundRobinChunkSettings], + shallow + ); + const setRoundRobinChunkInfo = useBookerStoreContext((state) => state.setRoundRobinChunkInfo); const { data: session } = useSession(); const routerQuery = useRouterQuery(); @@ -154,7 +159,40 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { useApiV2: props.useApiV2, bookerLayout, ...(props.entity.orgSlug ? { orgSlug: props.entity.orgSlug } : {}), + roundRobinChunkSettings: roundRobinChunkSettings ?? undefined, }); + const roundRobinChunkInfo = schedule.data?.roundRobinChunkInfo; + useEffect(() => { + setRoundRobinChunkInfo(roundRobinChunkInfo ?? null); + }, [roundRobinChunkInfo, setRoundRobinChunkInfo]); + const isManualRoundRobinChunking = roundRobinChunkSettings?.manual ?? false; + + useEffect(() => { + setRoundRobinChunkSettings(null); + setRoundRobinChunkInfo(null); + }, [ + props.username, + props.eventSlug, + props.entity.orgSlug, + props.entity.eventTypeId, + event.data?.id, + setRoundRobinChunkSettings, + setRoundRobinChunkInfo, + ]); + + const handleLoadNextRoundRobinChunk = useCallback(() => { + if (!roundRobinChunkInfo?.hasMoreNonFixedHosts || schedule.isFetching) return; + const currentOffset = + roundRobinChunkSettings?.chunkOffset ?? roundRobinChunkInfo.chunkOffset ?? 0; + setRoundRobinChunkSettings({ + manual: true, + chunkOffset: currentOffset + 1, + }); + }, [roundRobinChunkInfo, roundRobinChunkSettings, schedule.isFetching]); + + const handleResetRoundRobinChunkSelection = useCallback(() => { + setRoundRobinChunkSettings(null); + }, []); const bookings = useBookings({ event, hashedLink: props.hashedLink, @@ -255,6 +293,12 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { event={event} bookerLayout={bookerLayout} schedule={schedule} + onLoadNextRoundRobinChunk={ + roundRobinChunkInfo?.hasMoreNonFixedHosts ? handleLoadNextRoundRobinChunk : undefined + } + onResetRoundRobinChunkSelection={ + isManualRoundRobinChunking ? handleResetRoundRobinChunkSelection : undefined + } verifyCode={verifyCode} isPlatform={false} areInstantMeetingParametersSet={areInstantMeetingParametersSet} diff --git a/packages/platform/atoms/hooks/useAvailableSlots.ts b/packages/platform/atoms/hooks/useAvailableSlots.ts index e2ac30bab5398c..ec2c7f4d13cb9f 100644 --- a/packages/platform/atoms/hooks/useAvailableSlots.ts +++ b/packages/platform/atoms/hooks/useAvailableSlots.ts @@ -12,10 +12,15 @@ import http from "../lib/http"; 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 a2ccf214d1772d..caf17f0d1ad7f7 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 93ccb536b55c16..d90cfb8a4de27e 100644 --- a/packages/trpc/server/routers/viewer/slots/util.test.ts +++ b/packages/trpc/server/routers/viewer/slots/util.test.ts @@ -91,9 +91,13 @@ describe("round robin chunking", () => { const invokeChunking = async ({ hosts, isRRWeightsEnabled, + manualChunking = false, + chunkOffset, }: { hosts: ReturnType; isRRWeightsEnabled: boolean; + manualChunking?: boolean; + chunkOffset?: number; }) => { const service = new AvailableSlotsService(dependencyStub); const calculateSpy = vi @@ -120,7 +124,11 @@ describe("round robin chunking", () => { team: null, }, chunkSize: 20, - input: baseInput, + input: { + ...baseInput, + ...(manualChunking ? { roundRobinManualChunking: true } : {}), + ...(chunkOffset !== undefined ? { roundRobinChunkOffset: chunkOffset } : {}), + }, loggerWithEventDetails: loggerStub, startTime, endTime, @@ -151,6 +159,40 @@ describe("round robin chunking", () => { expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(20); expect(calculateSpy.mock.calls[1][0].hosts).toHaveLength(20); expect(result.aggregatedAvailability).toEqual(secondChunkAvailability); + expect(result.roundRobinChunkInfo).toEqual({ + totalHosts: hosts.length, + totalNonFixedHosts: hosts.length, + chunkSize: 20, + chunkOffset: 1, + loadedNonFixedHosts: 20, + 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: true, + manualChunking: true, + chunkOffset: manualChunkOffset, + }); + + expect(calculateSpy).toHaveBeenCalledTimes(1); + expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(20); + expect(result.roundRobinChunkInfo).toEqual({ + totalHosts: hosts.length, + totalNonFixedHosts: hosts.length, + chunkSize: 20, + chunkOffset: manualChunkOffset, + loadedNonFixedHosts: 20, + hasMoreNonFixedHosts: true, + manualChunking: true, + }); }); it("skips chunking when weights are disabled", async () => { @@ -219,5 +261,14 @@ describe("round robin chunking", () => { expect(calculateSpy).toHaveBeenCalledTimes(6); // 120 hosts / 20 per chunk expect(result.aggregatedAvailability).toEqual([]); + expect(result.roundRobinChunkInfo).toEqual({ + totalHosts: hosts.length, + totalNonFixedHosts: hosts.length, + chunkSize: 20, + chunkOffset: 5, + loadedNonFixedHosts: 20, + 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 bcb799d8827002..87d49a193206c8 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -41,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, @@ -95,6 +96,7 @@ export interface IGetAvailableSlots { >; // eslint-disable-next-line @typescript-eslint/no-explicit-any troubleshooter?: any; + roundRobinChunkInfo?: RoundRobinChunkInfo; } type AvailableSlotsServiceInstance = InstanceType; @@ -114,6 +116,14 @@ type HostsAvailabilityResult = { currentSeats: CurrentSeats | undefined; }; +type AggregatedHostsAvailability = HostsAvailabilityResult & { + aggregatedAvailability: ReturnType; +}; + +type ChunkedAvailabilityResult = AggregatedHostsAvailability & { + roundRobinChunkInfo?: RoundRobinChunkInfo; +}; + export interface IAvailableSlotsService { oooRepo: PrismaOOORepository; scheduleRepo: ScheduleRepository; @@ -1020,11 +1030,7 @@ export class AvailableSlotsService { bypassBusyCalendarTimes: boolean; silentCalendarFailures: boolean; mode?: CalendarFetchMode; - }): Promise< - HostsAvailabilityResult & { - aggregatedAvailability: ReturnType; - } - > { + }): Promise { const rrLog = rest.loggerWithEventDetails ?? log; const calculateForHosts = async (currentHosts: typeof hosts) => { @@ -1041,6 +1047,7 @@ export class AvailableSlotsService { }; 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}` ); @@ -1054,31 +1061,91 @@ export class AvailableSlotsService { return await calculateForHosts(hosts); } + const fixedHosts = hosts.filter((host) => host.isFixed); + const manualChunkingEnabled = roundRobinManualChunking === true; + const manualChunkOffset = Math.max(0, roundRobinChunkOffset); + const hostChunks = chunkArray(nonFixedHosts, chunkSize); + + const buildChunkInfo = ({ + chunkOffset, + loadedNonFixedHosts, + hasMoreNonFixedHosts, + manualChunking, + }: Omit & { + manualChunking: boolean; + }): RoundRobinChunkInfo => ({ + totalHosts: hosts.length, + totalNonFixedHosts: nonFixedHosts.length, + chunkSize, + 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 ${chunkSize}` ); - const fixedHosts = hosts.filter((host) => host.isFixed); - let lastResult: Awaited> | null = null; - let chunkIndex = 0; - - for (const hostChunk of chunkArray(nonFixedHosts, chunkSize)) { - chunkIndex += 1; + let lastResult: ChunkedAvailabilityResult | null = null; + for (let index = 0; index < hostChunks.length; index += 1) { + const hostChunk = hostChunks[index]; const hostsForChunk = [...hostChunk, ...fixedHosts]; - lastResult = await calculateForHosts(hostsForChunk); + 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 ${chunkIndex} checked: hosts=${hostChunk.length}, slotsFound=${lastResult.aggregatedAvailability.length}` + `RR chunk ${index + 1} checked: hosts=${hostChunk.length}, slotsFound=${chunkResult.aggregatedAvailability.length}` ); - if (lastResult.aggregatedAvailability.length > 0) { - return lastResult; + if (chunkResult.aggregatedAvailability.length > 0) { + return chunkResultWithInfo; } + lastResult = chunkResultWithInfo; } if (lastResult) { return lastResult; } - return await calculateForHosts(fixedHosts); + const fallbackResult = await calculateForHosts(fixedHosts); + return { + ...fallbackResult, + roundRobinChunkInfo: buildChunkInfo({ + chunkOffset: 0, + loadedNonFixedHosts: 0, + hasMoreNonFixedHosts: false, + manualChunking: false, + }), + }; } private async checkRestrictionScheduleEnabled(teamId?: number): Promise { @@ -1231,6 +1298,7 @@ export class AvailableSlotsService { usersWithCredentials, currentSeats, aggregatedAvailability, + roundRobinChunkInfo, } = await this.calculateAvailabilityWithRoundRobinChunks({ input, eventType, @@ -1299,6 +1367,7 @@ export class AvailableSlotsService { usersWithCredentials, currentSeats, aggregatedAvailability, + roundRobinChunkInfo, } = await this.calculateAvailabilityWithRoundRobinChunks({ input, eventType, @@ -1659,6 +1728,7 @@ export class AvailableSlotsService { return { slots: filteredSlotsMappedToDate, + roundRobinChunkInfo, ...troubleshooterData, }; } From 286687f763e7c01c9c82af439ae4a725926dc371 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 20 Jan 2026 10:24:34 -0300 Subject: [PATCH 04/11] change implemetation for dynamic fetch size % --- .../server/routers/viewer/slots/util.test.ts | 66 +++++++++++++++---- .../trpc/server/routers/viewer/slots/util.ts | 17 +++-- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts index d90cfb8a4de27e..32ea985649148f 100644 --- a/packages/trpc/server/routers/viewer/slots/util.test.ts +++ b/packages/trpc/server/routers/viewer/slots/util.test.ts @@ -68,6 +68,11 @@ describe("round robin chunking", () => { debug: vi.fn(), }; + const computeExpectedChunkSize = (count: number) => { + const dynamic = Math.ceil(count * 0.1); + return Math.min(50, Math.max(20, dynamic)); + }; + const createHosts = (count: number, weights?: number[]) => Array.from({ length: count }).map((_, index) => ({ isFixed: false, @@ -123,7 +128,6 @@ describe("round robin chunking", () => { isRRWeightsEnabled, team: null, }, - chunkSize: 20, input: { ...baseInput, ...(manualChunking ? { roundRobinManualChunking: true } : {}), @@ -156,15 +160,16 @@ describe("round robin chunking", () => { expect(calculateSpy).toHaveBeenCalledTimes(2); expect(getAggregatedAvailabilityMock).toHaveBeenCalledTimes(2); - expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(20); - expect(calculateSpy.mock.calls[1][0].hosts).toHaveLength(20); + 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: 20, + chunkSize: expectedChunkSize, chunkOffset: 1, - loadedNonFixedHosts: 20, + loadedNonFixedHosts: expectedChunkSize, hasMoreNonFixedHosts: true, manualChunking: false, }); @@ -183,18 +188,52 @@ describe("round robin chunking", () => { }); expect(calculateSpy).toHaveBeenCalledTimes(1); - expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(20); + 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: 20, + chunkSize: expectedChunkSize, chunkOffset: manualChunkOffset, - loadedNonFixedHosts: 20, + 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: true }); + + 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: true }); + + const expectedChunkSize = computeExpectedChunkSize(hosts.length); + expect(expectedChunkSize).toBe(50); + expect(calculateSpy.mock.calls[0][0].hosts).toHaveLength(expectedChunkSize); + }); + it("skips chunking when weights are disabled", async () => { const hosts = createHosts(150); const finalAvailability = [ @@ -239,7 +278,6 @@ describe("round robin chunking", () => { isRRWeightsEnabled: true, team: null, }, - chunkSize: 20, input: baseInput, loggerWithEventDetails: loggerStub, startTime: dayjs(), @@ -249,8 +287,9 @@ describe("round robin chunking", () => { 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, 20)); + expect(firstChunkWeights).toEqual(weights.slice(0, expectedChunkSize)); }); it("returns empty availability when all chunks have no slots", async () => { @@ -259,14 +298,15 @@ describe("round robin chunking", () => { const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: true }); - expect(calculateSpy).toHaveBeenCalledTimes(6); // 120 hosts / 20 per chunk + expect(calculateSpy).toHaveBeenCalledTimes(6); // 120 hosts / 20 per chunk (min size) expect(result.aggregatedAvailability).toEqual([]); + const expectedChunkSize = computeExpectedChunkSize(hosts.length); expect(result.roundRobinChunkInfo).toEqual({ totalHosts: hosts.length, totalNonFixedHosts: hosts.length, - chunkSize: 20, + chunkSize: expectedChunkSize, chunkOffset: 5, - loadedNonFixedHosts: 20, + loadedNonFixedHosts: expectedChunkSize, 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 8ec0d105b0ca12..bda9266cd446e0 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -65,6 +65,8 @@ import type { GetScheduleOptions } from "./types"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const DEFAULT_SLOTS_CACHE_TTL = 2000; const ROUND_ROBIN_USER_BATCH_SIZE = 20; +const ROUND_ROBIN_DYNAMIC_CHUNK_PERCENT = 0.1; +const ROUND_ROBIN_MAX_CHUNK_SIZE = 50; const ROUND_ROBIN_CHUNK_THRESHOLD = 100; const chunkArray = (items: T[], size: number) => { @@ -1022,7 +1024,7 @@ export class AvailableSlotsService { private async calculateAvailabilityWithRoundRobinChunks({ hosts, eventType, - chunkSize = ROUND_ROBIN_USER_BATCH_SIZE, + chunkSize, ...rest }: { hosts: { @@ -1073,7 +1075,14 @@ export class AvailableSlotsService { const fixedHosts = hosts.filter((host) => host.isFixed); const manualChunkingEnabled = roundRobinManualChunking === true; const manualChunkOffset = Math.max(0, roundRobinChunkOffset); - const hostChunks = chunkArray(nonFixedHosts, chunkSize); + const resolvedChunkSize = + chunkSize ?? + Math.min( + ROUND_ROBIN_MAX_CHUNK_SIZE, + Math.max(ROUND_ROBIN_USER_BATCH_SIZE, Math.ceil(nonFixedHosts.length * ROUND_ROBIN_DYNAMIC_CHUNK_PERCENT)) + ); + const effectiveChunkSize = Math.max(1, resolvedChunkSize); + const hostChunks = chunkArray(nonFixedHosts, effectiveChunkSize); const buildChunkInfo = ({ chunkOffset, @@ -1085,7 +1094,7 @@ export class AvailableSlotsService { }): RoundRobinChunkInfo => ({ totalHosts: hosts.length, totalNonFixedHosts: nonFixedHosts.length, - chunkSize, + chunkSize: effectiveChunkSize, chunkOffset, loadedNonFixedHosts, hasMoreNonFixedHosts, @@ -1114,7 +1123,7 @@ export class AvailableSlotsService { } rrLog.info( - `RR chunking enabled for eventType=${eventType.id} (team=${eventType.team?.id ?? "N/A"}): processing ${nonFixedHosts.length} hosts in batches of ${chunkSize}` + `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; From c499230b6fc2edbd0c5f20f7911d127b5ceeaaef Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 20 Jan 2026 10:36:35 -0300 Subject: [PATCH 05/11] Move logic to hook --- .../bookings/components/BookerWebWrapper.tsx | 51 +++++++----------- .../components/hooks/useRoundRobinChunking.ts | 54 +++++++++++++++++++ .../atoms/booker/BookerPlatformWrapper.tsx | 51 +++++++----------- 3 files changed, 91 insertions(+), 65 deletions(-) create mode 100644 packages/features/bookings/Booker/components/hooks/useRoundRobinChunking.ts diff --git a/apps/web/modules/bookings/components/BookerWebWrapper.tsx b/apps/web/modules/bookings/components/BookerWebWrapper.tsx index 2729e53565bca7..8aa093e68a07f8 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"; @@ -97,8 +98,6 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { (state) => [state.roundRobinChunkSettings, state.setRoundRobinChunkSettings], shallow ); - const setRoundRobinChunkInfo = useBookerStoreContext((state) => state.setRoundRobinChunkInfo); - const { data: session } = useSession(); const routerQuery = useRouterQuery(); const hasSession = !!session; @@ -162,38 +161,24 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { ...(props.entity.orgSlug ? { orgSlug: props.entity.orgSlug } : {}), roundRobinChunkSettings: roundRobinChunkSettings ?? undefined, }); - const roundRobinChunkInfo = schedule.data?.roundRobinChunkInfo; - useEffect(() => { - setRoundRobinChunkInfo(roundRobinChunkInfo ?? null); - }, [roundRobinChunkInfo, setRoundRobinChunkInfo]); - const isManualRoundRobinChunking = roundRobinChunkSettings?.manual ?? false; - - useEffect(() => { - setRoundRobinChunkSettings(null); - setRoundRobinChunkInfo(null); - }, [ - props.username, - props.eventSlug, - props.entity.orgSlug, - props.entity.eventTypeId, - event.data?.id, + const { + roundRobinChunkInfo, + isManualRoundRobinChunking, + handleLoadNextRoundRobinChunk, + handleResetRoundRobinChunkSelection, + } = useRoundRobinChunking({ + roundRobinChunkInfo: schedule.data?.roundRobinChunkInfo, + isFetching: schedule.isFetching, + roundRobinChunkSettings, setRoundRobinChunkSettings, - setRoundRobinChunkInfo, - ]); - - const handleLoadNextRoundRobinChunk = useCallback(() => { - if (!roundRobinChunkInfo?.hasMoreNonFixedHosts || schedule.isFetching) return; - const currentOffset = - roundRobinChunkSettings?.chunkOffset ?? roundRobinChunkInfo.chunkOffset ?? 0; - setRoundRobinChunkSettings({ - manual: true, - chunkOffset: currentOffset + 1, - }); - }, [roundRobinChunkInfo, roundRobinChunkSettings, schedule.isFetching]); - - const handleResetRoundRobinChunkSelection = useCallback(() => { - setRoundRobinChunkSettings(null); - }, []); + resetDeps: [ + props.username, + props.eventSlug, + props.entity.orgSlug, + props.entity.eventTypeId, + event.data?.id, + ], + }); const bookings = useBookings({ event, hashedLink: props.hashedLink, diff --git a/packages/features/bookings/Booker/components/hooks/useRoundRobinChunking.ts b/packages/features/bookings/Booker/components/hooks/useRoundRobinChunking.ts new file mode 100644 index 00000000000000..5cc8fc0a5695ff --- /dev/null +++ b/packages/features/bookings/Booker/components/hooks/useRoundRobinChunking.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect } from "react"; +import type { DependencyList } from "react"; + +import type { RoundRobinChunkSettings } from "@calcom/features/bookings/Booker/store"; +import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; +import type { RoundRobinChunkInfo } from "@calcom/lib/types/roundRobinChunkInfo"; + +type UseRoundRobinChunkingOptions = { + roundRobinChunkInfo?: RoundRobinChunkInfo | null; + isFetching: boolean; + resetDeps?: DependencyList; + roundRobinChunkSettings: RoundRobinChunkSettings | null; + setRoundRobinChunkSettings: (settings: RoundRobinChunkSettings | null) => 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]); + + const handleResetRoundRobinChunkSelection = useCallback(() => { + setRoundRobinChunkSettings(null); + }, [setRoundRobinChunkSettings]); + + return { + roundRobinChunkSettings, + roundRobinChunkInfo: roundRobinChunkInfo ?? null, + isManualRoundRobinChunking: roundRobinChunkSettings?.manual ?? false, + handleLoadNextRoundRobinChunk, + handleResetRoundRobinChunkSelection, + }; +}; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 2c3b7490088e0e..c8716b2c8c4ea9 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"; @@ -115,10 +116,9 @@ const BookerPlatformWrapperComponent = ( (state) => [state.roundRobinChunkSettings, state.setRoundRobinChunkSettings], shallow ); - const setRoundRobinChunkInfo = useBookerStoreContext((state) => state.setRoundRobinChunkInfo); 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") @@ -210,18 +210,6 @@ const BookerPlatformWrapperComponent = ( selectedDuration, }); - useEffect(() => { - setRoundRobinChunkSettings(null); - setRoundRobinChunkInfo(null); - }, [ - props.entity?.orgSlug, - props.username, - props.isTeamEvent ? props.teamId : null, - event?.data?.id, - setRoundRobinChunkSettings, - setRoundRobinChunkInfo, - ]); - const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts); useInitializeBookerStore({ ...props, @@ -370,11 +358,23 @@ const BookerPlatformWrapperComponent = ( _silentCalendarFailures: silentlyHandleCalendarFailures, ...routingParams, }); - const roundRobinChunkInfo = schedule.data?.roundRobinChunkInfo; - useEffect(() => { - setRoundRobinChunkInfo(roundRobinChunkInfo ?? null); - }, [roundRobinChunkInfo, setRoundRobinChunkInfo]); - const isManualRoundRobinChunking = roundRobinChunkSettings?.manual ?? false; + const { + roundRobinChunkInfo, + isManualRoundRobinChunking, + handleLoadNextRoundRobinChunk, + handleResetRoundRobinChunkSelection, + } = 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 ( @@ -387,19 +387,6 @@ const BookerPlatformWrapperComponent = ( } }, [schedule.data, schedule.isPending, schedule.error, onTimeslotsLoaded]); - const handleLoadNextRoundRobinChunk = useCallback(() => { - if (!roundRobinChunkInfo?.hasMoreNonFixedHosts || schedule.isFetching) return; - const currentOffset = roundRobinChunkSettings?.chunkOffset ?? roundRobinChunkInfo.chunkOffset ?? 0; - setRoundRobinChunkSettings({ - manual: true, - chunkOffset: currentOffset + 1, - }); - }, [roundRobinChunkInfo, roundRobinChunkSettings, schedule.isFetching]); - - const handleResetRoundRobinChunkSelection = useCallback(() => { - setRoundRobinChunkSettings(null); - }, []); - const bookerForm = useBookingForm({ event: event?.data, sessionEmail: From b633f1259122e7d482720270efaa4a18257d758f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:05:44 +0000 Subject: [PATCH 06/11] fix: add missing roundRobinChunk properties to test-utils mock store Co-Authored-By: Volnei Munhoz --- packages/features/bookings/Booker/__tests__/test-utils.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 Date: Tue, 20 Jan 2026 11:18:29 -0300 Subject: [PATCH 07/11] some cleanup --- .../components/AvailableTimeSlots.tsx | 2 -- .../modules/bookings/components/Booker.tsx | 2 -- .../bookings/components/BookerWebWrapper.tsx | 21 +++++++------------ .../components/hooks/useRoundRobinChunking.ts | 5 ----- packages/features/bookings/Booker/types.ts | 1 - .../atoms/booker/BookerPlatformWrapper.tsx | 10 +-------- 6 files changed, 8 insertions(+), 33 deletions(-) diff --git a/apps/web/modules/bookings/components/AvailableTimeSlots.tsx b/apps/web/modules/bookings/components/AvailableTimeSlots.tsx index 113be86fec7581..aa152535c8f5c7 100644 --- a/apps/web/modules/bookings/components/AvailableTimeSlots.tsx +++ b/apps/web/modules/bookings/components/AvailableTimeSlots.tsx @@ -53,7 +53,6 @@ type AvailableTimeSlotsProps = { confirmButtonDisabled?: boolean; onAvailableTimeSlotSelect: (time: string) => void; onLoadNextRoundRobinChunk?: () => void; - onResetRoundRobinChunkSelection?: () => void; }; /** @@ -79,7 +78,6 @@ export const AvailableTimeSlots = ({ confirmStepClassNames, onAvailableTimeSlotSelect, onLoadNextRoundRobinChunk, - onResetRoundRobinChunkSelection, ...props }: AvailableTimeSlotsProps) => { const { t } = useLocale(); diff --git a/apps/web/modules/bookings/components/Booker.tsx b/apps/web/modules/bookings/components/Booker.tsx index 4249f259740e9f..bfc256dc235389 100644 --- a/apps/web/modules/bookings/components/Booker.tsx +++ b/apps/web/modules/bookings/components/Booker.tsx @@ -90,7 +90,6 @@ const BookerComponent = ({ roundRobinHideOrgAndTeam, showNoAvailabilityDialog, onLoadNextRoundRobinChunk, - onResetRoundRobinChunkSelection, }: BookerProps & WrappedBookerProps) => { const searchParams = useCompatSearchParams(); const isPlatformBookerEmbed = useIsPlatformBookerEmbed(); @@ -543,7 +542,6 @@ const BookerComponent = ({ confirmButtonDisabled={confirmButtonDisabled} confirmStepClassNames={customClassNames?.confirmStep} onLoadNextRoundRobinChunk={onLoadNextRoundRobinChunk} - onResetRoundRobinChunkSelection={onResetRoundRobinChunkSelection} /> diff --git a/apps/web/modules/bookings/components/BookerWebWrapper.tsx b/apps/web/modules/bookings/components/BookerWebWrapper.tsx index 8aa093e68a07f8..413634de66010b 100644 --- a/apps/web/modules/bookings/components/BookerWebWrapper.tsx +++ b/apps/web/modules/bookings/components/BookerWebWrapper.tsx @@ -161,17 +161,13 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { ...(props.entity.orgSlug ? { orgSlug: props.entity.orgSlug } : {}), roundRobinChunkSettings: roundRobinChunkSettings ?? undefined, }); - const { - roundRobinChunkInfo, - isManualRoundRobinChunking, - handleLoadNextRoundRobinChunk, - handleResetRoundRobinChunkSelection, - } = useRoundRobinChunking({ - roundRobinChunkInfo: schedule.data?.roundRobinChunkInfo, - isFetching: schedule.isFetching, - roundRobinChunkSettings, - setRoundRobinChunkSettings, - resetDeps: [ + const { roundRobinChunkInfo, handleLoadNextRoundRobinChunk } = + useRoundRobinChunking({ + roundRobinChunkInfo: schedule.data?.roundRobinChunkInfo, + isFetching: schedule.isFetching, + roundRobinChunkSettings, + setRoundRobinChunkSettings, + resetDeps: [ props.username, props.eventSlug, props.entity.orgSlug, @@ -282,9 +278,6 @@ const BookerWebWrapperComponent = (props: BookerWebWrapperAtomProps) => { onLoadNextRoundRobinChunk={ roundRobinChunkInfo?.hasMoreNonFixedHosts ? handleLoadNextRoundRobinChunk : undefined } - onResetRoundRobinChunkSelection={ - isManualRoundRobinChunking ? handleResetRoundRobinChunkSelection : undefined - } verifyCode={verifyCode} isPlatform={false} areInstantMeetingParametersSet={areInstantMeetingParametersSet} diff --git a/packages/features/bookings/Booker/components/hooks/useRoundRobinChunking.ts b/packages/features/bookings/Booker/components/hooks/useRoundRobinChunking.ts index 5cc8fc0a5695ff..bc85536018bc38 100644 --- a/packages/features/bookings/Booker/components/hooks/useRoundRobinChunking.ts +++ b/packages/features/bookings/Booker/components/hooks/useRoundRobinChunking.ts @@ -40,15 +40,10 @@ export const useRoundRobinChunking = ({ }); }, [roundRobinChunkInfo, roundRobinChunkSettings, isFetching, setRoundRobinChunkSettings]); - const handleResetRoundRobinChunkSelection = useCallback(() => { - setRoundRobinChunkSettings(null); - }, [setRoundRobinChunkSettings]); - return { roundRobinChunkSettings, roundRobinChunkInfo: roundRobinChunkInfo ?? null, isManualRoundRobinChunking: roundRobinChunkSettings?.manual ?? false, handleLoadNextRoundRobinChunk, - handleResetRoundRobinChunkSelection, }; }; diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index fa129cb8dddf7b..57977d7b807cb1 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -134,7 +134,6 @@ export type WrappedBookerPropsMain = { renderCaptcha?: boolean; confirmButtonDisabled?: boolean; onLoadNextRoundRobinChunk?: () => void; - onResetRoundRobinChunkSelection?: () => void; }; export type WrappedBookerPropsForPlatform = WrappedBookerPropsMain & { diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index c8716b2c8c4ea9..b2b1238735a24c 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -358,12 +358,7 @@ const BookerPlatformWrapperComponent = ( _silentCalendarFailures: silentlyHandleCalendarFailures, ...routingParams, }); - const { - roundRobinChunkInfo, - isManualRoundRobinChunking, - handleLoadNextRoundRobinChunk, - handleResetRoundRobinChunkSelection, - } = useRoundRobinChunking({ + const { roundRobinChunkInfo, handleLoadNextRoundRobinChunk } = useRoundRobinChunking({ roundRobinChunkInfo: schedule.data?.roundRobinChunkInfo, isFetching: schedule.isFetching, roundRobinChunkSettings, @@ -676,9 +671,6 @@ const BookerPlatformWrapperComponent = ( onLoadNextRoundRobinChunk={ roundRobinChunkInfo?.hasMoreNonFixedHosts ? handleLoadNextRoundRobinChunk : undefined } - onResetRoundRobinChunkSelection={ - isManualRoundRobinChunking ? handleResetRoundRobinChunkSelection : undefined - } /> ); From df4f2d0616a304837d34ab1f48a3c8c99cd52112 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Tue, 20 Jan 2026 11:45:48 -0300 Subject: [PATCH 08/11] Apply suggestion from @keithwillcode --- packages/trpc/server/routers/viewer/slots/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 055b65a747079d..58504379d5a6e0 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -65,7 +65,7 @@ import type { GetScheduleOptions } from "./types"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const DEFAULT_SLOTS_CACHE_TTL = 2000; const ROUND_ROBIN_USER_BATCH_SIZE = 20; -const ROUND_ROBIN_DYNAMIC_CHUNK_PERCENT = 0.1; +const ROUND_ROBIN_DYNAMIC_CHUNK_PERCENT = 0.2; const ROUND_ROBIN_MAX_CHUNK_SIZE = 50; const ROUND_ROBIN_CHUNK_THRESHOLD = 100; From ff0f6ba28276ec07f987213336e2fae9eb1c4460 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Tue, 20 Jan 2026 12:15:22 -0300 Subject: [PATCH 09/11] Remove unnecessary variable --- packages/trpc/server/routers/viewer/slots/util.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 58504379d5a6e0..a28c369ea8d9d0 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -64,7 +64,7 @@ import type { GetScheduleOptions } from "./types"; const log = logger.getSubLogger({ prefix: ["[slots/util]"] }); const DEFAULT_SLOTS_CACHE_TTL = 2000; -const ROUND_ROBIN_USER_BATCH_SIZE = 20; + const ROUND_ROBIN_DYNAMIC_CHUNK_PERCENT = 0.2; const ROUND_ROBIN_MAX_CHUNK_SIZE = 50; const ROUND_ROBIN_CHUNK_THRESHOLD = 100; @@ -1079,7 +1079,7 @@ export class AvailableSlotsService { chunkSize ?? Math.min( ROUND_ROBIN_MAX_CHUNK_SIZE, - Math.max(ROUND_ROBIN_USER_BATCH_SIZE, Math.ceil(nonFixedHosts.length * ROUND_ROBIN_DYNAMIC_CHUNK_PERCENT)) + Math.ceil(nonFixedHosts.length * ROUND_ROBIN_DYNAMIC_CHUNK_PERCENT) ); const effectiveChunkSize = Math.max(1, resolvedChunkSize); const hostChunks = chunkArray(nonFixedHosts, effectiveChunkSize); From c594b05969f66c853761722ca643a4ae322c3bd0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:09:11 +0000 Subject: [PATCH 10/11] fix: change chunking to only work when weights are disabled Co-Authored-By: Volnei Munhoz --- .../server/routers/viewer/slots/util.test.ts | 29 ++++++++++--------- .../trpc/server/routers/viewer/slots/util.ts | 2 +- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/trpc/server/routers/viewer/slots/util.test.ts b/packages/trpc/server/routers/viewer/slots/util.test.ts index 32ea985649148f..d39ad8b47771aa 100644 --- a/packages/trpc/server/routers/viewer/slots/util.test.ts +++ b/packages/trpc/server/routers/viewer/slots/util.test.ts @@ -69,8 +69,8 @@ describe("round robin chunking", () => { }; const computeExpectedChunkSize = (count: number) => { - const dynamic = Math.ceil(count * 0.1); - return Math.min(50, Math.max(20, dynamic)); + const dynamic = Math.ceil(count * 0.2); + return Math.min(50, dynamic); }; const createHosts = (count: number, weights?: number[]) => @@ -156,7 +156,7 @@ describe("round robin chunking", () => { getAggregatedAvailabilityMock.mockReturnValueOnce(firstChunkAvailability).mockReturnValueOnce(secondChunkAvailability); - const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: true }); + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: false }); expect(calculateSpy).toHaveBeenCalledTimes(2); expect(getAggregatedAvailabilityMock).toHaveBeenCalledTimes(2); @@ -182,7 +182,7 @@ describe("round robin chunking", () => { const { result, calculateSpy } = await invokeChunking({ hosts, - isRRWeightsEnabled: true, + isRRWeightsEnabled: false, manualChunking: true, chunkOffset: manualChunkOffset, }); @@ -205,7 +205,7 @@ describe("round robin chunking", () => { const hosts = createHosts(250); getAggregatedAvailabilityMock.mockReturnValue([]); - const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: true }); + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: false }); const expectedChunkSize = computeExpectedChunkSize(hosts.length); expect(expectedChunkSize).toBeGreaterThan(20); @@ -227,14 +227,14 @@ describe("round robin chunking", () => { const hosts = createHosts(1000); getAggregatedAvailabilityMock.mockReturnValue([]); - const { calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: true }); + 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 disabled", async () => { + it("skips chunking when weights are enabled", async () => { const hosts = createHosts(150); const finalAvailability = [ { @@ -244,7 +244,7 @@ describe("round robin chunking", () => { ]; getAggregatedAvailabilityMock.mockReturnValue(finalAvailability); - const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: false }); + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: true }); expect(calculateSpy).toHaveBeenCalledTimes(1); expect(getAggregatedAvailabilityMock).toHaveBeenCalledTimes(1); @@ -275,7 +275,7 @@ describe("round robin chunking", () => { eventType: { id: 456, schedulingType: SchedulingType.ROUND_ROBIN, - isRRWeightsEnabled: true, + isRRWeightsEnabled: false, team: null, }, input: baseInput, @@ -296,17 +296,18 @@ describe("round robin chunking", () => { const hosts = createHosts(120); getAggregatedAvailabilityMock.mockReturnValue([]); - const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: true }); + const { result, calculateSpy } = await invokeChunking({ hosts, isRRWeightsEnabled: false }); - expect(calculateSpy).toHaveBeenCalledTimes(6); // 120 hosts / 20 per chunk (min size) - expect(result.aggregatedAvailability).toEqual([]); 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: 5, - loadedNonFixedHosts: 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 a28c369ea8d9d0..e126bf058075a5 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -1065,7 +1065,7 @@ export class AvailableSlotsService { const shouldChunk = eventType.schedulingType === SchedulingType.ROUND_ROBIN && - eventType.isRRWeightsEnabled && + !eventType.isRRWeightsEnabled && nonFixedHosts.length > ROUND_ROBIN_CHUNK_THRESHOLD; if (!shouldChunk) { From 51650d792580fade59a67810d991059b3c879bc4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:26:40 +0000 Subject: [PATCH 11/11] fix: move load more button to bottom with sticky positioning Co-Authored-By: Volnei Munhoz --- .../components/AvailableTimeSlots.tsx | 116 +++++++++--------- 1 file changed, 55 insertions(+), 61 deletions(-) diff --git a/apps/web/modules/bookings/components/AvailableTimeSlots.tsx b/apps/web/modules/bookings/components/AvailableTimeSlots.tsx index aa152535c8f5c7..981f2f1b0e6dbb 100644 --- a/apps/web/modules/bookings/components/AvailableTimeSlots.tsx +++ b/apps/web/modules/bookings/components/AvailableTimeSlots.tsx @@ -190,73 +190,60 @@ export const AvailableTimeSlots = ({ [overlayCalendarToggled, onTimeSelect, seatsPerTimeSlot, skipConfirmStep, toggleConfirmButton] ); - return ( - <> - {roundRobinChunkInfo && roundRobinChunkInfo.hasMoreNonFixedHosts && onLoadNextRoundRobinChunk && ( -
- {(() => { - 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 showLoadMoreButton = + roundRobinChunkInfo?.hasMoreNonFixedHosts && onLoadNextRoundRobinChunk; - const ProgressIcon = ( - - - - - ); + const renderLoadMoreButton = () => { + if (!showLoadMoreButton) return null; - return ( - - ); - })()} -
- )} + 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 && @@ -311,6 +299,12 @@ export const AvailableTimeSlots = ({
))}
- + + {showLoadMoreButton && ( +
+ {renderLoadMoreButton()} +
+ )} +
); };