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({