Skip to content
Open
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) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Undefined Timezone Handling (confidence: 100%)

When override.timeZone is undefined (the timeZone field on TimeRange is optional), dayjs(override.start.toString()).tz(undefined) will produce unexpected behavior. Dayjs tz(undefined) may fall back to the local timezone of the server rather than UTC, leading to incorrect offset calculations.

Evidence:

  • TimeRange.timeZone is defined as optional (timeZone?: string) in schedule.d.ts
  • Line 211: dayjs(override.start.toString()).tz(override.timeZone) — no fallback for undefined
  • dateOverrides are populated from availability.dateOverrides.map(override => ({ ...override, timeZone: availability.timeZone })) — if availability.timeZone is undefined, this propagates undefined
  • Intent specification notes: 'activeOverrides with a missing or undefined timeZone field on override will cause dayjs().tz(undefined) behavior'
  • packages/types/schedule.d.ts defines timeZone?: string — it's optional
  • The timeZone is only added to dateOverrides at line 411-414 via availability.timeZone, which could be undefined if a user's availability record lacks a timezone
  • Line 211: dayjs(override.start.toString()).tz(override.timeZone) — no null/undefined guard
  • Similarly line 212: dayjs(override.start.toString()).tz(timeZone) uses the invitee timeZone which should be present, but override.timeZone has no guarantee

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (9 lines, 1 file))

When override.timeZone is undefined (the timeZone field on TimeRange is optional), dayjs(override.start.toString()).tz(undefined) will produce unexpected behavior. Dayjs tz(undefined) may fall back to the local timezone of the server rather than UTC, leading to incorrect offset calculations.

