first commit
This commit is contained in:
382
calcom/packages/lib/slots.ts
Normal file
382
calcom/packages/lib/slots.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import type { IFromUser, IOutOfOfficeData, IToUser } from "@calcom/core/getUserAvailability";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { WorkingHours, TimeRange as DateOverride } from "@calcom/types/schedule";
|
||||
|
||||
import { getWorkingHours } from "./availability";
|
||||
import { getTimeZone } from "./date-fns";
|
||||
import type { DateRange } from "./date-ranges";
|
||||
|
||||
export type GetSlots = {
|
||||
inviteeDate: Dayjs;
|
||||
frequency: number;
|
||||
workingHours?: WorkingHours[];
|
||||
dateOverrides?: DateOverride[];
|
||||
dateRanges?: DateRange[];
|
||||
minimumBookingNotice: number;
|
||||
eventLength: number;
|
||||
offsetStart?: number;
|
||||
organizerTimeZone: string;
|
||||
datesOutOfOffice?: IOutOfOfficeData;
|
||||
};
|
||||
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
|
||||
|
||||
const minimumOfOne = (input: number) => (input < 1 ? 1 : input);
|
||||
const minimumOfZero = (input: number) => (input < 0 ? 0 : input);
|
||||
|
||||
function buildSlots({
|
||||
startOfInviteeDay,
|
||||
computedLocalAvailability,
|
||||
frequency,
|
||||
eventLength,
|
||||
offsetStart = 0,
|
||||
startDate,
|
||||
organizerTimeZone,
|
||||
inviteeTimeZone,
|
||||
}: {
|
||||
computedLocalAvailability: TimeFrame[];
|
||||
startOfInviteeDay: Dayjs;
|
||||
startDate: Dayjs;
|
||||
frequency: number;
|
||||
eventLength: number;
|
||||
offsetStart?: number;
|
||||
organizerTimeZone: string;
|
||||
inviteeTimeZone: string;
|
||||
}) {
|
||||
// no slots today
|
||||
if (startOfInviteeDay.isBefore(startDate, "day")) {
|
||||
return [];
|
||||
}
|
||||
// keep the old safeguards in; may be needed.
|
||||
frequency = minimumOfOne(frequency);
|
||||
eventLength = minimumOfOne(eventLength);
|
||||
offsetStart = minimumOfZero(offsetStart);
|
||||
|
||||
// A day starts at 00:00 unless the startDate is the same as the current day
|
||||
const dayStart = startOfInviteeDay.isSame(startDate, "day")
|
||||
? Math.ceil((startDate.hour() * 60 + startDate.minute()) / frequency) * frequency
|
||||
: 0;
|
||||
|
||||
// Record type so we can use slotStart as key
|
||||
const slotsTimeFrameAvailable: Record<
|
||||
string,
|
||||
{
|
||||
userIds: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
> = {};
|
||||
// get boundaries sorted by start time.
|
||||
const boundaries = computedLocalAvailability
|
||||
.map((item) => [item.startTime < dayStart ? dayStart : item.startTime, item.endTime])
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const ranges: number[][] = [];
|
||||
let currentRange: number[] = [];
|
||||
for (const [start, end] of boundaries) {
|
||||
// bypass invalid value
|
||||
if (start >= end) continue;
|
||||
// fill first elem
|
||||
if (!currentRange.length) {
|
||||
currentRange = [start, end];
|
||||
continue;
|
||||
}
|
||||
if (currentRange[1] < start) {
|
||||
ranges.push(currentRange);
|
||||
currentRange = [start, end];
|
||||
} else if (currentRange[1] < end) {
|
||||
currentRange[1] = end;
|
||||
}
|
||||
}
|
||||
if (currentRange) {
|
||||
ranges.push(currentRange);
|
||||
}
|
||||
|
||||
for (const [boundaryStart, boundaryEnd] of ranges) {
|
||||
// loop through the day, based on frequency.
|
||||
for (
|
||||
let slotStart = boundaryStart + offsetStart;
|
||||
slotStart < boundaryEnd;
|
||||
slotStart += offsetStart + frequency
|
||||
) {
|
||||
computedLocalAvailability.forEach((item) => {
|
||||
// TODO: This logic does not allow for past-midnight bookings.
|
||||
if (slotStart < item.startTime || slotStart > item.endTime + 1 - eventLength) {
|
||||
return;
|
||||
}
|
||||
slotsTimeFrameAvailable[slotStart.toString()] = {
|
||||
userIds: (slotsTimeFrameAvailable[slotStart]?.userIds || []).concat(item.userIds || []),
|
||||
startTime: slotStart,
|
||||
endTime: slotStart + eventLength,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const organizerDSTDiff =
|
||||
dayjs().tz(organizerTimeZone).utcOffset() - startOfInviteeDay.tz(organizerTimeZone).utcOffset();
|
||||
const inviteeDSTDiff =
|
||||
dayjs().tz(inviteeTimeZone).utcOffset() - startOfInviteeDay.tz(inviteeTimeZone).utcOffset();
|
||||
const slots: { time: Dayjs; userIds?: number[] }[] = [];
|
||||
const getTime = (time: number) => {
|
||||
const minutes = time + organizerDSTDiff - inviteeDSTDiff;
|
||||
|
||||
return startOfInviteeDay.tz(inviteeTimeZone).add(minutes, "minutes");
|
||||
};
|
||||
for (const item of Object.values(slotsTimeFrameAvailable)) {
|
||||
/*
|
||||
* @calcom/web:dev: 2022-11-06T00:00:00-04:00
|
||||
* @calcom/web:dev: 2022-11-06T01:00:00-04:00
|
||||
* @calcom/web:dev: 2022-11-06T01:00:00-04:00 <-- note there is no offset change, but we did lose an hour.
|
||||
* @calcom/web:dev: 2022-11-06T02:00:00-04:00
|
||||
* @calcom/web:dev: 2022-11-06T03:00:00-04:00
|
||||
* ...
|
||||
*/
|
||||
slots.push({
|
||||
userIds: item.userIds,
|
||||
time: getTime(item.startTime),
|
||||
});
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function buildSlotsWithDateRanges({
|
||||
dateRanges,
|
||||
frequency,
|
||||
eventLength,
|
||||
timeZone,
|
||||
minimumBookingNotice,
|
||||
organizerTimeZone,
|
||||
offsetStart,
|
||||
datesOutOfOffice,
|
||||
}: {
|
||||
dateRanges: DateRange[];
|
||||
frequency: number;
|
||||
eventLength: number;
|
||||
timeZone: string;
|
||||
minimumBookingNotice: number;
|
||||
organizerTimeZone: string;
|
||||
offsetStart?: number;
|
||||
datesOutOfOffice?: IOutOfOfficeData;
|
||||
}) {
|
||||
// keep the old safeguards in; may be needed.
|
||||
frequency = minimumOfOne(frequency);
|
||||
eventLength = minimumOfOne(eventLength);
|
||||
offsetStart = offsetStart ? minimumOfOne(offsetStart) : 0;
|
||||
const slots: {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
dateRanges.forEach((range) => {
|
||||
const dateYYYYMMDD = range.start.format("YYYY-MM-DD");
|
||||
const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute");
|
||||
|
||||
let slotStartTime = range.start.utc().isAfter(startTimeWithMinNotice)
|
||||
? range.start
|
||||
: startTimeWithMinNotice;
|
||||
|
||||
slotStartTime =
|
||||
slotStartTime.minute() % interval !== 0
|
||||
? slotStartTime.startOf("hour").add(Math.ceil(slotStartTime.minute() / interval) * interval, "minute")
|
||||
: slotStartTime;
|
||||
|
||||
// Adding 1 minute to date ranges that end at midnight to ensure that the last slot is included
|
||||
const rangeEnd = range.end
|
||||
.add(dayjs().tz(organizerTimeZone).utcOffset(), "minutes")
|
||||
.isSame(range.end.endOf("day").add(dayjs().tz(organizerTimeZone).utcOffset(), "minutes"), "minute")
|
||||
? range.end.add(1, "minute")
|
||||
: range.end;
|
||||
|
||||
slotStartTime = slotStartTime.add(offsetStart ?? 0, "minutes").tz(timeZone);
|
||||
|
||||
while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").utc().isAfter(rangeEnd)) {
|
||||
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.push(slotData);
|
||||
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes");
|
||||
}
|
||||
});
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function fromIndex<T>(cb: (val: T, i: number, a: T[]) => boolean, index: number) {
|
||||
return function (e: T, i: number, a: T[]) {
|
||||
return i >= index && cb(e, i, a);
|
||||
};
|
||||
}
|
||||
|
||||
const getSlots = ({
|
||||
inviteeDate,
|
||||
frequency,
|
||||
minimumBookingNotice,
|
||||
workingHours = [],
|
||||
dateOverrides = [],
|
||||
dateRanges,
|
||||
eventLength,
|
||||
offsetStart = 0,
|
||||
organizerTimeZone,
|
||||
datesOutOfOffice,
|
||||
}: GetSlots) => {
|
||||
if (dateRanges) {
|
||||
const slots = buildSlotsWithDateRanges({
|
||||
dateRanges,
|
||||
frequency,
|
||||
eventLength,
|
||||
timeZone: getTimeZone(inviteeDate),
|
||||
minimumBookingNotice,
|
||||
organizerTimeZone,
|
||||
offsetStart,
|
||||
datesOutOfOffice,
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
// current date in invitee tz
|
||||
const startDate = dayjs().utcOffset(inviteeDate.utcOffset()).add(minimumBookingNotice, "minute");
|
||||
|
||||
// This code is ran client side, startOf() does some conversions based on the
|
||||
// local tz of the client. Sometimes this shifts the day incorrectly.
|
||||
const startOfDayUTC = dayjs.utc().set("hour", 0).set("minute", 0).set("second", 0);
|
||||
const startOfInviteeDay = inviteeDate.startOf("day");
|
||||
// checks if the start date is in the past
|
||||
|
||||
/**
|
||||
* TODO: change "day" for "hour" to stop displaying 1 day before today
|
||||
* This is displaying a day as available as sometimes difference between two dates is < 24 hrs.
|
||||
* But when doing timezones an available day for an owner can be 2 days available in other users tz.
|
||||
*
|
||||
* */
|
||||
if (inviteeDate.isBefore(startDate, "day")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const timeZone: string = getTimeZone(inviteeDate);
|
||||
const workingHoursUTC = workingHours.map((schedule) => ({
|
||||
userId: schedule.userId,
|
||||
days: schedule.days,
|
||||
startTime: /* Why? */ startOfDayUTC.add(schedule.startTime, "minute"),
|
||||
endTime: /* Why? */ startOfDayUTC.add(schedule.endTime, "minute"),
|
||||
}));
|
||||
|
||||
const localWorkingHours = getWorkingHours(
|
||||
{
|
||||
// initialize current day with timeZone without conversion, just parse.
|
||||
utcOffset: -dayjs.tz(dayjs(), timeZone).utcOffset(),
|
||||
},
|
||||
workingHoursUTC
|
||||
).filter((hours) => hours.days.includes(inviteeDate.day()));
|
||||
|
||||
// Here we split working hour in chunks for every frequency available that can fit in whole working hours
|
||||
const computedLocalAvailability: TimeFrame[] = [];
|
||||
let tempComputeTimeFrame: TimeFrame | undefined;
|
||||
const computeLength = localWorkingHours.length - 1;
|
||||
const makeTimeFrame = (item: (typeof localWorkingHours)[0]): TimeFrame => ({
|
||||
userIds: item.userId ? [item.userId] : [],
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
});
|
||||
|
||||
localWorkingHours.forEach((item, index) => {
|
||||
if (!tempComputeTimeFrame) {
|
||||
tempComputeTimeFrame = makeTimeFrame(item);
|
||||
} else {
|
||||
// please check the comment in splitAvailableTime func for the added 1 minute
|
||||
if (tempComputeTimeFrame.endTime + 1 === item.startTime) {
|
||||
// to deal with time that across the day, e.g. from 11:59 to to 12:01
|
||||
tempComputeTimeFrame.endTime = item.endTime;
|
||||
} else {
|
||||
computedLocalAvailability.push(tempComputeTimeFrame);
|
||||
tempComputeTimeFrame = makeTimeFrame(item);
|
||||
}
|
||||
}
|
||||
if (index == computeLength) {
|
||||
computedLocalAvailability.push(tempComputeTimeFrame);
|
||||
}
|
||||
});
|
||||
// an override precedes all the local working hour availability logic.
|
||||
const activeOverrides = dateOverrides.filter((override) => {
|
||||
return dayjs.utc(override.start).isBetween(startOfInviteeDay, startOfInviteeDay.endOf("day"), null, "[)");
|
||||
});
|
||||
|
||||
if (activeOverrides.length) {
|
||||
const overrides = activeOverrides.flatMap((override) => ({
|
||||
userIds: override.userId ? [override.userId] : [],
|
||||
startTime: override.start.getUTCHours() * 60 + override.start.getUTCMinutes(),
|
||||
endTime: override.end.getUTCHours() * 60 + override.end.getUTCMinutes(),
|
||||
}));
|
||||
// unset all working hours that relate to this user availability override
|
||||
overrides.forEach((override) => {
|
||||
let i = -1;
|
||||
const indexes: number[] = [];
|
||||
while (
|
||||
(i = computedLocalAvailability.findIndex(
|
||||
fromIndex(
|
||||
(a) => !a.userIds?.length || (!!override.userIds[0] && a.userIds?.includes(override.userIds[0])),
|
||||
i + 1
|
||||
)
|
||||
)) != -1
|
||||
) {
|
||||
indexes.push(i);
|
||||
}
|
||||
// work backwards as splice modifies the original array.
|
||||
indexes.reverse().forEach((idx) => computedLocalAvailability.splice(idx, 1));
|
||||
});
|
||||
// and push all overrides as new computed availability
|
||||
computedLocalAvailability.push(...overrides);
|
||||
}
|
||||
|
||||
return buildSlots({
|
||||
computedLocalAvailability,
|
||||
startOfInviteeDay,
|
||||
startDate,
|
||||
frequency,
|
||||
eventLength,
|
||||
offsetStart,
|
||||
organizerTimeZone,
|
||||
inviteeTimeZone: timeZone,
|
||||
});
|
||||
};
|
||||
|
||||
export default getSlots;
|
||||
Reference in New Issue
Block a user