diff --git a/packages/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.test.ts b/packages/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.test.ts index 48cdaefb83..62113f8977 100644 --- a/packages/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.test.ts +++ b/packages/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.test.ts @@ -42,4 +42,101 @@ describe("filterRedundantDateRanges", () => { expect(filterRedundantDateRanges(dateRanges)).toEqual(dateRanges); }); + + it("should handle three identical ranges", () => { + const dateRanges = [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + ]; + + const result = filterRedundantDateRanges(dateRanges); + expect(result.length).toBe(1); + expect(result[0].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format()); + expect(result[0].end.format()).toEqual(dayjs("2025-01-23T12:00:00.000Z").format()); + }); + + it("should handle nested containment scenario", () => { + const dateRanges = [ + { start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") }, + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, + { start: dayjs("2025-01-23T11:30:00.000Z"), end: dayjs("2025-01-23T12:30:00.000Z") }, + ]; + + const result = filterRedundantDateRanges(dateRanges); + expect(result.length).toBe(1); + expect(result[0].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format()); + expect(result[0].end.format()).toEqual(dayjs("2025-01-23T14:00:00.000Z").format()); + }); + + it("should handle ranges with same start time", () => { + const dateRanges = [ + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") }, + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + ]; + + const result = filterRedundantDateRanges(dateRanges); + expect(result.length).toBe(1); + expect(result[0].end.format()).toEqual(dayjs("2025-01-23T14:00:00.000Z").format()); + }); + + it("should handle ranges with same end time", () => { + const dateRanges = [ + { start: dayjs("2025-01-23T09:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + { start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + ]; + + const result = filterRedundantDateRanges(dateRanges); + expect(result.length).toBe(1); + expect(result[0].start.format()).toEqual(dayjs("2025-01-23T09:00:00.000Z").format()); + }); + + it("should preserve invalid ranges", () => { + const dateRanges = [ + { start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T11:00:00.000Z") }, + { start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") }, + ]; + + const result = filterRedundantDateRanges(dateRanges); + expect(result.length).toBe(2); + }); + + it("should handle touching ranges", () => { + const dateRanges = [ + { start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + { start: dayjs("2025-01-23T12:00:00.000Z"), end: dayjs("2025-01-23T14:00:00.000Z") }, + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, + ]; + + const result = filterRedundantDateRanges(dateRanges); + expect(result.length).toBe(2); + }); + + it("should handle overlapping but non-containing ranges", () => { + const dateRanges = [ + { start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, + ]; + + const result = filterRedundantDateRanges(dateRanges); + expect(result.length).toBe(2); + expect(result[0].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format()); + expect(result[1].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format()); + }); + + it("should handle complex overlapping pattern that exposed cubic-dev-ai bug", () => { + const dateRanges = [ + { start: dayjs("2025-01-23T10:00:00.000Z"), end: dayjs("2025-01-23T12:00:00.000Z") }, + { start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, + { start: dayjs("2025-01-23T09:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, + ]; + + const result = filterRedundantDateRanges(dateRanges); + expect(result.length).toBe(3); + expect(result[0].start.format()).toEqual(dayjs("2025-01-23T09:00:00.000Z").format()); + expect(result[1].start.format()).toEqual(dayjs("2025-01-23T10:00:00.000Z").format()); + expect(result[2].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format()); + }); }); diff --git a/packages/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.ts b/packages/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.ts index 4fd457d43b..7bde5594f2 100644 --- a/packages/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.ts +++ b/packages/lib/getAggregatedAvailability/date-range-utils/filterRedundantDateRanges.ts @@ -1,7 +1,9 @@ import type { DateRange } from "@calcom/lib/date-ranges"; +import { IntervalTree, ContainmentSearchAlgorithm, createIntervalNodes } from "@calcom/lib/intervalTree"; /** * Filters out date ranges that are completely covered by other date ranges. + * Uses an interval tree for O(n log n) worst-case complexity. * Unlike mergeOverlappingDateRanges, this doesn't merge overlapping ranges, * it only removes ranges that are completely contained within others. */ @@ -9,27 +11,38 @@ export function filterRedundantDateRanges(dateRanges: DateRange[]): DateRange[] if (dateRanges.length <= 1) return dateRanges; const sortedRanges = [...dateRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf()); + const intervalNodes = createIntervalNodes( + sortedRanges, + (range) => range.start.valueOf(), + (range) => range.end.valueOf() + ); + const intervalTree = new IntervalTree(intervalNodes); + const searchAlgorithm = new ContainmentSearchAlgorithm(intervalTree); return sortedRanges.filter((range, index) => { if (range.end.valueOf() < range.start.valueOf()) { return true; } - for (let i = 0; i < sortedRanges.length; i++) { - if (i === index) continue; // Skip comparing with itself + const containingIntervals = searchAlgorithm.findContainingIntervals( + range.start.valueOf(), + range.end.valueOf(), + index + ); - const otherRange = sortedRanges[i]; - - if (otherRange.end.valueOf() < otherRange.start.valueOf()) { - continue; - } + for (const containingNode of containingIntervals) { + const otherRange = containingNode.item; + const otherIndex = containingNode.index; if ( - otherRange.start.valueOf() <= range.start.valueOf() && - otherRange.end.valueOf() >= range.end.valueOf() + otherRange.start.valueOf() === range.start.valueOf() && + otherRange.end.valueOf() === range.end.valueOf() ) { - return false; // This range is redundant, filter it out + return otherIndex > index; // Keep current range only if other range has higher index } + + // If we reach here, the other range actually contains this range + return false; } return true; // Keep this range diff --git a/packages/lib/intervalTree.ts b/packages/lib/intervalTree.ts new file mode 100644 index 0000000000..1e74113856 --- /dev/null +++ b/packages/lib/intervalTree.ts @@ -0,0 +1,94 @@ +export interface IntervalNode { + item: T; + index: number; + start: number; + end: number; + maxEnd: number; + left?: IntervalNode; + right?: IntervalNode; +} + +export function createIntervalNodes( + items: T[], + getStart: (item: T) => number, + getEnd: (item: T) => number +): IntervalNode[] { + return items.map((item, index) => ({ + item, + index, + start: getStart(item), + end: getEnd(item), + maxEnd: getEnd(item), + })); +} + +export class IntervalTree { + private root?: IntervalNode; + + constructor(nodes: IntervalNode[]) { + this.root = this.buildTree([...nodes]); + } + + private buildTree(nodes: IntervalNode[]): IntervalNode | undefined { + if (nodes.length === 0) return undefined; + + const mid = Math.floor(nodes.length / 2); + const node = nodes[mid]; + + const leftNodes = nodes.slice(0, mid); + const rightNodes = nodes.slice(mid + 1); + + node.left = this.buildTree(leftNodes); + node.right = this.buildTree(rightNodes); + + node.maxEnd = Math.max(node.end, node.left?.maxEnd ?? 0, node.right?.maxEnd ?? 0); + + return node; + } + + getRoot(): IntervalNode | undefined { + return this.root; + } +} + +export class ContainmentSearchAlgorithm { + private tree: IntervalTree; + + constructor(tree: IntervalTree) { + this.tree = tree; + } + + findContainingIntervals(targetStart: number, targetEnd: number, targetIndex: number): IntervalNode[] { + const result: IntervalNode[] = []; + this.searchContaining(this.tree.getRoot(), targetStart, targetEnd, targetIndex, result); + return result; + } + + private searchContaining( + node: IntervalNode | undefined, + targetStart: number, + targetEnd: number, + targetIndex: number, + result: IntervalNode[] + ): void { + if (!node) return; + + if (node.end < node.start) { + this.searchContaining(node.left, targetStart, targetEnd, targetIndex, result); + this.searchContaining(node.right, targetStart, targetEnd, targetIndex, result); + return; + } + + if (node.start <= targetStart && node.end >= targetEnd && node.index !== targetIndex) { + result.push(node); + } + + if (node.left && node.left.maxEnd >= targetStart) { + this.searchContaining(node.left, targetStart, targetEnd, targetIndex, result); + } + + if (node.right && node.start <= targetEnd) { + this.searchContaining(node.right, targetStart, targetEnd, targetIndex, result); + } + } +}