Conversation
For Cal Video (daily_video), each recurring booking occurrence now gets its own video room URL derived from its own uid, instead of all occurrences sharing the first booking's URL.
Allows team admins to manually adjust a host booking count calibration via the event type editor (visible when weights are enabled). Positive values reduce the host share; negative values increase it. - Add manualCalibration Int to Host schema and migration - Thread field through tRPC hostSchema, update handler, and DB queries - Apply offset in getLuckyUser calibration calculation - Add CalibrationDialog UI mirroring WeightDialog pattern - Wire calibration button into CheckedTeamSelect host list
|
Welcome to Cal.diy, @ajayjha1! Thanks for opening this pull request. A few things to keep in mind:
A maintainer will review your PR soon. Thanks for contributing! |
|
Hey there and thank you for opening this pull request! 👋🏼 We require pull request titles to follow the Conventional Commits specification and it looks like your proposed title needs to be adjusted. Details: |
📝 WalkthroughWalkthroughThis pull request introduces a manual calibration feature for event type hosts. The changes include adding an optional 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/features/eventtypes/lib/types.ts (1)
210-220:⚠️ Potential issue | 🟡 MinorAdd
manualCalibrationtoEventTypeHoststype for type consistency.
EventTypeHostsomitsmanualCalibrationwhile related types (Host,HostInput,CheckedSelectOption) include it. The Prisma schema defines this field, multiple repository queries select it, and UI components (CalibrationDialog, CheckedTeamSelect) reference it. Update the type to include this field:Type update
export type EventTypeHosts = { user: { timeZone: string; }; userId: number; scheduleId: number | null; isFixed: boolean; priority: number | null; weight: number | null; + manualCalibration?: number | null; groupId: string | null; }[];🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/features/eventtypes/lib/types.ts` around lines 210 - 220, The EventTypeHosts type is missing the manualCalibration field; update the exported EventTypeHosts type (the type declaration named EventTypeHosts) to include a manualCalibration property with the same shape as other related types (e.g., boolean or boolean | null to match the Prisma/Host/HostInput/CheckedSelectOption definitions) so repository selects and UI components (CalibrationDialog, CheckedTeamSelect) remain type-consistent.
🧹 Nitpick comments (3)
packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx (1)
314-315: Nit: TextField label duplicates the dialog title.The dialog is titled
t("set_calibration")("Set calibration") and the single TextField inside also useslabel={t("set_calibration")}, resulting in "Set calibration" appearing twice. Consider a shorter label liket("calibration")(or omitting the field label since theLabelabove it already reads "Calibration for {user}").🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx` around lines 314 - 315, The TextField inside HostEditDialogs.tsx duplicates the dialog title by using label={t("set_calibration")}; change that label to a shorter one like label={t("calibration")} or remove the label prop entirely (rely on the existing Label "Calibration for {user}") so the UI doesn't display "Set calibration" twice; update the TextField component's label prop (and related i18n key usage of t("set_calibration")) accordingly.packages/features/eventtypes/components/CheckedTeamSelect.tsx (1)
262-266:getCalibrationLabel— consider pre-pending sign for negatives viaIntlor template, and skip.toString().
manualCalibration.toString()is redundant inside a template literal, and you already hand-roll the+sign. Minor readability nit:♻️ Proposed cleanup
-const getCalibrationLabel = (baseLabel: string, manualCalibration?: number | null): string => { - if (manualCalibration == null || manualCalibration === 0) return baseLabel; - const sign = manualCalibration > 0 ? "+" : ""; - return `${baseLabel} (${sign}${manualCalibration.toString()})`; -}; +const getCalibrationLabel = (baseLabel: string, manualCalibration?: number | null): string => { + if (manualCalibration == null || manualCalibration === 0) return baseLabel; + const formatted = manualCalibration > 0 ? `+${manualCalibration}` : `${manualCalibration}`; + return `${baseLabel} (${formatted})`; +};🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/features/eventtypes/components/CheckedTeamSelect.tsx` around lines 262 - 266, getCalibrationLabel currently builds a sign manually and calls manualCalibration.toString() inside a template; simplify by removing .toString() and use a single formatting step that yields the sign for positives/negatives (e.g. Intl.NumberFormat(..., { signDisplay: 'always' }).format(manualCalibration) or simply use `${manualCalibration}` with a `manualCalibration > 0 ? '+' : ''` prefix), and return `${baseLabel} (${formattedCalibration})` while keeping the early return when manualCalibration == null || manualCalibration === 0; update the function getCalibrationLabel to use that formattedCalibration variable and drop the explicit .toString() call.packages/features/bookings/lib/handleConfirmation.ts (1)
179-243: Per-occurrence Cal Video URL logic is correct and consistent with existing patterns.The
isDailyVideoCallgate and per-uidgetPublicVideoCallUrlsubstitution correctly implement the existing pattern fromCalEventParser.ts: each Cal Video (daily_video) occurrence gets its own room URL, while other providers reuse the sharedmeetingUrlper series.The update patterns used elsewhere (e.g.,
bookingRepository.updateManyinhandleCancelBooking.tsfor bulk cancellations) differ by design — sequential per-booking updates here allow detailed relation queries, while bulk operations elsewhere optimize for different use cases.However, if this video URL fix is orthogonal to the PR's stated objectives, consider splitting into a dedicated PR for independent review and accurate changelog attribution.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/features/bookings/lib/handleConfirmation.ts` around lines 179 - 243, The per-occurrence Cal Video URL change is implemented inside updateBookingsPromise using isDailyVideoCall(evt.videoCallData) and getPublicVideoCallUrl(recurringBooking.uid) to set metadata.videoCallUrl (falling back to meetingUrl for non-daily_video); if this is unrelated to the PR's stated scope, remove this change from the current PR and instead create a dedicated PR that introduces the per-occurrence URL logic (implementing it in the same updateBookingsPromise block in handleConfirmation.ts so metadata.videoCallUrl is set per-recurringBooking.uid), and update the changelog/PR description accordingly for clear review attribution.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/features/eventtypes/components/CheckedTeamSelect.tsx`:
- Around line 163-192: The calibration button is only rendered when
isRRWeightsEnabled but manualCalibration still affects selection via
getLuckyUser/getHostsWithCalibration; move the calibration UI out of the
isRRWeightsEnabled branch so the calibration Button (the one that calls
setCalibrationDialogOpen and setCurrentOption and displays
getCalibrationLabel(..., option.manualCalibration)) is rendered whenever this
component is in round‑robin mode (i.e., whenever the component is responsible
for RR hosts), not only when weights are enabled; update CheckedTeamSelect.tsx
to render the calibration Button alongside (or after) the weight Button and keep
its classNames/customClassNames and onClick handlers unchanged so the dialog
logic continues to work—alternatively, if you prefer behavior change instead of
UI change, modify getLuckyUser/getHostsWithCalibration to ignore
option.manualCalibration when isRRWeightsEnabled is false and add a test for
that branch.
In `@packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx`:
- Around line 258-323: The input can produce NaN because parseInt("") yields
NaN, causing NaN to be saved; update the TextField onChange handler and the
setCalibration guard: in the TextField onChange (component: TextField) detect
empty string and call setNewCalibration(undefined) (or parse and if
Number.isFinite(result) set the number, else undefined), ensure the TextField
value is controlled with newCalibration ?? "" so clearing shows empty, and
change the setCalibration check (function: setCalibration) to only proceed when
Number.isFinite(newCalibration) (or newCalibration !== undefined &&
Number.isFinite(newCalibration)) so NaN is never propagated into onChange.
In `@packages/trpc/server/routers/viewer/eventTypes/types.ts`:
- Line 69: The schema for the manualCalibration field allows decimals but the
database expects an integer (Host.manualCalibration is Int?), so update the Zod
validator in types.ts: change the manualCalibration field definition (the
manualCalibration property in the event types schema) to use
z.number().int().optional().nullable() so non-integer values are rejected before
the nested Prisma write.
---
Outside diff comments:
In `@packages/features/eventtypes/lib/types.ts`:
- Around line 210-220: The EventTypeHosts type is missing the manualCalibration
field; update the exported EventTypeHosts type (the type declaration named
EventTypeHosts) to include a manualCalibration property with the same shape as
other related types (e.g., boolean or boolean | null to match the
Prisma/Host/HostInput/CheckedSelectOption definitions) so repository selects and
UI components (CalibrationDialog, CheckedTeamSelect) remain type-consistent.
---
Nitpick comments:
In `@packages/features/bookings/lib/handleConfirmation.ts`:
- Around line 179-243: The per-occurrence Cal Video URL change is implemented
inside updateBookingsPromise using isDailyVideoCall(evt.videoCallData) and
getPublicVideoCallUrl(recurringBooking.uid) to set metadata.videoCallUrl
(falling back to meetingUrl for non-daily_video); if this is unrelated to the
PR's stated scope, remove this change from the current PR and instead create a
dedicated PR that introduces the per-occurrence URL logic (implementing it in
the same updateBookingsPromise block in handleConfirmation.ts so
metadata.videoCallUrl is set per-recurringBooking.uid), and update the
changelog/PR description accordingly for clear review attribution.
In `@packages/features/eventtypes/components/CheckedTeamSelect.tsx`:
- Around line 262-266: getCalibrationLabel currently builds a sign manually and
calls manualCalibration.toString() inside a template; simplify by removing
.toString() and use a single formatting step that yields the sign for
positives/negatives (e.g. Intl.NumberFormat(..., { signDisplay: 'always'
}).format(manualCalibration) or simply use `${manualCalibration}` with a
`manualCalibration > 0 ? '+' : ''` prefix), and return `${baseLabel}
(${formattedCalibration})` while keeping the early return when manualCalibration
== null || manualCalibration === 0; update the function getCalibrationLabel to
use that formattedCalibration variable and drop the explicit .toString() call.
In `@packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx`:
- Around line 314-315: The TextField inside HostEditDialogs.tsx duplicates the
dialog title by using label={t("set_calibration")}; change that label to a
shorter one like label={t("calibration")} or remove the label prop entirely
(rely on the existing Label "Calibration for {user}") so the UI doesn't display
"Set calibration" twice; update the TextField component's label prop (and
related i18n key usage of t("set_calibration")) accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ec66ae97-4e20-4eb3-8cb1-454057bbbc39
📒 Files selected for processing (13)
apps/web/pages/api/book/recurring-event.test.tspackages/features/bookings/lib/getLuckyUser.tspackages/features/bookings/lib/handleConfirmation.tspackages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.tspackages/features/eventtypes/components/CheckedTeamSelect.tsxpackages/features/eventtypes/components/dialogs/HostEditDialogs.tsxpackages/features/eventtypes/lib/types.tspackages/features/eventtypes/repositories/eventTypeRepository.tspackages/i18n/locales/en/common.jsonpackages/prisma/migrations/20260420000000_add_host_manual_calibration/migration.sqlpackages/prisma/schema.prismapackages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.tspackages/trpc/server/routers/viewer/eventTypes/types.ts
💤 Files with no reviewable changes (1)
- apps/web/pages/api/book/recurring-event.test.ts
| {isRRWeightsEnabled ? ( | ||
| <Button | ||
| color="minimal" | ||
| className={classNames( | ||
| "mr-6 h-2 w-4 p-0 text-sm hover:bg-transparent", | ||
| customClassNames?.selectedHostList?.listItem?.changeWeightButton | ||
| )} | ||
| onClick={() => { | ||
| setWeightDialogOpen(true); | ||
| setCurrentOption(option); | ||
| }}> | ||
| {option.weight ?? 100}% | ||
| </Button> | ||
| <> | ||
| <Button | ||
| color="minimal" | ||
| className={classNames( | ||
| "mr-6 h-2 w-4 p-0 text-sm hover:bg-transparent", | ||
| customClassNames?.selectedHostList?.listItem?.changeWeightButton | ||
| )} | ||
| onClick={() => { | ||
| setWeightDialogOpen(true); | ||
| setCurrentOption(option); | ||
| }}> | ||
| {option.weight ?? 100}% | ||
| </Button> | ||
| <Button | ||
| color="minimal" | ||
| className={classNames( | ||
| "mr-6 h-2 p-0 text-sm hover:bg-transparent", | ||
| customClassNames?.selectedHostList?.listItem?.changeCalibrationButton | ||
| )} | ||
| onClick={() => { | ||
| setCalibrationDialogOpen(true); | ||
| setCurrentOption(option); | ||
| }}> | ||
| {getCalibrationLabel(t("set_calibration"), option.manualCalibration)} | ||
| </Button> | ||
| </> | ||
| ) : ( | ||
| <></> | ||
| )} |
There was a problem hiding this comment.
Calibration UI is hidden when weights are disabled, but values still affect distribution — possible footgun.
The calibration button only renders inside the isRRWeightsEnabled branch, yet manualCalibration is consumed by getLuckyUser/getHostsWithCalibration regardless of the weights toggle. A plausible scenario:
- Admin enables weights, sets
manualCalibration = +5for a host, saves. - Admin later disables weights.
- The
+5offset is still applied in RR selection but there's no UI to see or reset it — the host silently appears to have 5 extra bookings.
Consider either (a) showing the calibration button whenever the event type is round-robin (independent of weights), or (b) ignoring manualCalibration in getLuckyUser when isRRWeightsEnabled is false, so UI visibility and runtime effect stay in sync. Option (a) matches the stated semantics in the PR description and is probably preferable.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/features/eventtypes/components/CheckedTeamSelect.tsx` around lines
163 - 192, The calibration button is only rendered when isRRWeightsEnabled but
manualCalibration still affects selection via
getLuckyUser/getHostsWithCalibration; move the calibration UI out of the
isRRWeightsEnabled branch so the calibration Button (the one that calls
setCalibrationDialogOpen and setCurrentOption and displays
getCalibrationLabel(..., option.manualCalibration)) is rendered whenever this
component is in round‑robin mode (i.e., whenever the component is responsible
for RR hosts), not only when weights are enabled; update CheckedTeamSelect.tsx
to render the calibration Button alongside (or after) the weight Button and keep
its classNames/customClassNames and onClick handlers unchanged so the dialog
logic continues to work—alternatively, if you prefer behavior change instead of
UI change, modify getLuckyUser/getHostsWithCalibration to ignore
option.manualCalibration when isRRWeightsEnabled is false and add a test for
that branch.
| const [newCalibration, setNewCalibration] = useState<number | undefined>(); | ||
|
|
||
| const setCalibration = () => { | ||
| if (newCalibration !== undefined) { | ||
| const hosts: Host[] = getValues("hosts"); | ||
| const hostGroups = getValues("hostGroups"); | ||
| const rrHosts = hosts.filter((host) => !host.isFixed); | ||
| const groupedHosts = groupHostsByGroupId({ hosts: rrHosts, hostGroups }); | ||
| const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID]; | ||
|
|
||
| const updatedOptions = (hostGroupToSort ?? []).map((host) => { | ||
| const userOption = options.find((opt) => opt.value === host.userId.toString()); | ||
| let manualCalibration = host.manualCalibration; | ||
| if (host.userId === parseInt(option.value, 10)) { | ||
| manualCalibration = newCalibration; | ||
| } | ||
| return { | ||
| avatar: userOption?.avatar ?? "", | ||
| label: userOption?.label ?? host.userId.toString(), | ||
| value: host.userId.toString(), | ||
| priority: host.priority, | ||
| weight: host.weight, | ||
| manualCalibration, | ||
| isFixed: host.isFixed, | ||
| groupId: host.groupId, | ||
| }; | ||
| }); | ||
|
|
||
| const otherGroupsHosts = getHostsFromOtherGroups(rrHosts, option.groupId); | ||
| const otherGroupsOptions = otherGroupsHosts.map((host) => { | ||
| const userOption = options.find((opt) => opt.value === host.userId.toString()); | ||
| return { | ||
| avatar: userOption?.avatar ?? "", | ||
| label: userOption?.label ?? host.userId.toString(), | ||
| value: host.userId.toString(), | ||
| priority: host.priority, | ||
| weight: host.weight, | ||
| manualCalibration: host.manualCalibration, | ||
| isFixed: host.isFixed, | ||
| groupId: host.groupId, | ||
| }; | ||
| }); | ||
|
|
||
| onChange([...otherGroupsOptions, ...updatedOptions]); | ||
| } | ||
| setIsOpenDialog(false); | ||
| }; | ||
|
|
||
| return ( | ||
| <Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}> | ||
| <DialogContent title={t("set_calibration")} description={t("calibration_description")}> | ||
| <div className={classNames("mb-4 mt-2", customClassNames?.container)}> | ||
| <Label className={customClassNames?.label}> | ||
| {t("calibration_for_user", { userName: option.label })} | ||
| </Label> | ||
| <div className={classNames("w-36", customClassNames?.calibrationInput?.container)}> | ||
| <TextField | ||
| label={t("set_calibration")} | ||
| className={customClassNames?.calibrationInput?.input} | ||
| labelClassName={customClassNames?.calibrationInput?.label} | ||
| addOnClassname={customClassNames?.calibrationInput?.addOn} | ||
| value={newCalibration} | ||
| defaultValue={option.manualCalibration ?? 0} | ||
| type="number" | ||
| onChange={(e) => setNewCalibration(parseInt(e.target.value, 10))} | ||
| /> |
There was a problem hiding this comment.
NaN can be persisted when the input is cleared.
parseInt("", 10) returns NaN. If a user clears the calibration field and clicks Confirm, newCalibration becomes NaN, the newCalibration !== undefined guard on line 261 passes, and NaN propagates through onChange into the host's manualCalibration. This will later be sent to the backend where z.number().int() will reject it (or, worse, corrupt getLuckyUser math if it slips past validation on any path).
This is especially easy to hit here because the input has no required, and 0 is a meaningful value (reset), so users are likely to clear the field.
🐛 Proposed fix
- <div className={classNames("w-36", customClassNames?.calibrationInput?.container)}>
- <TextField
- label={t("set_calibration")}
- className={customClassNames?.calibrationInput?.input}
- labelClassName={customClassNames?.calibrationInput?.label}
- addOnClassname={customClassNames?.calibrationInput?.addOn}
- value={newCalibration}
- defaultValue={option.manualCalibration ?? 0}
- type="number"
- onChange={(e) => setNewCalibration(parseInt(e.target.value, 10))}
- />
- </div>
+ <div className={classNames("w-36", customClassNames?.calibrationInput?.container)}>
+ <TextField
+ label={t("set_calibration")}
+ className={customClassNames?.calibrationInput?.input}
+ labelClassName={customClassNames?.calibrationInput?.label}
+ addOnClassname={customClassNames?.calibrationInput?.addOn}
+ value={newCalibration ?? ""}
+ defaultValue={option.manualCalibration ?? 0}
+ type="number"
+ onChange={(e) => {
+ const raw = e.target.value;
+ if (raw === "" || raw === "-") {
+ setNewCalibration(undefined);
+ return;
+ }
+ const parsed = parseInt(raw, 10);
+ setNewCalibration(Number.isFinite(parsed) ? parsed : undefined);
+ }}
+ />
+ </div>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const [newCalibration, setNewCalibration] = useState<number | undefined>(); | |
| const setCalibration = () => { | |
| if (newCalibration !== undefined) { | |
| const hosts: Host[] = getValues("hosts"); | |
| const hostGroups = getValues("hostGroups"); | |
| const rrHosts = hosts.filter((host) => !host.isFixed); | |
| const groupedHosts = groupHostsByGroupId({ hosts: rrHosts, hostGroups }); | |
| const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID]; | |
| const updatedOptions = (hostGroupToSort ?? []).map((host) => { | |
| const userOption = options.find((opt) => opt.value === host.userId.toString()); | |
| let manualCalibration = host.manualCalibration; | |
| if (host.userId === parseInt(option.value, 10)) { | |
| manualCalibration = newCalibration; | |
| } | |
| return { | |
| avatar: userOption?.avatar ?? "", | |
| label: userOption?.label ?? host.userId.toString(), | |
| value: host.userId.toString(), | |
| priority: host.priority, | |
| weight: host.weight, | |
| manualCalibration, | |
| isFixed: host.isFixed, | |
| groupId: host.groupId, | |
| }; | |
| }); | |
| const otherGroupsHosts = getHostsFromOtherGroups(rrHosts, option.groupId); | |
| const otherGroupsOptions = otherGroupsHosts.map((host) => { | |
| const userOption = options.find((opt) => opt.value === host.userId.toString()); | |
| return { | |
| avatar: userOption?.avatar ?? "", | |
| label: userOption?.label ?? host.userId.toString(), | |
| value: host.userId.toString(), | |
| priority: host.priority, | |
| weight: host.weight, | |
| manualCalibration: host.manualCalibration, | |
| isFixed: host.isFixed, | |
| groupId: host.groupId, | |
| }; | |
| }); | |
| onChange([...otherGroupsOptions, ...updatedOptions]); | |
| } | |
| setIsOpenDialog(false); | |
| }; | |
| return ( | |
| <Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}> | |
| <DialogContent title={t("set_calibration")} description={t("calibration_description")}> | |
| <div className={classNames("mb-4 mt-2", customClassNames?.container)}> | |
| <Label className={customClassNames?.label}> | |
| {t("calibration_for_user", { userName: option.label })} | |
| </Label> | |
| <div className={classNames("w-36", customClassNames?.calibrationInput?.container)}> | |
| <TextField | |
| label={t("set_calibration")} | |
| className={customClassNames?.calibrationInput?.input} | |
| labelClassName={customClassNames?.calibrationInput?.label} | |
| addOnClassname={customClassNames?.calibrationInput?.addOn} | |
| value={newCalibration} | |
| defaultValue={option.manualCalibration ?? 0} | |
| type="number" | |
| onChange={(e) => setNewCalibration(parseInt(e.target.value, 10))} | |
| /> | |
| const [newCalibration, setNewCalibration] = useState<number | undefined>(); | |
| const setCalibration = () => { | |
| if (newCalibration !== undefined) { | |
| const hosts: Host[] = getValues("hosts"); | |
| const hostGroups = getValues("hostGroups"); | |
| const rrHosts = hosts.filter((host) => !host.isFixed); | |
| const groupedHosts = groupHostsByGroupId({ hosts: rrHosts, hostGroups }); | |
| const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID]; | |
| const updatedOptions = (hostGroupToSort ?? []).map((host) => { | |
| const userOption = options.find((opt) => opt.value === host.userId.toString()); | |
| let manualCalibration = host.manualCalibration; | |
| if (host.userId === parseInt(option.value, 10)) { | |
| manualCalibration = newCalibration; | |
| } | |
| return { | |
| avatar: userOption?.avatar ?? "", | |
| label: userOption?.label ?? host.userId.toString(), | |
| value: host.userId.toString(), | |
| priority: host.priority, | |
| weight: host.weight, | |
| manualCalibration, | |
| isFixed: host.isFixed, | |
| groupId: host.groupId, | |
| }; | |
| }); | |
| const otherGroupsHosts = getHostsFromOtherGroups(rrHosts, option.groupId); | |
| const otherGroupsOptions = otherGroupsHosts.map((host) => { | |
| const userOption = options.find((opt) => opt.value === host.userId.toString()); | |
| return { | |
| avatar: userOption?.avatar ?? "", | |
| label: userOption?.label ?? host.userId.toString(), | |
| value: host.userId.toString(), | |
| priority: host.priority, | |
| weight: host.weight, | |
| manualCalibration: host.manualCalibration, | |
| isFixed: host.isFixed, | |
| groupId: host.groupId, | |
| }; | |
| }); | |
| onChange([...otherGroupsOptions, ...updatedOptions]); | |
| } | |
| setIsOpenDialog(false); | |
| }; | |
| return ( | |
| <Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}> | |
| <DialogContent title={t("set_calibration")} description={t("calibration_description")}> | |
| <div className={classNames("mb-4 mt-2", customClassNames?.container)}> | |
| <Label className={customClassNames?.label}> | |
| {t("calibration_for_user", { userName: option.label })} | |
| </Label> | |
| <div className={classNames("w-36", customClassNames?.calibrationInput?.container)}> | |
| <TextField | |
| label={t("set_calibration")} | |
| className={customClassNames?.calibrationInput?.input} | |
| labelClassName={customClassNames?.calibrationInput?.label} | |
| addOnClassname={customClassNames?.calibrationInput?.addOn} | |
| value={newCalibration ?? ""} | |
| defaultValue={option.manualCalibration ?? 0} | |
| type="number" | |
| onChange={(e) => { | |
| const raw = e.target.value; | |
| if (raw === "" || raw === "-") { | |
| setNewCalibration(undefined); | |
| return; | |
| } | |
| const parsed = parseInt(raw, 10); | |
| setNewCalibration(Number.isFinite(parsed) ? parsed : undefined); | |
| }} | |
| /> | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx` around
lines 258 - 323, The input can produce NaN because parseInt("") yields NaN,
causing NaN to be saved; update the TextField onChange handler and the
setCalibration guard: in the TextField onChange (component: TextField) detect
empty string and call setNewCalibration(undefined) (or parse and if
Number.isFinite(result) set the number, else undefined), ensure the TextField
value is controlled with newCalibration ?? "" so clearing shows empty, and
change the setCalibration check (function: setCalibration) to only proceed when
Number.isFinite(newCalibration) (or newCalibration !== undefined &&
Number.isFinite(newCalibration)) so NaN is never propagated into onChange.
| isFixed: z.boolean().optional(), | ||
| priority: z.number().min(0).max(4).optional().nullable(), | ||
| weight: z.number().min(0).optional().nullable(), | ||
| manualCalibration: z.number().optional().nullable(), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Confirm manualCalibration is a Prisma Int and is written from the tRPC host input.
rg -n -C2 'manualCalibration|model Host|const hostSchema' \
packages/prisma/schema.prisma \
packages/trpc/server/routers/viewer/eventTypes/types.ts \
packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.tsRepository: calcom/cal.diy
Length of output: 5171
Validate manualCalibration as an integer.
Line 69 accepts decimals, but Host.manualCalibration is stored as Int?; a value like 1.5 can pass router validation and fail during the nested Prisma write. Use .int() here.
Proposed fix
- manualCalibration: z.number().optional().nullable(),
+ manualCalibration: z.number().int().optional().nullable(),📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| manualCalibration: z.number().optional().nullable(), | |
| manualCalibration: z.number().int().optional().nullable(), |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/trpc/server/routers/viewer/eventTypes/types.ts` at line 69, The
schema for the manualCalibration field allows decimals but the database expects
an integer (Host.manualCalibration is Int?), so update the Zod validator in
types.ts: change the manualCalibration field definition (the manualCalibration
property in the event types schema) to use
z.number().int().optional().nullable() so non-integer values are rejected before
the nested Prisma write.
Adds the ability to manually adjust a host's booking count calibration for round-robin event types.
Issue
When using round-robin scheduling with weights enabled:
New hosts or hosts returning from OOO had their calibration calculated automatically
There was no way to manually override or fine-tune the calibration value
Admins had no control over correcting miscalibrated booking distributions
Virtual queue scenarios made this even harder to manage
Fix
A new manualCalibration field is added to the Host model. When set, it is added on top of the auto-computed calibration (new-host and OOO offsets) during the round-robin lucky user calculation:
Positive value → system treats the host as having received extra bookings → they get fewer upcoming bookings
Negative value → system treats the host as having a deficit → they get more upcoming bookings
Zero / unset → no change to existing behavior
A "Set calibration" button appears next to each host in the event type editor (only visible when weights are enabled), opening a dialog to enter the offset value.
Changes
Host schema: new manualCalibration Int? column + migration
getLuckyUser.ts: offset applied in getHostsWithCalibration
tRPC hostSchema, update.handler.ts, eventTypeRepository.ts: field threaded through all DB reads/writes
HostEditDialogs.tsx: new CalibrationDialog component (mirrors WeightDialog)
CheckedTeamSelect.tsx: calibration button wired into host list row
en/common.json: 3 new translation keys
Closes #19822