From 63df3d9c14524ceed298130b96f488434a1214c1 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Wed, 30 Jul 2025 17:15:40 +0100 Subject: [PATCH] fix: merge working hours when adjacent (#21912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Adjacency issue when working hours connect over multiple days * Add tests to validate the new merging of day end logic * Update to correct annotation. Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Implement subsequent date ranges for date overrides also * The map needs to be updated on successful resolve. * test: add failing test for overlapping ranges with same end time Demonstrates bug where overlapping working hour ranges (6:00-10:00 and 8:00-10:00) lose the earlier portion (6:00-8:00), showing only 8:00 and 9:00 slots instead of all 4 slots (6:00, 7:00, 8:00, 9:00). Related to Carina's comment on PR #21912. Co-Authored-By: alex@cal.com * fix: properly merge overlapping ranges with same end time Fixes bug where overlapping working hour ranges with the same end time (e.g., 6:00-10:00 and 8:00-10:00) would lose the earlier portion of the first range. The merging logic now correctly preserves the earliest start time when ranges overlap and share the same end time. This ensures all expected time slots are available (6:00, 7:00, 8:00, 9:00) instead of losing the earlier slots (6:00, 7:00). Resolves the issue identified in Carina's comment on PR #21912. Co-Authored-By: alex@cal.com * perf: optimize overlapping range detection from O(n²) to O(n) Replaces Object.keys().find() with Map-based lookup for ranges with same end time. This optimization handles 2000+ date ranges efficiently, reducing complexity from 4M operations to linear time while maintaining the same merging behavior. Performance improvement for high-volume event types with many availability ranges. Co-Authored-By: alex@cal.com --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/lib/date-ranges.test.ts | 181 +++++++++++++++++++++++++++--- packages/lib/date-ranges.ts | 184 +++++++++++++++++++++++++------ 2 files changed, 315 insertions(+), 50 deletions(-) diff --git a/packages/lib/date-ranges.test.ts b/packages/lib/date-ranges.test.ts index 09649e5230..3c2f6fab1e 100644 --- a/packages/lib/date-ranges.test.ts +++ b/packages/lib/date-ranges.test.ts @@ -23,8 +23,8 @@ describe("processWorkingHours", () => { const dateFrom = dayjs.utc().startOf("day").day(2).add(1, "week"); const dateTo = dayjs.utc().endOf("day").day(3).add(1, "week"); - const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] }); - + const indexedResults = processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] }); + const results = Object.values(indexedResults); expect(results.length).toBe(2); // There should be two working days between the range // "America/New_York" day shifts -1, so we need to add a day to correct this shift. expect(results[0]).toEqual({ @@ -48,7 +48,9 @@ describe("processWorkingHours", () => { const dateFrom = dayjs().month(9).date(24); // starts before DST change const dateTo = dayjs().startOf("day").month(10).date(1); // first day of November - const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] }); + const results = Object.values( + processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] }) + ); const lastAvailableSlot = results[results.length - 1]; @@ -69,7 +71,9 @@ describe("processWorkingHours", () => { const dateFrom = dayjs(); const dateTo = dayjs().endOf("month"); - const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] }); + const results = Object.values( + processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] }) + ); expect(results).toStrictEqual([ { @@ -173,7 +177,9 @@ describe("processWorkingHours", () => { const dateFrom = dayjs().month(10).date(1).startOf("day"); const dateTo = dayjs().month(10).endOf("month"); - const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] }); + const results = Object.values( + processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] }) + ); const allDSTStartAt12 = results .filter((res) => res.start.isBefore(firstSundayOfNovember)) @@ -197,7 +203,9 @@ describe("processWorkingHours", () => { const dateFrom = dayjs("2023-11-07T00:00:00Z").tz(timeZone); // 2023-11-07T00:00:00 (America/New_York) const dateTo = dayjs("2023-11-08T00:00:00Z").tz(timeZone); // 2023-11-08T00:00:00 (America/New_York) - const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] }); + const results = Object.values( + processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] }) + ); expect(results).toEqual([]); }); @@ -213,7 +221,9 @@ describe("processWorkingHours", () => { const dateFrom = dayjs("2023-11-07T00:00:00Z").tz(timeZone); // 2023-11-07T00:00:00 (America/New_York) const dateTo = dayjs("2023-11-07T23:59:59Z").tz(timeZone); // 2023-11-07T23:59:59 (America/New_York) - const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] }); + const results = Object.values( + processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] }) + ); expect(results).toEqual([]); }); @@ -244,13 +254,18 @@ describe("processWorkingHours", () => { }, ]; - const resultsWithTravelSchedule = processWorkingHours({ - item, - timeZone, - dateFrom, - dateTo, - travelSchedules, - }); + const resultsWithTravelSchedule = Object.values( + processWorkingHours( + {}, + { + item, + timeZone, + dateFrom, + dateTo, + travelSchedules, + } + ) + ); const resultWithOriginalTz = resultsWithTravelSchedule.filter((result) => { return ( @@ -565,6 +580,144 @@ describe("buildDateRanges", () => { end: dayjs("2023-06-14T21:00:00Z").tz(timeZone), }); }); + it("supports availability past midnight through merging adjacent date ranges", () => { + // tests a 90 minute slot remains available + const items = [ + { + days: [1, 2, 3, 4, 5], + startTime: new Date(Date.UTC(0, 0, 0, 23, 0)), // 11 PM + endTime: new Date(Date.UTC(0, 0, 0, 23, 59)), // 11:59 PM (EOD) + }, + { + days: [2, 3, 4, 5, 6], + startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), // 12 AM + endTime: new Date(Date.UTC(0, 0, 0, 0, 30)), // 12:30 AM + }, + ]; + + const dateFrom = dayjs("2023-06-13T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York) + const dateTo = dayjs("2023-06-15T00:00:00Z"); + + const timeZone = "Europe/London"; + + const { dateRanges: results } = buildDateRanges({ + availability: items, + timeZone, + dateFrom, + dateTo, + travelSchedules: [], + }); + + expect(results.length).toBe(2); + + expect(results[0]).toEqual({ + start: dayjs.utc("2023-06-13T22:00:00Z").tz(timeZone), + end: dayjs.utc("2023-06-13T23:30:00Z").tz(timeZone), + }); + + expect(results[1]).toEqual({ + start: dayjs("2023-06-14T22:00:00Z").tz(timeZone), + end: dayjs("2023-06-14T23:30:00Z").tz(timeZone), + }); + }); + it("supports multi-day availability past midnight through merging adjacent date ranges", () => { + // tests a 2 day date range remains available + const items = [ + { + days: [1, 2], + startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), // 12 AM + endTime: new Date(Date.UTC(0, 0, 0, 23, 59)), // 11:59 PM (EOD) + }, + ]; + + const dateFrom = dayjs("2023-06-11T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York) + const dateTo = dayjs("2023-06-14T00:00:00Z"); + + const timeZone = "Europe/London"; + + const { dateRanges: results } = buildDateRanges({ + availability: items, + timeZone, + dateFrom, + dateTo, + travelSchedules: [], + }); + + expect(results.length).toBe(1); + + expect(results[0]).toEqual({ + start: dayjs.utc("2023-06-11T23:00:00Z").tz(timeZone), + end: dayjs.utc("2023-06-13T23:00:00Z").tz(timeZone), + }); + }); + it("supports multi-day availability past midnight through merging adjacent date ranges (date overrides)", () => { + // tests a 2 day date range remains available + const items = [ + { + date: new Date("2023-06-12T00:00:00Z"), + startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), // 11 PM + endTime: new Date(Date.UTC(0, 0, 0, 23, 59)), // 11:59 PM (EOD) + }, + { + date: new Date("2023-06-13T00:00:00Z"), + startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), // 11 PM + endTime: new Date(Date.UTC(0, 0, 0, 23, 59)), // 11:59 PM (EOD) + }, + ]; + + const dateFrom = dayjs("2023-06-11T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York) + const dateTo = dayjs("2023-06-14T00:00:00Z"); + + const timeZone = "Europe/London"; + + const { dateRanges: results } = buildDateRanges({ + availability: items, + timeZone, + dateFrom, + dateTo, + travelSchedules: [], + }); + + expect(results.length).toBe(1); + + expect(results[0]).toEqual({ + start: dayjs.utc("2023-06-11T23:00:00Z").tz(timeZone), + end: dayjs.utc("2023-06-13T23:00:00Z").tz(timeZone), + }); + }); + it("should not lose earlier time slots when overlapping ranges have the same end time", () => { + const items = [ + { + days: [1], + startTime: new Date(Date.UTC(0, 0, 0, 6, 0)), + endTime: new Date(Date.UTC(0, 0, 0, 10, 0)), + }, + { + days: [1], + startTime: new Date(Date.UTC(0, 0, 0, 8, 0)), + endTime: new Date(Date.UTC(0, 0, 0, 10, 0)), + }, + ]; + + const dateFrom = dayjs("2023-06-12T00:00:00Z"); + const dateTo = dayjs("2023-06-13T00:00:00Z"); + const timeZone = "UTC"; + + const { dateRanges: results } = buildDateRanges({ + availability: items, + timeZone, + dateFrom, + dateTo, + travelSchedules: [], + }); + + expect(results.length).toBe(1); + + expect(results[0]).toEqual({ + start: dayjs.utc("2023-06-12T06:00:00Z").tz(timeZone), + end: dayjs.utc("2023-06-12T10:00:00Z").tz(timeZone), + }); + }); }); describe("subtract", () => { diff --git a/packages/lib/date-ranges.ts b/packages/lib/date-ranges.ts index c17e713695..c453438771 100644 --- a/packages/lib/date-ranges.ts +++ b/packages/lib/date-ranges.ts @@ -28,21 +28,26 @@ function getAdjustedTimezone(date: Dayjs, timeZone: string, travelSchedules: Tra return adjustedTimezone; } -export function processWorkingHours({ - item, - timeZone, - dateFrom, - dateTo, - travelSchedules, -}: { - item: WorkingHours; - timeZone: string; - dateFrom: Dayjs; - dateTo: Dayjs; - travelSchedules: TravelSchedule[]; -}) { +// reducer +export function processWorkingHours( + results: Record, + { + item, + timeZone, + dateFrom, + dateTo, + travelSchedules, + }: { + item: WorkingHours; + timeZone: string; + dateFrom: Dayjs; + dateTo: Dayjs; + travelSchedules: TravelSchedule[]; + } +) { const utcDateTo = dateTo.utc(); - const results = []; + let endTimeToKeyMap: Map | undefined; + for (let date = dateFrom.startOf("day"); utcDateTo.isAfter(date); date = date.add(1, "day")) { const fromOffset = dateFrom.startOf("day").utcOffset(); @@ -82,11 +87,88 @@ export function processWorkingHours({ continue; } - results.push({ + const endTimeKey = endResult.valueOf(); + + // Create a map of end times to range keys for O(1) lookup + if (!endTimeToKeyMap) { + endTimeToKeyMap = new Map(); + for (const [key, range] of Object.entries(results)) { + const endTime = range.end.valueOf(); + if (!endTimeToKeyMap.has(endTime)) { + endTimeToKeyMap.set(endTime, []); + } + endTimeToKeyMap.get(endTime)!.push(Number(key)); + } + } + + // Check for overlapping ranges with the same end time using O(1) lookup + const keysWithSameEndTime = endTimeToKeyMap.get(endTimeKey) || []; + let foundOverlapping = false; + + for (const key of keysWithSameEndTime) { + const existingRange = results[key]; + if ( + startResult.valueOf() <= existingRange.end.valueOf() && + endResult.valueOf() >= existingRange.start.valueOf() + ) { + // Merge by taking the earliest start time and keeping the same end time + results[key] = { + start: dayjs.min(existingRange.start, startResult), + end: endResult, + }; + foundOverlapping = true; + break; + } + } + + if (foundOverlapping) { + continue; + } + + if (results[startResult.valueOf()]) { + // if a result already exists, we merge the end time + const oldKey = startResult.valueOf(); + const newKey = endResult.valueOf(); + + results[newKey] = { + start: results[oldKey].start, + end: dayjs.max(results[oldKey].end, endResult), + }; + + if (endTimeToKeyMap) { + const oldEndTime = results[oldKey].end.valueOf(); + const oldKeys = endTimeToKeyMap.get(oldEndTime) || []; + const filteredKeys = oldKeys.filter((k) => k !== oldKey); + if (filteredKeys.length === 0) { + endTimeToKeyMap.delete(oldEndTime); + } else { + endTimeToKeyMap.set(oldEndTime, filteredKeys); + } + + if (!endTimeToKeyMap.has(endTimeKey)) { + endTimeToKeyMap.set(endTimeKey, []); + } + endTimeToKeyMap.get(endTimeKey)!.push(newKey); + } + + delete results[oldKey]; // delete the previous end time + continue; + } + // otherwise we create a new result + const newKey = endResult.valueOf(); + results[newKey] = { start: startResult, end: endResult, - }); + }; + + if (endTimeToKeyMap) { + if (!endTimeToKeyMap.has(endTimeKey)) { + endTimeToKeyMap.set(endTimeKey, []); + } + endTimeToKeyMap.get(endTimeKey)!.push(newKey); + } } + return results; } @@ -157,25 +239,40 @@ export function buildDateRanges({ outOfOffice?: IOutOfOfficeData; }): { dateRanges: DateRange[]; oooExcludedDateRanges: DateRange[] } { const dateFromOrganizerTZ = dateFrom.tz(timeZone); - const groupedWorkingHours = groupByDate( - availability.reduce((processed: DateRange[], item) => { - if ("days" in item) { - processed = processed.concat( - processWorkingHours({ item, timeZone, dateFrom: dateFromOrganizerTZ, dateTo, travelSchedules }) - ); - } - return processed; - }, []) - ); - const OOOdates = outOfOffice - ? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs.utc(outOfOffice), timeZone)) - : []; - const groupedOOO = groupByDate(OOOdates); + const groupedWorkingHours = groupByDate( + Object.values( + availability.reduce((processed: Record, item) => { + if (!("days" in item)) { + return processed; + } + + processed = processWorkingHours(processed, { + item, + timeZone, + dateFrom: dateFromOrganizerTZ, + dateTo, + travelSchedules, + }); + + return processed; + }, {}) + ) + ); + + const groupedOOO = groupByDate( + outOfOffice + ? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs.utc(outOfOffice), timeZone)) + : [] + ); const groupedDateOverrides = groupByDate( - availability.reduce((processed: DateRange[], item) => { - if ("date" in item && !!item.date) { + Object.values( + availability.reduce((processed: Record, item) => { + // early return if item is not a date override + if (!("date" in item && !!item.date)) { + return processed; + } const itemDateAsUtc = dayjs.utc(item.date); // TODO: Remove the .subtract(1, "day") and .add(1, "day") part and // refactor this to actually work with correct dates. @@ -190,11 +287,26 @@ export function buildDateRanges({ "[]" ) ) { - processed.push(processDateOverride({ item, itemDateAsUtc, timeZone, travelSchedules })); + // unlike working hours, date overrides are always one. No loop per day. + const newProcessedDateOverride = processDateOverride({ + item, + itemDateAsUtc, + timeZone, + travelSchedules, + }); + if (processed[newProcessedDateOverride.start.valueOf()]) { + // if a result already exists, we merge the end time + processed[newProcessedDateOverride.start.valueOf()].end = dayjs.max( + processed[newProcessedDateOverride.start.valueOf()].end, + newProcessedDateOverride.end + ); + return processed; + } + processed[newProcessedDateOverride.end.valueOf()] = newProcessedDateOverride; } - } - return processed; - }, []) + return processed; + }, {}) + ) ); const dateRanges = Object.values({