From 1e0b0d949f54a35d45de262b4e4b3441a4dc5872 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:43:55 +0400 Subject: [PATCH] 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 --- .../components/tabs/setup/EventSetupTab.tsx | 35 ++--- packages/features/schedules/lib/slots.test.ts | 138 +++++++++++++++++- 2 files changed, 149 insertions(+), 24 deletions(-) diff --git a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx index 1ce751939d..c06675105e 100644 --- a/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx +++ b/apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx @@ -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 = (
+ {urlPrefix}/ {!isManagedEventType ? team @@ -187,7 +182,7 @@ export const EventSetupTab = (
{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 = (
diff --git a/packages/features/schedules/lib/slots.test.ts b/packages/features/schedules/lib/slots.test.ts index 62b2742108..ad6e5acb2f 100644 --- a/packages/features/schedules/lib/slots.test.ts +++ b/packages/features/schedules/lib/slots.test.ts @@ -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); + }); +});