feat: date range overhaul (#9802)
Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
parent
9c2e15f016
commit
7b1fbd2853
|
@ -96,8 +96,8 @@ const TestData = {
|
||||||
userId: null,
|
userId: null,
|
||||||
eventTypeId: null,
|
eventTypeId: null,
|
||||||
days: [0, 1, 2, 3, 4, 5, 6],
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
startTime: "1970-01-01T09:30:00.000Z",
|
startTime: new Date("1970-01-01T09:30:00.000Z"),
|
||||||
endTime: "1970-01-01T18:00:00.000Z",
|
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||||
date: null,
|
date: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -111,16 +111,16 @@ const TestData = {
|
||||||
userId: null,
|
userId: null,
|
||||||
eventTypeId: null,
|
eventTypeId: null,
|
||||||
days: [0, 1, 2, 3, 4, 5, 6],
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
startTime: "1970-01-01T09:30:00.000Z",
|
startTime: new Date("1970-01-01T09:30:00.000Z"),
|
||||||
endTime: "1970-01-01T18:00:00.000Z",
|
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||||
date: null,
|
date: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userId: null,
|
userId: null,
|
||||||
eventTypeId: null,
|
eventTypeId: null,
|
||||||
days: [0, 1, 2, 3, 4, 5, 6],
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
startTime: `1970-01-01T14:00:00.000Z`,
|
startTime: new Date("1970-01-01T14:00:00.000Z"),
|
||||||
endTime: `1970-01-01T18:00:00.000Z`,
|
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||||
date: dateString,
|
date: dateString,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -170,8 +170,8 @@ type InputUser = typeof TestData.users.example & { id: number } & {
|
||||||
userId: number | null;
|
userId: number | null;
|
||||||
eventTypeId: number | null;
|
eventTypeId: number | null;
|
||||||
days: number[];
|
days: number[];
|
||||||
startTime: string;
|
startTime: Date;
|
||||||
endTime: string;
|
endTime: Date;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
}[];
|
}[];
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
|
@ -392,16 +392,17 @@ describe("getSchedule", () => {
|
||||||
expect(scheduleForDayWithOneBooking).toHaveTimeSlots(
|
expect(scheduleForDayWithOneBooking).toHaveTimeSlots(
|
||||||
[
|
[
|
||||||
// "04:00:00.000Z", - This slot is unavailable because of the booking from 4:00 to 4:15
|
// "04:00:00.000Z", - This slot is unavailable because of the booking from 4:00 to 4:15
|
||||||
"04:45:00.000Z",
|
`04:15:00.000Z`,
|
||||||
"05:30:00.000Z",
|
`05:00:00.000Z`,
|
||||||
"06:15:00.000Z",
|
`05:45:00.000Z`,
|
||||||
"07:00:00.000Z",
|
`06:30:00.000Z`,
|
||||||
"07:45:00.000Z",
|
`07:15:00.000Z`,
|
||||||
"08:30:00.000Z",
|
`08:00:00.000Z`,
|
||||||
"09:15:00.000Z",
|
`08:45:00.000Z`,
|
||||||
"10:00:00.000Z",
|
`09:30:00.000Z`,
|
||||||
"10:45:00.000Z",
|
`10:15:00.000Z`,
|
||||||
"11:30:00.000Z",
|
`11:00:00.000Z`,
|
||||||
|
`11:45:00.000Z`,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
dateString: plus3DateString,
|
dateString: plus3DateString,
|
||||||
|
@ -845,6 +846,7 @@ describe("getSchedule", () => {
|
||||||
// A default Event Type which this user owns
|
// A default Event Type which this user owns
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
length: 15,
|
||||||
slotInterval: 45,
|
slotInterval: 45,
|
||||||
users: [{ id: 101 }],
|
users: [{ id: 101 }],
|
||||||
},
|
},
|
||||||
|
@ -900,17 +902,17 @@ describe("getSchedule", () => {
|
||||||
expect(thisUserAvailability).toHaveTimeSlots(
|
expect(thisUserAvailability).toHaveTimeSlots(
|
||||||
[
|
[
|
||||||
// `04:00:00.000Z`, // <- This slot should be occupied by the Collective Event
|
// `04:00:00.000Z`, // <- This slot should be occupied by the Collective Event
|
||||||
`04:45:00.000Z`,
|
`04:15:00.000Z`,
|
||||||
`05:30:00.000Z`,
|
`05:00:00.000Z`,
|
||||||
`06:15:00.000Z`,
|
`05:45:00.000Z`,
|
||||||
`07:00:00.000Z`,
|
`06:30:00.000Z`,
|
||||||
`07:45:00.000Z`,
|
`07:15:00.000Z`,
|
||||||
`08:30:00.000Z`,
|
`08:00:00.000Z`,
|
||||||
`09:15:00.000Z`,
|
`08:45:00.000Z`,
|
||||||
`10:00:00.000Z`,
|
`09:30:00.000Z`,
|
||||||
`10:45:00.000Z`,
|
`10:15:00.000Z`,
|
||||||
`11:30:00.000Z`,
|
`11:00:00.000Z`,
|
||||||
`12:15:00.000Z`,
|
`11:45:00.000Z`,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
dateString: plus2DateString,
|
dateString: plus2DateString,
|
||||||
|
@ -932,6 +934,7 @@ describe("getSchedule", () => {
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
slotInterval: 45,
|
slotInterval: 45,
|
||||||
|
schedulingType: "COLLECTIVE",
|
||||||
length: 45,
|
length: 45,
|
||||||
users: [
|
users: [
|
||||||
{
|
{
|
||||||
|
@ -1018,21 +1021,23 @@ describe("getSchedule", () => {
|
||||||
endTime: `${plus2DateString}T18:29:59.999Z`,
|
endTime: `${plus2DateString}T18:29:59.999Z`,
|
||||||
timeZone: Timezones["+5:30"],
|
timeZone: Timezones["+5:30"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// A user with blocked time in another event, still affects Team Event availability
|
// A user with blocked time in another event, still affects Team Event availability
|
||||||
// It's a collective availability, so both user 101 and 102 are considered for timeslots
|
// It's a collective availability, so both user 101 and 102 are considered for timeslots
|
||||||
expect(scheduleForTeamEventOnADayWithOneBookingForEachUser).toHaveTimeSlots(
|
expect(scheduleForTeamEventOnADayWithOneBookingForEachUser).toHaveTimeSlots(
|
||||||
[
|
[
|
||||||
//`04:00:00.000Z`, - Blocked with User 101
|
//`04:00:00.000Z`, - Blocked with User 101
|
||||||
`04:45:00.000Z`,
|
`04:15:00.000Z`,
|
||||||
//`05:30:00.000Z`, - Blocked with User 102 in event 2
|
//`05:30:00.000Z`, - Blocked with User 102 in event 2
|
||||||
`06:15:00.000Z`,
|
`05:45:00.000Z`,
|
||||||
`07:00:00.000Z`,
|
`06:30:00.000Z`,
|
||||||
`07:45:00.000Z`,
|
`07:15:00.000Z`,
|
||||||
`08:30:00.000Z`,
|
`08:00:00.000Z`,
|
||||||
`09:15:00.000Z`,
|
`08:45:00.000Z`,
|
||||||
`10:00:00.000Z`,
|
`09:30:00.000Z`,
|
||||||
`10:45:00.000Z`,
|
`10:15:00.000Z`,
|
||||||
`11:30:00.000Z`,
|
`11:00:00.000Z`,
|
||||||
|
`11:45:00.000Z`,
|
||||||
],
|
],
|
||||||
{ dateString: plus2DateString }
|
{ dateString: plus2DateString }
|
||||||
);
|
);
|
||||||
|
@ -1151,16 +1156,17 @@ describe("getSchedule", () => {
|
||||||
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots(
|
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots(
|
||||||
[
|
[
|
||||||
//`04:00:00.000Z`, // - Blocked with User 101 as well as User 102, so not available in Round Robin
|
//`04:00:00.000Z`, // - Blocked with User 101 as well as User 102, so not available in Round Robin
|
||||||
`04:45:00.000Z`,
|
`04:15:00.000Z`,
|
||||||
`05:30:00.000Z`,
|
`05:00:00.000Z`,
|
||||||
`06:15:00.000Z`,
|
`05:45:00.000Z`,
|
||||||
`07:00:00.000Z`,
|
`06:30:00.000Z`,
|
||||||
`07:45:00.000Z`,
|
`07:15:00.000Z`,
|
||||||
`08:30:00.000Z`,
|
`08:00:00.000Z`,
|
||||||
`09:15:00.000Z`,
|
`08:45:00.000Z`,
|
||||||
`10:00:00.000Z`,
|
`09:30:00.000Z`,
|
||||||
`10:45:00.000Z`,
|
`10:15:00.000Z`,
|
||||||
`11:30:00.000Z`,
|
`11:00:00.000Z`,
|
||||||
|
`11:45:00.000Z`,
|
||||||
],
|
],
|
||||||
{ dateString: plus3DateString }
|
{ dateString: plus3DateString }
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,7 +8,7 @@ import type { WorkingHours } from "@calcom/types/schedule";
|
||||||
export const getAggregateWorkingHours = (
|
export const getAggregateWorkingHours = (
|
||||||
usersWorkingHoursAndBusySlots: (Omit<
|
usersWorkingHoursAndBusySlots: (Omit<
|
||||||
Awaited<ReturnType<Awaited<typeof import("./getUserAvailability")>["getUserAvailability"]>>,
|
Awaited<ReturnType<Awaited<typeof import("./getUserAvailability")>["getUserAvailability"]>>,
|
||||||
"currentSeats"
|
"currentSeats" | "dateRanges"
|
||||||
> & { user?: { isFixed?: boolean } })[],
|
> & { user?: { isFixed?: boolean } })[],
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
schedulingType: SchedulingType | null
|
schedulingType: SchedulingType | null
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { DateRange } from "@calcom/lib/date-ranges";
|
||||||
|
import { intersect } from "@calcom/lib/date-ranges";
|
||||||
|
import { SchedulingType } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
export const getAggregatedAvailability = (
|
||||||
|
userAvailability: (Omit<
|
||||||
|
Awaited<ReturnType<Awaited<typeof import("./getUserAvailability")>["getUserAvailability"]>>,
|
||||||
|
"currentSeats"
|
||||||
|
> & { user?: { isFixed?: boolean } })[],
|
||||||
|
schedulingType: SchedulingType | null
|
||||||
|
): DateRange[] => {
|
||||||
|
const fixedHosts = userAvailability.filter(
|
||||||
|
({ user }) => !schedulingType || schedulingType === SchedulingType.COLLECTIVE || user?.isFixed
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateRangesToIntersect = fixedHosts.map((s) => s.dateRanges);
|
||||||
|
|
||||||
|
const unfixedHosts = userAvailability.filter(({ user }) => user?.isFixed !== true);
|
||||||
|
if (unfixedHosts.length) {
|
||||||
|
dateRangesToIntersect.push(unfixedHosts.flatMap((s) => s.dateRanges));
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = intersect(dateRangesToIntersect);
|
||||||
|
|
||||||
|
return mergeOverlappingDateRanges(availability);
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeOverlappingDateRanges(dateRanges: DateRange[]) {
|
||||||
|
const sortedDateRanges = dateRanges.sort((a, b) => a.start.diff(b.start)); //is it already sorted before?
|
||||||
|
|
||||||
|
const mergedDateRanges: DateRange[] = [];
|
||||||
|
|
||||||
|
let currentRange = sortedDateRanges[0];
|
||||||
|
if (!currentRange) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < sortedDateRanges.length; i++) {
|
||||||
|
const nextRange = sortedDateRanges[i];
|
||||||
|
if (
|
||||||
|
currentRange.start.utc().format("DD MM YY") === nextRange.start.utc().format("DD MM YY") &&
|
||||||
|
currentRange.end.isAfter(nextRange.start)
|
||||||
|
) {
|
||||||
|
currentRange = {
|
||||||
|
start: currentRange.start,
|
||||||
|
end: currentRange.end.isAfter(nextRange.end) ? currentRange.end : nextRange.end,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
mergedDateRanges.push(currentRange);
|
||||||
|
currentRange = nextRange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mergedDateRanges.push(currentRange);
|
||||||
|
|
||||||
|
return mergedDateRanges;
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import type { Dayjs } from "@calcom/dayjs";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
|
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
|
||||||
import { getWorkingHours } from "@calcom/lib/availability";
|
import { getWorkingHours } from "@calcom/lib/availability";
|
||||||
|
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { checkBookingLimit } from "@calcom/lib/server";
|
import { checkBookingLimit } from "@calcom/lib/server";
|
||||||
|
@ -244,9 +245,22 @@ export async function getUserAvailability(
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dateRanges = buildDateRanges({
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
availability,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedBusyTimes = bufferedBusyTimes.map((busy) => ({
|
||||||
|
start: dayjs(busy.start),
|
||||||
|
end: dayjs(busy.end),
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
busy: bufferedBusyTimes,
|
busy: bufferedBusyTimes,
|
||||||
timeZone,
|
timeZone,
|
||||||
|
dateRanges: subtract(dateRanges, formattedBusyTimes),
|
||||||
workingHours,
|
workingHours,
|
||||||
dateOverrides,
|
dateOverrides,
|
||||||
currentSeats,
|
currentSeats,
|
||||||
|
|
|
@ -345,11 +345,7 @@ async function ensureAvailableUsers(
|
||||||
const availableUsers: IsFixedAwareUser[] = [];
|
const availableUsers: IsFixedAwareUser[] = [];
|
||||||
/** Let's start checking for availability */
|
/** Let's start checking for availability */
|
||||||
for (const user of eventType.users) {
|
for (const user of eventType.users) {
|
||||||
const {
|
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
|
||||||
busy: bufferedBusyTimes,
|
|
||||||
workingHours,
|
|
||||||
dateOverrides,
|
|
||||||
} = await getUserAvailability(
|
|
||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
|
@ -358,18 +354,7 @@ async function ensureAvailableUsers(
|
||||||
{ user, eventType }
|
{ user, eventType }
|
||||||
);
|
);
|
||||||
|
|
||||||
// check if time slot is outside of schedule.
|
if (!dateRanges.length) {
|
||||||
if (
|
|
||||||
!isWithinAvailableHours(
|
|
||||||
{ start: input.dateFrom, end: input.dateTo },
|
|
||||||
{
|
|
||||||
workingHours,
|
|
||||||
dateOverrides,
|
|
||||||
organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || user.timeZone,
|
|
||||||
inviteeTimeZone: input.timeZone,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// user does not have availability at this time, skip user.
|
// user does not have availability at this time, skip user.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
|
|
||||||
|
import { buildDateRanges, processDateOverride, processWorkingHours, subtract } from "./date-ranges";
|
||||||
|
|
||||||
|
describe("processWorkingHours", () => {
|
||||||
|
it("should return the correct working hours given a specific availability, timezone, and date range", () => {
|
||||||
|
const item = {
|
||||||
|
days: [1, 2, 3, 4, 5], // Monday to Friday
|
||||||
|
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
|
||||||
|
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeZone = "America/New_York";
|
||||||
|
const dateFrom = dayjs.utc().startOf("day").day(2).add(1, "week");
|
||||||
|
const dateTo = dayjs.utc().endOf("day").day(3).add(1, "week");
|
||||||
|
|
||||||
|
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo });
|
||||||
|
|
||||||
|
expect(results.length).toBe(2); // There should be two working days between the range
|
||||||
|
// "America/New_York" day shifts -1, so we need to add a day to correct this shift.
|
||||||
|
expect(results[0]).toEqual({
|
||||||
|
start: dayjs(`${dateFrom.tz(timeZone).add(1, "day").format("YYYY-MM-DD")}T12:00:00Z`).tz(timeZone),
|
||||||
|
end: dayjs(`${dateFrom.tz(timeZone).add(1, "day").format("YYYY-MM-DD")}T21:00:00Z`).tz(timeZone),
|
||||||
|
});
|
||||||
|
expect(results[1]).toEqual({
|
||||||
|
start: dayjs(`${dateTo.tz(timeZone).format("YYYY-MM-DD")}T12:00:00Z`).tz(timeZone),
|
||||||
|
end: dayjs(`${dateTo.tz(timeZone).format("YYYY-MM-DD")}T21:00:00Z`).tz(timeZone),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processDateOverrides", () => {
|
||||||
|
it("should return the correct date override given a specific availability, timezone, and date", () => {
|
||||||
|
const item = {
|
||||||
|
date: new Date(Date.UTC(2023, 5, 12, 8, 0)),
|
||||||
|
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
|
||||||
|
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2023-06-12T20:00:00-04:00 (America/New_York)
|
||||||
|
const timeZone = "America/New_York";
|
||||||
|
|
||||||
|
const result = processDateOverride({ item, timeZone });
|
||||||
|
|
||||||
|
expect(result.start.format()).toEqual(dayjs("2023-06-12T12:00:00Z").tz(timeZone).format());
|
||||||
|
expect(result.end.format()).toEqual(dayjs("2023-06-12T21:00:00Z").tz(timeZone).format());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDateRanges", () => {
|
||||||
|
it("should return the correct date ranges", () => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
date: new Date(Date.UTC(2023, 5, 13)),
|
||||||
|
startTime: new Date(Date.UTC(0, 0, 0, 10, 0)), // 10 AM
|
||||||
|
endTime: new Date(Date.UTC(0, 0, 0, 15, 0)), // 3 PM
|
||||||
|
},
|
||||||
|
{
|
||||||
|
days: [1, 2, 3, 4, 5],
|
||||||
|
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
|
||||||
|
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)), // 5 PM
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const dateFrom = dayjs("2023-06-13T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York)
|
||||||
|
const dateTo = dayjs("2023-06-15T00:00:00Z");
|
||||||
|
|
||||||
|
const timeZone = "America/New_York";
|
||||||
|
|
||||||
|
const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
|
||||||
|
// [
|
||||||
|
// { s: '2023-06-13T10:00:00-04:00', e: '2023-06-13T15:00:00-04:00' },
|
||||||
|
// { s: '2023-06-14T08:00:00-04:00', e: '2023-06-14T17:00:00-04:00' }
|
||||||
|
// ]
|
||||||
|
|
||||||
|
expect(results.length).toBe(2);
|
||||||
|
|
||||||
|
expect(results[0]).toEqual({
|
||||||
|
start: dayjs("2023-06-13T14:00:00Z").tz(timeZone),
|
||||||
|
end: dayjs("2023-06-13T19:00:00Z").tz(timeZone),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results[1]).toEqual({
|
||||||
|
start: dayjs("2023-06-14T12:00:00Z").tz(timeZone),
|
||||||
|
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("should return correct date ranges with full day unavailable date override", () => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
date: new Date(Date.UTC(2023, 5, 13)),
|
||||||
|
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)),
|
||||||
|
endTime: new Date(Date.UTC(0, 0, 0, 0, 0)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
days: [1, 2, 3, 4, 5],
|
||||||
|
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)),
|
||||||
|
endTime: new Date(Date.UTC(2023, 5, 12, 17, 0)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const timeZone = "Europe/London";
|
||||||
|
|
||||||
|
const dateFrom = dayjs("2023-06-13T00:00:00Z");
|
||||||
|
const dateTo = dayjs("2023-06-15T00:00:00Z");
|
||||||
|
|
||||||
|
const results = buildDateRanges({ availability: items, timeZone, dateFrom, dateTo });
|
||||||
|
|
||||||
|
expect(results[0]).toEqual({
|
||||||
|
start: dayjs("2023-06-14T07:00:00Z").tz(timeZone),
|
||||||
|
end: dayjs("2023-06-14T16:00:00Z").tz(timeZone),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subtract", () => {
|
||||||
|
it("subtracts appropriately when excluded ranges are given in order", () => {
|
||||||
|
const data = {
|
||||||
|
sourceRanges: [
|
||||||
|
{ start: dayjs.utc("2023-07-05T04:00:00.000Z"), end: dayjs.utc("2023-07-05T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-06T04:00:00.000Z"), end: dayjs.utc("2023-07-06T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-07T04:00:00.000Z"), end: dayjs.utc("2023-07-07T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-10T04:00:00.000Z"), end: dayjs.utc("2023-07-10T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-11T04:00:00.000Z"), end: dayjs.utc("2023-07-11T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-12T04:00:00.000Z"), end: dayjs.utc("2023-07-12T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-13T04:00:00.000Z"), end: dayjs.utc("2023-07-13T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-14T04:00:00.000Z"), end: dayjs.utc("2023-07-14T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-17T04:00:00.000Z"), end: dayjs.utc("2023-07-17T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-18T04:00:00.000Z"), end: dayjs.utc("2023-07-18T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-19T04:00:00.000Z"), end: dayjs.utc("2023-07-19T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-20T04:00:00.000Z"), end: dayjs.utc("2023-07-20T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-21T04:00:00.000Z"), end: dayjs.utc("2023-07-21T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-24T04:00:00.000Z"), end: dayjs.utc("2023-07-24T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-25T04:00:00.000Z"), end: dayjs.utc("2023-07-25T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-26T04:00:00.000Z"), end: dayjs.utc("2023-07-26T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-27T04:00:00.000Z"), end: dayjs.utc("2023-07-27T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-28T04:00:00.000Z"), end: dayjs.utc("2023-07-28T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-31T04:00:00.000Z"), end: dayjs.utc("2023-07-31T12:00:00.000Z") },
|
||||||
|
],
|
||||||
|
excludedRanges: [
|
||||||
|
{ start: dayjs.utc("2023-07-05T04:00:00.000Z"), end: dayjs.utc("2023-07-05T04:15:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-05T04:45:00.000Z"), end: dayjs.utc("2023-07-05T05:00:00.000Z") },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = subtract(data["sourceRanges"], data["excludedRanges"]).map((range) => ({
|
||||||
|
start: range.start.format(),
|
||||||
|
end: range.end.format(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ start: "2023-07-05T04:15:00Z", end: "2023-07-05T04:45:00Z" },
|
||||||
|
{ start: "2023-07-05T05:00:00Z", end: "2023-07-05T12:00:00Z" },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts appropriately when excluded ranges are not given in order", () => {
|
||||||
|
const data = {
|
||||||
|
sourceRanges: [
|
||||||
|
{ start: dayjs.utc("2023-07-05T04:00:00.000Z"), end: dayjs.utc("2023-07-05T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-06T04:00:00.000Z"), end: dayjs.utc("2023-07-06T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-07T04:00:00.000Z"), end: dayjs.utc("2023-07-07T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-10T04:00:00.000Z"), end: dayjs.utc("2023-07-10T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-11T04:00:00.000Z"), end: dayjs.utc("2023-07-11T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-12T04:00:00.000Z"), end: dayjs.utc("2023-07-12T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-13T04:00:00.000Z"), end: dayjs.utc("2023-07-13T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-14T04:00:00.000Z"), end: dayjs.utc("2023-07-14T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-17T04:00:00.000Z"), end: dayjs.utc("2023-07-17T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-18T04:00:00.000Z"), end: dayjs.utc("2023-07-18T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-19T04:00:00.000Z"), end: dayjs.utc("2023-07-19T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-20T04:00:00.000Z"), end: dayjs.utc("2023-07-20T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-21T04:00:00.000Z"), end: dayjs.utc("2023-07-21T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-24T04:00:00.000Z"), end: dayjs.utc("2023-07-24T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-25T04:00:00.000Z"), end: dayjs.utc("2023-07-25T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-26T04:00:00.000Z"), end: dayjs.utc("2023-07-26T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-27T04:00:00.000Z"), end: dayjs.utc("2023-07-27T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-28T04:00:00.000Z"), end: dayjs.utc("2023-07-28T12:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-31T04:00:00.000Z"), end: dayjs.utc("2023-07-31T12:00:00.000Z") },
|
||||||
|
],
|
||||||
|
excludedRanges: [
|
||||||
|
{ start: dayjs.utc("2023-07-05T04:45:00.000Z"), end: dayjs.utc("2023-07-05T05:00:00.000Z") },
|
||||||
|
{ start: dayjs.utc("2023-07-05T04:00:00.000Z"), end: dayjs.utc("2023-07-05T04:15:00.000Z") },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = subtract(data["sourceRanges"], data["excludedRanges"]).map((range) => ({
|
||||||
|
start: range.start.format(),
|
||||||
|
end: range.end.format(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ start: "2023-07-05T04:15:00Z", end: "2023-07-05T04:45:00Z" },
|
||||||
|
{ start: "2023-07-05T05:00:00Z", end: "2023-07-05T12:00:00Z" },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,191 @@
|
||||||
|
import type { Dayjs } from "@calcom/dayjs";
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import type { Availability } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
export type DateRange = {
|
||||||
|
start: Dayjs;
|
||||||
|
end: Dayjs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DateOverride = Pick<Availability, "date" | "startTime" | "endTime">;
|
||||||
|
export type WorkingHours = Pick<Availability, "days" | "startTime" | "endTime">;
|
||||||
|
|
||||||
|
export function processWorkingHours({
|
||||||
|
item,
|
||||||
|
timeZone,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}: {
|
||||||
|
item: WorkingHours;
|
||||||
|
timeZone: string;
|
||||||
|
dateFrom: Dayjs;
|
||||||
|
dateTo: Dayjs;
|
||||||
|
}) {
|
||||||
|
const results = [];
|
||||||
|
for (let date = dateFrom.tz(timeZone).startOf("day"); dateTo.isAfter(date); date = date.add(1, "day")) {
|
||||||
|
if (!item.days.includes(date.day())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = date.hour(item.startTime.getUTCHours()).minute(item.startTime.getUTCMinutes()).second(0);
|
||||||
|
const end = date.hour(item.endTime.getUTCHours()).minute(item.endTime.getUTCMinutes()).second(0);
|
||||||
|
|
||||||
|
const startResult = dayjs.max(start, dateFrom.tz(timeZone));
|
||||||
|
const endResult = dayjs.min(end, dateTo.tz(timeZone));
|
||||||
|
|
||||||
|
if (startResult.isAfter(endResult)) {
|
||||||
|
// if an event ends before start, it's not a result.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
start: startResult,
|
||||||
|
end: endResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processDateOverride({ item, timeZone }: { item: DateOverride; timeZone: string }) {
|
||||||
|
const date = dayjs.utc(item.date);
|
||||||
|
|
||||||
|
const startTime = dayjs(item.startTime).utc().subtract(dayjs().tz(timeZone).utcOffset(), "minute");
|
||||||
|
const endTime = dayjs(item.endTime).utc().subtract(dayjs().tz(timeZone).utcOffset(), "minute");
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: date.hour(startTime.hour()).minute(startTime.minute()).second(0).tz(timeZone),
|
||||||
|
end: date.hour(endTime.hour()).minute(endTime.minute()).second(0).tz(timeZone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDateRanges({
|
||||||
|
availability,
|
||||||
|
timeZone /* Organizer timeZone */,
|
||||||
|
dateFrom /* Attendee dateFrom */,
|
||||||
|
dateTo /* `` dateTo */,
|
||||||
|
}: {
|
||||||
|
timeZone: string;
|
||||||
|
availability: (DateOverride | WorkingHours)[];
|
||||||
|
dateFrom: Dayjs;
|
||||||
|
dateTo: Dayjs;
|
||||||
|
}): DateRange[] {
|
||||||
|
const groupedWorkingHours = groupByDate(
|
||||||
|
availability.reduce((processed: DateRange[], item) => {
|
||||||
|
if ("days" in item) {
|
||||||
|
processed = processed.concat(processWorkingHours({ item, timeZone, dateFrom, dateTo }));
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
const groupedDateOverrides = groupByDate(
|
||||||
|
availability.reduce((processed: DateRange[], item) => {
|
||||||
|
if ("date" in item && !!item.date) {
|
||||||
|
processed.push(processDateOverride({ item, timeZone }));
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateRanges = Object.values({
|
||||||
|
...groupedWorkingHours,
|
||||||
|
...groupedDateOverrides,
|
||||||
|
}).map(
|
||||||
|
// remove 0-length overrides that were kept to cancel out working dates until now.
|
||||||
|
(ranges) => ranges.filter((range) => !range.start.isSame(range.end))
|
||||||
|
);
|
||||||
|
|
||||||
|
return dateRanges.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupByDate(ranges: DateRange[]): { [x: string]: DateRange[] } {
|
||||||
|
const results = ranges.reduce(
|
||||||
|
(
|
||||||
|
previousValue: {
|
||||||
|
[date: string]: DateRange[];
|
||||||
|
},
|
||||||
|
currentValue
|
||||||
|
) => {
|
||||||
|
const dateString = dayjs.utc(currentValue.start).format("YYYY-MM-DD");
|
||||||
|
|
||||||
|
previousValue[dateString] =
|
||||||
|
typeof previousValue[dateString] === "undefined"
|
||||||
|
? [currentValue]
|
||||||
|
: [...previousValue[dateString], currentValue];
|
||||||
|
return previousValue;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intersect(ranges: DateRange[][]): DateRange[] {
|
||||||
|
if (!ranges.length) return [];
|
||||||
|
// Get the ranges of the first user
|
||||||
|
let commonAvailability = ranges[0];
|
||||||
|
|
||||||
|
// For each of the remaining users, find the intersection of their ranges with the current common availability
|
||||||
|
for (let i = 1; i < ranges.length; i++) {
|
||||||
|
const userRanges = ranges[i];
|
||||||
|
|
||||||
|
const intersectedRanges: {
|
||||||
|
start: Dayjs;
|
||||||
|
end: Dayjs;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
commonAvailability.forEach((commonRange) => {
|
||||||
|
userRanges.forEach((userRange) => {
|
||||||
|
const intersection = getIntersection(commonRange, userRange);
|
||||||
|
if (intersection !== null) {
|
||||||
|
// If the current common range intersects with the user range, add the intersected time range to the new array
|
||||||
|
intersectedRanges.push(intersection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
commonAvailability = intersectedRanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the common availability is empty, there is no time when all users are available
|
||||||
|
if (commonAvailability.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return commonAvailability;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntersection(range1: DateRange, range2: DateRange) {
|
||||||
|
const start = range1.start.isAfter(range2.start) ? range1.start : range2.start;
|
||||||
|
const end = range1.end.isBefore(range2.end) ? range1.end : range2.end;
|
||||||
|
if (start.isBefore(end)) {
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subtract(sourceRanges: DateRange[], excludedRanges: DateRange[]) {
|
||||||
|
const result: DateRange[] = [];
|
||||||
|
|
||||||
|
for (const { start: sourceStart, end: sourceEnd } of sourceRanges) {
|
||||||
|
let currentStart = sourceStart;
|
||||||
|
|
||||||
|
const overlappingRanges = excludedRanges.filter(
|
||||||
|
({ start, end }) => start.isBefore(sourceEnd) && end.isAfter(sourceStart)
|
||||||
|
);
|
||||||
|
|
||||||
|
overlappingRanges.sort((a, b) => (a.start.isAfter(b.start) ? 1 : -1));
|
||||||
|
|
||||||
|
for (const { start: excludedStart, end: excludedEnd } of overlappingRanges) {
|
||||||
|
if (excludedStart.isAfter(currentStart)) {
|
||||||
|
result.push({ start: currentStart, end: excludedStart });
|
||||||
|
}
|
||||||
|
currentStart = excludedEnd.isAfter(currentStart) ? excludedEnd : currentStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceEnd.isAfter(currentStart)) {
|
||||||
|
result.push({ start: currentStart, end: sourceEnd });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
|
@ -2,12 +2,97 @@ import { describe, expect, it, beforeAll, vi } from "vitest";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { MINUTES_DAY_END, MINUTES_DAY_START } from "@calcom/lib/availability";
|
import { MINUTES_DAY_END, MINUTES_DAY_START } from "@calcom/lib/availability";
|
||||||
import getSlots from "@calcom/lib/slots";
|
|
||||||
|
|
||||||
|
import type { DateRange } from "./date-ranges";
|
||||||
|
import getSlots from "./slots";
|
||||||
|
|
||||||
|
let dateRangesNextDay: DateRange[];
|
||||||
|
|
||||||
|
let dateRangesMockDay: DateRange[];
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
vi.setSystemTime(new Date("2021-06-20T11:59:59Z"));
|
vi.setSystemTime(dayjs.utc("2021-06-20T11:59:59Z").toDate());
|
||||||
})
|
|
||||||
|
dateRangesMockDay = [{ start: dayjs.utc().startOf("day"), end: dayjs.utc().endOf("day") }];
|
||||||
|
|
||||||
|
dateRangesNextDay = [
|
||||||
|
{
|
||||||
|
start: dayjs.utc().add(1, "day").startOf("day"),
|
||||||
|
end: dayjs.utc().add(1, "day").endOf("day"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Tests the date-range slot logic", () => {
|
||||||
|
it("can fit 24 hourly slots for an empty day", async () => {
|
||||||
|
expect(
|
||||||
|
getSlots({
|
||||||
|
inviteeDate: dayjs.utc().add(1, "day"),
|
||||||
|
frequency: 60,
|
||||||
|
minimumBookingNotice: 0,
|
||||||
|
eventLength: 60,
|
||||||
|
organizerTimeZone: "Etc/GMT",
|
||||||
|
dateRanges: dateRangesNextDay,
|
||||||
|
})
|
||||||
|
).toHaveLength(24);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getSlots({
|
||||||
|
inviteeDate: dayjs.utc().add(1, "day"),
|
||||||
|
frequency: 60,
|
||||||
|
minimumBookingNotice: 0,
|
||||||
|
eventLength: 60,
|
||||||
|
organizerTimeZone: "America/Toronto",
|
||||||
|
dateRanges: dateRangesNextDay,
|
||||||
|
})
|
||||||
|
).toHaveLength(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only shows future booking slots on the same day", async () => {
|
||||||
|
// The mock date is 1s to midday, so 12 slots should be open given 0 booking notice.
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getSlots({
|
||||||
|
inviteeDate: dayjs.utc(),
|
||||||
|
frequency: 60,
|
||||||
|
minimumBookingNotice: 0,
|
||||||
|
dateRanges: dateRangesMockDay,
|
||||||
|
eventLength: 60,
|
||||||
|
offsetStart: 0,
|
||||||
|
organizerTimeZone: "America/Toronto",
|
||||||
|
})
|
||||||
|
).toHaveLength(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds minimum booking notice correctly", async () => {
|
||||||
|
// 24h in a day.
|
||||||
|
expect(
|
||||||
|
getSlots({
|
||||||
|
inviteeDate: dayjs.utc().add(1, "day").startOf("day"),
|
||||||
|
frequency: 60,
|
||||||
|
minimumBookingNotice: 1500,
|
||||||
|
dateRanges: dateRangesNextDay,
|
||||||
|
eventLength: 60,
|
||||||
|
offsetStart: 0,
|
||||||
|
organizerTimeZone: "America/Toronto",
|
||||||
|
})
|
||||||
|
).toHaveLength(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct time slots for 20 minutes long events with working hours that do not end at a full hour ", async () => {
|
||||||
|
// 72 20-minutes events in a 24h day
|
||||||
|
const result = getSlots({
|
||||||
|
inviteeDate: dayjs().add(1, "day"),
|
||||||
|
frequency: 20,
|
||||||
|
minimumBookingNotice: 0,
|
||||||
|
dateRanges: dateRangesNextDay,
|
||||||
|
eventLength: 20,
|
||||||
|
offsetStart: 0,
|
||||||
|
organizerTimeZone: "America/Toronto",
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(72);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Tests the slot logic", () => {
|
describe("Tests the slot logic", () => {
|
||||||
it("can fit 24 hourly slots for an empty day", async () => {
|
it("can fit 24 hourly slots for an empty day", async () => {
|
||||||
|
@ -120,26 +205,27 @@ describe("Tests the slot logic", () => {
|
||||||
).toHaveLength(11);
|
).toHaveLength(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows correct time slots for 20 minutes long events with working hours that do not end at a full hour ", async () => {
|
it("shows correct time slots for 20 minutes long events with working hours that do not end at a full hour", async () => {
|
||||||
// 72 20-minutes events in a 24h day
|
const result = getSlots({
|
||||||
expect(
|
inviteeDate: dayjs().add(1, "day"),
|
||||||
getSlots({
|
frequency: 20,
|
||||||
inviteeDate: dayjs.utc().add(1, "day"),
|
minimumBookingNotice: 0,
|
||||||
frequency: 20,
|
dateRanges: [{ start: dayjs("2021-06-21T00:00:00.000Z"), end: dayjs("2021-06-21T23:45:00.000Z") }],
|
||||||
minimumBookingNotice: 0,
|
/*workingHours: [
|
||||||
workingHours: [
|
{
|
||||||
{
|
userId: 1,
|
||||||
userId: 1,
|
days: Array.from(Array(7).keys()),
|
||||||
days: Array.from(Array(7).keys()),
|
startTime: MINUTES_DAY_START,
|
||||||
startTime: MINUTES_DAY_START,
|
endTime: MINUTES_DAY_END - 14, // 23:45
|
||||||
endTime: MINUTES_DAY_END - 14, // 23:45
|
},
|
||||||
},
|
],*/
|
||||||
],
|
eventLength: 20,
|
||||||
eventLength: 20,
|
offsetStart: 0,
|
||||||
offsetStart: 0,
|
organizerTimeZone: "America/Toronto",
|
||||||
organizerTimeZone: "America/Toronto",
|
});
|
||||||
})
|
|
||||||
).toHaveLength(71);
|
// 71 20-minutes events in a 24h - 15m day
|
||||||
|
expect(result).toHaveLength(71);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can fit 48 25 minute slots with a 5 minute offset for an empty day", async () => {
|
it("can fit 48 25 minute slots with a 5 minute offset for an empty day", async () => {
|
||||||
|
@ -162,4 +248,36 @@ describe("Tests the slot logic", () => {
|
||||||
})
|
})
|
||||||
).toHaveLength(48);
|
).toHaveLength(48);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tests the final slot of the day is included", async () => {
|
||||||
|
const slots = getSlots({
|
||||||
|
inviteeDate: dayjs.tz("2023-07-13T00:00:00.000+02:00", "Europe/Brussels"),
|
||||||
|
eventLength: 15,
|
||||||
|
workingHours: [
|
||||||
|
{
|
||||||
|
days: [1, 2, 3, 4, 5],
|
||||||
|
startTime: 480,
|
||||||
|
endTime: 960,
|
||||||
|
userId: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
days: [4],
|
||||||
|
startTime: 1170,
|
||||||
|
endTime: 1379,
|
||||||
|
userId: 9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dateOverrides: [],
|
||||||
|
offsetStart: 0,
|
||||||
|
dateRanges: [
|
||||||
|
{ start: dayjs("2023-07-13T07:00:00.000Z"), end: dayjs("2023-07-13T15:00:00.000Z") },
|
||||||
|
{ start: dayjs("2023-07-13T18:30:00.000Z"), end: dayjs("2023-07-13T20:59:59.000Z") },
|
||||||
|
],
|
||||||
|
minimumBookingNotice: 120,
|
||||||
|
frequency: 15,
|
||||||
|
organizerTimeZone: "Europe/London",
|
||||||
|
}).reverse();
|
||||||
|
|
||||||
|
expect(slots[0].time.format()).toBe("2023-07-13T22:45:00+02:00");
|
||||||
|
});
|
||||||
});
|
});
|
|
@ -4,15 +4,17 @@ import type { WorkingHours, TimeRange as DateOverride } from "@calcom/types/sche
|
||||||
|
|
||||||
import { getWorkingHours } from "./availability";
|
import { getWorkingHours } from "./availability";
|
||||||
import { getTimeZone } from "./date-fns";
|
import { getTimeZone } from "./date-fns";
|
||||||
|
import type { DateRange } from "./date-ranges";
|
||||||
|
|
||||||
export type GetSlots = {
|
export type GetSlots = {
|
||||||
inviteeDate: Dayjs;
|
inviteeDate: Dayjs;
|
||||||
frequency: number;
|
frequency: number;
|
||||||
workingHours: WorkingHours[];
|
workingHours?: WorkingHours[];
|
||||||
dateOverrides?: DateOverride[];
|
dateOverrides?: DateOverride[];
|
||||||
|
dateRanges?: DateRange[];
|
||||||
minimumBookingNotice: number;
|
minimumBookingNotice: number;
|
||||||
eventLength: number;
|
eventLength: number;
|
||||||
offsetStart: number;
|
offsetStart?: number;
|
||||||
organizerTimeZone: string;
|
organizerTimeZone: string;
|
||||||
};
|
};
|
||||||
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
|
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
|
||||||
|
@ -25,7 +27,7 @@ function buildSlots({
|
||||||
computedLocalAvailability,
|
computedLocalAvailability,
|
||||||
frequency,
|
frequency,
|
||||||
eventLength,
|
eventLength,
|
||||||
offsetStart,
|
offsetStart = 0,
|
||||||
startDate,
|
startDate,
|
||||||
organizerTimeZone,
|
organizerTimeZone,
|
||||||
inviteeTimeZone,
|
inviteeTimeZone,
|
||||||
|
@ -35,7 +37,7 @@ function buildSlots({
|
||||||
startDate: Dayjs;
|
startDate: Dayjs;
|
||||||
frequency: number;
|
frequency: number;
|
||||||
eventLength: number;
|
eventLength: number;
|
||||||
offsetStart: number;
|
offsetStart?: number;
|
||||||
organizerTimeZone: string;
|
organizerTimeZone: string;
|
||||||
inviteeTimeZone: string;
|
inviteeTimeZone: string;
|
||||||
}) {
|
}) {
|
||||||
|
@ -133,6 +135,61 @@ function buildSlots({
|
||||||
time: getTime(item.startTime),
|
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;
|
||||||
|
|
||||||
|
slotStartTime =
|
||||||
|
slotStartTime.utc().minute() % 15 !== 0
|
||||||
|
? slotStartTime
|
||||||
|
.startOf("day")
|
||||||
|
.add(slotStartTime.hour() * 60 + Math.ceil(slotStartTime.minute() / 15) * 15, "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");
|
||||||
|
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;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,14 +203,29 @@ const getSlots = ({
|
||||||
inviteeDate,
|
inviteeDate,
|
||||||
frequency,
|
frequency,
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
workingHours,
|
workingHours = [],
|
||||||
dateOverrides = [],
|
dateOverrides = [],
|
||||||
|
dateRanges,
|
||||||
eventLength,
|
eventLength,
|
||||||
offsetStart,
|
offsetStart = 0,
|
||||||
organizerTimeZone,
|
organizerTimeZone,
|
||||||
}: GetSlots) => {
|
}: GetSlots) => {
|
||||||
|
if (dateRanges) {
|
||||||
|
const slots = buildSlotsWithDateRanges({
|
||||||
|
dateRanges,
|
||||||
|
frequency,
|
||||||
|
eventLength,
|
||||||
|
timeZone: getTimeZone(inviteeDate),
|
||||||
|
minimumBookingNotice,
|
||||||
|
organizerTimeZone,
|
||||||
|
offsetStart,
|
||||||
|
});
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
// current date in invitee tz
|
// current date in invitee tz
|
||||||
const startDate = dayjs().utcOffset(inviteeDate.utcOffset()).add(minimumBookingNotice, "minute");
|
const startDate = dayjs().utcOffset(inviteeDate.utcOffset()).add(minimumBookingNotice, "minute");
|
||||||
|
|
||||||
// This code is ran client side, startOf() does some conversions based on the
|
// This code is ran client side, startOf() does some conversions based on the
|
||||||
// local tz of the client. Sometimes this shifts the day incorrectly.
|
// 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 startOfDayUTC = dayjs.utc().set("hour", 0).set("minute", 0).set("second", 0);
|
||||||
|
@ -218,7 +290,7 @@ const getSlots = ({
|
||||||
return dayjs.utc(override.start).isBetween(startOfInviteeDay, startOfInviteeDay.endOf("day"), null, "[)");
|
return dayjs.utc(override.start).isBetween(startOfInviteeDay, startOfInviteeDay.endOf("day"), null, "[)");
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!!activeOverrides.length) {
|
if (activeOverrides.length) {
|
||||||
const overrides = activeOverrides.flatMap((override) => ({
|
const overrides = activeOverrides.flatMap((override) => ({
|
||||||
userIds: override.userId ? [override.userId] : [],
|
userIds: override.userId ? [override.userId] : [],
|
||||||
startTime: override.start.getUTCHours() * 60 + override.start.getUTCMinutes(),
|
startTime: override.start.getUTCHours() * 60 + override.start.getUTCMinutes(),
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { countBy } from "lodash";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
import { getAggregateWorkingHours } from "@calcom/core/getAggregateWorkingHours";
|
import { getAggregateWorkingHours } from "@calcom/core/getAggregateWorkingHours";
|
||||||
|
import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability";
|
||||||
import type { CurrentSeats } from "@calcom/core/getUserAvailability";
|
import type { CurrentSeats } from "@calcom/core/getUserAvailability";
|
||||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||||
import type { Dayjs } from "@calcom/dayjs";
|
import type { Dayjs } from "@calcom/dayjs";
|
||||||
|
@ -10,7 +11,7 @@ import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||||
import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds";
|
import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { performance } from "@calcom/lib/server/perfObserver";
|
import { performance } from "@calcom/lib/server/perfObserver";
|
||||||
import getTimeSlots from "@calcom/lib/slots";
|
import getSlots from "@calcom/lib/slots";
|
||||||
import prisma, { availabilityUserSelect } from "@calcom/prisma";
|
import prisma, { availabilityUserSelect } from "@calcom/prisma";
|
||||||
import { SchedulingType } from "@calcom/prisma/enums";
|
import { SchedulingType } from "@calcom/prisma/enums";
|
||||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
@ -210,10 +211,14 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND" });
|
throw new TRPCError({ code: "NOT_FOUND" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime =
|
const getStartTime = (startTimeInput: string, timeZone?: string) => {
|
||||||
input.timeZone === "Etc/GMT"
|
const startTimeMin = dayjs.utc().add(eventType.minimumBookingNotice, "minutes");
|
||||||
? dayjs.utc(input.startTime)
|
const startTime = timeZone === "Etc/GMT" ? dayjs.utc(startTimeInput) : dayjs(startTimeInput).tz(timeZone);
|
||||||
: dayjs(input.startTime).utc().tz(input.timeZone);
|
|
||||||
|
return startTimeMin.isAfter(startTime) ? startTimeMin.tz(timeZone) : startTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = getStartTime(input.startTime, input.timeZone);
|
||||||
const endTime =
|
const endTime =
|
||||||
input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone);
|
input.timeZone === "Etc/GMT" ? dayjs.utc(input.endTime) : dayjs(input.endTime).utc().tz(input.timeZone);
|
||||||
|
|
||||||
|
@ -237,6 +242,7 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
|
||||||
busy,
|
busy,
|
||||||
workingHours,
|
workingHours,
|
||||||
dateOverrides,
|
dateOverrides,
|
||||||
|
dateRanges,
|
||||||
currentSeats: _currentSeats,
|
currentSeats: _currentSeats,
|
||||||
timeZone,
|
timeZone,
|
||||||
} = await getUserAvailability(
|
} = await getUserAvailability(
|
||||||
|
@ -258,11 +264,13 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
|
||||||
timeZone,
|
timeZone,
|
||||||
workingHours,
|
workingHours,
|
||||||
dateOverrides,
|
dateOverrides,
|
||||||
|
dateRanges,
|
||||||
busy,
|
busy,
|
||||||
user: currentUser,
|
user: currentUser,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// flattens availability of multiple users
|
// flattens availability of multiple users
|
||||||
const dateOverrides = userAvailability.flatMap((availability) =>
|
const dateOverrides = userAvailability.flatMap((availability) =>
|
||||||
availability.dateOverrides.map((override) => ({ userId: availability.user.id, ...override }))
|
availability.dateOverrides.map((override) => ({ userId: availability.user.id, ...override }))
|
||||||
|
@ -283,32 +291,21 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const getSlotsTime = 0;
|
const getSlotsTime = 0;
|
||||||
let checkForAvailabilityTime = 0;
|
const checkForAvailabilityTime = 0;
|
||||||
const getSlotsCount = 0;
|
const getSlotsCount = 0;
|
||||||
let checkForAvailabilityCount = 0;
|
const checkForAvailabilityCount = 0;
|
||||||
|
|
||||||
const timeSlots: ReturnType<typeof getTimeSlots> = [];
|
const timeSlots = getSlots({
|
||||||
|
inviteeDate: startTime,
|
||||||
for (
|
eventLength: input.duration || eventType.length,
|
||||||
let currentCheckedTime = startTime;
|
workingHours,
|
||||||
currentCheckedTime.isBefore(endTime);
|
dateOverrides,
|
||||||
currentCheckedTime = currentCheckedTime.add(1, "day")
|
offsetStart: eventType.offsetStart,
|
||||||
) {
|
dateRanges: getAggregatedAvailability(userAvailability, eventType.schedulingType),
|
||||||
// get slots retrieves the available times for a given day
|
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||||
timeSlots.push(
|
frequency: eventType.slotInterval || input.duration || eventType.length,
|
||||||
...getTimeSlots({
|
organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone,
|
||||||
inviteeDate: currentCheckedTime,
|
});
|
||||||
eventLength: input.duration || eventType.length,
|
|
||||||
workingHours,
|
|
||||||
dateOverrides,
|
|
||||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
|
||||||
offsetStart: eventType.offsetStart,
|
|
||||||
frequency: eventType.slotInterval || input.duration || eventType.length,
|
|
||||||
organizerTimeZone:
|
|
||||||
eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let availableTimeSlots: typeof timeSlots = [];
|
let availableTimeSlots: typeof timeSlots = [];
|
||||||
// Load cached busy slots
|
// Load cached busy slots
|
||||||
|
@ -332,44 +329,7 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
|
||||||
where: { eventTypeId: { equals: eventType.id }, id: { notIn: selectedSlots.map((item) => item.id) } },
|
where: { eventTypeId: { equals: eventType.id }, id: { notIn: selectedSlots.map((item) => item.id) } },
|
||||||
});
|
});
|
||||||
|
|
||||||
availableTimeSlots = timeSlots.filter((slot) => {
|
availableTimeSlots = timeSlots;
|
||||||
const fixedHosts = userAvailability.filter((availability) => availability.user.isFixed);
|
|
||||||
return fixedHosts.every((schedule) => {
|
|
||||||
const startCheckForAvailability = performance.now();
|
|
||||||
|
|
||||||
const isAvailable = checkIfIsAvailable({
|
|
||||||
time: slot.time,
|
|
||||||
...schedule,
|
|
||||||
...availabilityCheckProps,
|
|
||||||
});
|
|
||||||
const endCheckForAvailability = performance.now();
|
|
||||||
checkForAvailabilityCount++;
|
|
||||||
checkForAvailabilityTime += endCheckForAvailability - startCheckForAvailability;
|
|
||||||
return isAvailable;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// what else are you going to call it?
|
|
||||||
const looseHostAvailability = userAvailability.filter(({ user: { isFixed } }) => !isFixed);
|
|
||||||
if (looseHostAvailability.length > 0) {
|
|
||||||
availableTimeSlots = availableTimeSlots
|
|
||||||
.map((slot) => {
|
|
||||||
slot.userIds = slot.userIds?.filter((slotUserId) => {
|
|
||||||
const userSchedule = looseHostAvailability.find(
|
|
||||||
({ user: { id: userId } }) => userId === slotUserId
|
|
||||||
);
|
|
||||||
if (!userSchedule) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return checkIfIsAvailable({
|
|
||||||
time: slot.time,
|
|
||||||
...userSchedule,
|
|
||||||
...availabilityCheckProps,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return slot;
|
|
||||||
})
|
|
||||||
.filter((slot) => !!slot.userIds?.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedSlots?.length > 0) {
|
if (selectedSlots?.length > 0) {
|
||||||
let occupiedSeats: typeof selectedSlots = selectedSlots.filter(
|
let occupiedSeats: typeof selectedSlots = selectedSlots.filter(
|
||||||
|
@ -410,30 +370,41 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
|
||||||
});
|
});
|
||||||
currentSeats = availabilityCheckProps.currentSeats;
|
currentSeats = availabilityCheckProps.currentSeats;
|
||||||
}
|
}
|
||||||
|
|
||||||
availableTimeSlots = availableTimeSlots
|
availableTimeSlots = availableTimeSlots
|
||||||
.map((slot) => {
|
.map((slot) => {
|
||||||
slot.userIds = slot.userIds?.filter((slotUserId) => {
|
const busy = selectedSlots.reduce<EventBusyDate[]>((r, c) => {
|
||||||
const busy = selectedSlots.reduce<EventBusyDate[]>((r, c) => {
|
if (!c.isSeat) {
|
||||||
if (c.userId === slotUserId && !c.isSeat) {
|
r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate });
|
||||||
r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate });
|
|
||||||
}
|
|
||||||
return r;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!busy?.length && eventType.seatsPerTimeSlot === null) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
return r;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return checkIfIsAvailable({
|
if (
|
||||||
|
checkIfIsAvailable({
|
||||||
time: slot.time,
|
time: slot.time,
|
||||||
busy,
|
busy,
|
||||||
...availabilityCheckProps,
|
...availabilityCheckProps,
|
||||||
});
|
})
|
||||||
});
|
) {
|
||||||
return slot;
|
return slot;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
})
|
})
|
||||||
.filter((slot) => !!slot.userIds?.length);
|
.filter(
|
||||||
|
(
|
||||||
|
item:
|
||||||
|
| {
|
||||||
|
time: dayjs.Dayjs;
|
||||||
|
userIds?: number[] | undefined;
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
): item is {
|
||||||
|
time: dayjs.Dayjs;
|
||||||
|
userIds?: number[] | undefined;
|
||||||
|
} => {
|
||||||
|
return !!item;
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
availableTimeSlots = availableTimeSlots.filter((slot) => isTimeWithinBounds(slot.time));
|
availableTimeSlots = availableTimeSlots.filter((slot) => isTimeWithinBounds(slot.time));
|
||||||
|
|
Loading…
Reference in New Issue
Block a user