Skip to content

Automated Test: date-algorithm-enhanced #374

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(
{
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();
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(),
};
});
Comment on lines +211 to +225

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

override.timeZone may be undefined, causing incorrect offset calculation.

TimeRange.timeZone is optional. If a date override lacks a timeZone value, dayjs(...).tz(undefined) will produce unpredictable results — likely falling back to the local/system timezone rather than the organizer's timezone.

Add a fallback to the organizer timezone (or skip offset adjustment entirely when timeZone is absent):

Proposed fix
     const overrides = activeOverrides.flatMap((override) => {
-      const organizerUtcOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset();
+      const overrideTimeZone = override.timeZone || organizerTimeZone;
+      const organizerUtcOffset = dayjs(override.start.toString()).tz(overrideTimeZone).utcOffset();
       const inviteeUtcOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset();
       const offset = inviteeUtcOffset - organizerUtcOffset;

Additionally, flatMap is misleading here since each override produces exactly one object — map is more appropriate.

🤖 Prompt for AI Agents
In `@packages/lib/slots.ts` around lines 211 - 225, activeOverrides handling can
call dayjs(...).tz(override.timeZone) when override.timeZone is undefined,
producing wrong offsets; change the computation in the block that builds
overrides (the activeOverrides flatMap) to first fall back to the organizer
timezone (or skip offset adjustment) when override.timeZone is missing before
calling dayjs.tz, and replace flatMap with map since each override returns a
single object; update references in this block (override.timeZone,
organizerUtcOffset/inviteeUtcOffset/offset, and the activeOverrides flatMap →
map) so offset calculations only run when a valid timeZone exists or use
organizer timezone as the fallback.


// 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 (
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;
}
Comment on lines +114 to +116

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: Dayjs object identity comparison will always be false.

dayjs(date.start).add(utcOffset, "minutes") === dayjs(date.end).add(utcOffset, "minutes") compares two distinct Dayjs object references, not their underlying time values. This condition will never be true, even when start and end are identical.

Use .isSame() for value comparison:

Proposed fix
-        if (dayjs(date.start).add(utcOffset, "minutes") === dayjs(date.end).add(utcOffset, "minutes")) {
+        if (dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))) {
           return true;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (dayjs(date.start).add(utcOffset, "minutes") === dayjs(date.end).add(utcOffset, "minutes")) {
return true;
}
if (dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))) {
return true;
}
🤖 Prompt for AI Agents
In `@packages/trpc/server/routers/viewer/slots.ts` around lines 114 - 116, The
condition in slots.ts is comparing two Dayjs instances by reference causing it
to always be false; replace the strict equality check between
dayjs(date.start).add(utcOffset, "minutes") and dayjs(date.end).add(utcOffset,
"minutes") with a value comparison using the Dayjs method .isSame(), i.e. call
.isSame(...) on one of those Dayjs objects (optionally with a unit like "minute"
if needed) and keep the existing return true when they match.

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) {
Comment on lines +141 to +143

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Bug: end is computed from slotStartTime instead of slotEndTime, making the boundary check ineffective.

Both start and end resolve to the same value (slotStartTime.hour() * 60 + slotStartTime.minute()), so the end > workingHour.endTime check is equivalent to start > workingHour.endTime. Slots that start within working hours but extend past them won't be filtered out.

Proposed fix
         const start = slotStartTime.hour() * 60 + slotStartTime.minute();
-        const end = slotStartTime.hour() * 60 + slotStartTime.minute();
+        const end = slotEndTime.hour() * 60 + slotEndTime.minute();
         if (start < workingHour.startTime || end > workingHour.endTime) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const start = slotStartTime.hour() * 60 + slotStartTime.minute();
const end = slotStartTime.hour() * 60 + slotStartTime.minute();
if (start < workingHour.startTime || end > workingHour.endTime) {
const start = slotStartTime.hour() * 60 + slotStartTime.minute();
const end = slotEndTime.hour() * 60 + slotEndTime.minute();
if (start < workingHour.startTime || end > workingHour.endTime) {
🤖 Prompt for AI Agents
In `@packages/trpc/server/routers/viewer/slots.ts` around lines 141 - 143, The
boundary check wrongly computes end from slotStartTime causing start and end to
be identical; update the calculation that sets end so it uses slotEndTime (i.e.,
replace the second use of slotStartTime.hour()/minute() with
slotEndTime.hour()/minute()) in the block that defines start and end (look for
the variables start, end in the slots.ts logic that compares to
workingHour.startTime and workingHour.endTime) so slots that extend past working
hours are correctly filtered.

return true;
}
}
})
) {
// slot is outside of working hours
return false;
}
Comment on lines +102 to +151

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

find() callbacks should always return a value — use some() or add explicit returns.

Both find() calls (lines 106 and 139) rely on implicit undefined returns for non-matching branches, which the linter flags and makes the intent unclear. Since you're only checking for existence (not using the found element), some() is the right method.

Additionally, the mutable side-effect pattern (dateOverrideExist set inside find()) is fragile. Consider restructuring to separate the "does an override exist for this day" check from the "is the slot within that override" check.

Proposed refactor for the find→some pattern
-  if (
-    dateOverrides.find((date) => {
+  if (
+    dateOverrides.some((date) => {
       const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0;
       // ...
+      return false;
     })
   ) {
-  if (
-    workingHours.find((workingHour) => {
+  if (
+    workingHours.some((workingHour) => {
       if (workingHour.days.includes(slotStartTime.day())) {
         // ...
+        return start < workingHour.startTime || end > workingHour.endTime;
       }
+      return false;
     })
   ) {
🧰 Tools
🪛 Biome (2.3.13)

[error] 106-106: This callback passed to find() iterable method should always return a value.

Add missing return statements so that this callback returns a value on all execution paths.

(lint/suspicious/useIterableCallbackReturn)


[error] 139-139: This callback passed to find() iterable method should always return a value.

Add missing return statements so that this callback returns a value on all execution paths.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/trpc/server/routers/viewer/slots.ts` around lines 102 - 151, Replace
the two misused find() calls with some() and remove the mutable
dateOverrideExist side-effect: use dateOverrides.some(...) to first check
whether any override falls on the same day as slotStartTime (respecting
organizerTimeZone), then separately check if any matching override contains or
excludes the slot times and return accordingly; similarly replace the
workingHours.find(...) with workingHours.some(...), compute start =
slotStartTime.hour()*60 + slotStartTime.minute() and end = slotEndTime.hour()*60
+ slotEndTime.minute() (fixing the current bug where end is set from
slotStartTime), and return false when some() indicates the slot is outside
working hours. Reference symbols: dateOverrides, organizerTimeZone,
slotStartTime, slotEndTime, dateOverrideExist (remove), workingHours.


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;

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