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>pull/9938/head^2
parent
9c2e15f016
commit
7b1fbd2853
|
@ -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 }
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 { 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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 { 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");
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
|
|
|
@ -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));
|
||||
|
|
Loading…
Reference in New Issue