fix: merge working hours when adjacent (#21912)

* 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 <me@alexvanandel.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 <me@alexvanandel.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 <me@alexvanandel.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>
This commit is contained in:
authored and GitHub committed 2025-07-30 16:15:40 +00:00
1 parent 32d2fd51e5
commit 63df3d9c14
2 files changed
+315 -50

No files matched your search

+167 -14
View File
@@ -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", () => {
+148 -36
View File
@@ -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<number, DateRange>,
{
item,
timeZone,
dateFrom,
dateTo,
travelSchedules,
}: {
item: WorkingHours;
timeZone: string;
dateFrom: Dayjs;
dateTo: Dayjs;
travelSchedules: TravelSchedule[];
}
) {
const utcDateTo = dateTo.utc();
const results = [];
let endTimeToKeyMap: Map<number, number[]> | 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<number, number[]>();
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<number, DateRange>, 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<number, DateRange>, 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({