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
This commit is contained in:
Alex van Andel authored and GitHub committed 2026-01-11 19:44:09 -03:00
1 parent 58798f27d2
commit 312733ce68
4 files changed
+345 -60

No files matched your search

@@ -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,
},
};
@@ -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> = {}
): 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);
});
});
@@ -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<GetUserAvailabilityInitialData["user"]>["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,
},
};
}
@@ -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<GetUserAvailabilityResult> {
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 ||