feat: add 40-minute duration option for multiple duration event types (#27032)

- Add 40 minutes to the multipleDurationOptions array in EventSetupTab
- Add comprehensive tests for 40-minute slot generation including:
  - Full day slot generation
  - Correct time intervals
  - Default duration handling
  - Same day booking with time constraints
  - Multiple date ranges

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ali@cal.com <ali@cal.com>
This commit is contained in:
devin-ai-integration[bot]
2026-01-20 10:43:55 +04:00
committed by GitHub
co-authored by ali@cal.com
parent c60addc528
commit 1e0b0d949f
2 changed files with 149 additions and 24 deletions
@@ -1,18 +1,13 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { MultiValue } from "react-select";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { LocationCustomClassNames } from "../../locations/types";
import type {
EventTypeSetupProps,
FormValues,
InputClassNames,
LocationFormValues,
SelectClassNames,
SettingsToggleClassNames,
} from "@calcom/features/eventtypes/lib/types";
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import { MAX_EVENT_DURATION_MINUTES, MIN_EVENT_DURATION_MINUTES } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
@@ -20,14 +15,14 @@ import { slugify } from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import classNames from "@calcom/ui/classNames";
import { Editor } from "@calcom/ui/components/editor";
import { TextAreaField } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form";
import { Skeleton } from "@calcom/ui/components/skeleton";
import Locations from "@calcom/web/modules/event-types/components/locations/Locations";
import { useState } from "react";
import type { Control, FormState, UseFormGetValues, UseFormSetValue } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import type { MultiValue } from "react-select";
import type { LocationCustomClassNames } from "../../locations/types";
export type EventSetupTabCustomClassNames = {
wrapper?: string;
@@ -80,7 +75,7 @@ export const EventSetupTab = (
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
5, 10, 15, 20, 25, 30, 40, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
].map((mins) => ({
value: mins,
label: t("multiple_duration_mins", { count: mins }),
@@ -109,7 +104,7 @@ export const EventSetupTab = (
<div className={classNames("stack-y-4", customClassNames?.wrapper)}>
<div
className={classNames(
"border-subtle stack-y-6 rounded-lg border p-6",
"stack-y-6 rounded-lg border border-subtle p-6",
customClassNames?.titleSection?.container
)}>
<TextField
@@ -169,7 +164,7 @@ export const EventSetupTab = (
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
addOnLeading={
isPlatform ? undefined : (
<span className="max-w-24 md:max-w-56 inline-block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
<span className="inline-block min-w-0 max-w-24 overflow-hidden text-ellipsis whitespace-nowrap md:max-w-56">
{urlPrefix}/
{!isManagedEventType
? team
@@ -187,7 +182,7 @@ export const EventSetupTab = (
</div>
<div
className={classNames(
"border-subtle rounded-lg border p-6",
"rounded-lg border border-subtle p-6",
customClassNames?.durationSection?.container
)}>
{multipleDuration ? (
@@ -212,7 +207,7 @@ export const EventSetupTab = (
isSearchable={false}
isDisabled={lengthLockedProps.disabled}
className={classNames(
"min-h-[36px]! h-auto text-sm",
"h-auto min-h-[36px]! text-sm",
customClassNames?.durationSection?.multipleDuration?.availableDurationsSelect?.select
)}
innerClassNames={
@@ -306,7 +301,7 @@ export const EventSetupTab = (
message: t("duration_max_error", { max: MAX_EVENT_DURATION_MINUTES }),
},
})}
addOnSuffix={<>{t("minutes")}</>}
addOnSuffix={t("minutes")}
min={MIN_EVENT_DURATION_MINUTES}
max={MAX_EVENT_DURATION_MINUTES}
/>
@@ -341,7 +336,7 @@ export const EventSetupTab = (
</div>
<div
className={classNames(
"border-subtle rounded-lg border p-6",
"rounded-lg border border-subtle p-6",
customClassNames?.locationSection?.container
)}>
<div>
+134 -4
View File
@@ -1,8 +1,7 @@
import { describe, expect, it, beforeAll, vi } from "vitest";
import process from "node:process";
import dayjs from "@calcom/dayjs";
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
import { beforeAll, describe, expect, it, vi } from "vitest";
import getSlots from "./slots";
let dateRangesNextDay: DateRange[];
@@ -886,7 +885,7 @@ describe("Tests the date-range slot logic with showOptimizedSlots", () => {
});
expect(day2Slots.length).toBeGreaterThan(0);
// Day 2 slots should be marked as away
day2Slots.forEach((slot) => {
expect(slot.away).toBe(true);
@@ -1055,3 +1054,134 @@ describe("Tests the date-range slot logic with showOptimizedSlots", () => {
vi.useRealTimers();
});
});
describe("Tests 40-minute duration slot generation", () => {
beforeAll(() => {
vi.setSystemTime(dayjs.utc("2021-06-20T11:59:59Z").toDate());
});
it("generates correct number of 40-minute slots for a full day", async () => {
const nextDay = dayjs.utc().add(1, "day").startOf("day");
const dateRanges = [
{
start: nextDay,
end: nextDay.endOf("day"),
},
];
const slots = getSlots({
inviteeDate: nextDay,
frequency: 40,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 40,
offsetStart: 0,
});
// 24 hours = 1440 minutes, 1440 / 40 = 36 slots
expect(slots).toHaveLength(36);
});
it("generates 40-minute slots with correct time intervals", async () => {
const nextDay = dayjs.utc().add(1, "day").startOf("day");
const dateRanges = [
{
start: nextDay.hour(9),
end: nextDay.hour(11),
},
];
const slots = getSlots({
inviteeDate: nextDay,
frequency: 40,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 40,
offsetStart: 0,
});
// 2 hours = 120 minutes, 120 / 40 = 3 slots (9:00, 9:40, 10:20)
expect(slots).toHaveLength(3);
expect(slots[0].time.format("HH:mm")).toBe("09:00");
expect(slots[1].time.format("HH:mm")).toBe("09:40");
expect(slots[2].time.format("HH:mm")).toBe("10:20");
});
it("handles 40-minute slots as default duration in variable length event", async () => {
const nextDay = dayjs.utc().add(1, "day").startOf("day");
const dateRanges = [
{
start: nextDay.hour(10),
end: nextDay.hour(12),
},
];
const slots = getSlots({
inviteeDate: nextDay,
frequency: 40,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 40,
offsetStart: 0,
});
// 2 hours = 120 minutes, 120 / 40 = 3 slots
expect(slots).toHaveLength(3);
// Verify each slot has correct 40-minute spacing
for (let i = 1; i < slots.length; i++) {
const diff = slots[i].time.diff(slots[i - 1].time, "minute");
expect(diff).toBe(40);
}
});
it("generates 40-minute slots with minimum booking notice on same day", async () => {
// System time is set to 2021-06-20T11:59:59Z (almost noon)
const today = dayjs.utc().startOf("day");
const dateRanges = [
{
start: today,
end: today.endOf("day"),
},
];
const slots = getSlots({
inviteeDate: today,
frequency: 40,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 40,
offsetStart: 0,
});
// System time is ~12:00, so only slots from 12:00 onwards should be available
// From 12:00 to 24:00 = 12 hours = 720 minutes, 720 / 40 = 18 slots
expect(slots).toHaveLength(18);
});
it("handles 40-minute slots across multiple date ranges", async () => {
const nextDay = dayjs.utc().add(1, "day").startOf("day");
const dateRanges = [
{
start: nextDay.hour(9),
end: nextDay.hour(10).minute(20),
},
{
start: nextDay.hour(14),
end: nextDay.hour(15).minute(20),
},
];
const slots = getSlots({
inviteeDate: nextDay,
frequency: 40,
minimumBookingNotice: 0,
dateRanges: dateRanges,
eventLength: 40,
offsetStart: 0,
});
// First range: 9:00-10:20 = 80 minutes = 2 slots (9:00, 9:40)
// Second range: 14:00-15:20 = 80 minutes = 2 slots (14:00, 14:40)
expect(slots).toHaveLength(4);
});
});