Suggested change
const overrides = activeOverrides.flatMap((override) => {
// Fall back to "UTC" if override.timeZone is undefined (TimeRange.timeZone is optional).
// Using UTC (offset=0) is the safest neutral default: it avoids dayjs().tz(undefined)
// silently adopting the server's local timezone, which would produce incorrect offsets.
const organizerTimeZone = override.timeZone || "UTC";
const organizerUtcOffset = dayjs(override.start.toString()).tz(organizerTimeZone).utcOffset();
// timeZone here is the invitee's timezone, expected to always be defined in this context.
const inviteeUtcOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset();

🤖 Grapple PR auto-fix • major • confidence: 100%

const organizerUtcOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — DST Sensitivity (confidence: 100%)

The UTC offset is computed using only override.start for both organizerUtcOffset and inviteeUtcOffset. If a date override spans a DST transition (e.g., clocks change between override.start and override.end), the offset applied to override.end will be incorrect by the DST delta (typically ±60 minutes). This is an inherent limitation of the single-offset approach.

Evidence:

  • Line 211: dayjs(override.start.toString()).tz(override.timeZone).utcOffset() — uses override.start
  • Line 212: dayjs(override.start.toString()).tz(timeZone).utcOffset() — also uses override.start
  • The end time on line 219-221 applies the same offset computed from start
  • DST transitions during an override window (rare but possible) would cause the end time to be off by the DST shift amount

Agent: architecture

const inviteeUtcOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Code patterns (confidence: 100%)

The UTC offset is calculated only once from override.start, but if a DST (Daylight Saving Time) transition occurs between the override's start and end times, the offset may differ. Using a single offset value could produce incorrect minute-of-day calculations for overrides that span a DST boundary.

Evidence:

  • Line 210-211: offset is computed from override.start only
  • Line 212-216: the same offset is applied to both override.start and override.end
  • In regions with DST, dayjs.tz(date, tz).utcOffset() can differ between two times if DST changes between them
  • For a 2-hour override spanning a DST transition, the end time could be offset incorrectly by ±60 minutes

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — style agent (Larger fix (21 lines, 1 file) — review recommended)

The UTC offset is calculated only once from override.start, but if a DST (Daylight Saving Time) transition occurs between the override's start and end times, the offset may differ. Using a single offset value could produce incorrect minute-of-day calculations for overrides that span a DST boundary.

--- a/packages/lib/slots.ts
+++ b/packages/lib/slots.ts
@@ -210,15 +210,20 @@ const getSlots = ({
   if (!!activeOverrides.length) {
     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;
+      // Compute offsets independently for start and end to handle DST transitions:
+      // if a DST boundary falls within the override, the UTC offset may differ between
+      // the two endpoints, so we must not reuse the start offset for the end calculation.
+      const organizerStartOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset();
+      const inviteeStartOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset();
+      const startOffset = inviteeStartOffset - organizerStartOffset;
+
+      const organizerEndOffset = dayjs(override.end.toString()).tz(override.timeZone).utcOffset();
+      const inviteeEndOffset = dayjs(override.end.toString()).tz(timeZone).utcOffset();
+      const endOffset = inviteeEndOffset - organizerEndOffset;
 
       return {
         userIds: override.userId ? [override.userId] : [],
         startTime:
-          dayjs(override.start).utc().add(offset, "minute").hour() * 60 +
-          dayjs(override.start).utc().add(offset, "minute").minute(),
+          dayjs(override.start).utc().add(startOffset, "minute").hour() * 60 +
+          dayjs(override.start).utc().add(startOffset, "minute").minute(),
         endTime:
-          dayjs(override.end).utc().add(offset, "minute").hour() * 60 +
-          dayjs(override.end).utc().add(offset, "minute").minute(),
+          dayjs(override.end).utc().add(endOffset, "minute").hour() * 60 +
+          dayjs(override.end).utc().add(endOffset, "minute").minute(),
       };
     });

🤖 Grapple PR auto-fix • minor • Review this diff before applying

const offset = inviteeUtcOffset - organizerUtcOffset;

return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Code patterns (confidence: 100%)

Adding the offset delta and converting to minute-of-day does not use modular arithmetic. If the offset delta is large enough to shift a time past midnight, the minute-of-day calculation will wrap incorrectly (e.g., hour() will become negative or exceed 23). This is especially problematic for midnight-spanning overrides or large UTC offset differences.

Evidence:

  • Lines 212-213 and 215-216 compute hour() * 60 + minute() directly on offset-adjusted times
  • If an override ends at 23:00 UTC and offset is +120 minutes, the adjusted end time is 01:00 the next day
  • Calling .hour() * 60 + .minute() on a time past midnight returns an incorrect minute-of-day value
  • Should use .startOf('day').diff(adjustedTime, 'minutes') or similar to get minute-of-day with proper wrapping

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — style agent (Larger fix (17 lines, 1 file) — review recommended)

Adding the offset delta and converting to minute-of-day does not use modular arithmetic. If the offset delta is large enough to shift a time past midnight, the minute-of-day calculation will wrap incorrectly (e.g., hour() will become negative or exceed 23). This is especially problematic for midnight-spanning overrides or large UTC offset differences.

--- a/packages/lib/slots.ts
+++ b/packages/lib/slots.ts
@@ -210,16 +210,22 @@ const getSlots = ({
   if (!!activeOverrides.length) {
     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;
 
+      const adjustedStart = dayjs(override.start).utc().add(offset, "minute");
+      const adjustedEnd = dayjs(override.end).utc().add(offset, "minute");
+
+      // Use modular arithmetic to correctly handle times that cross midnight after
+      // the UTC offset is applied (e.g., 23:00 UTC + 120min offset = 01:00 next day).
+      // diff from startOf('day') gives true minute-of-day; % (24*60) keeps it in [0, 1440).
+      const startTime = Math.abs(adjustedStart.diff(adjustedStart.startOf("day"), "minute")) % (24 * 60);
+      const endTime = Math.abs(adjustedEnd.diff(adjustedEnd.startOf("day"), "minute")) % (24 * 60);
+
       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(),
+        startTime,
+        endTime,
       };
     });

🤖 Grapple PR auto-fix • minor • Review this diff before applying

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.

🔴 CRITICAL — Sign Convention Inconsistency (confidence: 100%)

UTC offset calculation is inconsistent between checkIfIsAvailable and getSlots. In checkIfIsAvailable (slots.ts line 109), offset is negated with utcOffset() * -1. In getSlots (slots.ts lines 212-213), the offset is used without negation: inviteeUtcOffset - organizerUtcOffset. This sign inversion inconsistency will produce incorrect timezone adjustments.

Evidence:

  • checkIfIsAvailable line 109: dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 — negates the offset
  • getSlots lines 212-213: const offset = inviteeUtcOffset - organizerUtcOffset; — no negation applied
  • These two code paths handle the same timezone offset adjustment but with opposite signs, leading to incorrect override times

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (8 lines, 1 file))

UTC offset calculation is inconsistent between checkIfIsAvailable and getSlots. In checkIfIsAvailable (slots.ts line 109), offset is negated with utcOffset() * -1. In getSlots (slots.ts lines 212-213), the offset is used without negation: inviteeUtcOffset - organizerUtcOffset. This sign inversion inconsistency will produce incorrect timezone adjustments.

Suggested change
startTime:
// Negate organizer offset (matching checkIfIsAvailable sign convention) then shift to invitee local.
// organizerUtcOffset * -1 converts from organizer-local to UTC; + inviteeUtcOffset shifts to invitee-local.
const organizerUtcOffset = dayjs.tz(override.start, override.timeZone).utcOffset() * -1;
const inviteeUtcOffset = dayjs.tz(override.start, timeZone).utcOffset();
const offset = organizerUtcOffset + inviteeUtcOffset;

🤖 Grapple PR auto-fix • critical • confidence: 100%

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 +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Edge cases (confidence: 100%)

The offset calculation in getSlots uses only override.start to compute both organizerUtcOffset and inviteeUtcOffset. If DST transitions occur between the override's start and end times (or between different override dates), the offset applied to endTime could be incorrect, leading to slots being generated for the wrong time window.

Evidence:

  • Line 211: const organizerUtcOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset();
  • Line 212: const inviteeUtcOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset();
  • Both offsets are computed at the override.start instant. If the override spans a DST transition (e.g., a date override from 00:00 to 23:59 on a DST change day), the end time would use the wrong offset.
  • This could cause slots to be generated an hour early or late at the boundaries of DST transitions.

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Edge cases (confidence: 100%)

When the offset delta shifts override start/end past midnight, the minute-of-day calculation (hour * 60 + minute) wraps incorrectly. For example, if the override ends at 23:30 UTC and the offset is +60 minutes, the result is 00:30 the next day = 30 minutes, which is less than startTime, causing incorrect slot generation.

Evidence:

  • The calculation dayjs(override.end).utc().add(offset, 'minute').hour() * 60 + dayjs(override.end).utc().add(offset, 'minute').minute() produces a minute-of-day value (0-1439).
  • If the offset shifts past midnight, the hour wraps to 0, giving a small minute value while the start might still be in the hundreds.
  • This results in endTime < startTime, which would cause no slots to be generated for that override.
  • No modular arithmetic or day-boundary handling is present.

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Midnight Wrapping / Negative Minutes (confidence: 100%)

The offset delta is applied to the override times and then .hour() * 60 + .minute() is extracted, producing a minute-of-day value in [0, 1439]. If the offset pushes a time past midnight (e.g., an override ending at 23:00 UTC with a +120 minute offset becomes 01:00 the next day), the minute-of-day value wraps to a small number (60), which would be less than startTime and produce an invalid/empty working hours range. There is no modular arithmetic or day-boundary handling.

Evidence:

  • Line 215-222: dayjs(override.start).utc().add(offset, 'minute').hour() * 60 + dayjs(override.start).utc().add(offset, 'minute').minute()
  • If override.start is 22:00 UTC and offset is +180 (3 hours), the result is 01:00 next day → startTime = 60
  • If override.end is 23:00 UTC with same offset, endTime = 120, but this is now on the wrong calendar day
  • The downstream slot generation logic expects startTime < endTime within the same day

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (22 lines, 1 file) — review recommended)

The offset calculation in getSlots uses only override.start to compute both organizerUtcOffset and inviteeUtcOffset. If DST transitions occur between the override's start and end times (or between different override dates), the offset applied to endTime could be incorrect, leading to slots being generated for the wrong time window.

--- a/packages/lib/slots.ts
+++ b/packages/lib/slots.ts
@@ -208,22 +208,27 @@ const getSlots = ({
   });
 
   if (!!activeOverrides.length) {
     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;
+      // Compute offsets independently at start and end instants to correctly handle
+      // DST transitions that may occur within the override window (e.g., a full-day
+      // override on a clock-change day). Using override.start for both offsets would
+      // apply the wrong UTC offset to endTime after a DST boundary is crossed.
+      const startOrganizerUtcOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset();
+      const startInviteeUtcOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset();
+      const startOffset = startInviteeUtcOffset - startOrganizerUtcOffset;
+
+      const endOrganizerUtcOffset = dayjs(override.end.toString()).tz(override.timeZone).utcOffset();
+      const endInviteeUtcOffset = dayjs(override.end.toString()).tz(timeZone).utcOffset();
+      const endOffset = endInviteeUtcOffset - endOrganizerUtcOffset;
 
       return {
         userIds: override.userId ? [override.userId] : [],
         startTime:
-          dayjs(override.start).utc().add(offset, "minute").hour() * 60 +
-          dayjs(override.start).utc().add(offset, "minute").minute(),
+          dayjs(override.start).utc().add(startOffset, "minute").hour() * 60 +
+          dayjs(override.start).utc().add(startOffset, "minute").minute(),
         endTime:
-          dayjs(override.end).utc().add(offset, "minute").hour() * 60 +
-          dayjs(override.end).utc().add(offset, "minute").minute(),
+          dayjs(override.end).utc().add(endOffset, "minute").hour() * 60 +
+          dayjs(override.end).utc().add(endOffset, "minute").minute(),
       };
     });
 
     // unset all working hours that relate to this user availability override
     overrides.forEach((override) => {
       let i = -1;

🤖 Grapple PR auto-fix • major • Review this diff before applying

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (20 lines, 1 file) — review recommended)

When the offset delta shifts override start/end past midnight, the minute-of-day calculation (hour * 60 + minute) wraps incorrectly. For example, if the override ends at 23:30 UTC and the offset is +60 minutes, the result is 00:30 the next day = 30 minutes, which is less than startTime, causing incorrect slot generation.

--- a/packages/lib/slots.ts
+++ b/packages/lib/slots.ts
@@ -208,22 +208,30 @@ const getSlots = ({
   });
 
   if (!!activeOverrides.length) {
     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;
 
+      const adjustedStart = dayjs(override.start).utc().add(offset, "minute");
+      const adjustedEnd = dayjs(override.end).utc().add(offset, "minute");
+
+      // Use startOfInviteeDay as the reference point so that times crossing
+      // midnight are represented as minutes since day-start (possibly > 1439
+      // for next-day overflow, or negative for previous-day underflow).
+      // This avoids the wrap-around bug where hour()*60+minute() gives a small
+      // value (e.g. 30) when the time has crossed past midnight, making
+      // endTime < startTime and producing zero slots.
+      const startTime = adjustedStart.diff(startOfInviteeDay, "minute");
+      const endTime = adjustedEnd.diff(startOfInviteeDay, "minute");
+
       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(),
+        startTime,
+        endTime,
       };
     });
 
     // unset all working hours that relate to this user availability override
     overrides.forEach((override) => {
       let i = -1;

🤖 Grapple PR auto-fix • major • Review this diff before applying

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (48 lines, 1 file) — review recommended)

The offset delta is applied to the override times and then .hour() * 60 + .minute() is extracted, producing a minute-of-day value in [0, 1439]. If the offset pushes a time past midnight (e.g., an override ending at 23:00 UTC with a +120 minute offset becomes 01:00 the next day), the minute-of-day value wraps to a small number (60), which would be less than startTime and produce an invalid/empty working hours range. There is no modular arithmetic or day-boundary handling.

--- a/packages/lib/slots.ts
+++ b/packages/lib/slots.ts
@@ -208,22 +208,36 @@ const getSlots = ({
   });
 
   if (!!activeOverrides.length) {
-    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(),
-      };
-    });
-
+    const overrides = activeOverrides.flatMap((override) => {
+      // Convert the override's absolute UTC timestamps into the invitee's local
+      // timezone so that hour/minute extraction reflects the correct calendar day
+      // for the invitee. Using .tz(timeZone) instead of manual offset arithmetic
+      // correctly handles DST transitions and midnight-wrapping: if a 23:00 UTC
+      // override becomes 01:00 in the invitee's zone, the minute-of-day value
+      // (60) would be less than a startTime computed on the same day, producing
+      // an invalid range. We detect and discard such out-of-day overrides below.
+      const startInInviteeTz = dayjs(override.start).tz(timeZone);
+      const endInInviteeTz = dayjs(override.end).tz(timeZone);
+
+      const startTime = startInInviteeTz.hour() * 60 + startInInviteeTz.minute();
+      const endTime = endInInviteeTz.hour() * 60 + endInInviteeTz.minute();
+
+      // Guard: if the timezone conversion pushed the override entirely past
+      // midnight (end wraps to a smaller minute-of-day than start, or the
+      // converted date no longer falls on the invitee's requested day), the
+      // downstream slot generator would produce an empty or inverted range.
+      // Return an empty array so flatMap drops this override silently.
+      if (endTime <= startTime) {
+        return [];
+      }
+
+      return [
+        {
+          userIds: override.userId ? [override.userId] : [],
+          startTime,
+          endTime,
+        },
+      ];
+    });
+
     // unset all working hours that relate to this user availability override
     overrides.forEach((override) => {
       let i = -1;

🤖 Grapple PR auto-fix • major • Review this diff before applying

dayjs(override.end).utc().add(offset, "minute").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.

🟠 MAJOR — Logic Bug — DST-Sensitive Offset Using Only Override Start (confidence: 100%)

The offset calculation in getSlots uses dayjs(override.start.toString()).tz(override.timeZone).utcOffset() — computing the UTC offset only at the override's start time. If a DST transition occurs within the override window (e.g., a 3-hour override spanning a clock change), the offset at the end of the window differs from the start, causing the endTime minute-of-day to be computed with the wrong offset.

Evidence:

  • const organizerUtcOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset(); — only override.start used for both start and end offset
  • const inviteeUtcOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset(); — same issue for invitee
  • DST transitions cause .utcOffset() to differ by 60 minutes before and after the change.
  • The endTime calculation dayjs(override.end).utc().add(offset, 'minute') uses the offset from override.start, which may be wrong at override.end if DST occurred in between.
  • This is a minor but real correctness issue for edge cases around daylight saving time changes.

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Logic Bug — Midnight-Spanning Override Wraps Without Modular Arithmetic (confidence: 100%)

When the UTC offset delta causes an override's end time to cross midnight, the minute-of-day calculation using .hour() * 60 + .minute() wraps to a small value (e.g., 00:30 → 30) instead of the expected value greater than 1440. This can make the override window appear inverted or empty, causing slots that should be included to be excluded and vice versa.

Evidence:

  • Example: override ends at 23:30 UTC, inviteeUtcOffset - organizerUtcOffset = +90 minutes. dayjs(override.end).utc().add(90, 'minute') = 01:00 next day. .hour() * 60 + .minute() = 60, not 1470.
  • The startTime might be computed as, say, 540 (09:00), but endTime becomes 60 (01:00 next day) — the window is nonsensical.
  • No modular arithmetic or day-overflow handling is present in the code.
  • This can cause all slots in the override window to be incorrectly filtered out or included.

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (21 lines, 1 file) — review recommended)

The offset calculation in getSlots uses dayjs(override.start.toString()).tz(override.timeZone).utcOffset() — computing the UTC offset only at the override's start time. If a DST transition occurs within the override window (e.g., a 3-hour override spanning a clock change), the offset at the end of the window differs from the start, causing the endTime minute-of-day to be computed with the wrong offset.

--- a/packages/lib/slots.ts
+++ b/packages/lib/slots.ts
@@ -210,18 +210,22 @@ const getSlots = ({
   if (!!activeOverrides.length) {
     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;
+      // Compute offsets independently at each boundary to handle DST transitions
+      // that may occur within the override window (e.g., a 3-hour override spanning
+      // a clock change where utcOffset() differs by 60 minutes at start vs. end).
+      const startOrganizerUtcOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset();
+      const startInviteeUtcOffset = dayjs(override.start.toString()).tz(timeZone).utcOffset();
+      const startOffset = startInviteeUtcOffset - startOrganizerUtcOffset;
+
+      const endOrganizerUtcOffset = dayjs(override.end.toString()).tz(override.timeZone).utcOffset();
+      const endInviteeUtcOffset = dayjs(override.end.toString()).tz(timeZone).utcOffset();
+      const endOffset = endInviteeUtcOffset - endOrganizerUtcOffset;
 
       return {
         userIds: override.userId ? [override.userId] : [],
         startTime:
-          dayjs(override.start).utc().add(offset, "minute").hour() * 60 +
-          dayjs(override.start).utc().add(offset, "minute").minute(),
+          dayjs(override.start).utc().add(startOffset, "minute").hour() * 60 +
+          dayjs(override.start).utc().add(startOffset, "minute").minute(),
         endTime:
-          dayjs(override.end).utc().add(offset, "minute").hour() * 60 +
-          dayjs(override.end).utc().add(offset, "minute").minute(),
+          dayjs(override.end).utc().add(endOffset, "minute").hour() * 60 +
+          dayjs(override.end).utc().add(endOffset, "minute").minute(),
       };
     });

🤖 Grapple PR auto-fix • major • Review this diff before applying

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (26 lines, 1 file) — review recommended)

When the UTC offset delta causes an override's end time to cross midnight, the minute-of-day calculation using .hour() * 60 + .minute() wraps to a small value (e.g., 00:30 → 30) instead of the expected value greater than 1440. This can make the override window appear inverted or empty, causing slots that should be included to be excluded and vice versa.

--- a/packages/lib/slots.ts
+++ b/packages/lib/slots.ts
@@ -208,22 +208,32 @@ const getSlots = ({
   });
 
   if (!!activeOverrides.length) {
     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;
 
+      const adjustedStart = dayjs(override.start).utc().add(offset, "minute");
+      const adjustedEnd = dayjs(override.end).utc().add(offset, "minute");
+
+      // If the UTC offset adjustment pushes the time past midnight, .hour() * 60 + .minute()
+      // wraps back to a small value (e.g., 01:00 → 60 instead of 1500). We detect day overflow
+      // by comparing the adjusted date's day-of-year to the original UTC date's day-of-year and
+      // add 1440 minutes per day crossed so that endTime > startTime always holds.
+      const startDayDelta =
+        adjustedStart.diff(dayjs(override.start).utc().startOf("day"), "day");
+      const endDayDelta =
+        adjustedEnd.diff(dayjs(override.end).utc().startOf("day"), "day");
+
       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(),
+        startTime:
+          adjustedStart.hour() * 60 +
+          adjustedStart.minute() +
+          startDayDelta * 1440,
+        endTime:
+          adjustedEnd.hour() * 60 +
+          adjustedEnd.minute() +
+          endDayDelta * 1440,
       };
     });

🤖 Grapple PR auto-fix • major • Review this diff before applying

// unset all working hours that relate to this user availability override
overrides.forEach((override) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Unnecessary work (confidence: 100%)

For each active override, dayjs(override.start.toString()).tz(override.timeZone) and dayjs(override.start.toString()).tz(timeZone) are called to compute utcOffset, and then dayjs(override.start).utc().add(offset, 'minute') and dayjs(override.end).utc().add(offset, 'minute') are each called twice (once for .hour() and once for .minute()). This is 6 dayjs constructions and tz parses per override, all of which could be collapsed to 2–3.

Evidence:

  • Line 211: dayjs(override.start.toString()).tz(override.timeZone).utcOffset() — one tz parse
  • Line 212: dayjs(override.start.toString()).tz(timeZone).utcOffset() — second tz parse
  • Lines 215–216: dayjs(override.start).utc().add(offset, 'minute').hour() and dayjs(override.start).utc().add(offset, 'minute').minute() — same computation done twice
  • Lines 217–218: same duplication for override.end
  • These are called inside flatMap over activeOverrides, which itself may be inside a per-day loop

Agent: performance

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (11 lines, 1 file) — review recommended)

