diff --git a/apps/web/modules/troubleshooter/components/LargeCalendar.tsx b/apps/web/modules/troubleshooter/components/LargeCalendar.tsx
index 757f1bdcfc..a36884c6d0 100644
--- a/apps/web/modules/troubleshooter/components/LargeCalendar.tsx
+++ b/apps/web/modules/troubleshooter/components/LargeCalendar.tsx
@@ -6,6 +6,7 @@ import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/hooks/us
import { useTimePreferences } from "@calcom/features/bookings/lib/timePreferences";
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
@@ -14,6 +15,7 @@ import { useSchedule } from "~/schedules/hooks/useSchedule";
import { OutOfOfficeInSlots } from "../../bookings/components/OutOfOfficeInSlots";
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
+ const { t } = useLocale();
const { timezone } = useTimePreferences();
const selectedDate = useTroubleshooterStore((state) => state.selectedDate);
const event = useTroubleshooterStore((state) => state.event);
@@ -34,7 +36,8 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
withSource: true,
},
{
- enabled: !!session?.user?.username,
+ // Busy Times need eventTypeId to correctly have all busy times. event.id check here also avoids sending double requests for availability.user
+ enabled: !!session?.user?.username && !!event?.id,
}
);
@@ -68,11 +71,13 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
// .toDate(),
const calendarEvents = busyEvents?.busy.map((event, idx) => {
+ const translatedTitle = event.title ? t(event.title) : t("busy_time.busy");
return {
id: idx,
- title: event.title ?? `Busy`,
+ title: translatedTitle,
start: new Date(event.start),
end: new Date(event.end),
+ source: event.source,
options: {
color:
event.source && calendarToColorMap[event.source] ? calendarToColorMap[event.source] : undefined,
@@ -104,6 +109,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
title: "Date Override",
start: dateOverrideStart.add(workingHoursForDay.startTime, "minutes").toDate(),
end: dateOverrideEnd.add(workingHoursForDay.endTime, "minutes").toDate(),
+ source: "date-override",
options: {
color: "black",
status: BookingStatus.ACCEPTED,
@@ -113,7 +119,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
});
}
return calendarEvents;
- }, [busyEvents, calendarToColorMap]);
+ }, [busyEvents, calendarToColorMap, t]);
return (
diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts
index d044b78e73..eb94e638d0 100644
--- a/packages/features/availability/lib/getUserAvailability.ts
+++ b/packages/features/availability/lib/getUserAvailability.ts
@@ -40,6 +40,7 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarFetchMode, EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { TimeRange, WorkingHours as WorkingHoursWithUserId } from "@calcom/types/schedule";
+import type { Ensure, Optional } from "@calcom/types/utils";
import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import { detectEventTypeScheduleForUser } from "./detectEventTypeScheduleForUser";
@@ -167,8 +168,10 @@ export type CurrentSeats = Awaited<
ReturnType<(typeof UserAvailabilityService)["prototype"]["_getCurrentSeats"]>
>;
+type UserAvailabilityBusyDetails = Optional;
+
export type GetUserAvailabilityResult = {
- busy: EventBusyDetails[];
+ busy: UserAvailabilityBusyDetails[];
timeZone: string;
dateRanges: {
start: dayjs.Dayjs;
@@ -338,6 +341,22 @@ export class UserAvailabilityService {
getCurrentSeats = withReporting(this._getCurrentSeats.bind(this), "getCurrentSeats");
+ private async parseLimits(eventType: { durationLimits?: unknown; bookingLimits?: unknown } | null) {
+ const bookingLimits =
+ eventType?.bookingLimits &&
+ typeof eventType.bookingLimits === "object" &&
+ Object.keys(eventType.bookingLimits).length > 0
+ ? parseBookingLimit(eventType.bookingLimits)
+ : null;
+ const durationLimits =
+ eventType?.durationLimits &&
+ typeof eventType.durationLimits === "object" &&
+ Object.keys(eventType.durationLimits).length > 0
+ ? parseDurationLimit(eventType.durationLimits)
+ : null;
+ return { bookingLimits, durationLimits };
+ }
+
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
async _getUserAvailability(
params: GetUserAvailabilityParams,
@@ -356,6 +375,7 @@ export class UserAvailabilityService {
bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
mode = "none",
+ withSource = false,
} = params;
log.debug(
@@ -371,7 +391,6 @@ export class UserAvailabilityService {
let eventType: EventType | null = initialData?.eventType || null;
if (!eventType && eventTypeId) eventType = await this.getEventType(eventTypeId);
-
/* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab
current bookings with a seats event type and display them on the calendar, even if they are full */
let currentSeats: CurrentSeats | null = initialData?.currentSeats || null;
@@ -510,19 +529,7 @@ export class UserAvailabilityService {
datesOutOfOffice: undefined,
};
- const bookingLimits =
- eventType?.bookingLimits &&
- typeof eventType.bookingLimits === "object" &&
- Object.keys(eventType.bookingLimits).length > 0
- ? parseBookingLimit(eventType.bookingLimits)
- : null;
-
- const durationLimits =
- eventType?.durationLimits &&
- typeof eventType.durationLimits === "object" &&
- Object.keys(eventType.durationLimits).length > 0
- ? parseDurationLimit(eventType.durationLimits)
- : null;
+ const { bookingLimits, durationLimits } = await this.parseLimits(eventType);
// TODO: only query what we need after applying limits (shrink date range)
const getBusyTimesStart = dateFrom.toISOString();
@@ -533,11 +540,9 @@ export class UserAvailabilityService {
: user.userLevelSelectedCalendars;
let busyTimesFromLimits: EventBusyDetails[] = [];
-
if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) {
busyTimesFromLimits = initialData.busyTimesFromLimits.get(user.id) || [];
} else if (eventType && (bookingLimits || durationLimits)) {
- // Fall back to individual query if not available in initialData
busyTimesFromLimits = await getBusyTimesFromLimits(
bookingLimits,
durationLimits,
@@ -612,18 +617,22 @@ export class UserAvailabilityService {
};
}
- const detailedBusyTimes: EventBusyDetails[] = [
+ const detailedBusyTimesWithSource: EventBusyDetails[] = [
...busyTimes.map((a) => ({
...a,
start: dayjs(a.start).toISOString(),
end: dayjs(a.end).toISOString(),
title: a.title,
- source: params.withSource ? a.source : undefined,
+ source: a.source,
})),
...busyTimesFromLimits,
...busyTimesFromTeamLimits,
];
+ const detailedBusyTimes: UserAvailabilityBusyDetails[] = withSource
+ ? detailedBusyTimesWithSource
+ : detailedBusyTimesWithSource.map(({ source, ...rest }) => rest);
+
log.debug(
`EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({
chosenSchedule: schedule,
@@ -657,6 +666,56 @@ export class UserAvailabilityService {
return result;
}
+ /**
+ * This fn mainly uses _getUserAvailability but sends busy times from limits too to _getUserAvailability as expected by _getUserAvailability.
+ */
+ async getUserAvailabilityIncludingBusyTimesFromLimits(
+ params: GetUserAvailabilityParams,
+ initialData: Ensure
+ ): Promise {
+ const { user } = initialData || {};
+ const { dateFrom, dateTo, eventTypeId } = params;
+ let eventType: EventType | null = initialData?.eventType || null;
+ if (!eventType && eventTypeId) eventType = await this.getEventType(eventTypeId);
+
+ const { bookingLimits, durationLimits } = await this.parseLimits(eventType);
+
+ let busyTimesFromLimitsBookings: EventBusyDetails[] | undefined =
+ initialData?.busyTimesFromLimitsBookings;
+ if (!busyTimesFromLimitsBookings && eventType && (bookingLimits || durationLimits)) {
+ const busyTimesService = getBusyTimesService();
+ const { limitDateFrom, limitDateTo } = busyTimesService.getStartEndDateforLimitCheck(
+ dateFrom.toISOString(),
+ dateTo.toISOString(),
+ bookingLimits,
+ durationLimits
+ );
+
+ // For team events with booking/duration limits, fetch bookings for all team members
+ // to accurately calculate if limits are reached for the event
+ const userIdsForLimitCheck =
+ eventType.hosts && eventType.hosts.length > 0
+ ? eventType.hosts.map((host) => host.user.id)
+ : [user.id];
+
+ busyTimesFromLimitsBookings = await busyTimesService.getBusyTimesForLimitChecks({
+ userIds: userIdsForLimitCheck,
+ eventTypeId: eventType.id,
+ startDate: limitDateFrom.format(),
+ endDate: limitDateTo.format(),
+ rescheduleUid: initialData?.rescheduleUid ?? undefined,
+ bookingLimits,
+ durationLimits,
+ });
+ }
+ const result = await this._getUserAvailability(params, {
+ ...initialData,
+ ...(eventType ? { eventType } : {}),
+ ...(busyTimesFromLimitsBookings ? { busyTimesFromLimitsBookings } : {}),
+ });
+ return result;
+ }
+
getUserAvailability = withReporting(this._getUserAvailability.bind(this), "getUserAvailability");
getPeriodStartDatesBetween = withReporting(
diff --git a/packages/features/availability/lib/getUserAvailabilityIncludingBusyTimesFromLimits.test.ts b/packages/features/availability/lib/getUserAvailabilityIncludingBusyTimesFromLimits.test.ts
new file mode 100644
index 0000000000..8e565d6e5c
--- /dev/null
+++ b/packages/features/availability/lib/getUserAvailabilityIncludingBusyTimesFromLimits.test.ts
@@ -0,0 +1,452 @@
+import dayjs from "@calcom/dayjs";
+import type { EventBusyDetails } from "@calcom/types/Calendar";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { EventType, GetUserAvailabilityInitialData } from "./getUserAvailability";
+import { UserAvailabilityService } from "./getUserAvailability";
+
+vi.mock("@calcom/features/di/containers/BusyTimes", () => ({
+ getBusyTimesService: vi.fn(() => mockBusyTimesService),
+}));
+
+vi.mock("@calcom/features/busyTimes/lib/getBusyTimesFromLimits", () => ({
+ getBusyTimesFromLimits: vi.fn().mockResolvedValue([]),
+ getBusyTimesFromTeamLimits: vi.fn().mockResolvedValue([]),
+}));
+
+vi.mock("@calcom/app-store/_utils/getCalendar", () => ({
+ getCalendar: vi.fn(),
+}));
+
+vi.mock("@calcom/lib/holidays", () => ({
+ getHolidayService: vi.fn(() => ({
+ getHolidayDatesInRange: vi.fn().mockResolvedValue([]),
+ })),
+}));
+
+const mockBusyTimesService = {
+ getStartEndDateforLimitCheck: vi.fn().mockReturnValue({
+ limitDateFrom: dayjs("2025-01-01T00:00:00Z"),
+ limitDateTo: dayjs("2025-01-31T23:59:59Z"),
+ }),
+ getBusyTimesForLimitChecks: vi.fn().mockResolvedValue([]),
+ getBusyTimes: vi.fn().mockResolvedValue([]),
+};
+
+const mockDependencies: ConstructorParameters[0] = {
+ oooRepo: {
+ findUserOOODays: vi.fn().mockResolvedValue([]),
+ },
+ bookingRepo: {
+ findAcceptedBookingByEventTypeId: vi.fn().mockResolvedValue([]),
+ },
+ redisClient: {
+ get: vi.fn().mockResolvedValue(null),
+ set: vi.fn(),
+ },
+ eventTypeRepo: {
+ findByIdForUserAvailability: vi.fn().mockResolvedValue(null),
+ findForSlots: vi.fn().mockResolvedValue(null),
+ },
+ holidayRepo: {
+ findUserSettingsSelect: vi.fn().mockResolvedValue(null),
+ },
+};
+
+const DATE_FROM = "2025-01-06T00:00:00Z";
+const DATE_TO = "2025-01-10T23:59:59Z";
+const DATE_FROM_ISO = "2025-01-06T00:00:00.000Z";
+const DATE_TO_ISO = "2025-01-10T23:59:59.000Z";
+const LIMIT_DATE_FROM_FORMATTED = "2025-01-01T00:00:00+00:00";
+const LIMIT_DATE_TO_FORMATTED = "2025-01-31T23:59:59+00:00";
+
+const createMockUser = (
+ overrides?: Partial>
+): NonNullable => ({
+ id: 1,
+ username: "testuser",
+ email: "test@example.com",
+ bufferTime: 0,
+ timeZone: "UTC",
+ availability: [
+ {
+ id: 1,
+ userId: 1,
+ eventTypeId: null,
+ scheduleId: 1,
+ days: [1, 2, 3, 4, 5],
+ startTime: new Date("1970-01-01T09:00:00Z"),
+ endTime: new Date("1970-01-01T17:00:00Z"),
+ date: null,
+ },
+ ],
+ timeFormat: 12,
+ defaultScheduleId: 1,
+ isPlatformManaged: false,
+ schedules: [
+ {
+ id: 1,
+ availability: [
+ {
+ days: [1, 2, 3, 4, 5],
+ startTime: new Date("1970-01-01T09:00:00Z"),
+ endTime: new Date("1970-01-01T17:00:00Z"),
+ date: null,
+ },
+ ],
+ timeZone: "UTC",
+ },
+ ],
+ credentials: [],
+ allSelectedCalendars: [],
+ userLevelSelectedCalendars: [],
+ travelSchedules: [],
+ ...overrides,
+});
+
+const createMockEventType = (overrides?: Partial>): NonNullable => ({
+ id: 100,
+ length: 30,
+ seatsPerTimeSlot: null,
+ timeZone: null,
+ schedule: null,
+ availability: [],
+ hosts: [],
+ bookingLimits: null,
+ durationLimits: null,
+ metadata: null,
+ useEventLevelSelectedCalendars: false,
+ team: null,
+ parent: null,
+ schedulingType: null,
+ assignAllTeamMembers: false,
+ ...overrides,
+});
+
+const createParams = (overrides?: Record) => ({
+ dateFrom: dayjs(DATE_FROM),
+ dateTo: dayjs(DATE_TO),
+ returnDateOverrides: false,
+ ...overrides,
+});
+
+describe("UserAvailabilityService.getUserAvailabilityIncludingBusyTimesFromLimits", () => {
+ let service: UserAvailabilityService;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ service = new UserAvailabilityService(mockDependencies);
+ });
+
+ it("skips fetching limit busy times when busyTimesFromLimitsBookings is already in initialData", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
+ const existingBusyTimes: EventBusyDetails[] = [
+ {
+ start: "2025-01-06T10:00:00Z",
+ end: "2025-01-06T23:59:59Z",
+ source: "Event Booking Limit",
+ },
+ ];
+
+ const result = await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ busyTimesFromLimitsBookings: existingBusyTimes,
+ });
+
+ expect(mockBusyTimesService.getBusyTimesForLimitChecks).not.toHaveBeenCalled();
+ expect(result).toBeDefined();
+ expect(result.busy).toBeDefined();
+ });
+
+ it("preserves pre-existing busyTimesFromLimitsBookings from initialData instead of overwriting with empty array", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
+ const existingBusyTimes: EventBusyDetails[] = [
+ {
+ start: "2025-01-06T10:00:00Z",
+ end: "2025-01-06T23:59:59Z",
+ source: "Event Booking Limit for User: 3 per day",
+ },
+ ];
+
+ const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service);
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ busyTimesFromLimitsBookings: existingBusyTimes,
+ });
+
+ // Verify that the pre-existing busyTimesFromLimitsBookings is passed through
+ // and NOT overwritten with an empty array
+ expect(getUserAvailabilitySpy).toHaveBeenCalledWith(
+ createParams(),
+ expect.objectContaining({
+ user,
+ eventType,
+ busyTimesFromLimitsBookings: existingBusyTimes,
+ })
+ );
+ });
+
+ it("fetches busy times when eventType has booking limits", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams({ eventTypeId: 100 }), {
+ user,
+ eventType,
+ });
+
+ expect(mockBusyTimesService.getStartEndDateforLimitCheck).toHaveBeenCalledWith(
+ DATE_FROM_ISO,
+ DATE_TO_ISO,
+ { PER_DAY: 3 },
+ null
+ );
+ expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
+ userIds: [1],
+ eventTypeId: 100,
+ startDate: LIMIT_DATE_FROM_FORMATTED,
+ endDate: LIMIT_DATE_TO_FORMATTED,
+ rescheduleUid: undefined,
+ bookingLimits: { PER_DAY: 3 },
+ durationLimits: null,
+ });
+ });
+
+ it("fetches busy times when eventType has duration limits", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType({ durationLimits: { PER_WEEK: 120 } });
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ });
+
+ expect(mockBusyTimesService.getStartEndDateforLimitCheck).toHaveBeenCalledWith(
+ DATE_FROM_ISO,
+ DATE_TO_ISO,
+ null,
+ { PER_WEEK: 120 }
+ );
+ expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
+ userIds: [1],
+ eventTypeId: 100,
+ startDate: LIMIT_DATE_FROM_FORMATTED,
+ endDate: LIMIT_DATE_TO_FORMATTED,
+ rescheduleUid: undefined,
+ bookingLimits: null,
+ durationLimits: { PER_WEEK: 120 },
+ });
+ });
+
+ it("passes both bookingLimits and durationLimits separately to getStartEndDateforLimitCheck", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType({
+ bookingLimits: { PER_DAY: 3 },
+ durationLimits: { PER_WEEK: 120 },
+ });
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ });
+
+ expect(mockBusyTimesService.getStartEndDateforLimitCheck).toHaveBeenCalledWith(
+ DATE_FROM_ISO,
+ DATE_TO_ISO,
+ { PER_DAY: 3 },
+ { PER_WEEK: 120 }
+ );
+ });
+
+ it("skips fetching limit busy times when eventType has no limits", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType();
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ });
+
+ expect(mockBusyTimesService.getStartEndDateforLimitCheck).not.toHaveBeenCalled();
+ expect(mockBusyTimesService.getBusyTimesForLimitChecks).not.toHaveBeenCalled();
+ });
+
+ it("skips fetching limit busy times when no eventType exists", async () => {
+ const user = createMockUser();
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ });
+
+ expect(mockBusyTimesService.getBusyTimesForLimitChecks).not.toHaveBeenCalled();
+ });
+
+ it("uses host user IDs for team events with booking limits", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType({
+ bookingLimits: { PER_DAY: 5 },
+ hosts: [
+ { user: { id: 1, email: "host1@test.com" }, schedule: null },
+ { user: { id: 2, email: "host2@test.com" }, schedule: null },
+ { user: { id: 3, email: "host3@test.com" }, schedule: null },
+ ],
+ });
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ });
+
+ expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
+ userIds: [1, 2, 3],
+ eventTypeId: 100,
+ startDate: LIMIT_DATE_FROM_FORMATTED,
+ endDate: LIMIT_DATE_TO_FORMATTED,
+ rescheduleUid: undefined,
+ bookingLimits: { PER_DAY: 5 },
+ durationLimits: null,
+ });
+ });
+
+ it("uses only the user ID for solo events with booking limits", async () => {
+ const user = createMockUser({ id: 42 });
+ const eventType = createMockEventType({
+ bookingLimits: { PER_DAY: 2 },
+ hosts: [],
+ });
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ });
+
+ expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
+ userIds: [42],
+ eventTypeId: 100,
+ startDate: LIMIT_DATE_FROM_FORMATTED,
+ endDate: LIMIT_DATE_TO_FORMATTED,
+ rescheduleUid: undefined,
+ bookingLimits: { PER_DAY: 2 },
+ durationLimits: null,
+ });
+ });
+
+ it("fetches eventType by eventTypeId when not provided in initialData", async () => {
+ const user = createMockUser();
+ const fetchedEventType = createMockEventType({ bookingLimits: { PER_DAY: 1 } });
+
+ vi.mocked(mockDependencies.eventTypeRepo.findByIdForUserAvailability).mockResolvedValueOnce(
+ fetchedEventType
+ );
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams({ eventTypeId: 100 }), {
+ user,
+ });
+
+ expect(mockDependencies.eventTypeRepo.findByIdForUserAvailability).toHaveBeenCalledWith({ id: 100 });
+ expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
+ userIds: [1],
+ eventTypeId: 100,
+ startDate: LIMIT_DATE_FROM_FORMATTED,
+ endDate: LIMIT_DATE_TO_FORMATTED,
+ rescheduleUid: undefined,
+ bookingLimits: { PER_DAY: 1 },
+ durationLimits: null,
+ });
+ });
+
+ it("passes fetched busyTimesFromLimitsBookings to _getUserAvailability", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
+ const fetchedBusyTimes: EventBusyDetails[] = [
+ {
+ start: "2025-01-06T09:00:00Z",
+ end: "2025-01-06T10:00:00Z",
+ source: "eventType-100-booking-1",
+ title: "Test Booking",
+ },
+ ];
+
+ mockBusyTimesService.getBusyTimesForLimitChecks.mockResolvedValueOnce(fetchedBusyTimes);
+
+ const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service);
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ });
+
+ expect(getUserAvailabilitySpy).toHaveBeenCalledWith(
+ createParams(),
+ expect.objectContaining({
+ user,
+ eventType,
+ busyTimesFromLimitsBookings: fetchedBusyTimes,
+ })
+ );
+ });
+
+ it("does not pass busyTimesFromLimitsBookings when no limits and no initialData bookings", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType(); // no limits
+
+ const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service);
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ });
+
+ // When there are no limits and no pre-existing busyTimesFromLimitsBookings,
+ // the property should not be spread into the call
+ const callArgs = getUserAvailabilitySpy.mock.calls[0];
+ expect(callArgs[1]).not.toHaveProperty("busyTimesFromLimitsBookings");
+ });
+
+ it("passes fetched eventType to _getUserAvailability to avoid duplicate DB query", async () => {
+ const user = createMockUser();
+ const fetchedEventType = createMockEventType({ bookingLimits: { PER_DAY: 1 } });
+
+ vi.mocked(mockDependencies.eventTypeRepo.findByIdForUserAvailability).mockResolvedValueOnce(
+ fetchedEventType
+ );
+
+ const getUserAvailabilitySpy = vi.spyOn(service, "_getUserAvailability" as keyof typeof service);
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams({ eventTypeId: 100 }), {
+ user,
+ });
+
+ expect(getUserAvailabilitySpy).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ eventType: fetchedEventType,
+ })
+ );
+ });
+
+ it("passes rescheduleUid to getBusyTimesForLimitChecks when provided", async () => {
+ const user = createMockUser();
+ const eventType = createMockEventType({ bookingLimits: { PER_DAY: 3 } });
+
+ await service.getUserAvailabilityIncludingBusyTimesFromLimits(createParams(), {
+ user,
+ eventType,
+ rescheduleUid: "reschedule-uid-123",
+ });
+
+ expect(mockBusyTimesService.getBusyTimesForLimitChecks).toHaveBeenCalledWith({
+ userIds: [1],
+ eventTypeId: 100,
+ startDate: LIMIT_DATE_FROM_FORMATTED,
+ endDate: LIMIT_DATE_TO_FORMATTED,
+ rescheduleUid: "reschedule-uid-123",
+ bookingLimits: { PER_DAY: 3 },
+ durationLimits: null,
+ });
+ });
+});
diff --git a/packages/features/busyTimes/lib/getBusyTimesFromLimits.ts b/packages/features/busyTimes/lib/getBusyTimesFromLimits.ts
index d0b16d783e..8320ae4fce 100644
--- a/packages/features/busyTimes/lib/getBusyTimesFromLimits.ts
+++ b/packages/features/busyTimes/lib/getBusyTimesFromLimits.ts
@@ -6,7 +6,7 @@ import { getCheckBookingLimitsService } from "@calcom/features/di/containers/Boo
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimits/intervalLimit";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
-import LimitManager from "@calcom/lib/intervalLimits/limitManager";
+import LimitManager, { LimitSources } from "@calcom/lib/intervalLimits/limitManager";
import { isBookingWithinPeriod } from "@calcom/lib/intervalLimits/utils";
import { getPeriodStartDatesBetween } from "@calcom/lib/intervalLimits/utils/getPeriodStartDatesBetween";
import { withReporting } from "@calcom/lib/sentryWrapper";
@@ -108,6 +108,10 @@ const _getBusyTimesFromBookingLimits = async (params: {
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
+ const { title, source } = teamId
+ ? LimitSources.teamBookingLimit({ limit, unit })
+ : LimitSources.eventBookingLimit({ limit, unit });
+
// special handling of yearly limits to improve performance
if (unit === "year") {
try {
@@ -124,7 +128,12 @@ const _getBusyTimesFromBookingLimits = async (params: {
timeZone,
});
} catch (_) {
- limitManager.addBusyTime(periodStart, unit);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ title,
+ source,
+ });
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
return;
}
@@ -142,7 +151,12 @@ const _getBusyTimesFromBookingLimits = async (params: {
}
totalBookings++;
if (totalBookings >= limit) {
- limitManager.addBusyTime(periodStart, unit);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ title,
+ source,
+ });
break;
}
}
@@ -172,8 +186,15 @@ const _getBusyTimesFromDurationLimits = async (
const selectedDuration = (duration || eventType.length) ?? 0;
+ const { title, source } = LimitSources.eventDurationLimit({ limit, unit });
+
if (selectedDuration > limit) {
- limitManager.addBusyTime(periodStart, unit);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ title,
+ source,
+ });
continue;
}
@@ -187,7 +208,12 @@ const _getBusyTimesFromDurationLimits = async (
rescheduleUid,
});
if (totalYearlyDuration + selectedDuration > limit) {
- limitManager.addBusyTime(periodStart, unit);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ title,
+ source,
+ });
if (periodStartDates.every((start) => limitManager.isAlreadyBusy(start, unit))) {
return;
}
@@ -205,7 +231,12 @@ const _getBusyTimesFromDurationLimits = async (
}
totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute");
if (totalDuration > limit) {
- limitManager.addBusyTime(periodStart, unit);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ title,
+ source,
+ });
break;
}
}
diff --git a/packages/features/busyTimes/services/getBusyTimes.ts b/packages/features/busyTimes/services/getBusyTimes.ts
index d3d442261b..3981466bbc 100644
--- a/packages/features/busyTimes/services/getBusyTimes.ts
+++ b/packages/features/busyTimes/services/getBusyTimes.ts
@@ -150,13 +150,17 @@ export class BusyTimesService {
if (minutesToBlockBeforeEvent) {
aggregate.push({
start: dayjs(startTime).subtract(minutesToBlockBeforeEvent, "minute").toDate(),
- end: dayjs(startTime).toDate(), // The event starts after the buffer
+ end: dayjs(startTime).toDate(),
+ title: "busy_time.buffer_time",
+ source: "Buffer Time for seated event (before)",
});
}
if (minutesToBlockAfterEvent) {
aggregate.push({
- start: dayjs(endTime).toDate(), // The event ends before the buffer
+ start: dayjs(endTime).toDate(),
end: dayjs(endTime).add(minutesToBlockAfterEvent, "minute").toDate(),
+ title: "busy_time.buffer_time",
+ source: "Buffer Time for seated event (after)",
});
}
return aggregate;
@@ -251,6 +255,7 @@ export class BusyTimesService {
...value,
end: dayjs(value.end),
start: dayjs(value.start),
+ source: value.source ?? "busy_time.calendar",
})),
openSeatsDateRanges
);
diff --git a/packages/features/calendars/weeklyview/components/event/EventList.tsx b/packages/features/calendars/weeklyview/components/event/EventList.tsx
index e173bd2857..d8785086c9 100644
--- a/packages/features/calendars/weeklyview/components/event/EventList.tsx
+++ b/packages/features/calendars/weeklyview/components/event/EventList.tsx
@@ -107,6 +107,7 @@ export function EventList({ day }: Props) {
)}
data-testid={event.options?.["data-test-id"]}
data-calendar-event-id={event.id}
+ data-calendar-event-source={event.source}
onMouseEnter={() => setHoveredEventId(event.id)}
onMouseLeave={() => setHoveredEventId(null)}
style={{
diff --git a/packages/features/schedules/lib/date-ranges.ts b/packages/features/schedules/lib/date-ranges.ts
index be3d6b566e..7fdc167b37 100644
--- a/packages/features/schedules/lib/date-ranges.ts
+++ b/packages/features/schedules/lib/date-ranges.ts
@@ -415,11 +415,16 @@ export function intersect(ranges: DateRange[][]): DateRange[] {
return commonAvailability.map(({ start, end }) => ({ start, end }));
}
-export function subtract(
- sourceRanges: (DateRange & { [x: string]: unknown })[],
- excludedRanges: DateRange[]
-) {
- const result = [];
+type SubtractedRange = Omit & {
+ start: TSourceRange["start"];
+ end: TSourceRange["end"];
+};
+
+export function subtract(
+ sourceRanges: TSourceRange[],
+ excludedRanges: TExcludedRange[]
+): SubtractedRange[] {
+ const result: SubtractedRange[] = [];
const sortedExcludedRanges = [...excludedRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf());
for (const { start: sourceStart, end: sourceEnd, ...passThrough } of sourceRanges) {
diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json
index a23c38047e..fc6ce420a4 100644
--- a/packages/i18n/locales/en/common.json
+++ b/packages/i18n/locales/en/common.json
@@ -4653,6 +4653,14 @@
"error_enabling_feature": "Error enabling feature. Please try again.",
"set_organizer_as_contact_owner": "Set booking organizer as contact owner",
"overwrite_existing_contact_owner": "Overwrite existing contact owner",
+ "busy_time": {
+ "busy": "Busy",
+ "event_booking_limit": "Event Booking Limit",
+ "event_duration_limit": "Event Duration Limit",
+ "team_booking_limit": "Team Booking Limit",
+ "buffer_time": "Buffer Time",
+ "calendar": "Calendar"
+ },
"invoice_number": "Invoice #",
"payment_method": "Payment Method",
"download_pdf": "Download PDF",
diff --git a/packages/lib/intervalLimits/limitManager.ts b/packages/lib/intervalLimits/limitManager.ts
index 7940184395..fa043ddac4 100644
--- a/packages/lib/intervalLimits/limitManager.ts
+++ b/packages/lib/intervalLimits/limitManager.ts
@@ -1,15 +1,35 @@
import type { Dayjs } from "@calcom/dayjs";
-import type { EventBusyDate } from "@calcom/types/Calendar";
-
+import type { EventBusyDetails } from "@calcom/types/Calendar";
import type { IntervalLimitUnit } from "./intervalLimitSchema";
type BusyMapKey = `${IntervalLimitUnit}-${ReturnType}`;
+/**
+ * Limit sources with both title and source information
+ * Returns user-facing title (translation key) and internal debug string
+ */
+export const LimitSources = {
+ eventBookingLimit: (params: { limit: number; unit: IntervalLimitUnit }) => ({
+ title: "busy_time.event_booking_limit",
+ source: `Event Booking Limit for User: ${params.limit} per ${params.unit}`,
+ }),
+
+ eventDurationLimit: (params: { limit: number; unit: IntervalLimitUnit }) => ({
+ title: "busy_time.event_duration_limit",
+ source: `Event Duration Limit for User: ${params.limit} minutes per ${params.unit}`,
+ }),
+
+ teamBookingLimit: (params: { limit: number; unit: IntervalLimitUnit }) => ({
+ title: "busy_time.team_booking_limit",
+ source: `Team Booking Limit: ${params.limit} per ${params.unit}`,
+ }),
+} as const;
+
/**
* Helps create, check, and return busy times from limits (with parallel support)
*/
export default class LimitManager {
- private busyMap: Map = new Map();
+ private busyMap: Map = new Map();
/**
* Creates a busy map key
@@ -48,13 +68,26 @@ export default class LimitManager {
}
/**
- * Adds a new busy time
+ * Adds a new busy time with title and source
+ * @param params.start - Start time of the busy period
+ * @param params.unit - Interval unit (day, week, month, year)
+ * @param params.timeZone - Optional timezone
+ * @param params.title - User-facing translation key (displayed in calendars)
+ * @param params.source - Internal debug string (for logging/troubleshooting)
*/
- addBusyTime(start: Dayjs, unit: IntervalLimitUnit, timeZone?: string) {
- const tzStart = timeZone ? start.tz(timeZone) : start;
- this.busyMap.set(`${unit}-${tzStart.toISOString()}`, {
+ addBusyTime(params: {
+ start: Dayjs;
+ unit: IntervalLimitUnit;
+ timeZone?: string;
+ title: string;
+ source: string;
+ }) {
+ const tzStart = params.timeZone ? params.start.tz(params.timeZone) : params.start;
+ this.busyMap.set(`${params.unit}-${tzStart.toISOString()}`, {
start: tzStart.toISOString(),
- end: tzStart.endOf(unit).toISOString(),
+ end: tzStart.endOf(params.unit).toISOString(),
+ title: params.title,
+ source: params.source,
});
}
diff --git a/packages/trpc/server/routers/viewer/availability/user.handler.ts b/packages/trpc/server/routers/viewer/availability/user.handler.ts
index 1daa0531a0..78ce3e9a96 100644
--- a/packages/trpc/server/routers/viewer/availability/user.handler.ts
+++ b/packages/trpc/server/routers/viewer/availability/user.handler.ts
@@ -1,10 +1,9 @@
+import { findUsersForAvailabilityCheck } from "@calcom/features/availability/lib/findUsersForAvailabilityCheck";
import { getUserAvailabilityService } from "@calcom/features/di/containers/GetUserAvailability";
-
+import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TUserInputSchema } from "./user.schema";
-import { findUsersForAvailabilityCheck } from "@calcom/features/availability/lib/findUsersForAvailabilityCheck";
-
type UserOptions = {
ctx: {
user: NonNullable;
@@ -23,7 +22,8 @@ function getUser(username: string) {
export const userHandler = async ({ input }: UserOptions) => {
const userAvailabilityService = getUserAvailabilityService();
const user = await getUser(input.username);
- return userAvailabilityService.getUserAvailability(
+ if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
+ return userAvailabilityService.getUserAvailabilityIncludingBusyTimesFromLimits(
{ returnDateOverrides: true, bypassBusyCalendarTimes: false, ...input },
{
user,
diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts
index 14fba5d1da..433672093b 100644
--- a/packages/trpc/server/routers/viewer/slots/util.ts
+++ b/packages/trpc/server/routers/viewer/slots/util.ts
@@ -37,7 +37,7 @@ import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/interva
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
-import LimitManager from "@calcom/lib/intervalLimits/limitManager";
+import LimitManager, { LimitSources } from "@calcom/lib/intervalLimits/limitManager";
import { isBookingWithinPeriod } from "@calcom/lib/intervalLimits/utils";
import {
BookingDateInPastError,
@@ -444,6 +444,8 @@ export class AvailableSlotsService {
const periodEnd = periodStart.endOf(unit);
let totalBookings = 0;
+ const { title, source } = LimitSources.eventBookingLimit({ limit, unit });
+
for (const booking of busyTimesFromLimitsBookings) {
if (!isBookingWithinPeriod(booking, periodStart, periodEnd, timeZone)) {
continue;
@@ -451,7 +453,13 @@ export class AvailableSlotsService {
totalBookings++;
if (totalBookings >= limit) {
- globalLimitManager.addBusyTime(periodStart, unit, timeZone);
+ globalLimitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ timeZone,
+ title,
+ source,
+ });
break;
}
}
@@ -503,6 +511,8 @@ export class AvailableSlotsService {
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue;
+ const { title, source } = LimitSources.eventBookingLimit({ limit, unit });
+
if (unit === "year") {
try {
await this.dependencies.checkBookingLimitsService.checkBookingLimit({
@@ -515,7 +525,13 @@ export class AvailableSlotsService {
timeZone,
});
} catch {
- limitManager.addBusyTime(periodStart, unit, timeZone);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ timeZone,
+ title,
+ source,
+ });
if (
periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone))
) {
@@ -535,7 +551,13 @@ export class AvailableSlotsService {
totalBookings++;
if (totalBookings >= limit) {
- limitManager.addBusyTime(periodStart, unit, timeZone);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ timeZone,
+ title,
+ source,
+ });
break;
}
}
@@ -561,8 +583,16 @@ export class AvailableSlotsService {
const selectedDuration = (duration || eventType.length) ?? 0;
+ const { title, source } = LimitSources.eventDurationLimit({ limit, unit });
+
if (selectedDuration > limit) {
- limitManager.addBusyTime(periodStart, unit, timeZone);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ timeZone,
+ title,
+ source,
+ });
continue;
}
@@ -581,7 +611,13 @@ export class AvailableSlotsService {
}
const totalYearlyDuration = yearlyDurationTotals.get(yearKey) ?? 0;
if (totalYearlyDuration + selectedDuration > limit) {
- limitManager.addBusyTime(periodStart, unit, timeZone);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ timeZone,
+ title,
+ source,
+ });
if (
periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone))
) {
@@ -600,7 +636,13 @@ export class AvailableSlotsService {
}
totalDuration += dayjs(booking.end).diff(dayjs(booking.start), "minute");
if (totalDuration > limit) {
- limitManager.addBusyTime(periodStart, unit, timeZone);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ timeZone,
+ title,
+ source,
+ });
break;
}
}
@@ -673,6 +715,8 @@ export class AvailableSlotsService {
const periodEnd = periodStart.endOf(unit);
let totalBookings = 0;
+ const { title, source } = LimitSources.teamBookingLimit({ limit, unit });
+
for (const booking of busyTimes) {
if (!isBookingWithinPeriod(booking, periodStart, periodEnd, timeZone)) {
continue;
@@ -680,7 +724,13 @@ export class AvailableSlotsService {
totalBookings++;
if (totalBookings >= limit) {
- globalLimitManager.addBusyTime(periodStart, unit, timeZone);
+ globalLimitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ timeZone,
+ title,
+ source,
+ });
break;
}
}
@@ -710,6 +760,8 @@ export class AvailableSlotsService {
for (const periodStart of periodStartDates) {
if (limitManager.isAlreadyBusy(periodStart, unit, timeZone)) continue;
+ const { title, source } = LimitSources.teamBookingLimit({ limit, unit });
+
if (unit === "year") {
try {
await this.dependencies.checkBookingLimitsService.checkBookingLimit({
@@ -723,7 +775,13 @@ export class AvailableSlotsService {
timeZone,
});
} catch {
- limitManager.addBusyTime(periodStart, unit, timeZone);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ timeZone,
+ title,
+ source,
+ });
if (
periodStartDates.every((start: Dayjs) => limitManager.isAlreadyBusy(start, unit, timeZone))
) {
@@ -743,7 +801,13 @@ export class AvailableSlotsService {
totalBookings++;
if (totalBookings >= limit) {
- limitManager.addBusyTime(periodStart, unit, timeZone);
+ limitManager.addBusyTime({
+ start: periodStart,
+ unit,
+ timeZone,
+ title,
+ source,
+ });
break;
}
}
diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts
index 8438c5c36d..eb9ac6db1b 100644
--- a/packages/types/Calendar.d.ts
+++ b/packages/types/Calendar.d.ts
@@ -62,7 +62,7 @@ export type EventBusyDate = {
export type EventBusyDetails = EventBusyDate & {
title?: string;
- source?: string | null;
+ source: string;
userId?: number | null;
};