diff --git a/src/app/features/schedule/map-schedule-data/create-sorted-blocker-blocks.spec.ts b/src/app/features/schedule/map-schedule-data/create-sorted-blocker-blocks.spec.ts index e80b8290e4..da596097db 100644 --- a/src/app/features/schedule/map-schedule-data/create-sorted-blocker-blocks.spec.ts +++ b/src/app/features/schedule/map-schedule-data/create-sorted-blocker-blocks.spec.ts @@ -263,31 +263,32 @@ describe('createBlockerBlocks()', () => { const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); expect(r.length).toEqual(2); + // Entries are sorted by start time within each block expect(r).toEqual([ { start: getDateTimeFromClockString('15:00', 0), end: getDateTimeFromClockString('19:00', 0), entries: [ { - data: fakeTasks[0], - end: fakeTasks[0].dueWithTime, - start: fakeTasks[0].dueWithTime, - type: 'ScheduledTask', - }, - { - data: fakeTasks[3], + data: fakeTasks[3], // 15:00 end: fakeTasks[3].dueWithTime! + hours(1), start: fakeTasks[3].dueWithTime, type: 'ScheduledTask', }, { - data: fakeTasks[4], + data: fakeTasks[4], // 15:30 end: fakeTasks[4].dueWithTime! + hours(2.5), start: fakeTasks[4].dueWithTime, type: 'ScheduledTask', }, { - data: fakeTasks[1], + data: fakeTasks[0], // 16:00 + end: fakeTasks[0].dueWithTime, + start: fakeTasks[0].dueWithTime, + type: 'ScheduledTask', + }, + { + data: fakeTasks[1], // 17:00 end: fakeTasks[1].dueWithTime! + hours(2), start: fakeTasks[1].dueWithTime, type: 'ScheduledTask', @@ -546,6 +547,7 @@ describe('createBlockerBlocks()', () => { ); expect(r.length).toEqual(30); + // Entries are sorted by start time within merged blocks expect(r).toEqual([ { end: getDateTimeFromClockString('09:00', new Date(1620259200000)), @@ -560,9 +562,9 @@ describe('createBlockerBlocks()', () => { data: { _showSubTasksMode: 2, attachments: [], - created: 1620239185383, + created: 1620227624280, doneOn: null, - id: 'RlHPfiXYk', + id: 'LayqneCZ0', isDone: false, issueAttachmentNr: null, issueId: null, @@ -572,19 +574,19 @@ describe('createBlockerBlocks()', () => { issueWasUpdated: null, notes: '', parentId: null, - dueWithTime: getDateTimeFromClockString('23:00', new Date(1620172800000)), + dueWithTime: getDateTimeFromClockString('21:00', new Date(1620172800000)), projectId: null, - reminderId: 'wctU7fdUV', + reminderId: 'NkonFINlM', repeatCfgId: null, subTaskIds: [], tagIds: ['TODAY'], - timeEstimate: 0, - timeSpent: 1999, - timeSpentOnDay: { '2021-05-05': 1999 }, - title: 'XXX', + timeEstimate: 1800000, + timeSpent: 0, + timeSpentOnDay: {}, + title: 'Sched1', }, - end: getDateTimeFromClockString('23:00', new Date(1620172800000)), - start: getDateTimeFromClockString('23:00', new Date(1620172800000)), + end: getDateTimeFromClockString('21:30', new Date(1620172800000)), + start: getDateTimeFromClockString('21:00', new Date(1620172800000)), type: 'ScheduledTask', }, { @@ -622,9 +624,9 @@ describe('createBlockerBlocks()', () => { data: { _showSubTasksMode: 2, attachments: [], - created: 1620227624280, + created: 1620239185383, doneOn: null, - id: 'LayqneCZ0', + id: 'RlHPfiXYk', isDone: false, issueAttachmentNr: null, issueId: null, @@ -634,19 +636,19 @@ describe('createBlockerBlocks()', () => { issueWasUpdated: null, notes: '', parentId: null, - dueWithTime: getDateTimeFromClockString('21:00', new Date(1620172800000)), + dueWithTime: getDateTimeFromClockString('23:00', new Date(1620172800000)), projectId: null, - reminderId: 'NkonFINlM', + reminderId: 'wctU7fdUV', repeatCfgId: null, subTaskIds: [], tagIds: ['TODAY'], - timeEstimate: 1800000, - timeSpent: 0, - timeSpentOnDay: {}, - title: 'Sched1', + timeEstimate: 0, + timeSpent: 1999, + timeSpentOnDay: { '2021-05-05': 1999 }, + title: 'XXX', }, - end: getDateTimeFromClockString('21:30', new Date(1620172800000)), - start: getDateTimeFromClockString('21:00', new Date(1620172800000)), + end: getDateTimeFromClockString('23:00', new Date(1620172800000)), + start: getDateTimeFromClockString('23:00', new Date(1620172800000)), type: 'ScheduledTask', }, ], @@ -1063,4 +1065,475 @@ describe('createBlockerBlocks()', () => { ] as any); }); }); + + describe('block merging algorithm', () => { + // These tests specifically verify the merging behavior of overlapping blocks + // to ensure the algorithm correctly handles all edge cases + + it('should return empty array when no tasks provided', () => { + const r = createSortedBlockerBlocks([], [], [], undefined, undefined, 0); + expect(r.length).toEqual(0); + }); + + it('should return single block for single task', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Single Task', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(1); + expect(r[0].entries.length).toEqual(1); + }); + + it('should keep non-overlapping blocks separate', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Task 1', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('09:00', 0), + }, + { + id: 'S2', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Task 2', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('14:00', 0), + }, + { + id: 'S3', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Task 3', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('20:00', 0), + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(3); + // Verify sorted by start time + expect(r[0].start).toBeLessThan(r[1].start); + expect(r[1].start).toBeLessThan(r[2].start); + }); + + it('should merge two directly adjacent blocks', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Task 1', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), + }, + { + id: 'S2', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Task 2', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('11:00', 0), // Starts exactly when S1 ends + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(1); + expect(r[0].start).toEqual(getDateTimeFromClockString('10:00', 0)); + expect(r[0].end).toEqual(getDateTimeFromClockString('12:00', 0)); + expect(r[0].entries.length).toEqual(2); + }); + + it('should merge block completely contained within another', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(4), + title: 'Long Task', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('09:00', 0), // 9:00 - 13:00 + }, + { + id: 'S2', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Short Task Inside', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), // 10:00 - 11:00 (inside S1) + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(1); + expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0)); + expect(r[0].end).toEqual(getDateTimeFromClockString('13:00', 0)); + expect(r[0].entries.length).toEqual(2); + }); + + it('should merge chain of overlapping blocks (A->B->C)', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(2), + title: 'Task A', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('09:00', 0), // 9:00 - 11:00 + }, + { + id: 'S2', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(2), + title: 'Task B', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:30', 0), // 10:30 - 12:30 (overlaps A) + }, + { + id: 'S3', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(2), + title: 'Task C', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('12:00', 0), // 12:00 - 14:00 (overlaps B) + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(1); + expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0)); + expect(r[0].end).toEqual(getDateTimeFromClockString('14:00', 0)); + expect(r[0].entries.length).toEqual(3); + }); + + it('should merge tasks given in reverse chronological order', () => { + const fakeTasks: any[] = [ + { + id: 'S3', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Task C (last)', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('11:00', 0), + }, + { + id: 'S2', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Task B (middle)', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), + }, + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + title: 'Task A (first)', + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('09:00', 0), + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(1); + expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0)); + expect(r[0].end).toEqual(getDateTimeFromClockString('12:00', 0)); + expect(r[0].entries.length).toEqual(3); + }); + + it('should handle multiple separate groups of overlapping blocks', () => { + const fakeTasks: any[] = [ + // Group 1: 9:00 - 11:00 + { + id: 'G1A', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('09:00', 0), + }, + { + id: 'G1B', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), + }, + // Gap from 11:00 - 14:00 + // Group 2: 14:00 - 16:00 + { + id: 'G2A', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('14:00', 0), + }, + { + id: 'G2B', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('15:00', 0), + }, + // Gap from 16:00 - 20:00 + // Group 3: 20:00 - 22:00 + { + id: 'G3A', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('20:00', 0), + }, + { + id: 'G3B', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('21:00', 0), + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(3); + expect(r[0].entries.length).toEqual(2); + expect(r[1].entries.length).toEqual(2); + expect(r[2].entries.length).toEqual(2); + }); + + it('should handle many overlapping blocks efficiently', () => { + // Create 50 overlapping tasks to test performance + const fakeTasks: any[] = []; + const baseTime = getDateTimeFromClockString('09:00', 0); + for (let i = 0; i < 50; i++) { + const offset = i * minutes(30); // Start every 30 min + fakeTasks.push({ + id: `S${i}`, + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(2), // 2 hour tasks + reminderId: 'xxx', + dueWithTime: baseTime + offset, + }); + } + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + // All should merge into one block since they overlap + expect(r.length).toEqual(1); + expect(r[0].entries.length).toEqual(50); + // First task starts at 9:00, last task (49 * 30min later = 24.5h) + 2h duration + expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0)); + }); + + it('should handle blocks with same start time', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), + }, + { + id: 'S2', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(2), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), // Same start + }, + { + id: 'S3', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(3), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), // Same start + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(1); + expect(r[0].start).toEqual(getDateTimeFromClockString('10:00', 0)); + expect(r[0].end).toEqual(getDateTimeFromClockString('13:00', 0)); // Longest task + expect(r[0].entries.length).toEqual(3); + }); + + it('should handle blocks with same end time', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(3), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('09:00', 0), // Ends at 12:00 + }, + { + id: 'S2', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(2), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), // Ends at 12:00 + }, + { + id: 'S3', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('11:00', 0), // Ends at 12:00 + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(1); + expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0)); + expect(r[0].end).toEqual(getDateTimeFromClockString('12:00', 0)); + expect(r[0].entries.length).toEqual(3); + }); + + it('should handle zero-duration tasks', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: 0, // Zero duration + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), + }, + { + id: 'S2', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(1); + expect(r[0].entries.length).toEqual(2); + }); + + it('should correctly sort output by start time', () => { + const fakeTasks: any[] = [ + { + id: 'Later', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('15:00', 0), + }, + { + id: 'Earlier', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('09:00', 0), + }, + { + id: 'Middle', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(1), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('12:00', 0), + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(3); + expect(r[0].start).toEqual(getDateTimeFromClockString('09:00', 0)); + expect(r[1].start).toEqual(getDateTimeFromClockString('12:00', 0)); + expect(r[2].start).toEqual(getDateTimeFromClockString('15:00', 0)); + }); + + it('should merge calendar events with scheduled tasks when overlapping', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(2), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('10:00', 0), // 10:00 - 12:00 + }, + ]; + const icalEventMap: ScheduleCalendarMapEntry[] = [ + { + items: [ + { + id: 'CalEvent', + calProviderId: 'PR', + start: getDateTimeFromClockString('11:00', 0), // 11:00 - 12:00 (overlaps task) + title: 'Calendar Event', + duration: hours(1), + }, + ], + }, + ]; + const r = createSortedBlockerBlocks( + fakeTasks, + [], + icalEventMap, + undefined, + undefined, + 0, + ); + expect(r.length).toEqual(1); + expect(r[0].entries.length).toEqual(2); + expect(r[0].start).toEqual(getDateTimeFromClockString('10:00', 0)); + expect(r[0].end).toEqual(getDateTimeFromClockString('12:00', 0)); + }); + + it('should handle overlapping blocks across midnight', () => { + const fakeTasks: any[] = [ + { + id: 'S1', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(3), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('22:00', 0), // 22:00 - 01:00 next day + }, + { + id: 'S2', + subTaskIds: [], + timeSpent: 0, + timeEstimate: hours(2), + reminderId: 'xxx', + dueWithTime: getDateTimeFromClockString('23:30', 0), // 23:30 - 01:30 next day + }, + ]; + const r = createSortedBlockerBlocks(fakeTasks, [], [], undefined, undefined, 0); + expect(r.length).toEqual(1); + expect(r[0].start).toEqual(getDateTimeFromClockString('22:00', 0)); + // End time should be 01:30 next day + expect(r[0].end).toEqual(getDateTimeFromClockString('23:30', 0) + hours(2)); + expect(r[0].entries.length).toEqual(2); + }); + }); }); diff --git a/src/app/features/schedule/map-schedule-data/create-sorted-blocker-blocks.ts b/src/app/features/schedule/map-schedule-data/create-sorted-blocker-blocks.ts index 0c50a69585..7891489489 100644 --- a/src/app/features/schedule/map-schedule-data/create-sorted-blocker-blocks.ts +++ b/src/app/features/schedule/map-schedule-data/create-sorted-blocker-blocks.ts @@ -283,29 +283,40 @@ const createBlockerBlocksForCalendarEvents = ( return blockedBlocks; }; -// NOTE: we recursively merge all overlapping blocks -// TODO find more effective algorithm +// Merge overlapping blocks using an efficient O(n log n) algorithm +// Sort by start time, then single-pass merge of consecutive overlapping blocks const mergeBlocksRecursively = (blockedBlocks: BlockedBlock[]): BlockedBlock[] => { - for (const blockedBlock of blockedBlocks) { - let wasMergedInner = false; - for (const blockedBlockInner of blockedBlocks) { - if ( - blockedBlockInner !== blockedBlock && - isOverlappingBlock(blockedBlockInner, blockedBlock) - ) { - blockedBlock.start = Math.min(blockedBlockInner.start, blockedBlock.start); - blockedBlock.end = Math.max(blockedBlockInner.end, blockedBlock.end); - blockedBlock.entries = blockedBlock.entries.concat(blockedBlockInner.entries); - blockedBlocks.splice(blockedBlocks.indexOf(blockedBlockInner), 1); - wasMergedInner = true; - break; - } - } - if (wasMergedInner) { - return mergeBlocksRecursively(blockedBlocks); + if (blockedBlocks.length <= 1) { + return blockedBlocks; + } + + // Sort by start time + blockedBlocks.sort((a, b) => a.start - b.start); + + const merged: BlockedBlock[] = [blockedBlocks[0]]; + + for (let i = 1; i < blockedBlocks.length; i++) { + const current = blockedBlocks[i]; + const last = merged[merged.length - 1]; + + // Check if current block overlaps or touches the last merged block + // Two blocks overlap/touch if current.start <= last.end + if (current.start <= last.end) { + // Merge: extend end time and combine entries + last.end = Math.max(last.end, current.end); + last.entries = last.entries.concat(current.entries); + } else { + // No overlap, add as new block + merged.push(current); } } - return blockedBlocks; + + // Sort entries within each merged block by start time for consistent ordering + for (const block of merged) { + block.entries.sort((a, b) => a.start - b.start); + } + + return merged; }; const isOverlappingBlock = (