For each active override, dayjs(override.start.toString()).tz(override.timeZone) and dayjs(override.start.toString()).tz(timeZone) are called to compute utcOffset, and then dayjs(override.start).utc().add(offset, 'minute') and dayjs(override.end).utc().add(offset, 'minute') are each called twice (once for .hour() and once for .minute()). This is 6 dayjs constructions and tz parses per override, all of which could be collapsed to 2–3.

--- a/packages/lib/slots.ts
+++ b/packages/lib/slots.ts
@@ -210,16 +210,14 @@ const getSlots = ({
   if (!!activeOverrides.length) {
     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;
 
+      const adjustedStart = dayjs(override.start).utc().add(offset, "minute");
+      const adjustedEnd = dayjs(override.end).utc().add(offset, "minute");
+
       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(),
+        startTime: adjustedStart.hour() * 60 + adjustedStart.minute(),
+        endTime: adjustedEnd.hour() * 60 + adjustedEnd.minute(),
       };
     });

🤖 Grapple PR auto-fix • major • Review this diff before applying

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — API Contract Change - Function Signature (confidence: 99%)

The checkIfIsAvailable function's parameter object has been extended with dateOverrides, workingHours, and organizerTimeZone. While these have default values, the function's behavior has fundamentally changed: it now returns false early based on date override and working hour checks before evaluating busy times. All call sites that pass dateOverrides or workingHours (lines 489-492, 514-517, 579-584) must consistently provide these parameters, but the diff is truncated and we cannot verify all invocations. Furthermore, the early-return semantics mean a slot outside a date override is rejected without checking currentSeats, potentially breaking the existing booking flow for seated events.

Evidence:

  • Lines 99-148 add early-return logic that short-circuits before the existing busy-time evaluation
  • Line 89-90: currentSeats check happens before the new date override logic, but a slot that matches currentSeats AND is outside a date override will still return true (line 88) — this is inconsistent since the slot shouldn't be available outside override hours
  • Multiple call sites in the diff pass organizerTimeZone: schedule.timeZone or organizerTimeZone: userSchedule?.timeZone but not all pass dateOverrides or workingHours explicitly
  • The truncated diff around line 453 shows getSlots receives dateOverrides but we can't confirm checkIfIsAvailable receives them in all code paths

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (15 lines, 1 file) — review recommended)

The checkIfIsAvailable function's parameter object has been extended with dateOverrides, workingHours, and organizerTimeZone. While these have default values, the function's behavior has fundamentally changed: it now returns false early based on date override and working hour checks before evaluating busy times. All call sites that pass dateOverrides or workingHours (lines 489-492, 514-517, 579-584) must consistently provide these parameters, but the diff is truncated and we cannot verify all invocations. Furthermore, the early-return semantics mean a slot outside a date override is rejected without checking currentSeats, potentially breaking the existing booking flow for seated events.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -75,7 +75,7 @@ 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;
-  }
-
   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) {
+    // slot is within a date override window — check seats and busy times
+    if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
+      return true;
+    }
     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();
+        const end = slotEndTime.hour() * 60 + slotEndTime.minute();
         if (start < workingHour.startTime || end > workingHour.endTime) {
           return true;
         }
       }
     })
   ) {
     // slot is outside of working hours
     return false;
   }
 
+  // After override/working-hours checks pass, a seated booking is always available
+  if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
+    return true;
+  }
+
   return busy.every((busyTime) => {

🤖 Grapple PR auto-fix • major • Review this diff before applying

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Naming Conventions (confidence: 76%)

Variable name 'dateOverrideExist' uses singular 'Exist' but should follow camelCase conventions with consistent tense

Evidence:

  • Line 100: let dateOverrideExist = false; — mixes past tense 'dateOverride' with present tense 'Exist'
  • More consistent naming would be dateOverrideExists (third-person singular present) or isDateOverride
  • This minor inconsistency affects readability when scanning related variable names

Agent: style

let dateOverrideExist = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Inconsistent UTC Offset Application (confidence: 100%)

In checkIfIsAvailable, the UTC offset is negated (* -1) when computing date override boundaries, but in getSlots (packages/lib/slots.ts), the offset is not negated — it uses inviteeUtcOffset - organizerUtcOffset directly. These two code paths handle the same date override data but apply offsets in opposite directions, which will produce inconsistent slot boundaries between slot generation and availability checking.

Evidence:

  • slots.ts line 211-213: offset = inviteeUtcOffset - organizerUtcOffset (no negation)
  • slots.ts line 103: utcOffset = dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 (negated, and only organizer offset, no invitee offset)
  • The intent specification flags this: 'The utcOffset is negated (* -1) inside checkIfIsAvailable but not negated inside getSlots; verify this is intentional and consistent across both code paths'
  • These two functions handle the same date overrides for different purposes (slot generation vs availability checking) and must agree on time boundaries

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

In checkIfIsAvailable, the UTC offset is negated (* -1) when computing date override boundaries, but in getSlots (packages/lib/slots.ts), the offset is not negated — it uses inviteeUtcOffset - organizerUtcOffset directly. These two code paths handle the same date override data but apply offsets in opposite directions, which will produce inconsistent slot boundaries between slot generation and availability checking.

Suggested change
let dateOverrideExist = false;
const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() : 0;

🤖 Grapple PR auto-fix • major • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Sign Inversion Inconsistency (confidence: 100%)

In checkIfIsAvailable, the utcOffset is computed as dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1, negating the offset. However, in packages/lib/slots.ts (getSlots), the organizerUtcOffset is computed as dayjs(...).tz(override.timeZone).utcOffset() WITHOUT negation, and then the difference inviteeUtcOffset - organizerUtcOffset is used. These two offset sign conventions are inconsistent and will produce incorrect date matching or incorrect slot times in one of the two code paths.

Evidence:

  • slots.ts line ~210: const organizerUtcOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset(); — no negation
  • viewer/slots.ts line 103: dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 — negated
  • Intent specification flags this: 'utcOffset * -1 in checkIfIsAvailable vs. no negation in slots.ts offset calculation — one of these is likely incorrect'
  • dayjs .utcOffset() returns a positive value for timezones east of UTC (e.g., +330 for IST). The negation in checkIfIsAvailable converts this to -330, which when added to the date.start shifts it in the wrong direction for eastern timezones.
  • slots.ts line 211: const organizerUtcOffset = dayjs(override.start.toString()).tz(override.timeZone).utcOffset(); — no negation
  • slots.ts line 213: const offset = inviteeUtcOffset - organizerUtcOffset; — subtraction, no negation
  • slots.ts (checkIfIsAvailable) line 103: dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 — explicit negation
  • dayjs .utcOffset() returns positive values for timezones east of UTC (e.g., +330 for IST). Negating this gives a negative number. The slots.ts getSlots path does not negate, creating inconsistency.
  • Both paths aim to convert organizer-local override times to UTC-comparable values

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

In checkIfIsAvailable, the utcOffset is computed as dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1, negating the offset. However, in packages/lib/slots.ts (getSlots), the organizerUtcOffset is computed as dayjs(...).tz(override.timeZone).utcOffset() WITHOUT negation, and then the difference inviteeUtcOffset - organizerUtcOffset is used. These two offset sign conventions are inconsistent and will produce incorrect date matching or incorrect slot times in one of the two code paths.

Suggested change
let dateOverrideExist = false;
const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() : 0;

🤖 Grapple PR auto-fix • critical • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Business logic correctness (confidence: 100%)

The UTC offset sign is inverted via * -1, which is inconsistent with the offset approach used in slots.ts and will produce incorrect date comparisons for non-UTC organizer timezones. For example, an organizer in UTC+5 would have their override dates shifted by -5 hours (moving them backward) rather than the correct direction for UTC comparison.

Evidence:

  • Line 103: const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0;
  • dayjs's .utcOffset() returns the offset in minutes from UTC (e.g., UTC+5 returns +300). Negating it gives -300.
  • Adding -300 minutes to an override start time in UTC+5 shifts it 5 hours backward, which converts from UTC to local time — the opposite of what's needed. The override dates (date.start, date.end) are already stored as Date objects (UTC), and the slot times are in UTC, so no offset conversion should be needed for date-matching, or if needed, the sign is wrong.
  • In slots.ts, the offset is computed as inviteeUtcOffset - organizerUtcOffset (no negation), showing an inconsistent convention.
  • The intent specification flags this: 'UTC offset sign convention... negates the offset before adding it; this is inconsistent with the slots.ts approach and may produce incorrect date comparisons for negative-offset timezones'.

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Edge cases (confidence: 100%)

When organizerTimeZone is undefined, utcOffset defaults to 0, meaning date-matching comparisons use raw UTC dates. For organizers in non-UTC timezones, this could cause date overrides to be matched against the wrong calendar day.

Evidence:

  • Line 103: const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0;
  • If organizerTimeZone is not provided (undefined), the offset is 0, treating all dates as UTC.
  • An organizer in UTC+5 with an override on January 15th local time might have the override stored as January 14th 19:00 UTC, and without the offset correction, it won't match a slot on January 15th UTC.

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (10 lines, 1 file))

The UTC offset sign is inverted via * -1, which is inconsistent with the offset approach used in slots.ts and will produce incorrect date comparisons for non-UTC organizer timezones. For example, an organizer in UTC+5 would have their override dates shifted by -5 hours (moving them backward) rather than the correct direction for UTC comparison.

Suggested change
let dateOverrideExist = false;
const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() : 0;
if (dayjs(date.start).isSame(dayjs(date.end))) {
slotEndTime.isBefore(dayjs(date.start)) ||
slotEndTime.isSame(dayjs(date.start))
if (slotStartTime.isAfter(dayjs(date.end))) {

🤖 Grapple PR auto-fix • critical • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (7 lines, 1 file))

When organizerTimeZone is undefined, utcOffset defaults to 0, meaning date-matching comparisons use raw UTC dates. For organizers in non-UTC timezones, this could cause date overrides to be matched against the wrong calendar day.

Suggested change
let dateOverrideExist = false;
// When organizerTimeZone is provided, compute the UTC offset for that timezone.
// When undefined, fall back to the offset embedded in the date itself so that
// organizers in non-UTC timezones still get correct calendar-day matching.
const utcOffset = organizerTimeZone
? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1
: dayjs(date.start).utcOffset() * -1;

🤖 Grapple PR auto-fix • minor • confidence: 100%


if (
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.

🔴 CRITICAL — Logic Bug — Incorrect UTC Offset Sign Convention (confidence: 100%)

The UTC offset is computed as dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1, negating the result before adding it to the date. Dayjs .utcOffset() returns a positive value for UTC+ timezones. Negating it and then adding means UTC+5 (offset=300) becomes -300 minutes, moving the time 5 hours backward instead of forward. This is the opposite sign convention used in slots.ts, creating inconsistent behavior and incorrect date comparisons for organizers in non-UTC timezones.

Evidence:

  • In slots.ts: const offset = inviteeUtcOffset - organizerUtcOffset; then dayjs(override.start).utc().add(offset, 'minute') — additive approach.
  • In slots.ts (viewer): const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0; then .add(utcOffset, 'minutes') — negated then added.
  • For UTC+6: .utcOffset() = 360, * -1 = -360, .add(-360, 'minutes') shifts 6 hours backward. This is wrong — to convert a UTC+6 local time to UTC you subtract 6 hours, but the intent here is to find the local date, which requires adding the offset.
  • For UTC-5: .utcOffset() = -300, * -1 = 300, .add(300, 'minutes') shifts 5 hours forward. Also incorrect for the same reason.
  • This sign error means the date-matching logic (format('YYYY MM DD') comparison) will compute the wrong local date for any organizer not in UTC, causing overrides to apply on the wrong calendar day.
  • Line 102: const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0;
  • If organizerTimeZone is undefined or missing, the offset is hardcoded to 0 (UTC)
  • This means date overrides are always checked against UTC dates, not the organizer's local date
  • Could mask bugs where organizers have no timezone set but expect local-timezone behavior
  • slots.ts line 211-213: organizerUtcOffset = dayjs(...).tz(override.timeZone).utcOffset() and inviteeUtcOffset = dayjs(...).tz(timeZone).utcOffset() then offset = inviteeUtcOffset - organizerUtcOffset
  • viewer/slots.ts line 103: utcOffset = dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1
  • For UTC+5:30 (India), utcOffset() = 330, negated = -330. For UTC-5 (EST), utcOffset() = -300, negated = +300.
  • The intent here appears to be converting organizer-local times to UTC, but the negation approach will produce wrong results for certain timezone/date combinations compared to the slots.ts approach

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (2 lines, 1 file))

The UTC offset is computed as dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1, negating the result before adding it to the date. Dayjs .utcOffset() returns a positive value for UTC+ timezones. Negating it and then adding means UTC+5 (offset=300) becomes -300 minutes, moving the time 5 hours backward instead of forward. This is the opposite sign convention used in slots.ts, creating inconsistent behavior and incorrect date comparisons for organizers in non-UTC timezones.

Suggested change
dateOverrides.find((date) => {
const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() : 0;

🤖 Grapple PR auto-fix • critical • confidence: 100%

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.

🟡 MINOR — Logic Bug — Missing organizerTimeZone Fallback in checkIfIsAvailable (confidence: 100%)

When organizerTimeZone is undefined, utcOffset defaults to 0, causing the date comparison to use UTC dates. For organizers in non-UTC timezones without a configured timezone, overrides may be matched against the wrong calendar day (e.g., a UTC-5 organizer's override for Monday would be matched against Sunday UTC).

Evidence:

  • const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0;
  • When organizerTimeZone is undefined, all date comparisons use UTC, which is only correct for UTC organizers.
  • The organizerTimeZone fallback chain in getSchedule (eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone) may produce undefined if none of these fields are set.
  • This edge case silently produces incorrect results rather than failing loudly.

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (7 lines, 1 file))

When organizerTimeZone is undefined, utcOffset defaults to 0, causing the date comparison to use UTC dates. For organizers in non-UTC timezones without a configured timezone, overrides may be matched against the wrong calendar day (e.g., a UTC-5 organizer's override for Monday would be matched against Sunday UTC).

Suggested change
const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0;
// Use "UTC" as fallback so dayjs.tz() always operates with a real timezone.
// Defaulting utcOffset to 0 without a timezone is only correct for UTC organizers;
// for all others it would match overrides against the wrong calendar day.
const resolvedOrganizerTimeZone = organizerTimeZone || "UTC";
const utcOffset = dayjs.tz(date.start, resolvedOrganizerTimeZone).utcOffset() * -1;

🤖 Grapple PR auto-fix • minor • confidence: 100%


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Code Organization (confidence: 95%)

Day-off date override detection uses reference equality (===) between two different Dayjs objects, which will always be false

Evidence:

  • Line 106: if (dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes')) — creates two separate Dayjs instances
  • Reference equality checks (===) on Dayjs objects will always be false even if they represent the same time
  • Day-off overrides (where start === end) will never be detected correctly and slots will not be marked unavailable

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (2 lines, 1 file))

Day-off date override detection uses reference equality (===) between two different Dayjs objects, which will always be false

Suggested change
if (dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))) {

🤖 Grapple PR auto-fix • minor • confidence: 95%

if (
dayjs(date.start).add(utcOffset, "minutes").format("YYYY MM DD") ===
slotStartTime.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.

🔴 CRITICAL — Reference Equality Bug (confidence: 100%)

Day-off detection uses === to compare two different Dayjs objects, which compares by reference, not by value. This will always evaluate to false, meaning day-off date overrides (where start equals end) will never be detected. Slots on a day-off will incorrectly be shown as available.

Evidence:

  • dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') creates two distinct Dayjs objects; JavaScript === compares object references, not values
  • The intent specification explicitly calls this out: 'A day-off date override (start === end) correctly causes the slot to be unavailable'
  • The edge cases section confirms: 'uses reference equality between two different Dayjs objects, which will always be false — day-off overrides will never be detected correctly'

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (5 lines, 1 file))

Day-off detection uses === to compare two different Dayjs objects, which compares by reference, not by value. This will always evaluate to false, meaning day-off date overrides (where start equals end) will never be detected. Slots on a day-off will incorrectly be shown as available.

Suggested change
slotStartTime.format("YYYY MM DD")
// Compare date values using .isSame(), not reference equality (===),
// since two Dayjs objects are never === even if they represent the same instant.
// A day-off override is represented as start === end (same timestamp).
if (dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))) {

🤖 Grapple PR auto-fix • critical • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Dayjs Reference Equality Bug (confidence: 100%)

Using === to compare two Dayjs objects compares object references, not values. This condition will NEVER be true, even when the start and end times are identical. This means zero-length date overrides (used to mark a day as unavailable) will not be detected, and slots on those days will incorrectly be marked as available.

Evidence:

  • dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') creates two distinct Dayjs objects — reference equality always returns false
  • Intent specification explicitly calls this out: 'uses reference equality (===) on Dayjs objects, which will never be true — this condition is a logic bug and should use .isSame()'
  • The purpose of this check is to detect empty-range overrides (day marked unavailable), which will now silently pass through

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

Using === to compare two Dayjs objects compares object references, not values. This condition will NEVER be true, even when the start and end times are identical. This means zero-length date overrides (used to mark a day as unavailable) will not be detected, and slots on those days will incorrectly be marked as available.

Suggested change
slotStartTime.format("YYYY MM DD")
if (dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))) {

🤖 Grapple PR auto-fix • critical • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Business logic correctness (confidence: 100%)

Zero-duration date override check uses === reference equality between two different Dayjs objects, which will always evaluate to false. This means zero-duration date overrides (where start equals end, typically used to mark a day as unavailable) will never be detected, and the function will incorrectly treat the slot as available within the override.

Evidence:

  • The expression dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') compares two distinct Dayjs object references, not their underlying values.
  • In JavaScript, === on objects checks reference identity, not structural equality. Two separate dayjs() calls always produce different object references.
  • A zero-duration override (start === end) is a common pattern to mark a day as completely unavailable. This bug causes such overrides to be silently ignored.
  • The intent specification confirms this as an edge case: 'uses reference equality between two different Dayjs objects, which will always be false — this branch is a bug and will never execute'.

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Logic Bug - Reference Equality (confidence: 100%)

Zero-duration date override check uses === between two different Dayjs objects, which compares by reference, not by value. This condition will always evaluate to false, meaning zero-duration overrides (intended to mark a day as unavailable) will never be detected. The slot will incorrectly pass through as available within the override.

Evidence:

  • dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') creates two distinct Dayjs object instances
  • JavaScript === on objects compares identity, not structural equality
  • dayjs provides .isSame() for value comparison between Dayjs instances
  • This was identified as an edge case in the intent specification and is confirmed in the code

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

Zero-duration date override check uses === reference equality between two different Dayjs objects, which will always evaluate to false. This means zero-duration date overrides (where start equals end, typically used to mark a day as unavailable) will never be detected, and the function will incorrectly treat the slot as available within the override.

Suggested change
slotStartTime.format("YYYY MM DD")
if (dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))) {

🤖 Grapple PR auto-fix • critical • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (4 lines, 1 file))

