Skip to content
18 changes: 18 additions & 0 deletions apps/web/test/lib/getSchedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,24 @@ describe("getSchedule", () => {
dateString: plus2DateString,
}
);

const scheduleForEventOnADayWithDateOverrideDifferentTimezone = await getSchedule(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here attendee has a different timezone than the organizer. This test would have failed before and fixes #8329

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This new assertion only covers the timezone-adjustment fix (#8329): it re-queries the existing single-user personal event (no schedulingType, hosts: []) with a +6:00 invitee timezone. It does not cover the primary #8207 fix — date overrides of fixed hosts in round-robin events. The only ROUND_ROBIN test in the suite uses hosts: [] (line ~1109), so fixedHosts = userAvailability.filter(a => a.user.isFixed) is empty and fixedHosts.every(checkIfIsAvailable(...)) is a vacuous no-op; the new checkIfIsAvailable date-override/working-hours block (slots.ts lines 102-151) is never reached on the round-robin path. Please add a test with a round-robin event where a fixed host has a date override that restricts slots while a loose host remains available.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 The added test is a single-user, non-team event (hosts: [], no schedulingType), so it never enters the eventType.hosts.map(({ isFixed, user }) => …) branch in getSchedule and never exercises getAggregateWorkingHours(..., ROUND_ROBIN) with date overrides — i.e. the PR's primary stated fix (#8207, "date overrides of fixed hosts are taken into account for round-robin events") is not covered. The only ROUND_ROBIN test in this file uses IstWorkHours (no override). The timezone sub-assertion (lines 788–804) does guard the override.timeZone offset fix, but only with a 30-minute invitee-tz delta and a single host, so cross-day-boundary selection of activeOverrides is untested.

Suggest adding a team scenario: schedulingType: 'ROUND_ROBIN', two hosts (one fixed with IstWorkHoursWithDateOverride(date), one loose with different hours), asserting the fixed host's override restricts the shared slot set and the loose host still contributes slots the override removed; mirror for 'COLLECTIVE'. Also add an assertion pair with a large tz delta (e.g. organizer +5:30, invitee -08:00) where the invitee day boundary moves relative to the override date.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 This test is a single-user personal event (hosts: [], no schedulingType), so fixedHosts = userAvailability.filter(a => a.user.isFixed) is empty and the new checkIfIsAvailable date-override/working-hours block is never reached on the round-robin path. The PR's primary fix (#8207 — date overrides of fixed hosts in round-robin events) is not covered. Suggest adding a team scenario: schedulingType: 'ROUND_ROBIN', one fixed host with a date override and one loose host with different hours, asserting the fixed host's override restricts the shared slot set.

{
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 () => {
Expand Down
21 changes: 16 additions & 5 deletions packages/lib/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
emrysal marked this conversation as resolved.
const inviteeUtcOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset();
const offset = inviteeUtcOffset - organizerUtcOffset;

return {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the times of activeOverrides show the date override in the organizer's availability timezone, we return the overrides in the time of the timezone set on the booking page

userIds: override.userId ? [override.userId] : [],
startTime:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The offset conversion can wrap past midnight for large timezone gaps. dayjs(override.start).utc().add(offset, "minute").hour() re-extracts the hour (mod 24), so when the shifted window straddles midnight in the invitee frame, startTime ends up greater than endTime (e.g. a 09:00-17:00 organizer override with a +13h offset becomes 22:00-06:00 → startTime 1320, endTime 360). buildSlots then drops the whole override via if (start >= end) continue, so the override day silently shows zero availability for far-apart tz pairs. Consider handling the overflow the way getWorkingHours does (split into prev/next day using MINUTES_IN_DAY) rather than a bare hour()*60+minute() extraction.

dayjs(override.start).utc().add(offset, "minute").hour() * 60 +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The offset conversion can wrap past midnight for large timezone gaps. dayjs(override.start).utc().add(offset, "minute").hour() re-extracts the hour (mod 24), so when the shifted window straddles midnight in the invitee frame, startTime ends up greater than endTime and buildSlots drops the whole override via if (start >= end) continue — the override day shows zero availability for far-apart tz pairs. Consider handling the overflow the way getWorkingHours does (split into prev/next day using MINUTES_IN_DAY).

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;
Expand Down
81 changes: 76 additions & 5 deletions packages/trpc/server/routers/viewer/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand All @@ -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 (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a date override exists on the day of the slot and the slot starts before that or ends after that date override it will return false (not available)

dateOverrides.find((date) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Multiple date overrides on the same day for one host are mishandled. dateOverrides.find does not stop once a covering override is found: a slot that is inside override A (callback returns nothing for A) is then evaluated against override B on the same day, and B's "slot is outside this window" branch (lines 117-121 / 123) returns true, so find returns truthy and the valid slot is marked unavailable (return false). Only the single-override-per-day case works. The loop should conclude "available" as soon as any override on the matching day fully contains the slot.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking — multiple same-day overrides reject all slots, and partial overlaps are accepted.

dateOverrides.find(cb) returns the first override whose callback returns truthy, and the callback returns truthy when the slot is outside that override (slotEndTime <= o.start or slotStartTime > o.end). With two same-day overrides (e.g. host A 10:00–11:00 and host B 14:00–15:00, which is exactly the collective/round-robin case this PR targets since dateOverrides is the flatMap across all hosts), a slot fully inside A returns undefined for A but true for B → find returns B → return false. Every slot is outside at least one same-day override, so a fixed host with 2+ same-day overrides (own or other hosts') gets zero availability that day.

Separately, the boundary checks only reject slots entirely-before or entirely-after an override, so a slot that partially overlaps (e.g. 09:30–10:30 vs override 10:00–11:00, generated from the aggregate window of another host) makes the callback return undefined, dateOverrideExist is true, and line 133 returns true — booking a slot that extends outside the fixed host's override, the exact thing this PR prevents.

Fix: collect same-day overrides, then require containment in at least one:

const slotLocalDate = organizerTimeZone ? time.tz(organizerTimeZone).format("YYYY MM DD") : slotStartTime.format("YYYY MM DD");
const sameDay = dateOverrides.filter((d) =>
  (organizerTimeZone ? dayjs.tz(d.start, organizerTimeZone).format("YYYY MM DD") : dayjs(d.start).format("YYYY MM DD")) === slotLocalDate
);
if (sameDay.length) {
  const contained = sameDay.some((d) => {
    const oStart = organizerTimeZone ? dayjs.tz(d.start, organizerTimeZone) : dayjs(d.start);
    const oEnd   = organizerTimeZone ? dayjs.tz(d.end,   organizerTimeZone) : dayjs(d.end);
    return !slotStartTime.isBefore(oStart) && !slotEndTime.isAfter(oEnd);
  });
  if (!contained) return false;
  return busy.every(/* ...existing busy check... */);
}

const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The override day-match compares a shifted date.start against the slot's raw UTC date, which mis-detects overrides whose window crosses UTC midnight.

date.start/date.end are storage artifacts built as dayjs.utc(override.date).hour(h).minute(m) (packages/core/getUserAvailability.ts:221), so their UTC wall-clock equals the organizer-local clock. utcOffset = dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 then subtracts the organizer offset, yielding the real UTC instant of the override start, and its UTC date is compared to slotStartTime.format("YYYY MM DD") (the slot's UTC date). For an organizer in +5:30 with an override 05:00–07:00 IST on date D, the real start is 23:30 UTC on D-1, so A = D-1; a valid slot at 06:00 IST = 00:30 UTC on D has B = D; A != B → override not detected → the slot falls through to the working-hours check and is rejected (06:00 is outside 09:30–18:00). Compare organizer-local dates on both sides instead:

const overrideLocalDate = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).format("YYYY MM DD") : dayjs(date.start).format("YYYY MM DD");
const slotLocalDate = organizerTimeZone ? time.tz(organizerTimeZone).format("YYYY MM DD") : slotStartTime.format("YYYY MM DD");
if (overrideLocalDate === slotLocalDate) { ... }


if (
dayjs(date.start).add(utcOffset, "minutes").format("YYYY MM DD") ===

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 dayjs(date.start) parses the Date in the server's local timezone (no .utc()), then .format("YYYY MM DD") is compared against slotStartTime.format(...) where slotStartTime = time.utc() (UTC). On a non-UTC host this can produce an off-by-one-day match near midnight and mis-classify whether a slot falls on an override day, silently dropping the override. The isBefore/isAfter checks below use absolute instants and are unaffected. Use dayjs.utc(date.start) so the date derivation is server-independent. (Production is typically UTC, so impact is latent.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 dayjs(date.start) parses the Date in the server's local timezone (no .utc()), then the formatted date is compared against slotStartTime which is UTC. On a non-UTC server this can produce an off-by-one-day match near midnight and mis-detect whether a slot falls on an override day. Use dayjs.utc(date.start) so the date derivation is server-independent (production is typically UTC, so impact is latent).

slotStartTime.format("YYYY MM DD")
) {
dateOverrideExist = true;
if (dayjs(date.start).add(utcOffset, "minutes") === dayjs(date.end).add(utcOffset, "minutes")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This === compares two freshly-constructed dayjs objects by reference. dayjs does not implement value equality for ===, so two distinct instances are never equal — this condition is always false and the intended zero-length-override (start === end) branch is dead code. Use .isSame(other, "minute") (or compare the underlying Date instants) if the zero-length case needs handling.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 === compares object identity. dayjs(date.start).add(...) and dayjs(date.end).add(...) each allocate a new Dayjs wrapper, so this condition is always false and the return true on line 115 is unreachable dead code — the intended zero-length-override short-circuit never fires.

if (dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))) {
  return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 === compares object identity. dayjs(date.start).add(...) and dayjs(date.end).add(...) each allocate a new Dayjs wrapper, so this is always false — the zero-length-override short-circuit is dead code. Use .isSame(..., "minute") if the zero-length case needs handling.

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;
}
Comment thread
emrysal marked this conversation as resolved.

if (dateOverrideExist) {
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 When a fixed host has a date override on a day, slots within the override window set dateOverrideExist = true and hit return true here — before the busy.every(...) check at line 153. The slot is marked available even if the host has a conflicting booking during the override, enabling double-booking. Fall through to the busy check instead of returning early:

if (dateOverrideExist) {
  // Override replaces working hours, but still check for conflicting bookings
  return busy.every((busyTime) => { /* ...existing busy check... */ });
}

}

//if no date override for slot exists check if it is within normal work hours
if (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking — workingHours.find rejects every slot on split-shift days.

The callback returns true (→ find truthy → slot unavailable) when the slot is out of a block. For a split shift with two same-day blocks (09:00–12:00 and 13:00–17:00, both days:[1]), a slot inside block 1 makes block 1 return undefined, then block 2 returns true because start < workingHour.startTime (600 < 780). find returns the first truthy → the slot is declared outside working hours. Since the blocks are disjoint, every slot is outside at least one block, so all slots on a multi-block day are filtered out. getWorkingHours (packages/lib/availability.ts) explicitly emits multiple WorkingHours entries per day for overflow/cross-midnight schedules, so this is reachable.

Invert to "available if any block contains the slot":

const inSomeBlock = workingHours.some((wh) => {
  if (!wh.days.includes(slotStartTime.day())) return false;
  const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  const end = slotEndTime.hour() * 60 + slotEndTime.minute();
  return start >= wh.startTime && end <= wh.endTime;
});
if (!inSomeBlock) {
  return false;
}

workingHours.find((workingHour) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 workingHours.find returns the first block the slot is outside, so a slot inside block 1 is rejected by block 2 on split-shift days. getWorkingHours (packages/lib/availability.ts) explicitly emits multiple WorkingHours entries for the same day for cross-midnight schedules. Since the blocks are disjoint, every slot is outside at least one block → all slots on multi-block days are filtered out. Use some with inside-semantics:

const inSomeBlock = workingHours.some((wh) => {
  if (!wh.days.includes(slotStartTime.day())) return false;
  const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  const end = slotEndTime.hour() * 60 + slotEndTime.minute();
  return start >= wh.startTime && end <= wh.endTime;
});
if (!inSomeBlock) return false;

if (workingHour.days.includes(slotStartTime.day())) {
const start = slotStartTime.hour() * 60 + slotStartTime.minute();
const end = slotStartTime.hour() * 60 + slotStartTime.minute();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 end is computed from slotStartTime — identical to start on the line above — so it ignores eventLength/slotEndTime. end > workingHour.endTime therefore tests the slot start against the working-hour end, and a slot that starts within working hours but extends past workingHour.endTime is not rejected here. This should be slotEndTime.hour() * 60 + slotEndTime.minute(). Impact is currently masked because buildSlots pre-bounds generated slots to the working window, but the local check is wrong and will mis-fire if slots reach this function by another path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking — end is computed from slotStartTime, not slotEndTime.

Line 141 sets start = slotStartTime.hour()*60 + slotStartTime.minute() and line 142 sets end to the same expression, so end === start. The guard end > workingHour.endTime therefore degenerates to start > workingHour.endTime, which only catches slots that start after the block end. A slot that starts before workingHour.endTime but whose end (start + eventLength) exceeds it passes the filter and is reported available — e.g. working hours 09:00–17:00, 60-min event, slot 16:30 → start=990, end=990 (should be 1050), 990 > 1020 is false → 16:30–17:30 offered though it overruns 17:00. Because checkIfIsAvailable runs per fixed host with that host's own workingHours while slots are generated from the aggregate working hours, a slot that fits the aggregate but overruns this host's narrower block-end is wrongly offered.

const start = slotStartTime.hour() * 60 + slotStartTime.minute();
const end = slotEndTime.hour() * 60 + slotEndTime.minute();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 end is computed from slotStartTime — identical to start on the line above — so it ignores eventLength/slotEndTime. The guard end > workingHour.endTime only catches slots that start after the block end; a slot that starts within working hours but extends past workingHour.endTime passes (e.g. working hours end 17:00, 60-min event, slot 16:30 → end=990 should be 1050, 990 > 1020 is false → offered though it overruns). Use slotEndTime:

const end = slotEndTime.hour() * 60 + slotEndTime.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);
Expand All @@ -115,7 +176,6 @@ const checkIfIsAvailable = ({
else if (startTime.isBetween(time, slotEndTime)) {
return false;
}

return true;
});
};
Expand Down Expand Up @@ -348,7 +408,11 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, 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 = {
Expand All @@ -372,6 +436,9 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:

const timeSlots: ReturnType<typeof getTimeSlots> = [];

const organizerTimeZone =
eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure userAvailability?.[0] works; I think you need to involve the defaultSchedule here


for (
let currentCheckedTime = startTime;
currentCheckedTime.isBefore(endTime);
Expand All @@ -386,8 +453,7 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
dateOverrides,
minimumBookingNotice: eventType.minimumBookingNotice,
frequency: eventType.slotInterval || input.duration || eventType.length,
organizerTimeZone:
eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone,
organizerTimeZone,
})
);
}
Expand Down Expand Up @@ -423,13 +489,15 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
time: slot.time,
...schedule,
...availabilityCheckProps,
organizerTimeZone: schedule.timeZone,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alex we want to have the schedule's timezone here

});
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) {
Expand All @@ -446,6 +514,7 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
time: slot.time,
...userSchedule,
...availabilityCheckProps,
organizerTimeZone: userSchedule.timeZone,
});
});
return slot;
Expand Down Expand Up @@ -507,17 +576,19 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, 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(
Expand Down
1 change: 1 addition & 0 deletions packages/types/schedule.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type TimeRange = {
userId?: number | null;
start: Date;
end: Date;
timeZone?: string;
};

export type Schedule = TimeRange[][];
Expand Down