From ec951cacaddbfdb4645151e9b31a22bd193cc58e Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 21 Jan 2026 00:51:01 -0500 Subject: [PATCH] perf: `getUserAvailability` if `dateRange` is empty, early return (#26000) * Early return if date range is empty * test: add tests for empty workingHours early return optimization Co-Authored-By: joe@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Volnei Munhoz --- apps/web/test/lib/getSchedule.test.ts | 257 +++++++++++++++++- .../availability/lib/getUserAvailability.ts | 243 +++++++++-------- 2 files changed, 384 insertions(+), 116 deletions(-) diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index e3534ee17d..bec8b328bb 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -24,12 +24,14 @@ import { expect, expectedSlotsForSchedule } from "./getSchedule/expects"; import { setupAndTeardown } from "./getSchedule/setupAndTeardown"; import { timeTravelToTheBeginningOfToday } from "./getSchedule/utils"; +/* eslint-disable @typescript-eslint/no-explicit-any */ constantsScenarios.set({ - IS_PRODUCTION: true as any, + IS_PRODUCTION: true, WEBAPP_URL: "http://localhost:3000", - RESERVED_SUBDOMAINS: ["auth", "docs"] as any, - SINGLE_ORG_SLUG: "" as any, -}); + RESERVED_SUBDOMAINS: ["auth", "docs"], + SINGLE_ORG_SLUG: "", +} as any); +/* eslint-enable @typescript-eslint/no-explicit-any */ describe("getSchedule", () => { const availableSlotsService = getAvailableSlotsService(); @@ -3630,4 +3632,251 @@ describe("getSchedule", () => { ); }); }); + + describe("Empty working hours - early return optimization", () => { + test("returns no slots when workingHours is empty", async () => { + vi.setSystemTime("2024-05-21T00:00:13Z"); + + const plus1DateString = "2024-05-22"; + const plus2DateString = "2024-05-23"; + + await createBookingScenario({ + eventTypes: [ + { + id: 1, + slotInterval: 60, + length: 60, + users: [ + { + id: 101, + }, + ], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [TestData.schedules.EmptyAvailability], + }, + ], + bookings: [], + }); + + const schedule = await availableSlotsService.getAvailableSlots({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: false, + orgSlug: null, + }, + }); + + expect(schedule).toHaveDateDisabled({ + dateString: plus2DateString, + }); + }); + + test("returns slots only for date override when workingHours is empty but date override exists", async () => { + vi.setSystemTime("2024-05-21T00:00:13Z"); + + const plus1DateString = "2024-05-22"; + const plus2DateString = "2024-05-23"; + + await createBookingScenario({ + eventTypes: [ + { + id: 1, + slotInterval: 60, + length: 60, + users: [ + { + id: 101, + }, + ], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [ + { + name: "Empty schedule with date override", + availability: [ + { + days: [], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T12:00:00.000Z"), + date: plus2DateString, + }, + ], + timeZone: Timezones["+5:30"], + }, + ], + }, + ], + bookings: [], + }); + + const schedule = await availableSlotsService.getAvailableSlots({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: false, + orgSlug: null, + }, + }); + + expect(schedule).toHaveTimeSlots( + [`03:30:00.000Z`, `04:30:00.000Z`, `05:30:00.000Z`], + { + dateString: plus2DateString, + } + ); + }); + + test("returns slots only for date overrides when workingHours is empty but multiple date overrides exist", async () => { + vi.setSystemTime("2024-05-21T00:00:13Z"); + + const plus1DateString = "2024-05-22"; + const plus2DateString = "2024-05-23"; + const plus3DateString = "2024-05-24"; + + await createBookingScenario({ + eventTypes: [ + { + id: 1, + slotInterval: 60, + length: 60, + users: [ + { + id: 101, + }, + ], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [ + { + name: "Empty schedule with multiple date overrides", + availability: [ + { + days: [], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T11:00:00.000Z"), + date: plus2DateString, + }, + { + days: [], + startTime: new Date("1970-01-01T14:00:00.000Z"), + endTime: new Date("1970-01-01T16:00:00.000Z"), + date: plus3DateString, + }, + ], + timeZone: Timezones["+5:30"], + }, + ], + }, + ], + bookings: [], + }); + + const schedule = await availableSlotsService.getAvailableSlots({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus3DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: false, + orgSlug: null, + }, + }); + + expect(schedule).toHaveTimeSlots([`03:30:00.000Z`, `04:30:00.000Z`], { + dateString: plus2DateString, + }); + + expect(schedule).toHaveTimeSlots([`08:30:00.000Z`, `09:30:00.000Z`], { + dateString: plus3DateString, + }); + }); + + test("returns no slots for dates without date override when workingHours is empty", async () => { + vi.setSystemTime("2024-05-21T00:00:13Z"); + + const plus1DateString = "2024-05-22"; + const plus2DateString = "2024-05-23"; + const plus3DateString = "2024-05-24"; + + await createBookingScenario({ + eventTypes: [ + { + id: 1, + slotInterval: 60, + length: 60, + users: [ + { + id: 101, + }, + ], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [ + { + name: "Empty schedule with date override only on plus3", + availability: [ + { + days: [], + startTime: new Date("1970-01-01T09:00:00.000Z"), + endTime: new Date("1970-01-01T12:00:00.000Z"), + date: plus3DateString, + }, + ], + timeZone: Timezones["+5:30"], + }, + ], + }, + ], + bookings: [], + }); + + const schedule = await availableSlotsService.getAvailableSlots({ + input: { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus3DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + isTeamEvent: false, + orgSlug: null, + }, + }); + + expect(schedule).toHaveDateDisabled({ + dateString: plus2DateString, + }); + + expect(schedule).toHaveTimeSlots( + [`03:30:00.000Z`, `04:30:00.000Z`, `05:30:00.000Z`], + { + dateString: plus3DateString, + } + ); + }); + }); }); diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index 41dab58c6d..2ddfc8b6cc 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -386,6 +386,132 @@ export class UserAvailabilityService { user, }); + if ( + !( + schedule?.availability || + (eventType?.availability.length ? eventType.availability : user.availability) + ) + ) { + throw new HttpError({ statusCode: 400, message: ErrorCode.AvailabilityNotFoundInSchedule }); + } + + const availability = ( + schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability) + ).map((a) => ({ + ...a, + userId: user.id, + })); + + let calendarTimezone: string | null = null; + let finalTimezone: string | null = null; + + if (!isTimezoneSet) { + calendarTimezone = await this.getTimezoneFromDelegatedCalendars(user); + if (calendarTimezone) { + finalTimezone = calendarTimezone; + } + } + + if (!finalTimezone) { + finalTimezone = schedule.timeZone; + } + + const workingHours = getWorkingHours({ timeZone: finalTimezone }, availability); + + const dateOverrides: TimeRange[] = []; + // NOTE: getSchedule is currently calling this function for every user in a team event + // but not using these values at all, wasting CPU. Adding this check here temporarily to avoid a larger refactor + // since other callers do using this data. + if (returnDateOverrides) { + const calculateDateOverridesSpan = Sentry.startInactiveSpan({ name: "calculateDateOverrides" }); + const availabilityWithDates = availability.filter((availability) => !!availability.date); + + for (let i = 0; i < availabilityWithDates.length; i++) { + const override = availabilityWithDates[i]; + const startTime = dayjs.utc(override.startTime); + const endTime = dayjs.utc(override.endTime); + const overrideStartDate = dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute()); + const overrideEndDate = dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute()); + if ( + overrideStartDate.isBetween(dateFrom, dateTo, null, "[]") || + overrideEndDate.isBetween(dateFrom, dateTo, null, "[]") + ) { + dateOverrides.push({ + start: overrideStartDate.toDate(), + end: overrideEndDate.toDate(), + }); + } + } + + calculateDateOverridesSpan.end(); + } + + const outOfOfficeDays = + initialData?.outOfOfficeDays ?? + (await this.dependencies.oooRepo.findUserOOODays({ + userId: user.id, + dateFrom: dateFrom.toISOString(), + dateTo: dateTo.toISOString(), + })); + + const datesOutOfOffice: IOutOfOfficeData = this.calculateOutOfOfficeRanges(outOfOfficeDays, availability); + + const holidayBlockedDates = await this.calculateHolidayBlockedDates( + user.id, + dateFrom.toDate(), + dateTo.toDate(), + availability + ); + + for (const [date, holidayData] of Object.entries(holidayBlockedDates)) { + if (!datesOutOfOffice[date]) { + datesOutOfOffice[date] = holidayData; + } + } + + const travelSchedules: { + startDate: Dayjs; + endDate?: Dayjs; + timeZone: string; + }[] = []; + + if (isDefaultSchedule) { + travelSchedules.push( + ...user.travelSchedules.map((schedule) => { + let endDate: Dayjs | undefined; + if (schedule.endDate) { + endDate = dayjs(schedule.endDate); + } + return { + startDate: dayjs(schedule.startDate), + endDate, + timeZone: schedule.timeZone, + }; + }) + ); + } + + const { dateRanges, oooExcludedDateRanges } = buildDateRanges({ + dateFrom, + dateTo, + availability, + timeZone: finalTimezone, + travelSchedules, + outOfOffice: datesOutOfOffice, + }); + + if (dateRanges.length === 0) + return { + busy: [], + timeZone: finalTimezone, + dateRanges: [], + oooExcludedDateRanges: [], + workingHours: [], + dateOverrides: [], + currentSeats: [], + datesOutOfOffice: undefined, + }; + const bookingLimits = eventType?.bookingLimits && typeof eventType.bookingLimits === "object" && @@ -408,20 +534,6 @@ export class UserAvailabilityService { ? user.allSelectedCalendars.filter((calendar) => calendar.eventTypeId === eventType.id) : user.userLevelSelectedCalendars; - let calendarTimezone: string | null = null; - let finalTimezone: string | null = null; - - if (!isTimezoneSet) { - calendarTimezone = await this.getTimezoneFromDelegatedCalendars(user); - if (calendarTimezone) { - finalTimezone = calendarTimezone; - } - } - - if (!finalTimezone) { - finalTimezone = schedule.timeZone; - } - let busyTimesFromLimits: EventBusyDetails[] = []; if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) { @@ -514,106 +626,13 @@ export class UserAvailabilityService { ...busyTimesFromTeamLimits, ]; - if ( - !( - schedule?.availability || - (eventType?.availability.length ? eventType.availability : user.availability) - ) - ) { - throw new HttpError({ statusCode: 400, message: ErrorCode.AvailabilityNotFoundInSchedule }); - } - - const availability = ( - schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability) - ).map((a) => ({ - ...a, - userId: user.id, - })); - - const workingHours = getWorkingHours({ timeZone: finalTimezone }, availability); - - const dateOverrides: TimeRange[] = []; - // NOTE: getSchedule is currently calling this function for every user in a team event - // but not using these values at all, wasting CPU. Adding this check here temporarily to avoid a larger refactor - // since other callers do using this data. - if (returnDateOverrides) { - const calculateDateOverridesSpan = Sentry.startInactiveSpan({ name: "calculateDateOverrides" }); - const availabilityWithDates = availability.filter((availability) => !!availability.date); - - for (let i = 0; i < availabilityWithDates.length; i++) { - const override = availabilityWithDates[i]; - const startTime = dayjs.utc(override.startTime); - const endTime = dayjs.utc(override.endTime); - const overrideStartDate = dayjs.utc(override.date).hour(startTime.hour()).minute(startTime.minute()); - const overrideEndDate = dayjs.utc(override.date).hour(endTime.hour()).minute(endTime.minute()); - if ( - overrideStartDate.isBetween(dateFrom, dateTo, null, "[]") || - overrideEndDate.isBetween(dateFrom, dateTo, null, "[]") - ) { - dateOverrides.push({ - start: overrideStartDate.toDate(), - end: overrideEndDate.toDate(), - }); - } - } - - calculateDateOverridesSpan.end(); - } - - const outOfOfficeDays = - initialData?.outOfOfficeDays ?? - (await this.dependencies.oooRepo.findUserOOODays({ - userId: user.id, - dateFrom: dateFrom.toISOString(), - dateTo: dateTo.toISOString(), - })); - - const datesOutOfOffice: IOutOfOfficeData = this.calculateOutOfOfficeRanges(outOfOfficeDays, availability); - - const holidayBlockedDates = await this.calculateHolidayBlockedDates( - user.id, - dateFrom.toDate(), - dateTo.toDate(), - availability + log.debug( + `EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({ + chosenSchedule: schedule, + eventTypeSchedule: eventType?.schedule, + })}` ); - for (const [date, holidayData] of Object.entries(holidayBlockedDates)) { - if (!datesOutOfOffice[date]) { - datesOutOfOffice[date] = holidayData; - } - } - - const travelSchedules: { - startDate: dayjs.Dayjs; - endDate?: dayjs.Dayjs; - timeZone: string; - }[] = []; - - if (isDefaultSchedule) { - travelSchedules.push( - ...user.travelSchedules.map((schedule) => { - let endDate: Dayjs | undefined; - if (schedule.endDate) { - endDate = dayjs(schedule.endDate); - } - return { - startDate: dayjs(schedule.startDate), - endDate, - timeZone: schedule.timeZone, - }; - }) - ); - } - - const { dateRanges, oooExcludedDateRanges } = buildDateRanges({ - dateFrom, - dateTo, - availability, - timeZone: finalTimezone, - travelSchedules, - outOfOffice: datesOutOfOffice, - }); - const formattedBusyTimes = detailedBusyTimes.map((busy) => ({ start: dayjs(busy.start), end: dayjs(busy.end),