Skip to content

Commit b1cbeeb

Browse files
authored
feat: Add option to include no shows in RR calculations (calcom#21063)
* Show distribution option for RR scheduling type * Add `includeNoShowInRRCalculation` to event type schema * Add `includeNoShowInRRCalculation` initial event type data * Add FE option for `includeNoShowInRRCalculation` * Consider `includeNoShowInRRCalculation` when building `where` clause when getting bookings for RR * Add `includeNoShowInRRCalculation` param to `getAllBookingsForRoundRobin` * Add `includeNoShow...` param to `GetLuckyUserParams` * Pass `includeNoShow...` param to `getBookingsOfInterval` calls * Add FE option for `includeNoShow...` (actual commit) * Type fixes * Fix brackets * Add booking repository test * Remove unused method * Add translation * Type fix * Type fix * Test passing * Fix tests * Fix tests * Fix test * Type fixes
1 parent 93bad28 commit b1cbeeb

16 files changed

Lines changed: 304 additions & 83 deletions

File tree

apps/web/public/static/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3189,6 +3189,7 @@
31893189
"routing_form_next_in_queue": "{{count}} next in queue",
31903190
"routing_form_select_members_to_email": "Send email responses to",
31913191
"routing_incomplete_booking_tab": "Incomplete Bookings",
3192+
"include_no_show_in_rr_calculation": "Include no show bookings in round robin calculations",
31923193
"matching": "Matching",
31933194
"event_redirect": "Event Redirect",
31943195
"reset_form": "Reset Form",

apps/web/test/lib/handleChildrenEventTypes.test.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ describe("handleChildrenEventTypes", () => {
114114
useEventTypeDestinationCalendarEmail,
115115
secondaryEmailId,
116116
autoTranslateDescriptionEnabled,
117+
includeNoShowInRRCalculation,
118+
instantMeetingScheduleId,
117119
...evType
118120
} = mockFindFirstEventType({
119121
id: 123,
@@ -137,7 +139,6 @@ describe("handleChildrenEventTypes", () => {
137139
users: { connect: [{ id: 4 }] },
138140
lockTimeZoneToggleOnBookingPage: false,
139141
requiresBookerEmailVerification: false,
140-
instantMeetingScheduleId: undefined,
141142
bookingLimits: undefined,
142143
durationLimits: undefined,
143144
recurringEvent: undefined,
@@ -170,6 +171,9 @@ describe("handleChildrenEventTypes", () => {
170171
lockTimeZoneToggleOnBookingPage,
171172
useEventTypeDestinationCalendarEmail,
172173
secondaryEmailId,
174+
assignRRMembersUsingSegment,
175+
includeNoShowInRRCalculation,
176+
instantMeetingScheduleId,
173177
...evType
174178
} = mockFindFirstEventType({
175179
metadata: { managedEventConfig: {} },
@@ -191,7 +195,6 @@ describe("handleChildrenEventTypes", () => {
191195
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
192196
data: {
193197
...rest,
194-
assignRRMembersUsingSegment: undefined,
195198
useEventLevelSelectedCalendars: undefined,
196199
customReplyToEmail: null,
197200
rrSegmentQueryValue: undefined,
@@ -275,6 +278,9 @@ describe("handleChildrenEventTypes", () => {
275278
useEventTypeDestinationCalendarEmail,
276279
secondaryEmailId,
277280
autoTranslateDescriptionEnabled,
281+
includeNoShowInRRCalculation,
282+
instantMeetingScheduleId,
283+
assignRRMembersUsingSegment,
278284
...evType
279285
} = mockFindFirstEventType({
280286
id: 123,
@@ -307,7 +313,6 @@ describe("handleChildrenEventTypes", () => {
307313
requiresBookerEmailVerification: false,
308314
userId: 4,
309315
workflows: undefined,
310-
hashedLink: undefined,
311316
rrSegmentQueryValue: undefined,
312317
assignRRMembersUsingSegment: false,
313318
},
@@ -332,6 +337,11 @@ describe("handleChildrenEventTypes", () => {
332337
lockTimeZoneToggleOnBookingPage,
333338
useEventTypeDestinationCalendarEmail,
334339
secondaryEmailId,
340+
includeNoShowInRRCalculation,
341+
instantMeetingScheduleId,
342+
assignRRMembersUsingSegment,
343+
rrSegmentQueryValue,
344+
useEventLevelSelectedCalendars,
335345
...evType
336346
} = mockFindFirstEventType({
337347
metadata: { managedEventConfig: {} },
@@ -354,17 +364,13 @@ describe("handleChildrenEventTypes", () => {
354364
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
355365
data: {
356366
...rest,
357-
assignRRMembersUsingSegment: undefined,
358-
useEventLevelSelectedCalendars: undefined,
359-
rrSegmentQueryValue: undefined,
360367
customReplyToEmail: null,
361368
locations: [],
362369
hashedLink: {
363370
deleteMany: {},
364371
},
365372
lockTimeZoneToggleOnBookingPage: false,
366373
requiresBookerEmailVerification: false,
367-
instantMeetingScheduleId: undefined,
368374
},
369375
where: {
370376
userId_parentId: {
@@ -396,6 +402,9 @@ describe("handleChildrenEventTypes", () => {
396402
useEventTypeDestinationCalendarEmail,
397403
secondaryEmailId,
398404
autoTranslateDescriptionEnabled,
405+
includeNoShowInRRCalculation,
406+
instantMeetingScheduleId,
407+
assignRRMembersUsingSegment,
399408
...evType
400409
} = mockFindFirstEventType({
401410
metadata: { managedEventConfig: {} },
@@ -418,10 +427,14 @@ describe("handleChildrenEventTypes", () => {
418427
schedulingType: SchedulingType.MANAGED,
419428
requiresBookerEmailVerification: false,
420429
lockTimeZoneToggleOnBookingPage: false,
430+
lockedTimeZone: "Europe/London",
421431
useEventTypeDestinationCalendarEmail: false,
422432
workflows: [],
423433
parentId: 1,
424434
locations: [],
435+
instantMeetingScheduleId: null,
436+
assignRRMembersUsingSegment: false,
437+
includeNoShowInRRCalculation: false,
425438
...evType,
426439
};
427440

@@ -449,7 +462,6 @@ describe("handleChildrenEventTypes", () => {
449462
recurringEvent: undefined,
450463
eventTypeColor: undefined,
451464
customReplyToEmail: null,
452-
instantMeetingScheduleId: undefined,
453465
locations: [],
454466
lockTimeZoneToggleOnBookingPage: false,
455467
requiresBookerEmailVerification: false,
@@ -461,28 +473,24 @@ describe("handleChildrenEventTypes", () => {
461473
workflows: {
462474
create: [{ workflowId: 11 }],
463475
},
464-
hashedLink: undefined,
465476
rrSegmentQueryValue: undefined,
466477
assignRRMembersUsingSegment: false,
467478
useEventLevelSelectedCalendars: false,
468479
},
469480
});
470-
const { profileId, ...rest } = evType;
481+
const { profileId, rrSegmentQueryValue, ...rest } = evType;
471482
if ("workflows" in rest) delete rest.workflows;
472483
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
473484
data: {
474485
...rest,
475486
locations: [],
476-
assignRRMembersUsingSegment: undefined,
477487
useEventLevelSelectedCalendars: undefined,
478488
customReplyToEmail: null,
479-
rrSegmentQueryValue: undefined,
480489
lockTimeZoneToggleOnBookingPage: false,
481490
requiresBookerEmailVerification: false,
482491
hashedLink: {
483492
deleteMany: {},
484493
},
485-
instantMeetingScheduleId: undefined,
486494
},
487495
where: {
488496
userId_parentId: {

packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
6262
requiresConfirmationForFreeEmail: true,
6363
requiresBookerEmailVerification: true,
6464
maxLeadThreshold: true,
65+
includeNoShowInRRCalculation: true,
6566
minimumBookingNotice: true,
6667
userId: true,
6768
price: true,

packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type EventType = Pick<
5050
| "isRRWeightsEnabled"
5151
| "rescheduleWithSameRoundRobinHost"
5252
| "teamId"
53+
| "includeNoShowInRRCalculation"
5354
>;
5455

5556
type InputProps = {

packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx

Lines changed: 60 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ export const EventTeamAssignmentTab = ({
621621
);
622622
});
623623
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
624-
const { getValues, setValue } = useFormContext<FormValues>();
624+
const { getValues, setValue, control } = useFormContext<FormValues>();
625625
const [assignAllTeamMembers, setAssignAllTeamMembers] = useState<boolean>(
626626
getValues("assignAllTeamMembers") ?? false
627627
);
@@ -632,6 +632,11 @@ export const EventTeamAssignmentTab = ({
632632
setAssignAllTeamMembers(false);
633633
};
634634

635+
const schedulingType = useWatch({
636+
control,
637+
name: "schedulingType",
638+
});
639+
635640
return (
636641
<div>
637642
{team && !isManagedEventType && (
@@ -682,44 +687,61 @@ export const EventTeamAssignmentTab = ({
682687
/>
683688
</div>
684689
</div>
685-
<div className="border-subtle mt-4 flex flex-col rounded-md">
686-
<div className="border-subtle rounded-t-md border p-6 pb-5">
687-
<Label className="mb-1 text-sm font-semibold">{t("rr_distribution_method")}</Label>
688-
<p className="text-subtle max-w-full break-words text-sm leading-tight">
689-
{t("rr_distribution_method_description")}
690-
</p>
691-
</div>
692-
<div className="border-subtle rounded-b-md border border-t-0 p-6">
693-
<Controller
694-
name="maxLeadThreshold"
695-
render={({ field: { value, onChange } }) => (
696-
<RadioArea.Group
697-
onValueChange={(val) => {
698-
if (val === "loadBalancing") onChange(3);
699-
else onChange(null);
700-
}}
701-
className="mt-1 flex flex-col gap-4">
702-
<RadioArea.Item
703-
value="maximizeAvailability"
704-
checked={value === null}
705-
className="w-full text-sm"
706-
classNames={{ container: "w-full" }}>
707-
<strong className="mb-1 block">{t("rr_distribution_method_availability_title")}</strong>
708-
<p>{t("rr_distribution_method_availability_description")}</p>
709-
</RadioArea.Item>
710-
<RadioArea.Item
711-
value="loadBalancing"
712-
checked={value !== null}
713-
className="text-sm"
714-
classNames={{ container: "w-full" }}>
715-
<strong className="mb-1 block">{t("rr_distribution_method_balanced_title")}</strong>
716-
<p>{t("rr_distribution_method_balanced_description")}</p>
717-
</RadioArea.Item>
718-
</RadioArea.Group>
719-
)}
720-
/>
690+
{schedulingType === "ROUND_ROBIN" && (
691+
<div className="border-subtle mt-4 flex flex-col rounded-md">
692+
<div className="border-subtle rounded-t-md border p-6 pb-5">
693+
<Label className="mb-1 text-sm font-semibold">{t("rr_distribution_method")}</Label>
694+
<p className="text-subtle max-w-full break-words text-sm leading-tight">
695+
{t("rr_distribution_method_description")}
696+
</p>
697+
</div>
698+
<div className="border-subtle rounded-b-md border border-t-0 p-6">
699+
<Controller
700+
name="maxLeadThreshold"
701+
render={({ field: { value, onChange } }) => (
702+
<RadioArea.Group
703+
onValueChange={(val) => {
704+
if (val === "loadBalancing") onChange(3);
705+
else onChange(null);
706+
}}
707+
className="mt-1 flex flex-col gap-4">
708+
<RadioArea.Item
709+
value="maximizeAvailability"
710+
checked={value === null}
711+
className="w-full text-sm"
712+
classNames={{ container: "w-full" }}>
713+
<strong className="mb-1 block">
714+
{t("rr_distribution_method_availability_title")}
715+
</strong>
716+
<p>{t("rr_distribution_method_availability_description")}</p>
717+
</RadioArea.Item>
718+
<RadioArea.Item
719+
value="loadBalancing"
720+
checked={value !== null}
721+
className="text-sm"
722+
classNames={{ container: "w-full" }}>
723+
<strong className="mb-1 block">{t("rr_distribution_method_balanced_title")}</strong>
724+
<p>{t("rr_distribution_method_balanced_description")}</p>
725+
</RadioArea.Item>
726+
</RadioArea.Group>
727+
)}
728+
/>
729+
<div className="mt-4">
730+
<Controller
731+
name="includeNoShowInRRCalculation"
732+
render={({ field: { value, onChange } }) => (
733+
<SettingsToggle
734+
title={t("include_no_show_in_rr_calculation")}
735+
labelClassName="mt-1.5"
736+
checked={value}
737+
onCheckedChange={(val) => onChange(val)}
738+
/>
739+
)}
740+
/>
741+
</div>
742+
</div>
721743
</div>
722-
</div>
744+
)}
723745
<Hosts
724746
orgId={orgId}
725747
isSegmentApplicable={isSegmentApplicable}

packages/lib/bookings/filterHostsByLeadThreshold.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export const filterHostsByLeadThreshold = async <T extends BaseHost<BaseUser>>({
9898
parentId?: number | null;
9999
rrResetInterval: RRResetInterval | null;
100100
} | null;
101+
includeNoShowInRRCalculation: boolean;
101102
};
102103
routingFormResponse: RoutingFormResponse | null;
103104
}) => {

packages/lib/bookings/findQualifiedHostsWithDelegationCredentials.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const findQualifiedHostsWithDelegationCredentials = async <
6868
schedulingType: SchedulingType | null;
6969
isRRWeightsEnabled: boolean;
7070
rescheduleWithSameRoundRobinHost: boolean;
71+
includeNoShowInRRCalculation: boolean;
7172
} & EventType;
7273
rescheduleUid: string | null;
7374
routedTeamMemberIds: number[];

packages/lib/defaultEvents.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ const commons = {
127127
autoTranslateDescriptionEnabled: false,
128128
fieldTranslations: [],
129129
maxLeadThreshold: null,
130+
includeNoShowInRRCalculation: false,
130131
useEventLevelSelectedCalendars: false,
131132
rrResetInterval: null,
132133
customReplyToEmail: null,

packages/lib/server/getLuckyUser.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ interface GetLuckyUserParams<T extends PartialUser> {
6767
id: number;
6868
isRRWeightsEnabled: boolean;
6969
team: { parentId?: number | null; rrResetInterval: RRResetInterval | null } | null;
70+
includeNoShowInRRCalculation: boolean;
7071
};
7172
// all routedTeamMemberIds or all hosts of event types
7273
allRRHosts: {
@@ -423,18 +424,21 @@ async function getBookingsOfInterval({
423424
users,
424425
virtualQueuesData,
425426
interval,
427+
includeNoShowInRRCalculation,
426428
}: {
427429
eventTypeId: number;
428430
users: { id: number; email: string }[];
429431
virtualQueuesData: VirtualQueuesDataType | null;
430432
interval: RRResetInterval;
433+
includeNoShowInRRCalculation: boolean;
431434
}) {
432435
return await BookingRepository.getAllBookingsForRoundRobin({
433436
eventTypeId: eventTypeId,
434437
users,
435438
startDate: getIntervalStartDate(interval),
436439
endDate: new Date(),
437440
virtualQueuesData,
441+
includeNoShowInRRCalculation,
438442
});
439443
}
440444

@@ -611,13 +615,15 @@ async function fetchAllDataNeededForCalculations<
611615
}),
612616
virtualQueuesData: virtualQueuesData ?? null,
613617
interval,
618+
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
614619
}),
615620

616621
getBookingsOfInterval({
617622
eventTypeId: eventType.id,
618623
users: notAvailableHosts,
619624
virtualQueuesData: virtualQueuesData ?? null,
620625
interval,
626+
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
621627
}),
622628

623629
getBookingsOfInterval({
@@ -627,6 +633,7 @@ async function fetchAllDataNeededForCalculations<
627633
}),
628634
virtualQueuesData: virtualQueuesData ?? null,
629635
interval,
636+
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
630637
}),
631638

632639
prisma.host.findMany({

0 commit comments

Comments
 (0)