Zero-duration date override check uses === between two different Dayjs objects, which compares by reference, not by value. This condition will always evaluate to false, meaning zero-duration overrides (intended to mark a day as unavailable) will never be detected. The slot will incorrectly pass through as available within the override.

Suggested change
slotStartTime.format("YYYY MM DD")
if (
dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))
) {

🤖 Grapple PR auto-fix • critical • confidence: 100%

) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Logic Bug - Reference Equality on Objects (confidence: 100%)

Dayjs objects are compared using === (reference equality), which will never be true since each dayjs() call creates a new object. This condition is meant to detect zero-length date overrides (start === end) but is dead code. As a result, zero-length overrides will never be recognized, and the logic will fall through to the boundary checks below, potentially producing incorrect availability results.

Evidence:

  • Line 113-114: if (dayjs(date.start).add(utcOffset, "minutes") === dayjs(date.end).add(utcOffset, "minutes")) — creates new Dayjs instances that are never reference-equal
  • Dayjs objects are instances; reference equality (===) checks object identity, not value equality
  • Should use .isSame() to compare datetime values correctly
  • dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') compares two distinct Dayjs object references
  • JavaScript === on objects checks referential identity, not value equality
  • Dayjs provides .isSame() for value comparison
  • Intent specification edge cases explicitly flag this: 'uses reference equality (===) on Dayjs objects, which will never be true — this condition is a logic bug and should use .isSame()'

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (4 lines, 1 file))

Dayjs objects are compared using === (reference equality), which will never be true since each dayjs() call creates a new object. This condition is meant to detect zero-length date overrides (start === end) but is dead code. As a result, zero-length overrides will never be recognized, and the logic will fall through to the boundary checks below, potentially producing incorrect availability results.

Suggested change
) {
if (
dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))
) {

🤖 Grapple PR auto-fix • critical • confidence: 100%

dateOverrideExist = 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.

🟡 MINOR — Code patterns (confidence: 88%)

Object reference comparison used for Dayjs instances will always return false. The condition dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') creates two separate Dayjs objects and compares them by reference, not by value. This zero-duration override detection will never work as intended.

Evidence:

  • Dayjs objects are created fresh each time; reference equality (===) between two different instances always fails
  • The intent appears to be detecting when an override has start === end (zero duration), but the current code creates new Dayjs instances that can never be reference-equal
  • Should use .isSame() method: dayjs(date.start).add(utcOffset, 'minutes').isSame(dayjs(date.end).add(utcOffset, 'minutes'))

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (4 lines, 1 file))

Object reference comparison used for Dayjs instances will always return false. The condition dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') creates two separate Dayjs objects and compares them by reference, not by value. This zero-duration override detection will never work as intended.

