Skip to content

Commit a4621da

Browse files
hariombalharadevin-ai-integration[bot]Udit-takkarbot_apk
authored
feat: make source required on EventBusyDetails for Troubleshooter display (calcom#27088)
* feat: make source required on EventBusyDetails for Troubleshooter display - Make source a required property on EventBusyDetails type - Update LimitManager to accept and store source when adding busy times - Add user-friendly source names for all busy time types: - 'Booking Limit' for booking limit busy times - 'Duration Limit' for duration limit busy times - 'Team Booking Limit' for team booking limit busy times - 'Buffer Time' for seated event buffer times - 'Calendar' for external calendar busy times - Ensure all entries in detailedBusyTimes have source set - Cover includeManagedEventsInLimits and teamBookingLimits branches Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * feat: add limit value and unit metadata to busy time sources - Include limit value and unit in source strings for Troubleshooter display - Booking Limit: shows as 'Booking Limit: 5 per day' - Duration Limit: shows as 'Duration Limit: 120 min per week' - Team Booking Limit: shows as 'Team Booking Limit: 10 per month' - Preserves existing calendar sources (e.g., 'google-calendar') Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * feat: enhance busy time management with new limit sources - Introduced new limit sources for busy times, including event booking and duration limits, with user-friendly titles for display. - Updated LimitManager to accept and store detailed busy time information, including title and source. - Refactored busy time addition logic across various services to utilize the new structure, improving clarity and maintainability. * fixes * feat: conditionally include source and translate busy time titles - Add withSource parameter to conditionally include/exclude source from response - Translate busy time titles on frontend using useLocale hook - Source is only included when withSource=true (for Troubleshooter display) Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * feat: enhance user availability service and busy time handling - Updated LargeCalendar component to include event ID check for enabling busy times. - Added translation for "busy" in common.json for better user experience. - Refactored getUserAvailability service to include new method for fetching user availability with busy times from limits. - Introduced parseLimits function to streamline booking and duration limit parsing. - Improved error handling in user handler for better user feedback. * refactor: remove unnecessary timeZone: undefined from addBusyTime calls Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * feat: add buffer_time and calendar translation keys for busy times Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * feat: add event source to calendar components and improve busy time handling - Updated EventList component to include event source in data attributes for better tracking. - Enhanced LargeCalendar component to pass event * fix: add missing source property to Date Override calendar event Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: use 'date-override' as source for Date Override calendar events Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * Avoid type assertion * fix: pass both bookingLimits and durationLimits to getStartEndDateforLimitCheck (calcom#27898) * test: add unit tests for getUserAvailabilityIncludingBusyTimesFromLimits Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: pass both bookingLimits and durationLimits to getStartEndDateforLimitCheck - Fix bug where bookingLimits || durationLimits was passed as single param - Skip getBusyTimesForLimitChecks when eventType has no limits - Remove as never casts, use proper typing for mock dependencies - Replace expect.any(String) with exact ISO date assertions Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: replace loose assertions with exact values in tests Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: address review feedback on busy time sources - Fix t("busy") fallback to t("busy_time.busy") for correct translation lookup - Add missing title property to buffer time entries for Troubleshooter display - Use descriptive debug strings for buffer time source field - Fix import ordering in LargeCalendar.tsx Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-Authored-By: unknown <> * fix: preserve pre-existing busyTimesFromLimitsBookings from initialData Address review feedback from @hariombalhara (comment #30, #31): - Initialize busyTimesFromLimitsBookings from initialData instead of [] to avoid silently overwriting pre-existing data with an empty array - Use conditional spread to only include busyTimesFromLimitsBookings when it has a value - Add test verifying pre-existing busyTimesFromLimitsBookings is preserved and passed through to _getUserAvailability - Add test verifying busyTimesFromLimitsBookings is not passed when there are no limits and no initialData bookings Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-Authored-By: bot_apk <apk@cognition.ai> * fix: pass fetched eventType to _getUserAvailability to avoid duplicate DB query Addresses Devin Review comment r2863593564: the wrapper method fetches eventType but wasn't passing it through, causing _getUserAvailability to re-fetch the same eventType from the database. Also adds a test verifying eventType is forwarded correctly. Co-Authored-By: bot_apk <apk@cognition.ai> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: bot_apk <apk@cognition.ai>
1 parent 3e87491 commit a4621da

12 files changed

Lines changed: 722 additions & 58 deletions

File tree

apps/web/modules/troubleshooter/components/LargeCalendar.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useAvailableTimeSlots } from "@calcom/features/bookings/Booker/hooks/us
66
import { useTimePreferences } from "@calcom/features/bookings/lib/timePreferences";
77
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
88
import { useTroubleshooterStore } from "@calcom/features/troubleshooter/store";
9+
import { useLocale } from "@calcom/lib/hooks/useLocale";
910
import { BookingStatus } from "@calcom/prisma/enums";
1011
import { trpc } from "@calcom/trpc/react";
1112

@@ -14,6 +15,7 @@ import { useSchedule } from "~/schedules/hooks/useSchedule";
1415
import { OutOfOfficeInSlots } from "../../bookings/components/OutOfOfficeInSlots";
1516

1617
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
18+
const { t } = useLocale();
1719
const { timezone } = useTimePreferences();
1820
const selectedDate = useTroubleshooterStore((state) => state.selectedDate);
1921
const event = useTroubleshooterStore((state) => state.event);
@@ -34,7 +36,8 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
3436
withSource: true,
3537
},
3638
{
37-
enabled: !!session?.user?.username,
39+
// Busy Times need eventTypeId to correctly have all busy times. event.id check here also avoids sending double requests for availability.user
40+
enabled: !!session?.user?.username && !!event?.id,
3841
}
3942
);
4043

