cal.pub0.org/packages/lib/slots.ts

361 lines
12 KiB
TypeScript
Raw Normal View History

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;
};
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,
}: {
dateRanges: DateRange[];
frequency: number;
eventLength: number;
timeZone: string;
minimumBookingNotice: number;
organizerTimeZone: string;
offsetStart?: number;
}) {
// 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[] }[] = [];
dateRanges.forEach((range) => {
const startTimeWithMinNotice = dayjs.utc().add(minimumBookingNotice, "minute");
let slotStartTime = range.start.isAfter(startTimeWithMinNotice) ? range.start : startTimeWithMinNotice;
fix: better slot starting times ## What does this PR do? Currently, we start the first slot always at the nearest 15 minutes. This is not ideal as for some duration other slot starting time make more sense. So with this PR the starting times are defined as follow: - Frequency is exact hours (60, 120, 180, ...), slot start time is a full hour - Frequency is half hours (30, 90, ...), slot start time is half or full hours (8:00, 8:30, ...) - Same with 20-minute events (20, 40, ...) and 10-minute events - Everything else will start at the nearest 15 min slot It also fixes that slot times are shifted when there is a busy slot with a different duration. Here is a before and after of a 30-min event with a 5-minute busy slot at 1:00 pm Before: ![Screenshot 2023-07-07 at 13 31 45](https://github.com/calcom/cal.com/assets/30310907/b92d4ff4-49f1-48f4-a973-99266f61d919) After ![Screenshot 2023-07-07 at 13 34 01](https://github.com/calcom/cal.com/assets/30310907/042c7ef7-8c2a-4cd9-b663-183bc07b5864) #### 30 Minute events, availability starting at 7:15 Before: ![Screenshot 2023-07-06 at 12 40 00](https://github.com/calcom/cal.com/assets/30310907/752ed978-83cf-4ee9-a38d-b5795df6daec) After: ![Screenshot 2023-07-06 at 12 40 42](https://github.com/calcom/cal.com/assets/30310907/5d51ec15-5be8-4f3b-b374-46dad35216b8) ## Type of change - Bug fix (non-breaking change which fixes an issue) ## How should this be tested? - Check if slot times are shown as described - Test with different intervals/durations - Test with busy times - Test with different availabilities ## Mandatory Tasks - [x] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
2023-07-10 22:32:26 +00:00
let previousStartTime;
// check if we we already have slots on that day (in organizer's timezone)
if (
slots.length &&
dayjs
.utc(range.start)
.add(range.start.utcOffset())
.isSame(dayjs.utc(slots[slots.length - 1].time).add(slots[slots.length - 1].time.utcOffset()), "day")
) {
previousStartTime = slots[slots.length - 1].time;
}
if (!previousStartTime) {
let interval = 15;
const intervalsWithDefinedStartTimes = [60, 30, 20, 10];
for (let i = 0; i < intervalsWithDefinedStartTimes.length; i++) {
if (frequency % intervalsWithDefinedStartTimes[i] === 0) {
interval = intervalsWithDefinedStartTimes[i];
break;
}
}
slotStartTime =
slotStartTime.utc().minute() % interval !== 0
? slotStartTime
.startOf("hour")
.add(Math.ceil(slotStartTime.minute() / interval) * interval, "minute")
: slotStartTime;
} else {
const minuteOffset =
Math.ceil(slotStartTime.diff(previousStartTime, "minutes") / frequency) * frequency;
slotStartTime = previousStartTime.add(minuteOffset, "minutes");
}
// 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");
while (!slotStartTime.add(eventLength, "minutes").subtract(1, "second").isAfter(rangeEnd)) {
slots.push({
time: slotStartTime.tz(timeZone),
});
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,
}: GetSlots) => {
if (dateRanges) {
const slots = buildSlotsWithDateRanges({
dateRanges,
frequency,
eventLength,
timeZone: getTimeZone(inviteeDate),
minimumBookingNotice,
organizerTimeZone,
offsetStart,
});
return slots;
}
// current date in invitee tz
Feature/fixed hosts (#6423) * Save design updates for fixed round robin support * wip - added Host relation * DRY hostsFixed select * Changes to allow isFixed in the Availability page * Allow booking with fixed hosts * Replace users with hosts if hosts is set * Also prefer hosts over users here * Prevent duplicates when hosts is saved * Accidental slot duplication * Attempt at making isFixed optional * Sydney and Shiraz can live in harmony again * No fixed hosts causes every to be true.. * Make hosts undefinable * Small handleNewBooking fixes * Similar fix to the hosts to check for empty-ness * Default to isFixed false instead of true * Fix event type list avatars * Filter availableTimeSlots, collective ts's wont magically re-enable. * (Further) Fixes to getAggregateWorkingHours * Weird userId artifact that preceeds this branch, investigate later * On user delete, remove host, on event type delete, also remove host * Dynamic event types were incorrectly marked as isFixed=false * Fixed notFound error when there are no users (but hosts) * Fixes userIsOwner * Oops, fixed isFixed users being included correctly * Fixed Button styling on secondary darkmode * Create exclusivity in selectable options * fix: Location dropdown is overflowing #6376 (#6415) * add `menuPlacement` react-select's props to `getReactSelectProps` to avoid dropdown overflow on small screen. By default, set to "auto". * CALCOM-6362 - [CAL-728] App Sidebar. Child items aren't sized/spaced properly (#6424) * [CAL-728] App Sidebar. Child items aren't sized/spaced properly * fix: undo logo size Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com> Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * Fixing colors (#6429) * Update website * Update console * Update yarn.lock * Uses disable instead of filtering to be more clear * Update EventTeamTab.tsx * Merge conflict cleanup * During test cases the dayjs() utcOffset is local time, this fixes that Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Thomas Brodusch <3238312+thomasbrodusch@users.noreply.github.com> Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com> Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com> Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com>
2023-01-12 21:09:12 +00:00
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;