diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index ec19757ca2..fb1475a5fc 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -784,6 +784,24 @@ describe("getSchedule", () => { dateString: plus2DateString, } ); + + const scheduleForEventOnADayWithDateOverrideDifferentTimezone = await getSchedule( + { + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+6:00"], + }, + ctx + ); + // it should return the same as this is the utc time + expect(scheduleForEventOnADayWithDateOverrideDifferentTimezone).toHaveTimeSlots( + ["08:30:00.000Z", "09:30:00.000Z", "10:30:00.000Z", "11:30:00.000Z"], + { + dateString: plus2DateString, + } + ); }); test("that a user is considered busy when there's a booking they host", async () => { diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index 4d65d228af..9c688e94ad 100644 --- a/packages/lib/slots.ts +++ b/packages/lib/slots.ts @@ -208,11 +208,22 @@ const getSlots = ({ }); if (!!activeOverrides.length) { - const overrides = activeOverrides.flatMap((override) => ({ - userIds: override.userId ? [override.userId] : [], - startTime: override.start.getUTCHours() * 60 + override.start.getUTCMinutes(), - endTime: override.end.getUTCHours() * 60 + override.end.getUTCMinutes(), - })); + const overrides = activeOverrides.flatMap((override) => { + const organizerUtcOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset(); + const inviteeUtcOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset(); + const offset = inviteeUtcOffset - organizerUtcOffset; + + return { + userIds: override.userId ? [override.userId] : [], + startTime: + dayjs(override.start).utc().add(offset, "minute").hour() * 60 + + dayjs(override.start).utc().add(offset, "minute").minute(), + endTime: + dayjs(override.end).utc().add(offset, "minute").hour() * 60 + + dayjs(override.end).utc().add(offset, "minute").minute(), + }; + }); + // unset all working hours that relate to this user availability override overrides.forEach((override) => { let i = -1; diff --git a/packages/trpc/server/routers/viewer/slots.ts b/packages/trpc/server/routers/viewer/slots.ts index d15162c336..3a8d4a69fb 100644 --- a/packages/trpc/server/routers/viewer/slots.ts +++ b/packages/trpc/server/routers/viewer/slots.ts @@ -19,6 +19,7 @@ import type prisma from "@calcom/prisma"; import { availabilityUserSelect } from "@calcom/prisma"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDate } from "@calcom/types/Calendar"; +import type { WorkingHours } from "@calcom/types/schedule"; import { TRPCError } from "@trpc/server"; @@ -75,12 +76,21 @@ const checkIfIsAvailable = ({ time, busy, eventLength, + dateOverrides = [], + workingHours = [], currentSeats, + organizerTimeZone, }: { time: Dayjs; busy: EventBusyDate[]; eventLength: number; + dateOverrides?: { + start: Date; + end: Date; + }[]; + workingHours?: WorkingHours[]; currentSeats?: CurrentSeats; + organizerTimeZone?: string; }): boolean => { if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) { return true; @@ -89,6 +99,57 @@ const checkIfIsAvailable = ({ const slotEndTime = time.add(eventLength, "minutes").utc(); const slotStartTime = time.utc(); + //check if date override for slot exists + let dateOverrideExist = false; + + if ( + dateOverrides.find((date) => { + const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0; + + if ( + dayjs(date.start).add(utcOffset, "minutes").format("YYYY MM DD") === + slotStartTime.format("YYYY MM DD") + ) { + dateOverrideExist = true; + if (dayjs(date.start).add(utcOffset, "minutes") === dayjs(date.end).add(utcOffset, "minutes")) { + return true; + } + if ( + slotEndTime.isBefore(dayjs(date.start).add(utcOffset, "minutes")) || + slotEndTime.isSame(dayjs(date.start).add(utcOffset, "minutes")) + ) { + return true; + } + if (slotStartTime.isAfter(dayjs(date.end).add(utcOffset, "minutes"))) { + return true; + } + } + }) + ) { + // slot is not within the date override + return false; + } + + if (dateOverrideExist) { + return true; + } + + //if no date override for slot exists check if it is within normal work hours + if ( + workingHours.find((workingHour) => { + if (workingHour.days.includes(slotStartTime.day())) { + const start = slotStartTime.hour() * 60 + slotStartTime.minute(); + const end = slotStartTime.hour() * 60 + slotStartTime.minute(); + if (start < workingHour.startTime || end > workingHour.endTime) { + return true; + } + } + }) + ) { + // slot is outside of working hours + return false; + } + return busy.every((busyTime) => { const startTime = dayjs.utc(busyTime.start).utc(); const endTime = dayjs.utc(busyTime.end); @@ -115,7 +176,6 @@ const checkIfIsAvailable = ({ else if (startTime.isBetween(time, slotEndTime)) { return false; } - return true; }); }; @@ -348,7 +408,11 @@ export async function getSchedule(input: z.infer, ctx: ); // flattens availability of multiple users const dateOverrides = userAvailability.flatMap((availability) => - availability.dateOverrides.map((override) => ({ userId: availability.user.id, ...override })) + availability.dateOverrides.map((override) => ({ + userId: availability.user.id, + timeZone: availability.timeZone, + ...override, + })) ); const workingHours = getAggregateWorkingHours(userAvailability, eventType.schedulingType); const availabilityCheckProps = { @@ -372,6 +436,9 @@ export async function getSchedule(input: z.infer, ctx: const timeSlots: ReturnType = []; + const organizerTimeZone = + eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone; + for ( let currentCheckedTime = startTime; currentCheckedTime.isBefore(endTime); @@ -386,8 +453,7 @@ export async function getSchedule(input: z.infer, ctx: dateOverrides, minimumBookingNotice: eventType.minimumBookingNotice, frequency: eventType.slotInterval || input.duration || eventType.length, - organizerTimeZone: - eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone, + organizerTimeZone, }) ); } @@ -423,6 +489,7 @@ export async function getSchedule(input: z.infer, ctx: time: slot.time, ...schedule, ...availabilityCheckProps, + organizerTimeZone: schedule.timeZone, }); const endCheckForAvailability = performance.now(); checkForAvailabilityCount++; @@ -430,6 +497,7 @@ export async function getSchedule(input: z.infer, ctx: return isAvailable; }); }); + // what else are you going to call it? const looseHostAvailability = userAvailability.filter(({ user: { isFixed } }) => !isFixed); if (looseHostAvailability.length > 0) { @@ -446,6 +514,7 @@ export async function getSchedule(input: z.infer, ctx: time: slot.time, ...userSchedule, ...availabilityCheckProps, + organizerTimeZone: userSchedule.timeZone, }); }); return slot; @@ -507,17 +576,19 @@ export async function getSchedule(input: z.infer, ctx: return false; } + const userSchedule = userAvailability.find(({ user: { id: userId } }) => userId === slotUserId); + return checkIfIsAvailable({ time: slot.time, busy, ...availabilityCheckProps, + organizerTimeZone: userSchedule?.timeZone, }); }); return slot; }) .filter((slot) => !!slot.userIds?.length); } - availableTimeSlots = availableTimeSlots.filter((slot) => isTimeWithinBounds(slot.time)); const computedAvailableSlots = availableTimeSlots.reduce( diff --git a/packages/types/schedule.d.ts b/packages/types/schedule.d.ts index 7b36261da4..7e390124a1 100644 --- a/packages/types/schedule.d.ts +++ b/packages/types/schedule.d.ts @@ -2,6 +2,7 @@ export type TimeRange = { userId?: number | null; start: Date; end: Date; + timeZone?: string; }; export type Schedule = TimeRange[][];