Suggested change
dateOverrideExist = true;
if (
dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))
) {

🤖 Grapple PR auto-fix • minor • confidence: 88%

if (dayjs(date.start).add(utcOffset, "minutes") === 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.

🟠 MAJOR — Unnecessary work (confidence: 100%)

The utcOffset is recomputed via dayjs.tz() on every call to checkIfIsAvailable for every override in the dateOverrides array. Since checkIfIsAvailable is called O(slots) times per day and dateOverrides is constant across those calls, the tz computation is repeated thousands of times with identical inputs.

Evidence:

  • Line 103: const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0; — inside the .find() callback, which runs per override per checkIfIsAvailable call
  • checkIfIsAvailable is invoked inside the availableTimeSlots filter at line ~489, which iterates over all slots for all days
  • For a 30-day window with 48 half-hour slots and 10 overrides: 30 × 48 × 10 = 14,400 redundant tz parses

Agent: performance

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — performance agent (Small fix (7 lines, 1 file))

The utcOffset is recomputed via dayjs.tz() on every call to checkIfIsAvailable for every override in the dateOverrides array. Since checkIfIsAvailable is called O(slots) times per day and dateOverrides is constant across those calls, the tz computation is repeated thousands of times with identical inputs.

Suggested change
}
// Pre-compute utcOffset once per checkIfIsAvailable call — organizerTimeZone is constant
// across all overrides, so computing it inside .find() repeats the tz parse O(overrides) times
// per slot call, leading to ~14,400 redundant parses over a 30-day/48-slot/10-override window.
const utcOffset = organizerTimeZone ? dayjs.tz(time.toDate(), organizerTimeZone).utcOffset() * -1 : 0;

🤖 Grapple PR auto-fix • major • confidence: 100%

if (
slotEndTime.isBefore(dayjs(date.start).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.

🟠 MAJOR — Boundary Condition (confidence: 100%)

The date override boundary check marks a slot as outside the override when slotEndTime equals the override start (via isSame), but does not apply the same isSame check for slotStartTime vs override end. This creates an asymmetric boundary condition: a slot ending exactly at override start is rejected, but a slot starting exactly at override end is not rejected (since only isAfter is checked, not isSame).

Evidence:

  • Lines 113-117: checks slotEndTime.isBefore(...) OR slotEndTime.isSame(...) for the start boundary
  • Line 118: checks only slotStartTime.isAfter(...) for the end boundary, without an isSame check
  • A slot starting exactly at the override end time would not be caught as outside the override, potentially showing an unavailable slot as available

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

The date override boundary check marks a slot as outside the override when slotEndTime equals the override start (via isSame), but does not apply the same isSame check for slotStartTime vs override end. This creates an asymmetric boundary condition: a slot ending exactly at override start is rejected, but a slot starting exactly at override end is not rejected (since only isAfter is checked, not isSame).

Suggested change
slotEndTime.isBefore(dayjs(date.start).add(utcOffset, "minutes")) ||
if (slotStartTime.isAfter(dayjs(date.end).add(utcOffset, "minutes")) || slotStartTime.isSame(dayjs(date.end).add(utcOffset, "minutes"))) {

🤖 Grapple PR auto-fix • major • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Boundary Condition - Slot Ending at Override Start (confidence: 100%)

The condition slotEndTime.isBefore(overrideStart) || slotEndTime.isSame(overrideStart) marks a slot as 'outside the override' when the slot ends exactly at the override start time. However, this returns true from find, which then causes return false — marking the slot as unavailable. A slot ending exactly at the override boundary is indeed outside the override, so the logic is correct for exclusion. But if we consider the complementary case: a slot that STARTS exactly at the override END time (slotStartTime.isAfter(overrideEnd)) — isAfter is strict, so a slot starting exactly at override end is NOT excluded. This asymmetry could lead to an off-by-one slot being included at the end of an override window.

Evidence:

  • Line 115-118: slotEndTime.isBefore or isSame to override start — exclusive boundary
  • Line 119-121: slotStartTime.isAfter override end — strict after, so a slot starting exactly at override end is NOT caught by this condition
  • This means a slot starting at exactly the override end time would not be found by find, dateOverrideExist would be true, and it would be returned as available (line 130)
  • Intent specification notes this asymmetry
  • Lines 117-122: if (slotEndTime.isBefore(dayjs(date.start).add(utcOffset, 'minutes')) || slotEndTime.isSame(dayjs(date.start).add(utcOffset, 'minutes'))) returns true from find, which triggers return false (unavailable)
  • A slot [08:00-08:30] with override starting at 08:30 would be marked unavailable — this is likely correct but should be explicit

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (5 lines, 1 file))

The condition slotEndTime.isBefore(overrideStart) || slotEndTime.isSame(overrideStart) marks a slot as 'outside the override' when the slot ends exactly at the override start time. However, this returns true from find, which then causes return false — marking the slot as unavailable. A slot ending exactly at the override boundary is indeed outside the override, so the logic is correct for exclusion. But if we consider the complementary case: a slot that STARTS exactly at the override END time (slotStartTime.isAfter(overrideEnd)) — isAfter is strict, so a slot starting exactly at override end is NOT excluded. This asymmetry could lead to an off-by-one slot being included at the end of an override window.

Suggested change
slotEndTime.isBefore(dayjs(date.start).add(utcOffset, "minutes")) ||
if (
slotStartTime.isAfter(dayjs(date.end).add(utcOffset, "minutes")) ||
slotStartTime.isSame(dayjs(date.end).add(utcOffset, "minutes"))
) {

🤖 Grapple PR auto-fix • major • confidence: 100%

slotEndTime.isSame(dayjs(date.start).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.

🔴 CRITICAL — Logic Bug — Reference Equality on Dayjs Objects (confidence: 100%)

Zero-duration date override detection uses JavaScript reference equality (===) between two different Dayjs object instances. Two distinct Dayjs objects will NEVER be === even if they represent the same moment. This means the zero-duration (unavailable all day) override branch can never execute, causing all-day blocked overrides to be treated as normal overrides with a valid window.

Evidence:

  • dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') creates two separate Dayjs object instances.
  • In JavaScript, object reference equality (===) compares memory addresses, not values. Two separately constructed objects are never ===.
  • This means a date override where start === end (intended to block the entire day) will silently fall through to the window-check logic, potentially allowing bookings that should be blocked.
  • The fix requires .isSame(): dayjs(date.start).add(utcOffset, 'minutes').isSame(dayjs(date.end).add(utcOffset, 'minutes'))

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (2 lines, 1 file))

Zero-duration date override detection uses JavaScript reference equality (===) between two different Dayjs object instances. Two distinct Dayjs objects will NEVER be === even if they represent the same moment. This means the zero-duration (unavailable all day) override branch can never execute, causing all-day blocked overrides to be treated as normal overrides with a valid window.

Suggested change
}
if (dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))) {

🤖 Grapple PR auto-fix • critical • confidence: 100%

if (slotStartTime.isAfter(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.

🟡 MINOR — Multiple Date Overrides (confidence: 100%)

Using dateOverrides.find() means only the first matching date override for a given date is evaluated. If a user has multiple overlapping date overrides on the same date, subsequent overrides are ignored, which could lead to incorrect availability.

Evidence:

  • Line 101: dateOverrides.find((date) => { ... }) — returns on first match
  • The edge cases section notes: 'Multiple date overrides on the same date: dateOverrides.find() returns on the first match; if a user has overlapping overrides, only the first is evaluated'

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (8 lines, 1 file))

Using dateOverrides.find() means only the first matching date override for a given date is evaluated. If a user has multiple overlapping date overrides on the same date, subsequent overrides are ignored, which could lead to incorrect availability.

Suggested change
if (slotStartTime.isAfter(dayjs(date.end).add(utcOffset, "minutes"))) {
// check if any date override for slot exists
// Use `some()` instead of `find()` so that ALL date overrides on the same date
// are evaluated. With `find()`, only the first matching override was considered,
// causing subsequent overlapping overrides to be silently ignored.
dateOverrides.some((date) => {
return false;

🤖 Grapple PR auto-fix • minor • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Unnecessary work (confidence: 100%)

Dayjs object reference equality (===) is used to compare two distinct Dayjs instances. This condition can never be true, so the 'empty override window' early-return branch (return true inside the find, causing the outer if to return false) is permanently dead code. Any date override with start === end intended to mean 'available all day' will be misidentified as a bounded window and slots will be incorrectly marked unavailable.

Evidence:

  • Line 120: dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') — each call to dayjs() creates a new object; === on objects tests reference identity, not value equality
  • JavaScript/TypeScript object === always returns false for two separately constructed objects even with identical values
  • This is called out explicitly in the PR's own edge cases section: 'dayjs(date.start).add(utcOffset, 'minutes') === dayjs(date.end).add(utcOffset, 'minutes') uses reference equality (=== ) on Dayjs objects, which will never be true'

Agent: performance

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — performance agent (Small fix (5 lines, 1 file))

Dayjs object reference equality (===) is used to compare two distinct Dayjs instances. This condition can never be true, so the 'empty override window' early-return branch (return true inside the find, causing the outer if to return false) is permanently dead code. Any date override with start === end intended to mean 'available all day' will be misidentified as a bounded window and slots will be incorrectly marked unavailable.

Suggested change
if (slotStartTime.isAfter(dayjs(date.end).add(utcOffset, "minutes"))) {
// Use .isSame() for value equality — === on Dayjs objects tests reference identity
// and always returns false for two separately constructed instances.
// start === end signals an "available all day" override; return true (slot is available).
if (dayjs(date.start).add(utcOffset, "minutes").isSame(dayjs(date.end).add(utcOffset, "minutes"))) {

🤖 Grapple PR auto-fix • critical • confidence: 100%

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.

🟠 MAJOR — Incorrect Availability Logic (confidence: 100%)

When dateOverrideExist is true (an override exists for this date and the slot falls within it), the function immediately returns true without checking busy times. This means a slot that overlaps with an existing booking will still be shown as available if it falls within a date override window.

Evidence:

  • The intent specification states: 'If a date override exists and the slot falls within it, availability should be determined solely by busy times without further working-hours checks'
  • Lines 125-126 return true unconditionally, skipping the busy.every(...) check that follows
  • The busy time check on lines 141-155 is never reached when a date override exists and the slot is within the override window

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (8 lines, 1 file))

When dateOverrideExist is true (an override exists for this date and the slot falls within it), the function immediately returns true without checking busy times. This means a slot that overlaps with an existing booking will still be shown as available if it falls within a date override window.

Suggested change
}
// if no date override for slot exists, check if it is within normal work hours
// if a date override exists, skip working hours check and fall through to busy times
!dateOverrideExist &&

🤖 Grapple PR auto-fix • major • confidence: 100%

})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Inverted Date Override Logic (confidence: 100%)

The find callback returns true when the slot is OUTSIDE the date override window (before override start or after override end), then the outer if block returns false (unavailable). But the internal dateOverrideExist flag is set to true inside the same find. If find returns undefined (slot is WITHIN the override) and dateOverrideExist is true, execution falls through to line 130 which returns true — but this skips the busy-time check entirely. This means a slot within a date override that is already booked will still be returned as available, enabling double-booking.

Evidence:

  • When a date override exists and the slot is within it: find returns undefined (no match), dateOverrideExist is true, line 130 returns true immediately
  • The busy-time check (busy.every(...)) at line 145+ is never reached for slots within date overrides
  • Intent specification notes: 'When a date override exists for the slot's day and the slot is inside the override window, busy-time checking proceeds and checkIfIsAvailable returns true if not busy'
  • Early return on line 130 violates the acceptance criteria that busy-time checking should still proceed

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (4 lines, 1 file))

The find callback returns true when the slot is OUTSIDE the date override window (before override start or after override end), then the outer if block returns false (unavailable). But the internal dateOverrideExist flag is set to true inside the same find. If find returns undefined (slot is WITHIN the override) and dateOverrideExist is true, execution falls through to line 130 which returns true — but this skips the busy-time check entirely. This means a slot within a date override that is already booked will still be returned as available, enabling double-booking.

Suggested change
})
// slot is within the date override window — still need to check busy times below

🤖 Grapple PR auto-fix • major • confidence: 100%

) {
// slot is not within the date override
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Day-of-Week Timezone Mismatch (confidence: 93%)

The working hours check uses slotStartTime.day() which returns the UTC day of week (since slotStartTime = time.utc()). Working hours are typically defined in the organizer's local timezone, where the day of the week may differ from UTC near midnight boundaries. This can cause slots near midnight UTC to be checked against the wrong day's working hours.

Evidence:

  • Line 100: const slotStartTime = time.utc(); — day() returns UTC day
  • Working hours days array is typically based on the organizer's local day of week
  • The edge cases section notes: 'slotStartTime.day() (0-6 UTC day of week) may differ from the organizer's local day of week — could cause incorrect working-hours matching near midnight'

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (12 lines, 1 file) — review recommended)

