Skip to content

Feat/manual rr calibration#28945

Open
ajayjha1 wants to merge 3 commits intocalcom:mainfrom
ajayjha1:feat/manual-rr-calibration
Open

Feat/manual rr calibration#28945
ajayjha1 wants to merge 3 commits intocalcom:mainfrom
ajayjha1:feat/manual-rr-calibration

Conversation

@ajayjha1
Copy link
Copy Markdown
Contributor

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

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
@github-actions
Copy link
Copy Markdown
Contributor

Welcome to Cal.diy, @ajayjha1! Thanks for opening this pull request.

A few things to keep in mind:

  • This is Cal.diy, not Cal.com. Cal.diy is a community-driven, fully open-source fork of Cal.com licensed under MIT. Your changes here will be part of Cal.diy — they will not be deployed to the Cal.com production app.
  • Please review our Contributing Guidelines if you haven't already.
  • Make sure your PR title follows the Conventional Commits format.

A maintainer will review your PR soon. Thanks for contributing!

@github-actions
Copy link
Copy Markdown
Contributor

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:

No release type found in pull request title "Feat/manual rr calibration". Add a prefix to indicate what kind of release this pull request corresponds to. For reference, see https://www.conventionalcommits.org/

Available types:
 - feat: A new feature
 - fix: A bug fix
 - docs: Documentation only changes
 - style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
 - refactor: A code change that neither fixes a bug nor adds a feature
 - perf: A code change that improves performance
 - test: Adding missing tests or correcting existing tests
 - build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
 - ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
 - chore: Other changes that don't modify src or test files
 - revert: Reverts a previous commit

@github-actions github-actions bot added consumer Medium priority Created by Linear-GitHub Sync Stale teams area: teams, round robin, collective, managed event-types ✨ feature New feature or request ❗️ migrations contains migration files labels Apr 20, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 20, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a manual calibration feature for event type hosts. The changes include adding an optional manualCalibration integer field to the Host database table and schema, extending TypeScript types throughout the codebase to carry this field, updating the booking calibration logic in getLuckyUser.ts to include manual calibration values in host availability calculations, and adding a new CalibrationDialog UI component for managing host calibration settings. Related updates include modified host selection queries in repositories, updated API handlers for fetching and persisting the field, validation schema updates, localization strings, and removal of outdated test comments. The implementation maintains backward compatibility by making the field optional.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Feat/manual rr calibration' directly describes the main change: adding manual calibration for round-robin scheduling.
Description check ✅ Passed The description comprehensively explains the feature, problem statement, solution, and implementation details related to the changeset.
Linked Issues check ✅ Passed The PR implements the manual calibration mechanism requested in #19822, though with host-level rather than per-queue calibration as originally proposed.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the manual calibration feature; no unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Add manualCalibration to EventTypeHosts type for type consistency.

EventTypeHosts omits manualCalibration while 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 uses label={t("set_calibration")}, resulting in "Set calibration" appearing twice. Consider a shorter label like t("calibration") (or omitting the field label since the Label above 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 via Intl or 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 isDailyVideoCall gate and per-uid getPublicVideoCallUrl substitution correctly implement the existing pattern from CalEventParser.ts: each Cal Video (daily_video) occurrence gets its own room URL, while other providers reuse the shared meetingUrl per series.

The update patterns used elsewhere (e.g., bookingRepository.updateMany in handleCancelBooking.ts for 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

📥 Commits

Reviewing files that changed from the base of the PR and between e9c6d0e and 9c56fd2.

📒 Files selected for processing (13)
  • apps/web/pages/api/book/recurring-event.test.ts
  • packages/features/bookings/lib/getLuckyUser.ts
  • packages/features/bookings/lib/handleConfirmation.ts
  • packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
  • packages/features/eventtypes/components/CheckedTeamSelect.tsx
  • packages/features/eventtypes/components/dialogs/HostEditDialogs.tsx
  • packages/features/eventtypes/lib/types.ts
  • packages/features/eventtypes/repositories/eventTypeRepository.ts
  • packages/i18n/locales/en/common.json
  • packages/prisma/migrations/20260420000000_add_host_manual_calibration/migration.sql
  • packages/prisma/schema.prisma
  • packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts
  • packages/trpc/server/routers/viewer/eventTypes/types.ts
💤 Files with no reviewable changes (1)
  • apps/web/pages/api/book/recurring-event.test.ts

Comment on lines 163 to 192
{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>
</>
) : (
<></>
)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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:

  1. Admin enables weights, sets manualCalibration = +5 for a host, saves.
  2. Admin later disables weights.
  3. The +5 offset 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.

Comment on lines +258 to +323
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))}
/>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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.ts

Repository: 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.

Suggested change
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

consumer ✨ feature New feature or request Medium priority Created by Linear-GitHub Sync ❗️ migrations contains migration files size/L Stale teams area: teams, round robin, collective, managed event-types

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CAL-5274] Manual calibration

1 participant