Files
calendar/packages/lib/slots.ts
T
0bcb81bedd feat: to enable optimized slots (#16579)
* feature to enable optimized slots

* type check (was not caught running locally)

* update for typechecks

* update for type checks

* initialize form

* update slots test for showOptimizedSlots

* added some more tests

* handled edge case

* added test for edge case

* generalized condition

* refactored instead of ternary and added comments

* update condition

* Update apps/web/public/static/locales/en/common.json

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* moved showOptimizedSlots setting to advanced tab

* updated to always move to next 15min if not possible to next interval

* correct after merge

* chore

* fix typecheck after latest merges

* remove workinghours when dateRanges input

* refactor: extract to fn

* chore

* remove unrelated changes

* remove unrealted change

* updated tests

* unrelated format changes no-verify

* unrelated format changes no-verify

* handle current day booking cases, to show roundedoff minutes

* remove unrelated format changes in openapi.json

* add test for new condition

* remove unrelated auto-format changes

* remove unrelated auto-format changes

* update after merge

* For current day bookings, normalizing the seconds to zero to avoid issues with time calculations

* Update packages/platform/atoms/event-types/hooks/useEventTypeForm.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* update for typecheck due to latest changes

* resolve merge conflicts

* Smaller description

* Minor style change to prefer early return

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-30 14:57:24 +00:00

243 lines
8.1 KiB
TypeScript

import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { IFromUser, IOutOfOfficeData, IToUser } from "@calcom/lib/getUserAvailability";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { DateRange } from "./date-ranges";
import { getTimeZone } from "./dayjs";
export type GetSlots = {
inviteeDate: Dayjs;
frequency: number;
dateRanges: DateRange[];
minimumBookingNotice: number;
eventLength: number;
offsetStart?: number;
datesOutOfOffice?: IOutOfOfficeData;
showOptimizedSlots?: boolean | null;
};
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
const minimumOfOne = (input: number) => (input < 1 ? 1 : input);
function getCorrectedSlotStartTime({
slotStartTime,
range,
showOptimizedSlots,
interval,
}: {
showOptimizedSlots: boolean | null | undefined;
interval: number;
slotStartTime: Dayjs;
range: DateRange;
}) {
if (showOptimizedSlots) {
let correctedSlotStartTime = slotStartTime;
// if showOptimizedSlots option is selected, the slotStartTime should not be modified,
// so that maximum possible slots are shown.
// The below logic in this entire `if branch` only tries to add an increment if sufficient minutes are available (after max possible slots are consumed),
// so that slots are shown respecting the 'Start of the Hour'.
const minutesRequiredToMoveToNextSlot = interval - (slotStartTime.minute() % interval);
const minutesRequiredToMoveTo15MinSlot = 15 - (slotStartTime.minute() % 15);
const minutesRequiredToMoveTo5MinSlot = 5 - (slotStartTime.minute() % 5);
const extraMinutesAvailable = range.end.diff(slotStartTime, "minutes") % interval;
if (extraMinutesAvailable >= minutesRequiredToMoveToNextSlot) {
// For cases like, Availability -> 9:05 - 12:00, 60Min EventTypes.
// Total available minutes are 175, so only 2 60Min slots can be provided max
// And still 175-120 = 55mins are available, hence 'slotStartTime' is pushed to 10:00 to respect 'Start of the Hour'.
// Slots will be shown as '10:00, 11:00' instead of '09:05, 10:05'
correctedSlotStartTime = slotStartTime.add(minutesRequiredToMoveToNextSlot, "minute");
} else if (extraMinutesAvailable >= minutesRequiredToMoveTo15MinSlot) {
// For cases like, Availability -> 9:05 - 11:55, 60Min EventTypes.
// Total available minutes are 170, so only 2 60Min slots can be provided max
// And still 175-120 = 50mins are available, but it is less 55mins which is required to push to 10:00
// so slotStartTime is pushed to next 15Min slot 09:15, instead of showing slots like 9:05,10:05 now slots will be 9:15,10:15
correctedSlotStartTime = slotStartTime.add(minutesRequiredToMoveTo15MinSlot, "minute");
} else if (extraMinutesAvailable >= minutesRequiredToMoveTo5MinSlot) {
// so slotStartTime is pushed to next 5Min, instead of showing slots like 11:22,11:37 now slots will be 11:25,11:40
correctedSlotStartTime = slotStartTime.add(minutesRequiredToMoveTo5MinSlot, "minute");
}
return correctedSlotStartTime;
}
return slotStartTime.startOf("hour").add(Math.ceil(slotStartTime.minute() / interval) * interval, "minute");
}
function buildSlotsWithDateRanges({
dateRanges,
frequency,
eventLength,
timeZone,
minimumBookingNotice,
offsetStart,
datesOutOfOffice,
showOptimizedSlots,
}: {
dateRanges: DateRange[];
frequency: number;
eventLength: number;
timeZone: string;
minimumBookingNotice: number;
offsetStart?: number;
datesOutOfOffice?: IOutOfOfficeData;
showOptimizedSlots?: boolean | null;
}) {
// keep the old safeguards in; may be needed.
frequency = minimumOfOne(frequency);
eventLength = minimumOfOne(eventLength);
offsetStart = offsetStart ? minimumOfOne(offsetStart) : 0;
const orderedDateRanges = dateRanges.sort((a, b) => a.start.valueOf() - b.start.valueOf());
// there can only ever be one slot at a given start time, and based on duration also only a single length.
const slots = new Map<
string,
{
time: Dayjs;
userIds?: number[];
away?: boolean;
fromUser?: IFromUser;
toUser?: IToUser;
reason?: string;
emoji?: string;
}
>();
let interval = Number(process.env.NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL) || 1;
const intervalsWithDefinedStartTimes = [60, 30, 20, 15, 10, 5];
for (let i = 0; i < intervalsWithDefinedStartTimes.length; i++) {
if (frequency % intervalsWithDefinedStartTimes[i] === 0) {
interval = intervalsWithDefinedStartTimes[i];
break;
}
}
const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute");
const slotBoundaries = new Map<number, true>();
orderedDateRanges.forEach((range) => {
const dateYYYYMMDD = range.start.format("YYYY-MM-DD");
let slotStartTime = range.start.utc().isAfter(startTimeWithMinNotice)
? range.start
: startTimeWithMinNotice;
// For current day bookings, normalizing the seconds to zero to avoid issues with time calculations
slotStartTime = slotStartTime.set("second", 0).set("millisecond", 0);
if (slotStartTime.minute() % interval !== 0) {
slotStartTime = getCorrectedSlotStartTime({
showOptimizedSlots,
interval,
slotStartTime,
range,
});
}
slotStartTime = slotStartTime.add(offsetStart ?? 0, "minutes").tz(timeZone);
// Find the nearest appropriate slot boundary if this time falls within an existing slot
const slotBoundariesValueArray = Array.from(slotBoundaries.keys());
if (slotBoundariesValueArray.length > 0) {
slotBoundariesValueArray.sort((a, b) => a - b);
let prevBoundary = null;
for (let i = slotBoundariesValueArray.length - 1; i >= 0; i--) {
if (slotBoundariesValueArray[i] < slotStartTime.valueOf()) {
prevBoundary = slotBoundariesValueArray[i];
break;
}
}
if (prevBoundary) {
const prevBoundaryEnd = dayjs(prevBoundary).add(frequency + (offsetStart ?? 0), "minutes");
if (prevBoundaryEnd.isAfter(slotStartTime)) {
const dayjsPrevBoundary = dayjs(prevBoundary);
if (!dayjsPrevBoundary.isBefore(range.start)) {
slotStartTime = dayjsPrevBoundary;
} else {
slotStartTime = prevBoundaryEnd;
}
slotStartTime = slotStartTime.tz(timeZone);
}
}
}
while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").utc().isAfter(range.end)) {
const slotKey = slotStartTime.toISOString();
if (slots.has(slotKey)) {
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
continue;
}
slotBoundaries.set(slotStartTime.valueOf(), true);
const dateOutOfOfficeExists = datesOutOfOffice?.[dateYYYYMMDD];
let slotData: {
time: Dayjs;
userIds?: number[];
away?: boolean;
fromUser?: IFromUser;
toUser?: IToUser;
reason?: string;
emoji?: string;
} = {
time: slotStartTime,
};
if (dateOutOfOfficeExists) {
const { toUser, fromUser, reason, emoji } = dateOutOfOfficeExists;
slotData = {
time: slotStartTime,
away: true,
...(fromUser && { fromUser }),
...(toUser && { toUser }),
...(reason && { reason }),
...(emoji && { emoji }),
};
}
slots.set(slotKey, slotData);
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
}
});
return Array.from(slots.values());
}
const getSlots = ({
inviteeDate,
frequency,
minimumBookingNotice,
dateRanges,
eventLength,
offsetStart = 0,
datesOutOfOffice,
showOptimizedSlots,
}: GetSlots): {
time: Dayjs;
userIds?: number[];
away?: boolean;
fromUser?: IFromUser;
toUser?: IToUser;
reason?: string;
emoji?: string;
}[] => {
return buildSlotsWithDateRanges({
dateRanges,
frequency,
eventLength,
timeZone: getTimeZone(inviteeDate),
minimumBookingNotice,
offsetStart,
datesOutOfOffice,
showOptimizedSlots,
});
};
export default withReporting(getSlots, "getSlots");