The working hours check uses slotStartTime.day() which returns the UTC day of week (since slotStartTime = time.utc()). Working hours are typically defined in the organizer's local timezone, where the day of the week may differ from UTC near midnight boundaries. This can cause slots near midnight UTC to be checked against the wrong day's working hours.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -137,7 +137,10 @@ const checkIfIsAvailable = ({
   //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();
+      // Convert to organizer's local time for day-of-week comparison to avoid
+      // UTC midnight boundary mismatch (slotStartTime is UTC, but working hours
+      // days[] are defined in the organizer's local timezone).
+      const slotStartLocal = organizerTimeZone
+        ? slotStartTime.tz(organizerTimeZone)
+        : slotStartTime;
+      if (workingHour.days.includes(slotStartLocal.day())) {
+        const start = slotStartLocal.hour() * 60 + slotStartLocal.minute();
+        const end = slotStartLocal.hour() * 60 + slotStartLocal.minute();
         if (start < workingHour.startTime || end > workingHour.endTime) {
           return true;
         }

🤖 Grapple PR auto-fix • minor • Review this diff before applying


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Business logic correctness (confidence: 100%)

The working-hours day-of-week check uses .day() on a UTC Dayjs object (slotStartTime), which returns the day-of-week in UTC. For organizers in timezones where the local calendar day differs from UTC (e.g., UTC+12 at 23:00 UTC on Monday = Tuesday local), this will check the wrong day's working hours.

Evidence:

  • Line 100: const slotStartTime = time.utc(); — this is a UTC Dayjs object.
  • Line 132: if (workingHour.days.includes(slotStartTime.day())).day() returns the UTC day-of-week.
  • Working hours are typically defined in the organizer's local timezone, so the day-of-week comparison should use the organizer's local day, not UTC day.
  • The intent specification identifies this: '.day() (local day) on a UTC Dayjs object; this may produce the wrong day-of-week for users in timezones where UTC and local calendar days differ'.

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (11 lines, 1 file) — review recommended)

The working-hours day-of-week check uses .day() on a UTC Dayjs object (slotStartTime), which returns the day-of-week in UTC. For organizers in timezones where the local calendar day differs from UTC (e.g., UTC+12 at 23:00 UTC on Monday = Tuesday local), this will check the wrong day's working hours.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -136,10 +136,14 @@ const checkIfIsAvailable = ({
   //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();
+      // Convert UTC slotStartTime to organizer's local time before checking day-of-week
+      // and time-of-day, since workingHours are defined in the organizer's timezone.
+      const localSlotStartTime = organizerTimeZone
+        ? slotStartTime.tz(organizerTimeZone)
+        : slotStartTime;
+      if (workingHour.days.includes(localSlotStartTime.day())) {
+        const start = localSlotStartTime.hour() * 60 + localSlotStartTime.minute();
+        const end = localSlotStartTime.hour() * 60 + localSlotStartTime.minute();
         if (start < workingHour.startTime || end > workingHour.endTime) {
           return true;
         }

🤖 Grapple PR auto-fix • major • Review this diff before applying

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.

🔴 CRITICAL — Copy-Paste Bug (confidence: 100%)

The end variable in the working hours check is computed using slotStartTime instead of slotEndTime. Both start and end will always have the same value, making the end-boundary check against workingHour.endTime incorrect. A slot that extends past working hours will not be correctly rejected.

Evidence:

  • Line 133: const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Line 134: const end = slotStartTime.hour() * 60 + slotStartTime.minute(); — should use slotEndTime
  • The intent specification edge cases explicitly note: 'end should use slotEndTime, not slotStartTime'

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

The end variable in the working hours check is computed using slotStartTime instead of slotEndTime. Both start and end will always have the same value, making the end-boundary check against workingHour.endTime incorrect. A slot that extends past working hours will not be correctly rejected.

Suggested change
return true;
const end = slotEndTime.hour() * 60 + slotEndTime.minute();

🤖 Grapple PR auto-fix • critical • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Timezone Day-of-Week Mismatch (confidence: 100%)

The working hours check uses slotStartTime.day() where slotStartTime is time.utc(). The .day() method returns the day-of-week in UTC. However, workingHours[].days are defined relative to the organizer's local timezone. For organizers in timezones significantly offset from UTC, a slot at e.g. 23:00 UTC on Monday might be Tuesday in the organizer's timezone (UTC+2), causing the wrong day's working hours to be applied.

Evidence:

  • Line 100: const slotStartTime = time.utc();
  • Line 134: workingHour.days.includes(slotStartTime.day())
  • WorkingHours days are typically defined in the organizer's local timezone context
  • The organizerTimeZone parameter is available but not used to convert the slot time to the organizer's local day before comparison

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (10 lines, 1 file))

The working hours check uses slotStartTime.day() where slotStartTime is time.utc(). The .day() method returns the day-of-week in UTC. However, workingHours[].days are defined relative to the organizer's local timezone. For organizers in timezones significantly offset from UTC, a slot at e.g. 23:00 UTC on Monday might be Tuesday in the organizer's timezone (UTC+2), causing the wrong day's working hours to be applied.

Suggested change
return true;
// Convert slot time to organizer's local timezone for day-of-week and
// hour/minute extraction, since workingHours.days and startTime/endTime
// are defined relative to the organizer's local timezone, not UTC.
const slotInOrganizerTz = organizerTimeZone ? dayjs.tz(time, organizerTimeZone) : slotStartTime;
if (workingHour.days.includes(slotInOrganizerTz.day())) {
const start = slotInOrganizerTz.hour() * 60 + slotInOrganizerTz.minute();
const end = slotInOrganizerTz.hour() * 60 + slotInOrganizerTz.minute();

🤖 Grapple PR auto-fix • major • confidence: 100%

}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Duplicate Variable Bug (confidence: 100%)

Both start and end are computed from slotStartTime, making them always identical. The end variable should use slotEndTime instead. This means the working hours end-time boundary check is always wrong — it checks the slot's start time against the working hour end time instead of the slot's end time, allowing slots that extend beyond working hours to be incorrectly marked as available.

Evidence:

  • Line 134: const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Line 135: const end = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Both use slotStartTimeend should use slotEndTime (defined on line 100 as time.add(eventLength, 'minutes').utc())
  • Intent specification confirms: 'end should use slotEndTime instead'

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

Both start and end are computed from slotStartTime, making them always identical. The end variable should use slotEndTime instead. This means the working hours end-time boundary check is always wrong — it checks the slot's start time against the working hour end time instead of the slot's end time, allowing slots that extend beyond working hours to be incorrectly marked as available.

Suggested change
}
const end = slotEndTime.hour() * 60 + slotEndTime.minute();

🤖 Grapple PR auto-fix • critical • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Business logic correctness (confidence: 100%)

The end variable in the working-hours boundary check is computed from slotStartTime instead of slotEndTime, making the end-time guard completely ineffective. A slot whose end time exceeds workingHour.endTime will never be caught.

Evidence:

  • Line 134: const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Line 135: const end = slotStartTime.hour() * 60 + slotStartTime.minute();
  • start and end are always identical because both use slotStartTime.
  • The condition end > workingHour.endTime will only trigger when start > workingHour.endTime, meaning slots that start within working hours but extend past the end will incorrectly pass the check.
  • The intent specification identifies this: 'end should use slotEndTime — meaning end > workingHour.endTime will never catch a slot that overruns end-of-day'.

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

The end variable in the working-hours boundary check is computed from slotStartTime instead of slotEndTime, making the end-time guard completely ineffective. A slot whose end time exceeds workingHour.endTime will never be caught.

Suggested change
}
const end = slotEndTime.hour() * 60 + slotEndTime.minute();

🤖 Grapple PR auto-fix • critical • confidence: 100%


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Logic Bug - Duplicate Variable Assignment (confidence: 100%)

Both start and end are computed from slotStartTime, making the working hours end-boundary check always identical to the start-boundary check. The end variable should use slotEndTime instead. This means a slot that starts within working hours but extends past the end of working hours will incorrectly pass validation.

Evidence:

  • Line 135: const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Line 136: const end = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Both lines use slotStartTimeend should use slotEndTime which is defined at line 100 as time.add(eventLength, 'minutes').utc()
  • This means start === end always, so end > workingHour.endTime is equivalent to start > workingHour.endTime, and slots extending past working hours end will not be caught

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (2 lines, 1 file))

Both start and end are computed from slotStartTime, making the working hours end-boundary check always identical to the start-boundary check. The end variable should use slotEndTime instead. This means a slot that starts within working hours but extends past the end of working hours will incorrectly pass validation.

Suggested change
const end = slotEndTime.hour() * 60 + slotEndTime.minute();

🤖 Grapple PR auto-fix • critical • confidence: 100%

//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.

🟡 MINOR — Working Hours Day Check Missing slotEndTime (confidence: 100%)

The working hours check only verifies that slotStartTime.day() is in the working hour days. If a slot spans midnight (e.g., 23:30-00:30), the end time falls on the next day, which might have different working hours. This edge case is not handled.

Evidence:

  • Line 133: workingHour.days.includes(slotStartTime.day()) only checks the start day
  • For event types with long durations, the slot could span into the next calendar day

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (21 lines, 1 file) — review recommended)

The working hours check only verifies that slotStartTime.day() is in the working hour days. If a slot spans midnight (e.g., 23:30-00:30), the end time falls on the next day, which might have different working hours. This edge case is not handled.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -138,12 +138,30 @@ const checkIfIsAvailable = ({
   //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();
+        const end = slotEndTime.hour() * 60 + slotEndTime.minute();
+        // If the slot spans midnight, only validate the start portion against this working hour
+        const effectiveEnd = slotEndTime.day() !== slotStartTime.day() ? 24 * 60 : end;
         if (start < workingHour.startTime || end > workingHour.endTime) {
           return true;
         }
       }
     })
   ) {
     // slot is outside of working hours
     return false;
   }
+
+  // If the slot spans midnight, also verify the end portion falls within working hours on the next day
+  if (slotEndTime.day() !== slotStartTime.day()) {
+    const endMinutes = slotEndTime.hour() * 60 + slotEndTime.minute();
+    const endDayHasValidWorkingHour = workingHours.some((workingHour) => {
+      if (workingHour.days.includes(slotEndTime.day())) {
+        // The slot end portion runs from midnight (0) to endMinutes
+        return endMinutes <= workingHour.endTime && workingHour.startTime === 0;
+      }
+      return false;
+    });
+    if (!endDayHasValidWorkingHour) {
+      // slot end crosses into a day with no covering working hours
+      return false;
+    }
+  }
+

🤖 Grapple PR auto-fix • minor • Review this diff before applying

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Logic Bug - Variable Reuse (confidence: 100%)

The end variable is computed from slotStartTime instead of slotEndTime, making it identical to start. The condition end > workingHour.endTime will therefore never catch a slot whose end time exceeds working hours. A 30-minute slot starting at 17:30 with endTime at 18:00 would not be rejected even if working hours end at 17:45.

Evidence:

  • Line 137: const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Line 138: const end = slotStartTime.hour() * 60 + slotStartTime.minute(); — uses slotStartTime again
  • slotEndTime is defined on line 100 as time.add(eventLength, 'minutes').utc() and is the correct variable to use
  • This makes start === end always true, so end > workingHour.endTime is equivalent to start > workingHour.endTime, missing the end-of-day boundary check entirely

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (2 lines, 1 file))

The end variable is computed from slotStartTime instead of slotEndTime, making it identical to start. The condition end > workingHour.endTime will therefore never catch a slot whose end time exceeds working hours. A 30-minute slot starting at 17:30 with endTime at 18:00 would not be rejected even if working hours end at 17:45.

Suggested change
if (
const end = slotEndTime.hour() * 60 + slotEndTime.minute();

🤖 Grapple PR auto-fix • critical • confidence: 100%

workingHours.find((workingHour) => {
if (workingHour.days.includes(slotStartTime.day())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Missing Working Hours Check for Non-Override Days (confidence: 100%)

The working hours check only rejects a slot if workingHours.find() returns a matching working hour entry where the slot is outside bounds. If a slot falls on a day that has no matching working hour entry at all (i.e., the organizer doesn't work that day of the week), no working hour object is found, find returns undefined (falsy), and the slot is NOT rejected. This means slots on non-working days will pass through to the busy-time check and may be shown as available.

Evidence:

  • The find callback only enters the comparison block when workingHour.days.includes(slotStartTime.day()) — if no working hour includes this day, find returns undefined
  • The if (workingHours.find(...)) block only returns false when find returns truthy, meaning 'no matching working hours at all' is treated the same as 'slot is within working hours'
  • The intent says: 'A slot outside working hours should be marked unavailable (return false)'

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (25 lines, 1 file) — review recommended)

The working hours check only rejects a slot if workingHours.find() returns a matching working hour entry where the slot is outside bounds. If a slot falls on a day that has no matching working hour entry at all (i.e., the organizer doesn't work that day of the week), no working hour object is found, find returns undefined (falsy), and the slot is NOT rejected. This means slots on non-working days will pass through to the busy-time check and may be shown as available.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -137,14 +137,17 @@ const checkIfIsAvailable = ({
   //if no date override for slot exists check if it is within normal work hours
+  // A slot with no matching working hour entry means the organizer doesn't work that day at all.
+  const workingHoursForDay = workingHours.filter((workingHour) =>
+    workingHour.days.includes(slotStartTime.day())
+  );
+
+  // No working hour entry covers this day — organizer is not available
+  if (workingHoursForDay.length === 0) {
+    return false;
+  }
+
   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;
-        }
-      }
-    })
+    workingHoursForDay.every((workingHour) => {
+      const start = slotStartTime.hour() * 60 + slotStartTime.minute();
+      const end = slotEndTime.hour() * 60 + slotEndTime.minute();
+      // Return true (slot is "outside") if it falls outside this entry's bounds
+      return start < workingHour.startTime || end > workingHour.endTime;
+    })
   ) {
     // slot is outside of working hours
     return false;
   }

