Skip to content

Commit 63df3d9

Browse files
emrysalcubic-dev-ai[bot]devin-ai-integration[bot]
authored
fix: merge working hours when adjacent (calcom#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 calcom#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 calcom#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>
1 parent 32d2fd5 commit 63df3d9

2 files changed

Lines changed: 313 additions & 48 deletions

File tree

packages/lib/date-ranges.test.ts

Lines changed: 167 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ describe("processWorkingHours", () => {
2323
const dateFrom = dayjs.utc().startOf("day").day(2).add(1, "week");
2424
const dateTo = dayjs.utc().endOf("day").day(3).add(1, "week");
2525

26-
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
27-
26+
const indexedResults = processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] });
27+
const results = Object.values(indexedResults);
2828
expect(results.length).toBe(2); // There should be two working days between the range
2929
// "America/New_York" day shifts -1, so we need to add a day to correct this shift.
3030
expect(results[0]).toEqual({
@@ -48,7 +48,9 @@ describe("processWorkingHours", () => {
4848
const dateFrom = dayjs().month(9).date(24); // starts before DST change
4949
const dateTo = dayjs().startOf("day").month(10).date(1); // first day of November
5050

51-
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
51+
const results = Object.values(
52+
processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] })
53+
);
5254

5355
const lastAvailableSlot = results[results.length - 1];
5456

@@ -69,7 +71,9 @@ describe("processWorkingHours", () => {
6971
const dateFrom = dayjs();
7072
const dateTo = dayjs().endOf("month");
7173

72-
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
74+
const results = Object.values(
75+
processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] })
76+
);
7377

7478
expect(results).toStrictEqual([
7579
{
@@ -173,7 +177,9 @@ describe("processWorkingHours", () => {
173177
const dateFrom = dayjs().month(10).date(1).startOf("day");
174178
const dateTo = dayjs().month(10).endOf("month");
175179

176-
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
180+
const results = Object.values(
181+
processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] })
182+
);
177183

178184
const allDSTStartAt12 = results
179185
.filter((res) => res.start.isBefore(firstSundayOfNovember))
@@ -197,7 +203,9 @@ describe("processWorkingHours", () => {
197203
const dateFrom = dayjs("2023-11-07T00:00:00Z").tz(timeZone); // 2023-11-07T00:00:00 (America/New_York)
198204
const dateTo = dayjs("2023-11-08T00:00:00Z").tz(timeZone); // 2023-11-08T00:00:00 (America/New_York)
199205

200-
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
206+
const results = Object.values(
207+
processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] })
208+
);
201209

202210
expect(results).toEqual([]);
203211
});
@@ -213,7 +221,9 @@ describe("processWorkingHours", () => {
213221
const dateFrom = dayjs("2023-11-07T00:00:00Z").tz(timeZone); // 2023-11-07T00:00:00 (America/New_York)
214222
const dateTo = dayjs("2023-11-07T23:59:59Z").tz(timeZone); // 2023-11-07T23:59:59 (America/New_York)
215223

216-
const results = processWorkingHours({ item, timeZone, dateFrom, dateTo, travelSchedules: [] });
224+
const results = Object.values(
225+
processWorkingHours({}, { item, timeZone, dateFrom, dateTo, travelSchedules: [] })
226+
);
217227

218228
expect(results).toEqual([]);
219229
});
@@ -244,13 +254,18 @@ describe("processWorkingHours", () => {
244254
},
245255
];
246256

247-
const resultsWithTravelSchedule = processWorkingHours({
248-
item,
249-
timeZone,
250-
dateFrom,
251-
dateTo,
252-
travelSchedules,
253-
});
257+
const resultsWithTravelSchedule = Object.values(
258+
processWorkingHours(
259+
{},
260+
{
261+
item,
262+
timeZone,
263+
dateFrom,
264+
dateTo,
265+
travelSchedules,
266+
}
267+
)
268+
);
254269

