mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-24 00:17:23 +00:00
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:
parent
a92a7aaf9c
commit
7ec5bfba90
2 changed files with 533 additions and 49 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue