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:
2 files changed
+315
-50
No files matched your search
@@ -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
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user