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:
Alex van Andel 2023-07-05 18:47:41 +02:00 committed by GitHub
parent 9c2e15f016
commit 7b1fbd2853
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 793 additions and 179 deletions

View File

@ -96,8 +96,8 @@ const TestData = {
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: "1970-01-01T09:30:00.000Z",
endTime: "1970-01-01T18:00:00.000Z",
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
],
@ -111,16 +111,16 @@ const TestData = {
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: "1970-01-01T09:30:00.000Z",
endTime: "1970-01-01T18:00:00.000Z",
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: `1970-01-01T14:00:00.000Z`,
endTime: `1970-01-01T18:00:00.000Z`,
startTime: new Date("1970-01-01T14:00:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: dateString,
},
],
@ -170,8 +170,8 @@ type InputUser = typeof TestData.users.example & { id: number } & {
userId: number | null;
eventTypeId: number | null;
days: number[];
startTime: string;
endTime: string;
startTime: Date;
endTime: Date;
date: string | null;
}[];
timeZone: string;
@ -392,16 +392,17 @@ describe("getSchedule", () => {
expect(scheduleForDayWithOneBooking).toHaveTimeSlots(
[
// "04:00:00.000Z", - This slot is unavailable because of the booking from 4:00 to 4:15
"04:45:00.000Z",
"05:30:00.000Z",
"06:15:00.000Z",
"07:00:00.000Z",
"07:45:00.000Z",
"08:30:00.000Z",
"09:15:00.000Z",
"10:00:00.000Z",
"10:45:00.000Z",
"11:30:00.000Z",
`04:15:00.000Z`,
`05:00:00.000Z`,
`05:45:00.000Z`,
`06:30:00.000Z`,
`07:15:00.000Z`,
`08:00:00.000Z`,
`08:45:00.000Z`,
`09:30:00.000Z`,
`10:15:00.000Z`,
`11:00:00.000Z`,
`11:45:00.000Z`,
],
{
dateString: plus3DateString,
@ -845,6 +846,7 @@ describe("getSchedule", () => {
// A default Event Type which this user owns
{
id: 2,
length: 15,
slotInterval: 45,
users: [{ id: 101 }],
},
@ -900,17 +902,17 @@ describe("getSchedule", () => {
expect(thisUserAvailability).toHaveTimeSlots(
[
// `04:00:00.000Z`, // <- This slot should be occupied by the Collective Event
`04:45:00.000Z`,
`05:30:00.000Z`,
`06:15:00.000Z`,
`07:00:00.000Z`,
`07:45:00.000Z`,
`08:30:00.000Z`,
`09:15:00.000Z`,
`10:00:00.000Z`,
`10:45:00.000Z`,
`11:30:00.000Z`,
`12:15:00.000Z`,
`04:15:00.000Z`,
`05:00:00.000Z`,
`05:45:00.000Z`,
`06:30:00.000Z`,
`07:15:00.000Z`,
`08:00:00.000Z`,
`08:45:00.000Z`,
`09:30:00.000Z`,
`10:15:00.000Z`,
`11:00:00.000Z`,
`11:45:00.000Z`,
],
{
dateString: plus2DateString,
@ -932,6 +934,7 @@ describe("getSchedule", () => {
{
id: 1,
slotInterval: 45,
schedulingType: "COLLECTIVE",
length: 45,
users: [
{
@ -1018,21 +1021,23 @@ describe("getSchedule", () => {
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
});
// 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
expect(scheduleForTeamEventOnADayWithOneBookingForEachUser).toHaveTimeSlots(
[
//`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
`06:15:00.000Z`,
`07:00:00.000Z`,
`07:45:00.000Z`,
`08:30:00.000Z`,
`09:15:00.000Z`,
`10:00:00.000Z`,
`10:45:00.000Z`,
`11:30:00.000Z`,
`05:45:00.000Z`,
`06:30:00.000Z`,
`07:15:00.000Z`,
`08:00:00.000Z`,
`08:45:00.000Z`,
`09:30:00.000Z`,
`10:15:00.000Z`,
`11:00:00.000Z`,
`11:45:00.000Z`,
],
{ dateString: plus2DateString }
);
@ -1151,16 +1156,17 @@ describe("getSchedule", () => {
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots(
[
//`04:00:00.000Z`, // - Blocked with User 101 as well as User 102, so not available in Round Robin
`04:45:00.000Z`,
`05:30:00.000Z`,
`06:15:00.000Z`,
`07:00:00.000Z`,
`07:45:00.000Z`,
`08:30:00.000Z`,
`09:15:00.000Z`,
`10:00:00.000Z`,
`10:45:00.000Z`,
`11:30:00.000Z`,
`04:15:00.000Z`,
`05:00:00.000Z`,
`05:45:00.000Z`,
`06:30:00.000Z`,
`07:15:00.000Z`,
`08:00:00.000Z`,
`08:45:00.000Z`,
`09:30:00.000Z`,
`10:15:00.000Z`,
`11:00:00.000Z`,
`11:45:00.000Z`,
],
{ dateString: plus3DateString }
);

View File

@ -8,7 +8,7 @@ import type { WorkingHours } from "@calcom/types/schedule";
export const getAggregateWorkingHours = (
usersWorkingHoursAndBusySlots: (Omit<
Awaited<ReturnType<Awaited<typeof import("./getUserAvailability")>["getUserAvailability"]>>,
"currentSeats"
"currentSeats" | "dateRanges"
> & { user?: { isFixed?: boolean } })[],
// eslint-disable-next-line @typescript-eslint/no-unused-vars
schedulingType: SchedulingType | null

View File

@ -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;
}

View File

@ -5,6 +5,7 @@ import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
import { getWorkingHours } from "@calcom/lib/availability";
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
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 {
busy: bufferedBusyTimes,
timeZone,
dateRanges: subtract(dateRanges, formattedBusyTimes),
workingHours,
dateOverrides,
currentSeats,

View File

@ -345,11 +345,7 @@ async function ensureAvailableUsers(
const availableUsers: IsFixedAwareUser[] = [];
/** Let's start checking for availability */
for (const user of eventType.users) {
const {
busy: bufferedBusyTimes,
workingHours,
dateOverrides,
} = await getUserAvailability(
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
{
userId: user.id,
eventTypeId: eventType.id,
@ -358,18 +354,7 @@ async function ensureAvailableUsers(
{ user, eventType }
);
// check if time slot is outside of schedule.
if (
!isWithinAvailableHours(
{ start: input.dateFrom, end: input.dateTo },
{
workingHours,
dateOverrides,
organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || user.timeZone,
inviteeTimeZone: input.timeZone,
}
)
) {
if (!dateRanges.length) {
// user does not have availability at this time, skip user.
continue;
}

View File

@ -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" },
])
);
});
});

191
packages/lib/date-ranges.ts Normal file
View File

@ -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;
}

View File

@ -2,12 +2,97 @@ import { describe, expect, it, beforeAll, vi } from "vitest";
import dayjs from "@calcom/dayjs";
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(() => {
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", () => {
it("can fit 24 hourly slots for an empty day", async () => {
@ -120,26 +205,27 @@ describe("Tests the slot logic", () => {
).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
expect(
getSlots({
inviteeDate: dayjs.utc().add(1, "day"),
frequency: 20,
minimumBookingNotice: 0,
workingHours: [
{
userId: 1,
days: Array.from(Array(7).keys()),
startTime: MINUTES_DAY_START,
endTime: MINUTES_DAY_END - 14, // 23:45
},
],
eventLength: 20,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
})
).toHaveLength(71);
it("shows correct time slots for 20 minutes long events with working hours that do not end at a full hour", async () => {
const result = getSlots({
inviteeDate: dayjs().add(1, "day"),
frequency: 20,
minimumBookingNotice: 0,
dateRanges: [{ start: dayjs("2021-06-21T00:00:00.000Z"), end: dayjs("2021-06-21T23:45:00.000Z") }],
/*workingHours: [
{
userId: 1,
days: Array.from(Array(7).keys()),
startTime: MINUTES_DAY_START,
endTime: MINUTES_DAY_END - 14, // 23:45
},
],*/
eventLength: 20,
offsetStart: 0,
organizerTimeZone: "America/Toronto",
});
// 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 () => {
@ -162,4 +248,36 @@ describe("Tests the slot logic", () => {
})
).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");
});
});

View File

@ -4,15 +4,17 @@ import type { WorkingHours, TimeRange as DateOverride } from "@calcom/types/sche
import { getWorkingHours } from "./availability";
import { getTimeZone } from "./date-fns";
import type { DateRange } from "./date-ranges";
export type GetSlots = {
inviteeDate: Dayjs;
frequency: number;
workingHours: WorkingHours[];
workingHours?: WorkingHours[];
dateOverrides?: DateOverride[];
dateRanges?: DateRange[];
minimumBookingNotice: number;
eventLength: number;
offsetStart: number;
offsetStart?: number;
organizerTimeZone: string;
};
export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number };
@ -25,7 +27,7 @@ function buildSlots({
computedLocalAvailability,
frequency,
eventLength,
offsetStart,
offsetStart = 0,
startDate,
organizerTimeZone,
inviteeTimeZone,
@ -35,7 +37,7 @@ function buildSlots({
startDate: Dayjs;
frequency: number;
eventLength: number;
offsetStart: number;
offsetStart?: number;
organizerTimeZone: string;
inviteeTimeZone: string;
}) {
@ -133,6 +135,61 @@ function buildSlots({
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;
}
@ -146,14 +203,29 @@ const getSlots = ({
inviteeDate,
frequency,
minimumBookingNotice,
workingHours,
workingHours = [],
dateOverrides = [],
dateRanges,
eventLength,
offsetStart,
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
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);
@ -218,7 +290,7 @@ const getSlots = ({
return dayjs.utc(override.start).isBetween(startOfInviteeDay, startOfInviteeDay.endOf("day"), null, "[)");
});
if (!!activeOverrides.length) {
if (activeOverrides.length) {
const overrides = activeOverrides.flatMap((override) => ({
userIds: override.userId ? [override.userId] : [],
startTime: override.start.getUTCHours() * 60 + override.start.getUTCMinutes(),

View File

@ -2,6 +2,7 @@ import { countBy } from "lodash";
import { v4 as uuid } from "uuid";
import { getAggregateWorkingHours } from "@calcom/core/getAggregateWorkingHours";
import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability";
import type { CurrentSeats } from "@calcom/core/getUserAvailability";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import type { Dayjs } from "@calcom/dayjs";
@ -10,7 +11,7 @@ import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
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 { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@ -210,10 +211,14 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const startTime =
input.timeZone === "Etc/GMT"
? dayjs.utc(input.startTime)
: dayjs(input.startTime).utc().tz(input.timeZone);
const getStartTime = (startTimeInput: string, timeZone?: string) => {
const startTimeMin = dayjs.utc().add(eventType.minimumBookingNotice, "minutes");
const startTime = timeZone === "Etc/GMT" ? dayjs.utc(startTimeInput) : dayjs(startTimeInput).tz(timeZone);
return startTimeMin.isAfter(startTime) ? startTimeMin.tz(timeZone) : startTime;
};
const startTime = getStartTime(input.startTime, input.timeZone);
const endTime =
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,
workingHours,
dateOverrides,
dateRanges,
currentSeats: _currentSeats,
timeZone,
} = await getUserAvailability(
@ -258,11 +264,13 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
timeZone,
workingHours,
dateOverrides,
dateRanges,
busy,
user: currentUser,
};
})
);
// flattens availability of multiple users
const dateOverrides = userAvailability.flatMap((availability) =>
availability.dateOverrides.map((override) => ({ userId: availability.user.id, ...override }))
@ -283,32 +291,21 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
});
const getSlotsTime = 0;
let checkForAvailabilityTime = 0;
const checkForAvailabilityTime = 0;
const getSlotsCount = 0;
let checkForAvailabilityCount = 0;
const checkForAvailabilityCount = 0;
const timeSlots: ReturnType<typeof getTimeSlots> = [];
for (
let currentCheckedTime = startTime;
currentCheckedTime.isBefore(endTime);
currentCheckedTime = currentCheckedTime.add(1, "day")
) {
// get slots retrieves the available times for a given day
timeSlots.push(
...getTimeSlots({
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,
})
);
}
const timeSlots = getSlots({
inviteeDate: startTime,
eventLength: input.duration || eventType.length,
workingHours,
dateOverrides,
offsetStart: eventType.offsetStart,
dateRanges: getAggregatedAvailability(userAvailability, eventType.schedulingType),
minimumBookingNotice: eventType.minimumBookingNotice,
frequency: eventType.slotInterval || input.duration || eventType.length,
organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone,
});
let availableTimeSlots: typeof timeSlots = [];
// 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) } },
});
availableTimeSlots = timeSlots.filter((slot) => {
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);
}
availableTimeSlots = timeSlots;
if (selectedSlots?.length > 0) {
let occupiedSeats: typeof selectedSlots = selectedSlots.filter(
@ -410,30 +370,41 @@ export async function getSchedule(input: TGetScheduleInputSchema) {
});
currentSeats = availabilityCheckProps.currentSeats;
}
availableTimeSlots = availableTimeSlots
.map((slot) => {
slot.userIds = slot.userIds?.filter((slotUserId) => {
const busy = selectedSlots.reduce<EventBusyDate[]>((r, c) => {
if (c.userId === slotUserId && !c.isSeat) {
r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate });
}
return r;
}, []);
if (!busy?.length && eventType.seatsPerTimeSlot === null) {
return false;
const busy = selectedSlots.reduce<EventBusyDate[]>((r, c) => {
if (!c.isSeat) {
r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate });
}
return r;
}, []);
return checkIfIsAvailable({
if (
checkIfIsAvailable({
time: slot.time,
busy,
...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));