@@ -68,11 +71,13 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
6871
// .toDate(),
6972

7073
const calendarEvents = busyEvents?.busy.map((event, idx) => {
74+
const translatedTitle = event.title ? t(event.title) : t("busy_time.busy");
7175
return {
7276
id: idx,
73-
title: event.title ?? `Busy`,
77+
title: translatedTitle,
7478
start: new Date(event.start),
7579
end: new Date(event.end),
80+
source: event.source,
7681
options: {
7782
color:
7883
event.source && calendarToColorMap[event.source] ? calendarToColorMap[event.source] : undefined,
@@ -104,6 +109,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
104109
title: "Date Override",
105110
start: dateOverrideStart.add(workingHoursForDay.startTime, "minutes").toDate(),
106111
end: dateOverrideEnd.add(workingHoursForDay.endTime, "minutes").toDate(),
112+
source: "date-override",
107113
options: {
108114
color: "black",
109115
status: BookingStatus.ACCEPTED,
@@ -113,7 +119,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
113119
});
114120
}
115121
return calendarEvents;
116-
}, [busyEvents, calendarToColorMap]);
122+
}, [busyEvents, calendarToColorMap, t]);
117123

118124
return (
119125
<div className="h-full [--calendar-dates-sticky-offset:66px]">

packages/features/availability/lib/getUserAvailability.ts

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
4040
import type { CalendarFetchMode, EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar";
4141
import type { CredentialForCalendarService } from "@calcom/types/Credential";
4242
import type { TimeRange, WorkingHours as WorkingHoursWithUserId } from "@calcom/types/schedule";
43+
import type { Ensure, Optional } from "@calcom/types/utils";
4344
import * as Sentry from "@sentry/nextjs";
4445
import { z } from "zod";
4546
import { detectEventTypeScheduleForUser } from "./detectEventTypeScheduleForUser";
@@ -167,8 +168,10 @@ export type CurrentSeats = Awaited<
167168
ReturnType<(typeof UserAvailabilityService)["prototype"]["_getCurrentSeats"]>
168169
>;
169170

171+
type UserAvailabilityBusyDetails = Optional<EventBusyDetails, "source">;
172+
170173
export type GetUserAvailabilityResult = {
171-
busy: EventBusyDetails[];
174+
busy: UserAvailabilityBusyDetails[];
172175
timeZone: string;
173176
dateRanges: {
174177
start: dayjs.Dayjs;
@@ -338,6 +341,22 @@ export class UserAvailabilityService {
338341

339342
getCurrentSeats = withReporting(this._getCurrentSeats.bind(this), "getCurrentSeats");
340343

344+
private async parseLimits(eventType: { durationLimits?: unknown; bookingLimits?: unknown } | null) {
345+
const bookingLimits =
346+
eventType?.bookingLimits &&
347+
typeof eventType.bookingLimits === "object" &&
348+
Object.keys(eventType.bookingLimits).length > 0
349+
? parseBookingLimit(eventType.bookingLimits)
350+
: null;
351+
const durationLimits =
352+
eventType?.durationLimits &&
353+
typeof eventType.durationLimits === "object" &&
354+
Object.keys(eventType.durationLimits).length > 0
355+
? parseDurationLimit(eventType.durationLimits)
356+
: null;
357+
return { bookingLimits, durationLimits };
358+
}
359+
341360
/** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */
342361
async _getUserAvailability(
343362
params: GetUserAvailabilityParams,
@@ -356,6 +375,7 @@ export class UserAvailabilityService {
356375
bypassBusyCalendarTimes = false,
357376
silentlyHandleCalendarFailures = false,
358377
mode = "none",
378+
withSource = false,
359379
} = params;
360380

361381
log.debug(
@@ -371,7 +391,6 @@ export class UserAvailabilityService {
371391

372392
let eventType: EventType | null = initialData?.eventType || null;
373393
if (!eventType && eventTypeId) eventType = await this.getEventType(eventTypeId);
374-
375394
/* 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
376395
current bookings with a seats event type and display them on the calendar, even if they are full */
377396
let currentSeats: CurrentSeats | null = initialData?.currentSeats || null;
@@ -510,19 +529,7 @@ export class UserAvailabilityService {
510529
datesOutOfOffice: undefined,
511530
};
512531

513-
const bookingLimits =
514-
eventType?.bookingLimits &&
515-
typeof eventType.bookingLimits === "object" &&
516-
Object.keys(eventType.bookingLimits).length > 0
517-
? parseBookingLimit(eventType.bookingLimits)
518-
: null;
519-
520-
const durationLimits =
521-
eventType?.durationLimits &&
522-
typeof eventType.durationLimits === "object" &&
523-
Object.keys(eventType.durationLimits).length > 0
524-
? parseDurationLimit(eventType.durationLimits)
525-
: null;
532+
const { bookingLimits, durationLimits } = await this.parseLimits(eventType);
526533

527534
// TODO: only query what we need after applying limits (shrink date range)
528535
const getBusyTimesStart = dateFrom.toISOString();
@@ -533,11 +540,9 @@ export class UserAvailabilityService {
533540
: user.userLevelSelectedCalendars;
534541

535542
let busyTimesFromLimits: EventBusyDetails[] = [];
536-
537543
if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) {
538544
busyTimesFromLimits = initialData.busyTimesFromLimits.get(user.id) || [];
539545
} else if (eventType && (bookingLimits || durationLimits)) {
540-
// Fall back to individual query if not available in initialData
541546
busyTimesFromLimits = await getBusyTimesFromLimits(
542547
bookingLimits,
543548
durationLimits,
@@ -612,18 +617,22 @@ export class UserAvailabilityService {
612617
};
613618
}
614619

615-
const detailedBusyTimes: EventBusyDetails[] = [
620+
const detailedBusyTimesWithSource: EventBusyDetails[] = [
616621
...busyTimes.map((a) => ({
617622
...a,
618623
start: dayjs(a.start).toISOString(),
619624
end: dayjs(a.end).toISOString(),
620625
title: a.title,
621-
source: params.withSource ? a.source : undefined,
626+
source: a.source,
622627
})),
623628
...busyTimesFromLimits,
624629
...busyTimesFromTeamLimits,
625630
];
626631

632+
const detailedBusyTimes: UserAvailabilityBusyDetails[] = withSource
633+
? detailedBusyTimesWithSource
634+
: detailedBusyTimesWithSource.map(({ source, ...rest }) => rest);
635+
627636
log.debug(
628637
`EventType: ${eventTypeId} | User: ${username} (ID: ${userId}) - usingSchedule: ${safeStringify({
629638
chosenSchedule: schedule,
@@ -657,6 +666,56 @@ export class UserAvailabilityService {
657666
return result;
658667
}
659668

669+
/**
670+
* This fn mainly uses _getUserAvailability but sends busy times from limits too to _getUserAvailability as expected by _getUserAvailability.
671+
*/
672+
async getUserAvailabilityIncludingBusyTimesFromLimits(
673+
params: GetUserAvailabilityParams,
674+
initialData: Ensure<GetUserAvailabilityInitialData, "user">
675+
): Promise<GetUserAvailabilityResult> {
676+
const { user } = initialData || {};
677+
const { dateFrom, dateTo, eventTypeId } = params;
678+
let eventType: EventType | null = initialData?.eventType || null;
679+
if (!eventType && eventTypeId) eventType = await this.getEventType(eventTypeId);
680+
681+
const { bookingLimits, durationLimits } = await this.parseLimits(eventType);
682+
683+
let busyTimesFromLimitsBookings: EventBusyDetails[] | undefined =
684+
initialData?.busyTimesFromLimitsBookings;
685+
if (!busyTimesFromLimitsBookings && eventType && (bookingLimits || durationLimits)) {
686+
const busyTimesService = getBusyTimesService();
687+
const { limitDateFrom, limitDateTo } = busyTimesService.getStartEndDateforLimitCheck(
688+
dateFrom.toISOString(),
689+
dateTo.toISOString(),
690+
bookingLimits,
691+
durationLimits
692+
);
693+
694+
// For team events with booking/duration limits, fetch bookings for all team members
695+
// to accurately calculate if limits are reached for the event
696+
const userIdsForLimitCheck =
697+
eventType.hosts && eventType.hosts.length > 0
698+
? eventType.hosts.map((host) => host.user.id)
699+
: [user.id];
700+
701+
busyTimesFromLimitsBookings = await busyTimesService.getBusyTimesForLimitChecks({
702+
userIds: userIdsForLimitCheck,
703+
eventTypeId: eventType.id,
704+
startDate: limitDateFrom.format(),
705+
endDate: limitDateTo.format(),
706+
rescheduleUid: initialData?.rescheduleUid ?? undefined,
707+
bookingLimits,
708+
durationLimits,
709+
});
710+
}
711+
const result = await this._getUserAvailability(params, {
712+
...initialData,
713+
...(eventType ? { eventType } : {}),
714+
...(busyTimesFromLimitsBookings ? { busyTimesFromLimitsBookings } : {}),
715+
});
716+
return result;
717+
}
718+
660719
getUserAvailability = withReporting(this._getUserAvailability.bind(this), "getUserAvailability");
661720

662721
getPeriodStartDatesBetween = withReporting(

0 commit comments

Comments
 (0)