perf(schedule): improve block merging algorithm from O(n²) to O(n log n)

Replace nested loop + splice + recursion with sort-then-merge approach.
Add 15 comprehensive edge case tests for the merging algorithm.
This commit is contained in:
Johannes Millan 2026-01-15 21:29:15 +01:00
parent a92a7aaf9c
commit 7ec5bfba90
2 changed files with 533 additions and 49 deletions

View file

@ -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);
});
});
});

View file

@ -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 = (