🤖 Grapple PR auto-fix • major • Review this diff before applying

const start = 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.

🔴 CRITICAL — Logic error (confidence: 100%)

The end variable in the working-hours check is assigned slotStartTime.hour() * 60 + slotStartTime.minute() instead of using slotEndTime. This makes the end-time boundary check ineffective—the condition end > workingHour.endTime will never catch slots that overrun the end of the working day.

Evidence:

  • Line 140: const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Line 141: const end = slotStartTime.hour() * 60 + slotStartTime.minute(); — should be slotEndTime
  • Slots extending past working hours will not be caught if they start before the end time
  • This is a clear copy-paste error and violates the logical intent of the working-hours validation

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Logic Bug — Day-of-Week from UTC Object (confidence: 100%)

The working-hours check uses slotStartTime.day() to get the day of week. slotStartTime is a UTC Dayjs object (time.utc()). Calling .day() on a UTC object returns the UTC day of week, not the organizer's local day. For organizers in UTC+10, a slot at 23:00 UTC on Monday is actually Tuesday locally, but this code would check Monday's working hours.

Evidence:

  • const slotStartTime = time.utc(); — explicitly UTC
  • if (workingHour.days.includes(slotStartTime.day())).day() returns UTC day
  • Working hours are configured in the organizer's local timezone, so the day-of-week lookup must also be in that timezone.
  • For organizers in UTC+12 or UTC-12, this mismatch is particularly pronounced and will incorrectly allow or block slots near day boundaries.

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (2 lines, 1 file))

The end variable in the working-hours check is assigned slotStartTime.hour() * 60 + slotStartTime.minute() instead of using slotEndTime. This makes the end-time boundary check ineffective—the condition end > workingHour.endTime will never catch slots that overrun the end of the working day.

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

🤖 Grapple PR auto-fix • critical • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — security agent (Larger fix (13 lines, 1 file) — review recommended)

The working-hours check uses slotStartTime.day() to get the day of week. slotStartTime is a UTC Dayjs object (time.utc()). Calling .day() on a UTC object returns the UTC day of week, not the organizer's local day. For organizers in UTC+10, a slot at 23:00 UTC on Monday is actually Tuesday locally, but this code would check Monday's working hours.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -137,11 +137,19 @@ const checkIfIsAvailable = ({
   //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();
+      // Convert the UTC slot time to the organizer's local timezone so that
+      // day-of-week and time-of-day comparisons match how working hours were
+      // configured.  Without this, a UTC+10 organizer's 23:00 UTC Monday slot
+      // would be checked against Monday's hours instead of Tuesday's.
+      const slotInOrganizerTz = organizerTimeZone
+        ? slotStartTime.tz(organizerTimeZone)
+        : slotStartTime;
+      if (workingHour.days.includes(slotInOrganizerTz.day())) {
+        const start = slotInOrganizerTz.hour() * 60 + slotInOrganizerTz.minute();
+        const end = slotInOrganizerTz.hour() * 60 + slotInOrganizerTz.minute();
         if (start < workingHour.startTime || end > workingHour.endTime) {
           return true;
         }
       }
     })
   ) {

🤖 Grapple PR auto-fix • major • Review this diff before applying

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.

🔴 CRITICAL — Logic Bug - Variable Assignment (confidence: 100%)

In the working hours validation block, the end variable is assigned the same value as start. Both use slotStartTime.hour() * 60 + slotStartTime.minute(), but end should use slotEndTime instead. This makes the end-time boundary check always incorrect.

Evidence:

  • Line 140: const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Line 141: const end = slotStartTime.hour() * 60 + slotStartTime.minute(); — uses slotStartTime, not slotEndTime
  • This identical assignment will cause slots to always fail the end-time check end > workingHour.endTime incorrectly, since end equals start

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (2 lines, 1 file))

In the working hours validation block, the end variable is assigned the same value as start. Both use slotStartTime.hour() * 60 + slotStartTime.minute(), but end should use slotEndTime instead. This makes the end-time boundary check always incorrect.

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

🤖 Grapple PR auto-fix • critical • confidence: 100%

if (start < workingHour.startTime || end > workingHour.endTime) {
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.

🔴 CRITICAL — Logic Bug — Wrong Variable for End-Time Boundary Check (confidence: 100%)

In the workingHours check, both start and end are computed from slotStartTime. The variable end should be derived from slotEndTime but instead copies slotStartTime. As a result, the condition end > workingHour.endTime can never catch a slot that overruns the end of the working day — the working-hours upper boundary is completely ineffective.

Evidence:

  • const start = slotStartTime.hour() * 60 + slotStartTime.minute(); — correct
  • const end = slotStartTime.hour() * 60 + slotStartTime.minute(); — should be slotEndTime
  • Because start === end always, end > workingHour.endTime fires only when the slot start itself is past end-of-day, which means a 60-minute slot starting at 23:30 (extending past midnight) would not be caught.
  • This is a functional correctness defect that causes incorrect availability to be returned, potentially allowing overbooking outside configured working hours.

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — security agent (Small fix (2 lines, 1 file))

In the workingHours check, both start and end are computed from slotStartTime. The variable end should be derived from slotEndTime but instead copies slotStartTime. As a result, the condition end > workingHour.endTime can never catch a slot that overruns the end of the working day — the working-hours upper boundary is completely ineffective.

Suggested change
}
const end = slotEndTime.hour() * 60 + slotEndTime.minute();

🤖 Grapple PR auto-fix • critical • confidence: 100%

})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Code patterns (confidence: 100%)

The working-hours check uses .day() on a UTC Dayjs object (slotStartTime.utc()), which returns the day-of-week in UTC. For users in timezones where the UTC calendar day differs from the local calendar day, this may validate against the wrong day's working hours.

Evidence:

  • Line 99: const slotStartTime = time.utc(); — this is a UTC-converted time
  • Line 137: if (workingHour.days.includes(slotStartTime.day())).day() on a UTC object returns UTC day-of-week
  • For a user in UTC+8, a slot on Monday UTC could correspond to Tuesday local time
  • The check should convert to the organizer's local timezone before calling .day()

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — style agent (Larger fix (11 lines, 1 file) — review recommended)

The working-hours check uses .day() on a UTC Dayjs object (slotStartTime.utc()), which returns the day-of-week in UTC. For users in timezones where the UTC calendar day differs from the local calendar day, this may validate against the wrong day's working hours.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -137,11 +137,13 @@ const checkIfIsAvailable = ({
   //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();
+      // Convert to organizer's local time before comparing day-of-week and time-of-day,
+      // so that e.g. a UTC Monday slot that falls on Tuesday in UTC+8 is validated correctly.
+      const localSlotStartTime = organizerTimeZone
+        ? slotStartTime.tz(organizerTimeZone)
+        : slotStartTime;
+      if (workingHour.days.includes(localSlotStartTime.day())) {
+        const start = localSlotStartTime.hour() * 60 + localSlotStartTime.minute();
+        const end = localSlotStartTime.hour() * 60 + localSlotStartTime.minute();
         if (start < workingHour.startTime || end > workingHour.endTime) {
           return true;
         }

🤖 Grapple PR auto-fix • minor • Review this diff before applying

) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Unnecessary work (confidence: 100%)

In the working hours check block, both start and end are computed from slotStartTime, making the end boundary check always compare the slot start against workingHour.endTime. This means the end-of-slot boundary is never validated — slots that extend past working hours will not be caught, and the check is logically equivalent to only checking the start boundary.

Evidence:

  • Line 141: const start = slotStartTime.hour() * 60 + slotStartTime.minute();
  • Line 142: const end = slotStartTime.hour() * 60 + slotStartTime.minute(); — identical expression, should be slotEndTime
  • With start === end, the condition end > workingHour.endTime is equivalent to start > workingHour.endTime, so a slot starting just before endTime but running past it is incorrectly allowed
  • This is called out in the PR's edge cases: 'both start and end are computed as slotStartTime.hour() * 60 + slotStartTime.minute() (identical expressions) — end should use slotEndTime instead'

Agent: performance

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — performance agent (Small fix (2 lines, 1 file))

In the working hours check block, both start and end are computed from slotStartTime, making the end boundary check always compare the slot start against workingHour.endTime. This means the end-of-slot boundary is never validated — slots that extend past working hours will not be caught, and the check is logically equivalent to only checking the start boundary.

Suggested change
) {
const end = slotEndTime.hour() * 60 + slotEndTime.minute();

🤖 Grapple PR auto-fix • critical • confidence: 100%

// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CRITICAL — Algorithmic complexity (confidence: 100%)

The dateOverrides and workingHours arrays are searched with .find() inside checkIfIsAvailable, which is called inside a nested loop (per-day × per-slot × per-user). With large availability windows this is O(days × slots × users × overrides), but more critically, dayjs.tz() is called multiple times per override per invocation — each call is expensive and the result is never cached. With 30 days × 48 slots × 5 users × N overrides this becomes a significant performance regression.

Evidence:

  • checkIfIsAvailable is called inside the slot-filtering loop at line ~489 and ~514 in slots.ts
  • Inside checkIfIsAvailable, dateOverrides.find() iterates all overrides; for each override dayjs.tz(date.start, organizerTimeZone).utcOffset() is called (line 103) — a full tz parse per iteration
  • dayjs(date.start).add(utcOffset, 'minutes') is called 3–4 times per override per find() iteration with no memoization
  • The outer getSchedule loop iterates over every time slot for every day in the requested range, calling checkIfIsAvailable each time

Agent: performance

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (31 lines, 1 file) — review recommended)