255270
const resultWithOriginalTz = resultsWithTravelSchedule.filter((result) => {
256271
return (
@@ -565,6 +580,144 @@ describe("buildDateRanges", () => {
565580
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
566581
});
567582
});
583+
it("supports availability past midnight through merging adjacent date ranges", () => {
584+
// tests a 90 minute slot remains available
585+
const items = [
586+
{
587+
days: [1, 2, 3, 4, 5],
588+
startTime: new Date(Date.UTC(0, 0, 0, 23, 0)), // 11 PM
589+
endTime: new Date(Date.UTC(0, 0, 0, 23, 59)), // 11:59 PM (EOD)
590+
},
591+
{
592+
days: [2, 3, 4, 5, 6],
593+
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), // 12 AM
594+
endTime: new Date(Date.UTC(0, 0, 0, 0, 30)), // 12:30 AM
595+
},
596+
];
597+
598+
const dateFrom = dayjs("2023-06-13T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York)
599+
const dateTo = dayjs("2023-06-15T00:00:00Z");
600+
601+
const timeZone = "Europe/London";
602+
603+
const { dateRanges: results } = buildDateRanges({
604+
availability: items,
605+
timeZone,
606+
dateFrom,
607+
dateTo,
608+
travelSchedules: [],
609+
});
610+
611+
expect(results.length).toBe(2);
612+
613+
expect(results[0]).toEqual({
614+
start: dayjs.utc("2023-06-13T22:00:00Z").tz(timeZone),
615+
end: dayjs.utc("2023-06-13T23:30:00Z").tz(timeZone),
616+
});
617+
618+
expect(results[1]).toEqual({
619+
start: dayjs("2023-06-14T22:00:00Z").tz(timeZone),
620+
end: dayjs("2023-06-14T23:30:00Z").tz(timeZone),
621+
});
622+
});
623+
it("supports multi-day availability past midnight through merging adjacent date ranges", () => {
624+
// tests a 2 day date range remains available
625+
const items = [
626+
{
627+
days: [1, 2],
628+
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), // 12 AM
629+
endTime: new Date(Date.UTC(0, 0, 0, 23, 59)), // 11:59 PM (EOD)
630+
},
631+
];
632+
633+
const dateFrom = dayjs("2023-06-11T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York)
634+
const dateTo = dayjs("2023-06-14T00:00:00Z");
635+
636+
const timeZone = "Europe/London";
637+
638+
const { dateRanges: results } = buildDateRanges({
639+
availability: items,
640+
timeZone,
641+
dateFrom,
642+
dateTo,
643+
travelSchedules: [],
644+
});
645+
646+
expect(results.length).toBe(1);
647+
648+
expect(results[0]).toEqual({
649+
start: dayjs.utc("2023-06-11T23:00:00Z").tz(timeZone),
650+
end: dayjs.utc("2023-06-13T23:00:00Z").tz(timeZone),
651+
});
652+
});
653+
it("supports multi-day availability past midnight through merging adjacent date ranges (date overrides)", () => {
654+
// tests a 2 day date range remains available
655+
const items = [
656+
{
657+
date: new Date("2023-06-12T00:00:00Z"),
658+
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), // 11 PM
659+
endTime: new Date(Date.UTC(0, 0, 0, 23, 59)), // 11:59 PM (EOD)
660+
},
661+
{
662+
date: new Date("2023-06-13T00:00:00Z"),
663+
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), // 11 PM
664+
endTime: new Date(Date.UTC(0, 0, 0, 23, 59)), // 11:59 PM (EOD)
665+
},
666+
];
667+
668+
const dateFrom = dayjs("2023-06-11T00:00:00Z"); // 2023-06-12T20:00:00-04:00 (America/New_York)
669+
const dateTo = dayjs("2023-06-14T00:00:00Z");
670+
671+
const timeZone = "Europe/London";
672+
673+
const { dateRanges: results } = buildDateRanges({
674+
availability: items,
675+
timeZone,
676+
dateFrom,
677+
dateTo,
678+
travelSchedules: [],
679+
});
680+
681+
expect(results.length).toBe(1);
682+
683+
expect(results[0]).toEqual({
684+
start: dayjs.utc("2023-06-11T23:00:00Z").tz(timeZone),
685+
end: dayjs.utc("2023-06-13T23:00:00Z").tz(timeZone),
686+
});
687+
});
688+
it("should not lose earlier time slots when overlapping ranges have the same end time", () => {
689+
const items = [
690+
{
691+
days: [1],
692+
startTime: new Date(Date.UTC(0, 0, 0, 6, 0)),
693+
endTime: new Date(Date.UTC(0, 0, 0, 10, 0)),
694+
},
695+
{
696+
days: [1],
697+
startTime: new Date(Date.UTC(0, 0, 0, 8, 0)),
698+
endTime: new Date(Date.UTC(0, 0, 0, 10, 0)),
699+
},
700+
];
701+
702+
const dateFrom = dayjs("2023-06-12T00:00:00Z");
703+
const dateTo = dayjs("2023-06-13T00:00:00Z");
704+
const timeZone = "UTC";
705+
706+
const { dateRanges: results } = buildDateRanges({
707+
availability: items,
708+
timeZone,
709+
dateFrom,
710+
dateTo,
711+
travelSchedules: [],
712+
});
713+
714+
expect(results.length).toBe(1);
715+
716+
expect(results[0]).toEqual({
717+
start: dayjs.utc("2023-06-12T06:00:00Z").tz(timeZone),
718+
end: dayjs.utc("2023-06-12T10:00:00Z").tz(timeZone),
719+
});
720+
});
568721
});
569722

570723
describe("subtract", () => {

0 commit comments

Comments
 (0)