feat: Add option to include no shows in RR calculations (#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
This commit is contained in:
Joe Au-Yeung
2025-05-09 16:29:57 +00:00
committed by GitHub
parent 93bad282d7
commit b1cbeebe48
16 changed files with 304 additions and 83 deletions
@@ -3189,6 +3189,7 @@
"routing_form_next_in_queue": "{{count}} next in queue",
"routing_form_select_members_to_email": "Send email responses to",
"routing_incomplete_booking_tab": "Incomplete Bookings",
"include_no_show_in_rr_calculation": "Include no show bookings in round robin calculations",
"matching": "Matching",
"event_redirect": "Event Redirect",
"reset_form": "Reset Form",
@@ -114,6 +114,8 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -137,7 +139,6 @@ describe("handleChildrenEventTypes", () => {
users: { connect: [{ id: 4 }] },
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
instantMeetingScheduleId: undefined,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
@@ -170,6 +171,9 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
assignRRMembersUsingSegment,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -191,7 +195,6 @@ describe("handleChildrenEventTypes", () => {
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
customReplyToEmail: null,
rrSegmentQueryValue: undefined,
@@ -275,6 +278,9 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -307,7 +313,6 @@ describe("handleChildrenEventTypes", () => {
requiresBookerEmailVerification: false,
userId: 4,
workflows: undefined,
hashedLink: undefined,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
},
@@ -332,6 +337,11 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
rrSegmentQueryValue,
useEventLevelSelectedCalendars,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -354,9 +364,6 @@ describe("handleChildrenEventTypes", () => {
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
rrSegmentQueryValue: undefined,
customReplyToEmail: null,
locations: [],
hashedLink: {
@@ -364,7 +371,6 @@ describe("handleChildrenEventTypes", () => {
},
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
instantMeetingScheduleId: undefined,
},
where: {
userId_parentId: {
@@ -396,6 +402,9 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -418,10 +427,14 @@ describe("handleChildrenEventTypes", () => {
schedulingType: SchedulingType.MANAGED,
requiresBookerEmailVerification: false,
lockTimeZoneToggleOnBookingPage: false,
lockedTimeZone: "Europe/London",
useEventTypeDestinationCalendarEmail: false,
workflows: [],
parentId: 1,
locations: [],
instantMeetingScheduleId: null,
assignRRMembersUsingSegment: false,
includeNoShowInRRCalculation: false,
...evType,
};
@@ -449,7 +462,6 @@ describe("handleChildrenEventTypes", () => {
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
instantMeetingScheduleId: undefined,
locations: [],
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
@@ -461,28 +473,24 @@ describe("handleChildrenEventTypes", () => {
workflows: {
create: [{ workflowId: 11 }],
},
hashedLink: undefined,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useEventLevelSelectedCalendars: false,
},
});
const { profileId, ...rest } = evType;
const { profileId, rrSegmentQueryValue, ...rest } = evType;
if ("workflows" in rest) delete rest.workflows;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
locations: [],
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
customReplyToEmail: null,
rrSegmentQueryValue: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
hashedLink: {
deleteMany: {},
},
instantMeetingScheduleId: undefined,
},
where: {
userId_parentId: {
@@ -62,6 +62,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
requiresConfirmationForFreeEmail: true,
requiresBookerEmailVerification: true,
maxLeadThreshold: true,
includeNoShowInRRCalculation: true,
minimumBookingNotice: true,
userId: true,
price: true,
@@ -50,6 +50,7 @@ type EventType = Pick<
| "isRRWeightsEnabled"
| "rescheduleWithSameRoundRobinHost"
| "teamId"
| "includeNoShowInRRCalculation"
>;
type InputProps = {
@@ -621,7 +621,7 @@ export const EventTeamAssignmentTab = ({
);
});
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
const { getValues, setValue } = useFormContext<FormValues>();
const { getValues, setValue, control } = useFormContext<FormValues>();
const [assignAllTeamMembers, setAssignAllTeamMembers] = useState<boolean>(
getValues("assignAllTeamMembers") ?? false
);
@@ -632,6 +632,11 @@ export const EventTeamAssignmentTab = ({
setAssignAllTeamMembers(false);
};
const schedulingType = useWatch({
control,
name: "schedulingType",
});
return (
<div>
{team && !isManagedEventType && (
@@ -682,44 +687,61 @@ export const EventTeamAssignmentTab = ({
/>
</div>
</div>
<div className="border-subtle mt-4 flex flex-col rounded-md">
<div className="border-subtle rounded-t-md border p-6 pb-5">
<Label className="mb-1 text-sm font-semibold">{t("rr_distribution_method")}</Label>
<p className="text-subtle max-w-full break-words text-sm leading-tight">
{t("rr_distribution_method_description")}
</p>
{schedulingType === "ROUND_ROBIN" && (
<div className="border-subtle mt-4 flex flex-col rounded-md">
<div className="border-subtle rounded-t-md border p-6 pb-5">
<Label className="mb-1 text-sm font-semibold">{t("rr_distribution_method")}</Label>
<p className="text-subtle max-w-full break-words text-sm leading-tight">
{t("rr_distribution_method_description")}
</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<Controller
name="maxLeadThreshold"
render={({ field: { value, onChange } }) => (
<RadioArea.Group
onValueChange={(val) => {
if (val === "loadBalancing") onChange(3);
else onChange(null);
}}
className="mt-1 flex flex-col gap-4">
<RadioArea.Item
value="maximizeAvailability"
checked={value === null}
className="w-full text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">
{t("rr_distribution_method_availability_title")}
</strong>
<p>{t("rr_distribution_method_availability_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value="loadBalancing"
checked={value !== null}
className="text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("rr_distribution_method_balanced_title")}</strong>
<p>{t("rr_distribution_method_balanced_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
)}
/>
<div className="mt-4">
<Controller
name="includeNoShowInRRCalculation"
render={({ field: { value, onChange } }) => (
<SettingsToggle
title={t("include_no_show_in_rr_calculation")}
labelClassName="mt-1.5"
checked={value}
onCheckedChange={(val) => onChange(val)}
/>
)}
/>
</div>
</div>
</div>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<Controller
name="maxLeadThreshold"
render={({ field: { value, onChange } }) => (
<RadioArea.Group
onValueChange={(val) => {
if (val === "loadBalancing") onChange(3);
else onChange(null);
}}
className="mt-1 flex flex-col gap-4">
<RadioArea.Item
value="maximizeAvailability"
checked={value === null}
className="w-full text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("rr_distribution_method_availability_title")}</strong>
<p>{t("rr_distribution_method_availability_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value="loadBalancing"
checked={value !== null}
className="text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("rr_distribution_method_balanced_title")}</strong>
<p>{t("rr_distribution_method_balanced_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
)}
/>
</div>
</div>
)}
<Hosts
orgId={orgId}
isSegmentApplicable={isSegmentApplicable}
@@ -98,6 +98,7 @@ export const filterHostsByLeadThreshold = async <T extends BaseHost<BaseUser>>({
parentId?: number | null;
rrResetInterval: RRResetInterval | null;
} | null;
includeNoShowInRRCalculation: boolean;
};
routingFormResponse: RoutingFormResponse | null;
}) => {
@@ -68,6 +68,7 @@ export const findQualifiedHostsWithDelegationCredentials = async <
schedulingType: SchedulingType | null;
isRRWeightsEnabled: boolean;
rescheduleWithSameRoundRobinHost: boolean;
includeNoShowInRRCalculation: boolean;
} & EventType;
rescheduleUid: string | null;
routedTeamMemberIds: number[];
+1
View File
@@ -127,6 +127,7 @@ const commons = {
autoTranslateDescriptionEnabled: false,
fieldTranslations: [],
maxLeadThreshold: null,
includeNoShowInRRCalculation: false,
useEventLevelSelectedCalendars: false,
rrResetInterval: null,
customReplyToEmail: null,
+7
View File
@@ -67,6 +67,7 @@ interface GetLuckyUserParams<T extends PartialUser> {
id: number;
isRRWeightsEnabled: boolean;
team: { parentId?: number | null; rrResetInterval: RRResetInterval | null } | null;
includeNoShowInRRCalculation: boolean;
};
// all routedTeamMemberIds or all hosts of event types
allRRHosts: {
@@ -423,11 +424,13 @@ async function getBookingsOfInterval({
users,
virtualQueuesData,
interval,
includeNoShowInRRCalculation,
}: {
eventTypeId: number;
users: { id: number; email: string }[];
virtualQueuesData: VirtualQueuesDataType | null;
interval: RRResetInterval;
includeNoShowInRRCalculation: boolean;
}) {
return await BookingRepository.getAllBookingsForRoundRobin({
eventTypeId: eventTypeId,
@@ -435,6 +438,7 @@ async function getBookingsOfInterval({
startDate: getIntervalStartDate(interval),
endDate: new Date(),
virtualQueuesData,
includeNoShowInRRCalculation,
});
}
@@ -611,6 +615,7 @@ async function fetchAllDataNeededForCalculations<
}),
virtualQueuesData: virtualQueuesData ?? null,
interval,
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
}),
getBookingsOfInterval({
@@ -618,6 +623,7 @@ async function fetchAllDataNeededForCalculations<
users: notAvailableHosts,
virtualQueuesData: virtualQueuesData ?? null,
interval,
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
}),
getBookingsOfInterval({
@@ -627,6 +633,7 @@ async function fetchAllDataNeededForCalculations<
}),
virtualQueuesData: virtualQueuesData ?? null,
interval,
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
}),
prisma.host.findMany({
@@ -0,0 +1,192 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BookingStatus } from "@calcom/prisma/enums";
import { BookingRepository } from "./booking";
const createAttendeeNoShowTestBookings = async () => {
await Promise.all([
prismaMock.booking.create({
data: {
userId: 1,
uid: "123",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: false,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T00:00:00.000Z"),
endTime: new Date("2025-05-01T01:00:00.000Z"),
title: "Test Event",
},
}),
prismaMock.booking.create({
data: {
userId: 1,
uid: "123",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: true,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T00:00:00.000Z"),
endTime: new Date("2025-05-01T01:00:00.000Z"),
title: "Test Event",
},
}),
]);
};
const createHostNoShowTestBookings = async () => {
await Promise.all([
prismaMock.booking.create({
data: {
userId: 1,
uid: "123",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: false,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T00:00:00.000Z"),
endTime: new Date("2025-05-01T01:00:00.000Z"),
title: "Test Event",
noShowHost: false,
},
}),
prismaMock.booking.create({
data: {
userId: 1,
uid: "123",
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
attendees: {
create: {
email: "test1@example.com",
noShow: false,
name: "Test 1",
timeZone: "America/Toronto",
},
},
startTime: new Date("2025-05-01T00:00:00.000Z"),
endTime: new Date("2025-05-01T01:00:00.000Z"),
title: "Test Event",
noShowHost: true,
},
}),
]);
};
describe("BookingRepository", () => {
beforeEach(() => {
vi.setSystemTime(new Date("2025-05-01T00:00:00.000Z"));
vi.resetAllMocks();
});
describe("getAllBookingsForRoundRobin", () => {
describe("includeNoShowInRRCalculation", () => {
it("it should not include bookings where the attendee is a no show", async () => {
await createAttendeeNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: false,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(1);
});
it("it should include bookings where the attendee is a no show", async () => {
await createAttendeeNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(2);
});
it("it should not include bookings where the host is a no show", async () => {
await createHostNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: false,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(1);
});
it("it should include bookings where the host is a no show", async () => {
await createHostNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(2);
});
it("it should not include bookings where the host or an attendee is a no show", async () => {
await createHostNoShowTestBookings();
await createAttendeeNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: false,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(2);
});
it("it should include bookings where the host or an attendee is a no show", async () => {
await createHostNoShowTestBookings();
await createAttendeeNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
endDate: new Date(),
includeNoShowInRRCalculation: true,
virtualQueuesData: null,
});
expect(bookings).toHaveLength(4);
});
});
});
});
+11 -32
View File
@@ -45,6 +45,7 @@ const buildWhereClauseForActiveBookings = ({
endDate,
users,
virtualQueuesData,
includeNoShowInRRCalculation = false,
}: {
eventTypeId: number;
startDate?: Date;
@@ -57,20 +58,18 @@ const buildWhereClauseForActiveBookings = ({
selectedOptionIds: string | number | string[];
};
} | null;
includeNoShowInRRCalculation: boolean;
}): Prisma.BookingWhereInput => ({
OR: [
{
userId: {
in: users.map((user) => user.id),
},
OR: [
{
noShowHost: false,
},
{
noShowHost: null,
},
],
...(!includeNoShowInRRCalculation
? {
OR: [{ noShowHost: false }, { noShowHost: null }],
}
: {}),
},
{
attendees: {
@@ -82,7 +81,7 @@ const buildWhereClauseForActiveBookings = ({
},
},
],
attendees: { some: { noShow: false } },
...(!includeNoShowInRRCalculation ? { attendees: { some: { noShow: false } } } : {}),
status: BookingStatus.ACCEPTED,
eventTypeId,
...(startDate || endDate
@@ -166,29 +165,6 @@ export class BookingRepository {
});
}
static async groupByActiveBookingCounts({
users,
eventTypeId,
startDate,
}: {
users: { id: number; email: string }[];
eventTypeId: number;
startDate?: Date;
}) {
return await prisma.booking.groupBy({
by: ["userId"],
where: buildWhereClauseForActiveBookings({
users,
eventTypeId,
startDate,
virtualQueuesData: null,
}),
_count: {
_all: true,
},
});
}
static async findAllExistingBookingsForEventTypeBetween({
eventTypeId,
seatedEvent = false,
@@ -307,6 +283,7 @@ export class BookingRepository {
startDate,
endDate,
virtualQueuesData,
includeNoShowInRRCalculation,
}: {
users: { id: number; email: string }[];
eventTypeId: number;
@@ -319,6 +296,7 @@ export class BookingRepository {
selectedOptionIds: string | number | string[];
};
} | null;
includeNoShowInRRCalculation: boolean;
}) {
const allBookings = await prisma.booking.findMany({
where: buildWhereClauseForActiveBookings({
@@ -327,6 +305,7 @@ export class BookingRepository {
endDate,
users,
virtualQueuesData,
includeNoShowInRRCalculation,
}),
select: {
id: true,
@@ -674,6 +674,7 @@ export class EventTypeRepository {
},
secondaryEmailId: true,
maxLeadThreshold: true,
includeNoShowInRRCalculation: true,
useEventLevelSelectedCalendars: true,
});
@@ -811,6 +812,7 @@ export class EventTypeRepository {
rrSegmentQueryValue: true,
isRRWeightsEnabled: true,
maxLeadThreshold: true,
includeNoShowInRRCalculation: true,
useEventLevelSelectedCalendars: true,
team: {
select: {
+1
View File
@@ -127,6 +127,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
disableRescheduling: false,
seatsShowAvailabilityCount: null,
maxLeadThreshold: null,
includeNoShowInRRCalculation: false,
schedulingType: null,
scheduleId: null,
bookingLimits: null,
@@ -127,6 +127,7 @@ export const useEventTypeForm = ({
},
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
maxLeadThreshold: eventType.maxLeadThreshold,
includeNoShowInRRCalculation: eventType.includeNoShowInRRCalculation,
useEventLevelSelectedCalendars: eventType.useEventLevelSelectedCalendars,
customReplyToEmail: eventType.customReplyToEmail || null,
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "includeNoShowInRRCalculation" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -169,6 +169,7 @@ model EventType {
isRRWeightsEnabled Boolean @default(false)
fieldTranslations EventTypeTranslation[]
maxLeadThreshold Int?
includeNoShowInRRCalculation Boolean @default(false)
selectedCalendars SelectedCalendar[]
allowReschedulingPastBookings Boolean @default(false)
hideOrganizerEmail Boolean @default(false)