Skip to content

Commit 1e0b0d9

Browse files
devin-ai-integration[bot]ali@cal.com
andauthored
feat: add 40-minute duration option for multiple duration event types (calcom#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>
1 parent c60addc commit 1e0b0d9

2 files changed

Lines changed: 149 additions & 24 deletions

File tree

apps/web/modules/event-types/components/tabs/setup/EventSetupTab.tsx

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,28 @@
1-
import { useState } from "react";
2-
import { Controller, useFormContext } from "react-hook-form";
3-
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
4-
import type { MultiValue } from "react-select";
5-
61
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
72
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
8-
import type { LocationCustomClassNames } from "../../locations/types";
93
import type {
104
EventTypeSetupProps,
5+
FormValues,
116
InputClassNames,
7+
LocationFormValues,
128
SelectClassNames,
139
SettingsToggleClassNames,
1410
} from "@calcom/features/eventtypes/lib/types";
15-
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
1611
import { MAX_EVENT_DURATION_MINUTES, MIN_EVENT_DURATION_MINUTES } from "@calcom/lib/constants";
1712
import { useLocale } from "@calcom/lib/hooks/useLocale";
1813
import { md } from "@calcom/lib/markdownIt";
1914
import { slugify } from "@calcom/lib/slugify";
2015
import turndown from "@calcom/lib/turndownService";
2116
import classNames from "@calcom/ui/classNames";
2217
import { Editor } from "@calcom/ui/components/editor";
23-
import { TextAreaField } from "@calcom/ui/components/form";
24-
import { Label } from "@calcom/ui/components/form";
25-
import { TextField } from "@calcom/ui/components/form";
26-
import { Select } from "@calcom/ui/components/form";
27-
import { SettingsToggle } from "@calcom/ui/components/form";
18+
import { Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form";
2819
import { Skeleton } from "@calcom/ui/components/skeleton";
29-
3020
import Locations from "@calcom/web/modules/event-types/components/locations/Locations";
21+
import { useState } from "react";
22+
import type { Control, FormState, UseFormGetValues, UseFormSetValue } from "react-hook-form";
23+
import { Controller, useFormContext } from "react-hook-form";
24+
import type { MultiValue } from "react-select";
25+
import type { LocationCustomClassNames } from "../../locations/types";
3126

3227
export type EventSetupTabCustomClassNames = {
3328
wrapper?: string;
@@ -80,7 +75,7 @@ export const EventSetupTab = (
8075
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
8176

8277
const multipleDurationOptions = [
83-
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
78+
5, 10, 15, 20, 25, 30, 40, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
8479
].map((mins) => ({
8580
value: mins,
8681
label: t("multiple_duration_mins", { count: mins }),
@@ -109,7 +104,7 @@ export const EventSetupTab = (
109104
<div className={classNames("stack-y-4", customClassNames?.wrapper)}>
110105
<div
111106
className={classNames(
112-
"border-subtle stack-y-6 rounded-lg border p-6",
107+
"stack-y-6 rounded-lg border border-subtle p-6",
113108
customClassNames?.titleSection?.container
114109
)}>
115110
<TextField
@@ -169,7 +164,7 @@ export const EventSetupTab = (
169164
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
170165
addOnLeading={
171166
isPlatform ? undefined : (
172-
<span className="max-w-24 md:max-w-56 inline-block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
167+
<span className="inline-block min-w-0 max-w-24 overflow-hidden text-ellipsis whitespace-nowrap md:max-w-56">
173168
{urlPrefix}/
174169
{!isManagedEventType
175170
? team
@@ -187,7 +182,7 @@ export const EventSetupTab = (
187182
</div>
188183
<div
189184
className={classNames(
190-
"border-subtle rounded-lg border p-6",
185+
"rounded-lg border border-subtle p-6",
191186
customClassNames?.durationSection?.container
192187
)}>
193188
{multipleDuration ? (
@@ -212,7 +207,7 @@ export const EventSetupTab = (
212207
isSearchable={false}
213208
isDisabled={lengthLockedProps.disabled}
214209
className={classNames(
215-
"min-h-[36px]! h-auto text-sm",
210+
"h-auto min-h-[36px]! text-sm",
216211
customClassNames?.durationSection?.multipleDuration?.availableDurationsSelect?.select
217212
)}
218213
innerClassNames={
@@ -306,7 +301,7 @@ export const EventSetupTab = (
306301
message: t("duration_max_error", { max: MAX_EVENT_DURATION_MINUTES }),
307302
},
308303
})}
309-
addOnSuffix={<>{t("minutes")}</>}
304+
addOnSuffix={t("minutes")}
310305
min={MIN_EVENT_DURATION_MINUTES}
311306
max={MAX_EVENT_DURATION_MINUTES}
312307
/>
@@ -341,7 +336,7 @@ export const EventSetupTab = (
341336
</div>
342337
<div
343338
className={classNames(
344-
"border-subtle rounded-lg border p-6",
339+
"rounded-lg border border-subtle p-6",
345340
customClassNames?.locationSection?.container
346341
)}>
347342
<div>

packages/features/schedules/lib/slots.test.ts

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { describe, expect, it, beforeAll, vi } from "vitest";
2-
1+
import process from "node:process";
32
import dayjs from "@calcom/dayjs";
43
import type { DateRange } from "@calcom/features/schedules/lib/date-ranges";
5-
4+
import { beforeAll, describe, expect, it, vi } from "vitest";
65
import getSlots from "./slots";
76

87
let dateRangesNextDay: DateRange[];
@@ -886,7 +885,7 @@ describe("Tests the date-range slot logic with showOptimizedSlots", () => {
886885
});
887886

888887
expect(day2Slots.length).toBeGreaterThan(0);
889-
888+
890889
// Day 2 slots should be marked as away
891890
day2Slots.forEach((slot) => {
892891
expect(slot.away).toBe(true);
@@ -1055,3 +1054,134 @@ describe("Tests the date-range slot logic with showOptimizedSlots", () => {
10551054
vi.useRealTimers();
10561055
});
10571056
});
1057+
1058+
describe("Tests 40-minute duration slot generation", () => {
1059+
beforeAll(() => {
1060+
vi.setSystemTime(dayjs.utc("2021-06-20T11:59:59Z").toDate());
1061+
});
1062+
1063+
it("generates correct number of 40-minute slots for a full day", async () => {
1064+
const nextDay = dayjs.utc().add(1, "day").startOf("day");
1065+
const dateRanges = [
1066+
{
1067+
start: nextDay,
1068+
end: nextDay.endOf("day"),
1069+
},
1070+
];
1071+
1072+
const slots = getSlots({
1073+
inviteeDate: nextDay,
1074+
frequency: 40,
1075+
minimumBookingNotice: 0,
1076+
dateRanges: dateRanges,
1077+
eventLength: 40,
1078+
offsetStart: 0,
1079+
});
1080+
1081+
// 24 hours = 1440 minutes, 1440 / 40 = 36 slots
1082+
expect(slots).toHaveLength(36);
1083+
});
1084+
1085+
it("generates 40-minute slots with correct time intervals", async () => {
1086+
const nextDay = dayjs.utc().add(1, "day").startOf("day");
1087+
const dateRanges = [
1088+
{
1089+
start: nextDay.hour(9),
1090+
end: nextDay.hour(11),
1091+
},
1092+
];
1093+
1094+
const slots = getSlots({
1095+
inviteeDate: nextDay,
1096+
frequency: 40,
1097+
minimumBookingNotice: 0,
1098+
dateRanges: dateRanges,
1099+
eventLength: 40,
1100+
offsetStart: 0,
1101+
});
1102+
1103+
// 2 hours = 120 minutes, 120 / 40 = 3 slots (9:00, 9:40, 10:20)
1104+
expect(slots).toHaveLength(3);
1105+
expect(slots[0].time.format("HH:mm")).toBe("09:00");
1106+
expect(slots[1].time.format("HH:mm")).toBe("09:40");
1107+
expect(slots[2].time.format("HH:mm")).toBe("10:20");
1108+
});
1109+
1110+
it("handles 40-minute slots as default duration in variable length event", async () => {
1111+
const nextDay = dayjs.utc().add(1, "day").startOf("day");
1112+
const dateRanges = [
1113+
{
1114+
start: nextDay.hour(10),
1115+
end: nextDay.hour(12),
1116+
},
1117+
];
1118+
1119+
const slots = getSlots({
1120+
inviteeDate: nextDay,
1121+
frequency: 40,
1122+
minimumBookingNotice: 0,
1123+
dateRanges: dateRanges,
1124+
eventLength: 40,
1125+
offsetStart: 0,
1126+
});
1127+
1128+
// 2 hours = 120 minutes, 120 / 40 = 3 slots
1129+
expect(slots).toHaveLength(3);
1130+
// Verify each slot has correct 40-minute spacing
1131+
for (let i = 1; i < slots.length; i++) {
1132+
const diff = slots[i].time.diff(slots[i - 1].time, "minute");
1133+
expect(diff).toBe(40);
1134+
}
1135+
});
1136+
1137+
it("generates 40-minute slots with minimum booking notice on same day", async () => {
1138+
// System time is set to 2021-06-20T11:59:59Z (almost noon)
1139+
const today = dayjs.utc().startOf("day");
1140+
const dateRanges = [
1141+
{
1142+
start: today,
1143+
end: today.endOf("day"),
1144+
},
1145+
];
1146+
1147+
const slots = getSlots({
1148+
inviteeDate: today,
1149+
frequency: 40,
1150+
minimumBookingNotice: 0,
1151+
dateRanges: dateRanges,
1152+
eventLength: 40,
1153+
offsetStart: 0,
1154+
});
1155+
1156+
// System time is ~12:00, so only slots from 12:00 onwards should be available
1157+
// From 12:00 to 24:00 = 12 hours = 720 minutes, 720 / 40 = 18 slots
1158+
expect(slots).toHaveLength(18);
1159+
});
1160+
1161+
it("handles 40-minute slots across multiple date ranges", async () => {
1162+
const nextDay = dayjs.utc().add(1, "day").startOf("day");
1163+
const dateRanges = [
1164+
{
1165+
start: nextDay.hour(9),
1166+
end: nextDay.hour(10).minute(20),
1167+
},
1168+
{
1169+
start: nextDay.hour(14),
1170+
end: nextDay.hour(15).minute(20),
1171+
},
1172+
];
1173+
1174+
const slots = getSlots({
1175+
inviteeDate: nextDay,
1176+
frequency: 40,
1177+
minimumBookingNotice: 0,
1178+
dateRanges: dateRanges,
1179+
eventLength: 40,
1180+
offsetStart: 0,
1181+
});
1182+
1183+
// First range: 9:00-10:20 = 80 minutes = 2 slots (9:00, 9:40)
1184+
// Second range: 14:00-15:20 = 80 minutes = 2 slots (14:00, 14:40)
1185+
expect(slots).toHaveLength(4);
1186+
});
1187+
});

0 commit comments

Comments
 (0)