The dateOverrides and workingHours arrays are searched with .find() inside checkIfIsAvailable, which is called inside a nested loop (per-day × per-slot × per-user). With large availability windows this is O(days × slots × users × overrides), but more critically, dayjs.tz() is called multiple times per override per invocation — each call is expensive and the result is never cached. With 30 days × 48 slots × 5 users × N overrides this becomes a significant performance regression.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -99,50 +99,56 @@ const checkIfIsAvailable = ({
   const slotEndTime = time.add(eventLength, "minutes").utc();
   const slotStartTime = time.utc();
 
+  // Pre-compute the slot's date string once, outside any loop
+  const slotStartDay = slotStartTime.format("YYYY MM DD");
+
   //check if date override for slot exists
   let dateOverrideExist = false;
 
+  // Pre-compute adjusted override boundaries once per override (not per-comparison),
+  // avoiding repeated dayjs.tz() + dayjs().add() calls inside the find() predicate.
   if (
     dateOverrides.find((date) => {
-      const utcOffset = organizerTimeZone ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1 : 0;
-
+      // dayjs.tz() is expensive — compute utcOffset exactly once per override entry
+      const utcOffset = organizerTimeZone
+        ? dayjs.tz(date.start, organizerTimeZone).utcOffset() * -1
+        : 0;
+
+      // Compute adjusted start/end once per override, not 3-4× per predicate call
+      const adjustedStart = dayjs(date.start).add(utcOffset, "minutes");
+      const adjustedEnd = dayjs(date.end).add(utcOffset, "minutes");
+
       if (
-        dayjs(date.start).add(utcOffset, "minutes").format("YYYY MM DD") ===
-        slotStartTime.format("YYYY MM DD")
+        adjustedStart.format("YYYY MM DD") === slotStartDay
       ) {
         dateOverrideExist = true;
-        if (dayjs(date.start).add(utcOffset, "minutes") === dayjs(date.end).add(utcOffset, "minutes")) {
+        // All-day blocked override: start === end means fully blocked
+        if (adjustedStart.isSame(adjustedEnd)) {
           return true;
         }
         if (
-          slotEndTime.isBefore(dayjs(date.start).add(utcOffset, "minutes")) ||
-          slotEndTime.isSame(dayjs(date.start).add(utcOffset, "minutes"))
+          slotEndTime.isBefore(adjustedStart) ||
+          slotEndTime.isSame(adjustedStart)
         ) {
           return true;
         }
-        if (slotStartTime.isAfter(dayjs(date.end).add(utcOffset, "minutes"))) {
+        if (slotStartTime.isAfter(adjustedEnd)) {
           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();
+        // Bug fix: end should use slotEndTime, not slotStartTime (was a copy-paste error)
+        const end = slotEndTime.hour() * 60 + slotEndTime.minute();
         if (start < workingHour.startTime || end > workingHour.endTime) {
           return true;
         }
       }
     })
   ) {
     // slot is outside of working hours
     return false;
   }

🤖 Grapple PR auto-fix • critical • Review this diff before applying

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 (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Unnecessary work (confidence: 99%)

organizerTimeZone is derived from eventType.timeZone, eventType?.schedule?.timeZone, or userAvailability?.[0]?.timeZone once correctly before the loop (good), but at lines 489 and 514 it is overridden per-slot with schedule.timeZone or userSchedule.timeZone. The per-slot lookup userAvailability.find(({ user: { id: userId } }) => userId === slotUserId) at line ~573 is O(users) and executed for every slot in the collective availability path.

Evidence:

  • Line ~573: const userSchedule = userAvailability.find(({ user: { id: userId } }) => userId === slotUserId); inside a slot-level loop
  • For N slots × U users this is O(N×U) linear scans; should be a Map built once
  • userAvailability is constant across all slot iterations — the Map can be built before the loop

Agent: performance

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — performance agent (Small fix (9 lines, 1 file))

organizerTimeZone is derived from eventType.timeZone, eventType?.schedule?.timeZone, or userAvailability?.[0]?.timeZone once correctly before the loop (good), but at lines 489 and 514 it is overridden per-slot with schedule.timeZone or userSchedule.timeZone. The per-slot lookup userAvailability.find(({ user: { id: userId } }) => userId === slotUserId) at line ~573 is O(users) and executed for every slot in the collective availability path.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -489,6 +489,12 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
   // what else are you going to call it?
   const looseHostAvailability = userAvailability.filter(({ user: { isFixed } }) => !isFixed);
   if (looseHostAvailability.length > 0) {
+    // Build a Map<userId, schedule> once — userAvailability is constant across all slot
+    // iterations, so repeated Array.find() inside the loop would be O(N×U). Using a Map
+    // makes each per-slot lookup O(1).
+    const userAvailabilityMap = new Map(
+      userAvailability.map((schedule) => [schedule.user.id, schedule])
+    );
+
     availableTimeSlots = availableTimeSlots
       .map((slot) => {
         slot.userIds = slot.userIds?.filter((slotUserId) => {
@@ -576,7 +582,7 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
             return false;
           }
 
-          const userSchedule = userAvailability.find(({ user: { id: userId } }) => userId === slotUserId);
+          const userSchedule = userAvailabilityMap.get(slotUserId);
 
           return checkIfIsAvailable({
             time: slot.time,

🤖 Grapple PR auto-fix • major • Review this diff before applying

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 INFO — Code Organization (confidence: 81%)

Inconsistent organizerTimeZone parameter passing: passing schedule.timeZone to checkIfIsAvailable which expects organizerTimeZone

Evidence:

  • Line 489: organizerTimeZone: schedule.timeZone, — passes schedule.timeZone
  • Line 440: organizerTimeZone is already computed from eventType/schedule/userAvailability
  • This creates two separate sources of truth for the organizer's timezone and could lead to inconsistency if schedule.timeZone differs from the computed organizerTimeZone

Agent: style

...availabilityCheckProps,
organizerTimeZone: schedule.timeZone,
});
const endCheckForAvailability = performance.now();
checkForAvailabilityCount++;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Missing Propagation of New Parameters (confidence: 100%)

The checkIfIsAvailable call at line 489 (and at line 514) passes organizerTimeZone: schedule.timeZone or organizerTimeZone: userSchedule.timeZone, but dateOverrides and workingHours are not explicitly passed — they rely on spread from availabilityCheckProps. It is unclear from the truncated diff whether availabilityCheckProps includes dateOverrides and workingHours. If it does not, the new date override and working hours checks in checkIfIsAvailable will silently use empty defaults and have no effect.

Evidence:

  • availabilityCheckProps is defined around line 436 but its contents are not fully visible in the diff
  • The new parameters have defaults: dateOverrides = [] and workingHours = [], so missing props won't throw — they'll silently skip the checks
  • Intent specification notes: 'Truncated diff: cannot confirm checkIfIsAvailable is actually invoked with the new parameters'

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Small fix (4 lines, 1 file))

The checkIfIsAvailable call at line 489 (and at line 514) passes organizerTimeZone: schedule.timeZone or organizerTimeZone: userSchedule.timeZone, but dateOverrides and workingHours are not explicitly passed — they rely on spread from availabilityCheckProps. It is unclear from the truncated diff whether availabilityCheckProps includes dateOverrides and workingHours. If it does not, the new date override and working hours checks in checkIfIsAvailable will silently use empty defaults and have no effect.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -485,7 +485,9 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
       const isAvailable = checkIfIsAvailable({
         time: slot.time,
         ...schedule,
         ...availabilityCheckProps,
+        dateOverrides: schedule.dateOverrides ?? dateOverrides,
+        workingHours: schedule.workingHours ?? workingHours,
         organizerTimeZone: schedule.timeZone,
       });
       const endCheckForAvailability = performance.now();
@@ -510,7 +512,9 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
           checkIfIsAvailable({
             time: slot.time,
             ...userSchedule,
             ...availabilityCheckProps,
+            dateOverrides: userSchedule.dateOverrides ?? dateOverrides,
+            workingHours: userSchedule.workingHours ?? workingHours,
             organizerTimeZone: userSchedule.timeZone,
           });

🤖 Grapple PR auto-fix • minor • Review this diff before applying

checkForAvailabilityTime += endCheckForAvailability - startCheckForAvailability;
return isAvailable;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 MAJOR — Data flow (confidence: 94%)

At the call site on line 489, checkIfIsAvailable is called with ...schedule spread, but neither dateOverrides nor workingHours appear to be properties of schedule. The new parameters dateOverrides and workingHours default to empty arrays, meaning the date override and working hours validation logic will be entirely skipped at this call site.

Evidence:

  • The function signature has dateOverrides = [] and workingHours = [] as defaults.
  • The schedule object is derived from per-user schedule data (containing busy and potentially timeZone), not from the flattened dateOverrides or workingHours computed earlier in getSchedule.
  • Without passing these parameters, the new availability checks (date override validation, working hours validation) are bypassed, and only the original busy-time check runs.
  • The intent specification states: 'The truncated portion of getSchedule in viewer/slots.ts is not visible; it is unclear whether dateOverrides and workingHours are actually passed into checkIfIsAvailable call sites with the correct data'.

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (12 lines, 1 file) — review recommended)

At the call site on line 489, checkIfIsAvailable is called with ...schedule spread, but neither dateOverrides nor workingHours appear to be properties of schedule. The new parameters dateOverrides and workingHours default to empty arrays, meaning the date override and working hours validation logic will be entirely skipped at this call site.

--- a/packages/trpc/server/routers/viewer/slots.ts
+++ b/packages/trpc/server/routers/viewer/slots.ts
@@ -486,9 +486,11 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
       const isAvailable = checkIfIsAvailable({
         time: slot.time,
-        ...schedule,
-        ...availabilityCheckProps,
+        busy: schedule.busy,
+        dateOverrides: schedule.dateOverrides,
+        workingHours: schedule.workingHours,
+        ...availabilityCheckProps,
         organizerTimeZone: schedule.timeZone,
       });
       const endCheckForAvailability = performance.now();
@@ -511,8 +513,10 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
           return checkIfIsAvailable({
             time: slot.time,
-            ...userSchedule,
-            ...availabilityCheckProps,
+            busy: userSchedule.busy,
+            dateOverrides: userSchedule.dateOverrides,
+            workingHours: userSchedule.workingHours,
+            ...availabilityCheckProps,
             organizerTimeZone: userSchedule.timeZone,
           });

🤖 Grapple PR auto-fix • major • Review this diff before applying

});
});

// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Type Safety - Module Boundary (confidence: 98%)

Adding timeZone?: string to the TimeRange type is a cross-cutting change that affects all consumers of this type across the codebase (Schedule type, working hours, any code destructuring TimeRange). While it's optional and non-breaking at the type level, it broadens the semantic scope of TimeRange beyond a simple time range. Consider whether a separate type like TimeRangeWithZone or extending only the date override type would be more architecturally appropriate.

Evidence:

  • TimeRange is used in Schedule = TimeRange[][] which represents weekly schedules, not just date overrides
  • Only date overrides need the timeZone field based on this diff
  • Adding it to the base type means any TimeRange consumer could incorrectly assume timeZone is always populated

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Type Definition - Missing Documentation (confidence: 88%)

The new optional timeZone field on TimeRange type has no documentation. Given that this field is critical for timezone offset calculations in the override pipeline (as evidenced by its use in slots.ts and checkIfIsAvailable), it should be documented to clarify when and why it must be populated.

Evidence:

  • Field added at line 5: timeZone?: string; without JSDoc or comment
  • This field is essential for the date override timezone logic in getSlots and checkIfIsAvailable
  • Consumers of TimeRange may not know they should populate this field for override processing to work correctly

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (10 lines, 1 file))

Adding timeZone?: string to the TimeRange type is a cross-cutting change that affects all consumers of this type across the codebase (Schedule type, working hours, any code destructuring TimeRange). While it's optional and non-breaking at the type level, it broadens the semantic scope of TimeRange beyond a simple time range. Consider whether a separate type like TimeRangeWithZone or extending only the date override type would be more architecturally appropriate.

Suggested change
timeZone?: string;
/**
* A TimeRange that carries timezone information, used specifically for date overrides
* where the organizer's or invitee's timezone must be tracked alongside the time window.
* Do NOT use this for weekly schedule slots (Schedule = TimeRange[][]) where timezone
* context is handled separately via WorkingHours / getWorkingHours().
*/
export type TimeRangeWithZone = TimeRange & {
timeZone?: string;
};

🤖 Grapple PR auto-fix • minor • confidence: 98%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — style agent (Small fix (7 lines, 1 file))

The new optional timeZone field on TimeRange type has no documentation. Given that this field is critical for timezone offset calculations in the override pipeline (as evidenced by its use in slots.ts and checkIfIsAvailable), it should be documented to clarify when and why it must be populated.

Suggested change
timeZone?: string;
/**
* Optional IANA timezone string (e.g. "America/New_York") associated with this time range.
* Must be populated when this TimeRange represents a date override so that timezone offset
* calculations between the organizer and invitee are applied correctly in the override
* pipeline (see lib/slots.ts and the TRPC slots router `checkIfIsAvailable`).
* When absent, no timezone conversion is performed and start/end are treated as-is.
*/

🤖 Grapple PR auto-fix • minor • confidence: 88%

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MINOR — Missing Type Propagation (confidence: 98%)

The timeZone field is added as optional to TimeRange, but the dateOverrides parameter type in checkIfIsAvailable (viewer/slots.ts line 82-85) is defined inline as { start: Date; end: Date; }[] without including timeZone. This means the type system does not enforce that timezone context is carried through to checkIfIsAvailable, and the function cannot access timeZone from individual overrides even though it's available in the data.

Evidence:

  • packages/types/schedule.d.ts line 5: timeZone?: string; added to TimeRange
  • packages/trpc/server/routers/viewer/slots.ts line 82-85: dateOverrides?: { start: Date; end: Date; }[] — does not include timeZone
  • The function uses a single organizerTimeZone parameter instead, which means per-override timezone info is lost if multiple users have different timezones

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Grapple PR] Auto-fix — architecture agent (Small fix (4 lines, 1 file))

The timeZone field is added as optional to TimeRange, but the dateOverrides parameter type in checkIfIsAvailable (viewer/slots.ts line 82-85) is defined inline as { start: Date; end: Date; }[] without including timeZone. This means the type system does not enforce that timezone context is carried through to checkIfIsAvailable, and the function cannot access timeZone from individual overrides even though it's available in the data.

Suggested change
timeZone?: string;
dateOverrides, // TimeRange already includes optional timeZone per override
dateOverrides?: TimeRange[];

🤖 Grapple PR auto-fix • minor • confidence: 98%

};

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