From 312733ce688438b5dcfa882e6e7f2400cb4f08e9 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Sun, 11 Jan 2026 22:44:09 +0000 Subject: [PATCH] refactor: Extract schedule determination to seperate fn (#26600) * refactor: Extract schedule determination to seperate fn * refactor: Extract schedule detection to seperate file and add tests * RouterOutputs should be used in hooks, not direct type imports --- .../hooks/useOnboardingCalendarEvents.ts | 18 +- .../detectEventTypeScheduleForUser.test.ts | 189 ++++++++++++++++++ .../lib/detectEventTypeScheduleForUser.ts | 101 ++++++++++ .../availability/lib/getUserAvailability.ts | 97 ++++----- 4 files changed, 345 insertions(+), 60 deletions(-) create mode 100644 packages/features/availability/lib/detectEventTypeScheduleForUser.test.ts create mode 100644 packages/features/availability/lib/detectEventTypeScheduleForUser.ts diff --git a/apps/web/modules/onboarding/hooks/useOnboardingCalendarEvents.ts b/apps/web/modules/onboarding/hooks/useOnboardingCalendarEvents.ts index fc3a9f4804..8030c7ea06 100644 --- a/apps/web/modules/onboarding/hooks/useOnboardingCalendarEvents.ts +++ b/apps/web/modules/onboarding/hooks/useOnboardingCalendarEvents.ts @@ -2,17 +2,23 @@ import { useSession } from "next-auth/react"; import { useMemo, useEffect } from "react"; import dayjs from "@calcom/dayjs"; -import type { GetUserAvailabilityResult } from "@calcom/features/availability/lib/getUserAvailability"; + import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events"; import { BookingStatus } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; type UseOnboardingCalendarEventsProps = { startDate: Date; endDate: Date; }; -const emptyAvailabilityData = { +type UseOnboardingCalendarEventsResult = { + events: CalendarEvent[]; + isLoading: boolean; +}; + +const emptyAvailabilityData: RouterOutputs["viewer"]["availability"]["user"] = { busy: [], timeZone: "", dateRanges: [], @@ -21,9 +27,12 @@ const emptyAvailabilityData = { dateOverrides: [], currentSeats: null, datesOutOfOffice: {}, -} as GetUserAvailabilityResult; +}; -export const useOnboardingCalendarEvents = ({ startDate, endDate }: UseOnboardingCalendarEventsProps) => { +export const useOnboardingCalendarEvents = ({ + startDate, + endDate, +}: UseOnboardingCalendarEventsProps): UseOnboardingCalendarEventsResult => { const { data: session } = useSession(); const utils = trpc.useUtils(); @@ -65,7 +74,6 @@ export const useOnboardingCalendarEvents = ({ startDate, endDate }: UseOnboardin start: new Date(event.start), end: new Date(event.end), options: { - color: event.source ? undefined : undefined, status: BookingStatus.ACCEPTED, }, }; diff --git a/packages/features/availability/lib/detectEventTypeScheduleForUser.test.ts b/packages/features/availability/lib/detectEventTypeScheduleForUser.test.ts new file mode 100644 index 0000000000..cc4d747eee --- /dev/null +++ b/packages/features/availability/lib/detectEventTypeScheduleForUser.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_SCHEDULE_DATA, detectEventTypeScheduleForUser } from "./detectEventTypeScheduleForUser"; +import type { DetectEventTypeScheduleForUserInput } from "./detectEventTypeScheduleForUser"; + +describe("detectEventTypeScheduleForUser", () => { + const mockUser = { + id: 1, + timeZone: "America/New_York", + defaultScheduleId: 100, + schedules: [ + { + id: 100, + timeZone: "America/New_York", + availability: [], + }, + { + id: 101, + timeZone: "Europe/London", + availability: [], + }, + ], + }; + + const createInput = ( + overrides: Partial = {} + ): DetectEventTypeScheduleForUserInput => ({ + user: mockUser, + eventType: null, + ...overrides, + }); + + it("should prioritize eventType.schedule if present", () => { + const eventTypeSchedule = { + id: 999, + timeZone: "Asia/Tokyo", + availability: [], + }; + + const input = createInput({ + eventType: { + hosts: [], + timeZone: null, + schedule: eventTypeSchedule, + }, + }); + + const result = detectEventTypeScheduleForUser(input); + + expect(result.schedule).toEqual(expect.objectContaining({ id: 999, timeZone: "Asia/Tokyo" })); + expect(result.isTimezoneSet).toBe(true); + expect(result.isDefaultSchedule).toBe(false); + }); + + it("should prioritize host schedule if eventType.schedule is missing", () => { + const hostSchedule = { + id: 888, + timeZone: "Europe/Berlin", + availability: [], + }; + + const input = createInput({ + eventType: { + hosts: [ + { + user: { id: 1 }, + schedule: hostSchedule, + }, + ], + timeZone: null, + schedule: null, + }, + }); + + const result = detectEventTypeScheduleForUser(input); + + expect(result.schedule).toEqual(expect.objectContaining({ id: 888, timeZone: "Europe/Berlin" })); + expect(result.isTimezoneSet).toBe(true); + expect(result.isDefaultSchedule).toBe(false); + }); + + it("should prioritize user default schedule if neither event nor host schedule is present", () => { + const input = createInput({ + eventType: { + hosts: [], + timeZone: null, + schedule: null, + }, + }); + + const result = detectEventTypeScheduleForUser(input); + + expect(result.schedule).toEqual(expect.objectContaining({ id: 100, timeZone: "America/New_York" })); + expect(result.isTimezoneSet).toBe(true); + // It matches the user's default schedule ID + expect(result.isDefaultSchedule).toBe(true); + }); + + it("should use user schedule if it acts as host schedule", () => { + // This covers the case where host schedule is explicitly set and matches a user schedule + const userSchedule = mockUser.schedules[1]; // id 101 + const input = createInput({ + eventType: { + hosts: [ + { + user: { id: 1 }, + schedule: userSchedule, + }, + ], + timeZone: null, + schedule: null, + }, + }); + + const result = detectEventTypeScheduleForUser(input); + expect(result.schedule).toEqual(expect.objectContaining({ id: 101 })); + expect(result.isDefaultSchedule).toBe(false); // 101 is not default (100 is) + }); + + it("should use fallback schedule if no schedules are found", () => { + const userWithNoSchedules = { + ...mockUser, + schedules: [], + defaultScheduleId: null, + }; + + const input = createInput({ + user: userWithNoSchedules, + eventType: { + hosts: [], + timeZone: null, + schedule: null, + }, + }); + + const result = detectEventTypeScheduleForUser(input); + + expect(result.schedule.id).toBe(0); // Default schedule ID + expect(result.schedule.availability?.[0]?.days).toEqual(DEFAULT_SCHEDULE_DATA.availability?.[0]?.days); + expect(result.isTimezoneSet).toBe(false); // Fallback schedule doesn't count as "set" + expect(result.isDefaultSchedule).toBe(false); + }); + + it("should use eventType timezone as fallback if schedule timezone is missing", () => { + const userWithNoSchedules = { + ...mockUser, + schedules: [], + defaultScheduleId: null, + }; + + const input = createInput({ + user: userWithNoSchedules, + eventType: { + hosts: [], + timeZone: "Pacific/Honolulu", + schedule: null, + }, + }); + + const result = detectEventTypeScheduleForUser(input); + + // Schedule itself (fallback) has no timezone, so it picks up the fallback + expect(result.schedule.timeZone).toBe("Pacific/Honolulu"); + expect(result.isTimezoneSet).toBe(false); + }); + + it("should use user timezone as fallback if everything else is missing", () => { + const userWithNoSchedules = { + ...mockUser, + schedules: [], + defaultScheduleId: null, + timeZone: "Africa/Cairo", + }; + + const input = createInput({ + user: userWithNoSchedules, + eventType: { + hosts: [], + timeZone: null, + schedule: null, + }, + }); + + const result = detectEventTypeScheduleForUser(input); + + expect(result.schedule.timeZone).toBe("Africa/Cairo"); + expect(result.isTimezoneSet).toBe(false); + }); +}); diff --git a/packages/features/availability/lib/detectEventTypeScheduleForUser.ts b/packages/features/availability/lib/detectEventTypeScheduleForUser.ts new file mode 100644 index 0000000000..7ea1c6a152 --- /dev/null +++ b/packages/features/availability/lib/detectEventTypeScheduleForUser.ts @@ -0,0 +1,101 @@ +import type { GetUserAvailabilityInitialData } from "./getUserAvailability"; + +export type ScheduleWithoutTimeZone = { + id: number; + availability?: { + days: number[]; + startTime: Date; + endTime: Date; + date: Date | null; + }[]; +}; + +export const DEFAULT_SCHEDULE_DATA: ScheduleWithoutTimeZone = { + availability: [ + { + startTime: new Date("1970-01-01T09:00:00Z"), + endTime: new Date("1970-01-01T17:00:00Z"), + days: [1, 2, 3, 4, 5], // Monday to Friday + date: null, + }, + ], + id: 0, +}; + +export type DetectEventTypeScheduleForUserInput = { + eventType?: { + hosts: { + user: { + id: number; + }; + schedule: + | (ScheduleWithoutTimeZone & { + timeZone: string | null; + }) + | null; + }[]; + timeZone: string | null; + schedule: + | (ScheduleWithoutTimeZone & { + timeZone: string | null; + }) + | null; + } | null; + user: { + schedules: NonNullable["schedules"]; + defaultScheduleId: number | null; + timeZone: string; + id: number; + }; +}; + +export type DetectEventTypeScheduleForUserOutput = { + isDefaultSchedule: boolean; + isTimezoneSet: boolean; + schedule: ScheduleWithoutTimeZone & { + timeZone: string; + }; +}; + +export function detectEventTypeScheduleForUser({ + eventType, + user, +}: DetectEventTypeScheduleForUserInput): DetectEventTypeScheduleForUserOutput { + const userSchedule = user.schedules.filter( + (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId + )[0]; + const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule; + + // TODO: It uses default timezone of user. Should we use timezone of team ? + const fallbackTimezoneIfScheduleIsMissing = eventType?.timeZone || user.timeZone; + + const fallbackSchedule = { + ...DEFAULT_SCHEDULE_DATA, + timeZone: fallbackTimezoneIfScheduleIsMissing, + }; + + let potentialSchedule = null; + + if (eventType?.schedule) { + potentialSchedule = eventType.schedule; + } else if (hostSchedule) { + potentialSchedule = hostSchedule; + } else if (userSchedule) { + potentialSchedule = userSchedule; + } + + const schedule = potentialSchedule ?? fallbackSchedule; + + const isDefaultSchedule = !!(userSchedule && userSchedule.id === schedule?.id); + + const isTimezoneSet = Boolean(potentialSchedule && potentialSchedule.timeZone !== null); + + return { + isDefaultSchedule, + isTimezoneSet, + schedule: { + ...schedule, + timeZone: schedule.timeZone || fallbackTimezoneIfScheduleIsMissing, + }, + }; +} diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index 811bc0c86a..596897ed7d 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -14,10 +14,12 @@ import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/ev import type { PrismaHolidayRepository } from "@calcom/features/holidays/repositories/PrismaHolidayRepository"; import type { PrismaOOORepository } from "@calcom/features/ooo/repositories/PrismaOOORepository"; import type { IRedisService } from "@calcom/features/redis/IRedisService"; +import type { WorkingHours as WorkingHoursWithUserId } from "@calcom/types/schedule"; import type { DateOverride, WorkingHours } from "@calcom/features/schedules/lib/date-ranges"; import { buildDateRanges, subtract } from "@calcom/features/schedules/lib/date-ranges"; import { getWorkingHours } from "@calcom/lib/availability"; import { stringToDayjsZod } from "@calcom/lib/dayjs"; +import { detectEventTypeScheduleForUser } from "./detectEventTypeScheduleForUser"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { getHolidayService } from "@calcom/lib/holidays"; import { getHolidayEmoji } from "@calcom/lib/holidays/getHolidayEmoji"; @@ -169,9 +171,30 @@ export type CurrentSeats = Awaited< ReturnType<(typeof UserAvailabilityService)["prototype"]["_getCurrentSeats"]> >; -export type GetUserAvailabilityResult = Awaited< - ReturnType<(typeof UserAvailabilityService)["prototype"]["_getUserAvailability"]> ->; +export type GetUserAvailabilityResult = { + busy: EventBusyDetails[]; + timeZone: string; + dateRanges: { + start: dayjs.Dayjs; + end: dayjs.Dayjs; + }[]; + oooExcludedDateRanges: { + start: dayjs.Dayjs; + end: dayjs.Dayjs; + }[]; + workingHours: WorkingHoursWithUserId[]; + dateOverrides: TimeRange[]; + currentSeats: + | { + uid: string; + startTime: Date; + _count: { + attendees: number; + }; + }[] + | null; + datesOutOfOffice?: IOutOfOfficeData; +}; export interface IFromUser { id: number; @@ -323,7 +346,7 @@ export class UserAvailabilityService { async _getUserAvailability( params: GetUserAvailabilityParams, initialData?: GetUserAvailabilityInitialData - ) { + ): Promise { const { username, userId, @@ -360,38 +383,10 @@ export class UserAvailabilityService { currentSeats = await this.getCurrentSeats(eventType, dateFrom, dateTo); } - const userSchedule = user.schedules.filter( - (schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId - )[0]; - - const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule; - - // TODO: It uses default timezone of user. Should we use timezone of team ? - const fallbackTimezoneIfScheduleIsMissing = eventType?.timeZone || user.timeZone; - - const fallbackSchedule = { - availability: [ - { - startTime: new Date("1970-01-01T09:00:00Z"), - endTime: new Date("1970-01-01T17:00:00Z"), - days: [1, 2, 3, 4, 5], // Monday to Friday - date: null, - }, - ], - id: 0, - - timeZone: fallbackTimezoneIfScheduleIsMissing, - }; - - // possible timezones that have been set by or for a user - const potentialSchedule = eventType?.schedule - ? eventType.schedule - : hostSchedule - ? hostSchedule - : userSchedule; - - // if no schedules set by or for a user, use fallbackSchedule - const schedule = potentialSchedule ?? fallbackSchedule; + const { isDefaultSchedule, isTimezoneSet, schedule } = detectEventTypeScheduleForUser({ + eventType, + user, + }); const bookingLimits = eventType?.bookingLimits && @@ -415,16 +410,19 @@ export class UserAvailabilityService { ? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id }) : user.userLevelSelectedCalendars; - const isTimezoneSet = Boolean(potentialSchedule && potentialSchedule.timeZone !== null); + let calendarTimezone: string | null = null; + let finalTimezone: string | null = null; - // this timezone is synced with google/outlook calendars timezone usingg delegated credentials - // it's a fallback for delegated credentials users who want to sync their timezone with third party calendars - const calendarTimezone = !isTimezoneSet ? await this.getTimezoneFromDelegatedCalendars(user) : null; + if (!isTimezoneSet) { + calendarTimezone = await this.getTimezoneFromDelegatedCalendars(user); + if (calendarTimezone) { + finalTimezone = calendarTimezone; + } + } - const finalTimezone = - !isTimezoneSet && calendarTimezone - ? calendarTimezone - : schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing; + if (!finalTimezone) { + finalTimezone = schedule.timeZone; + } let busyTimesFromLimits: EventBusyDetails[] = []; @@ -518,17 +516,6 @@ export class UserAvailabilityService { ...busyTimesFromTeamLimits, ]; - const isDefaultSchedule = userSchedule && userSchedule.id === schedule?.id; - - log.debug( - `EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({ - chosenSchedule: schedule, - eventTypeSchedule: eventType?.schedule, - userSchedule: userSchedule, - hostSchedule: hostSchedule, - })}` - ); - if ( !( schedule?.availability ||