mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
algorithmic implementation of scheduling feature
This commit is contained in:
parent
2814d28f95
commit
19b1daa883
8 changed files with 357 additions and 17 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -111,6 +111,4 @@ e2e-webdav-data
|
|||
playwright-report/
|
||||
|
||||
|
||||
electron-builder-appx.yaml
|
||||
SCHEDULING_FEATURE_IMPLEMENTATION.md
|
||||
SCHEDULING_ROADMAP.md
|
||||
electron-builder-appx.yaml
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
SVE,
|
||||
SVEEntryForNextDay,
|
||||
} from '../schedule.model';
|
||||
import { ScheduleConfig } from '../../config/global-config.model';
|
||||
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
|
||||
import { SCHEDULE_TASK_MIN_DURATION_IN_MS, SVEType } from '../schedule.const';
|
||||
import { createViewEntriesForDay } from './create-view-entries-for-day';
|
||||
|
|
@ -29,6 +30,7 @@ export const createScheduleDays = (
|
|||
blockerBlocksDayMap: BlockedBlockByDayMap,
|
||||
workStartEndCfg: ScheduleWorkStartEndCfg | undefined,
|
||||
now: number,
|
||||
scheduleConfig: ScheduleConfig,
|
||||
): ScheduleDay[] => {
|
||||
let viewEntriesPushedToNextDay: SVEEntryForNextDay[];
|
||||
let flowTasksLeftAfterDay: TaskWithoutReminder[] = nonScheduledTasks.map((task) => {
|
||||
|
|
@ -100,6 +102,16 @@ export const createScheduleDays = (
|
|||
return beyond;
|
||||
})();
|
||||
|
||||
// Calculate day end time for task splitting prevention
|
||||
let dayEnd = nextDayStart;
|
||||
if (workStartEndCfg) {
|
||||
const workEndTime = getDateTimeFromClockString(
|
||||
workStartEndCfg.endTime,
|
||||
dateStrToUtcDate(dayDate),
|
||||
);
|
||||
dayEnd = workEndTime;
|
||||
}
|
||||
|
||||
viewEntries = createViewEntriesForDay(
|
||||
dayDate,
|
||||
startTime,
|
||||
|
|
@ -107,11 +119,51 @@ export const createScheduleDays = (
|
|||
within,
|
||||
blockerBlocksForDay,
|
||||
viewEntriesPushedToNextDay,
|
||||
scheduleConfig,
|
||||
dayEnd,
|
||||
);
|
||||
// beyondBudgetTasks = beyond;
|
||||
beyondBudgetTasks = [];
|
||||
flowTasksLeftAfterDay = [...nonSplitBeyondTasks];
|
||||
|
||||
// Handle task splitting prevention if configured
|
||||
if (!scheduleConfig.isAllowTaskSplitting) {
|
||||
// Filter out tasks that would extend beyond day boundary
|
||||
const tasksToKeep: SVE[] = [];
|
||||
const tasksToMoveToNextDay: SVE[] = [];
|
||||
|
||||
viewEntries.forEach((entry) => {
|
||||
if (
|
||||
entry.type === SVEType.Task ||
|
||||
entry.type === SVEType.TaskPlannedForDay ||
|
||||
entry.type === SVEType.RepeatProjection
|
||||
) {
|
||||
const taskEnd = entry.start + entry.duration;
|
||||
if (taskEnd > dayEnd) {
|
||||
// Task would split - move entire task to next day
|
||||
tasksToMoveToNextDay.push(entry);
|
||||
} else {
|
||||
tasksToKeep.push(entry);
|
||||
}
|
||||
} else {
|
||||
// Keep non-task entries (blocked blocks, etc.)
|
||||
tasksToKeep.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
viewEntries = tasksToKeep;
|
||||
// Add tasks that need to move to the next day entries
|
||||
tasksToMoveToNextDay.forEach((task) => {
|
||||
if (
|
||||
task.type === SVEType.Task ||
|
||||
task.type === SVEType.TaskPlannedForDay ||
|
||||
task.type === SVEType.RepeatProjection
|
||||
) {
|
||||
viewEntriesPushedToNextDay.push(task as SVEEntryForNextDay);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const viewEntriesToRenderForDay: SVE[] = [];
|
||||
viewEntriesPushedToNextDay = [];
|
||||
viewEntries.forEach((entry) => {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ import {
|
|||
SVERepeatProjection,
|
||||
SVETask,
|
||||
} from '../schedule.model';
|
||||
import { ScheduleConfig } from '../../config/global-config.model';
|
||||
import { createScheduleViewEntriesForNormalTasks } from './create-schedule-view-entries-for-normal-tasks';
|
||||
import { insertBlockedBlocksViewEntriesForSchedule } from './insert-blocked-blocks-view-entries-for-schedule';
|
||||
import { placeTasksInGaps } from './place-tasks-in-gaps';
|
||||
import { placeTasksRespectingBlocks } from './place-tasks-respecting-blocks';
|
||||
import { sortTasksByStrategy } from './sort-tasks-by-strategy';
|
||||
import { SCHEDULE_VIEW_TYPE_ORDER, SVEType } from '../schedule.const';
|
||||
|
||||
export const createViewEntriesForDay = (
|
||||
|
|
@ -21,6 +25,8 @@ export const createViewEntriesForDay = (
|
|||
nonScheduledTasksForDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[],
|
||||
blockedBlocksForDay: BlockedBlock[],
|
||||
viewEntriesPushedToNextDay: SVEEntryForNextDay[],
|
||||
scheduleConfig: ScheduleConfig,
|
||||
dayEndTime: number,
|
||||
): SVE[] => {
|
||||
let viewEntries: SVE[] = [];
|
||||
let startTime = initialStartTime;
|
||||
|
|
@ -46,9 +52,42 @@ export const createViewEntriesForDay = (
|
|||
}
|
||||
|
||||
if (nonScheduledTasksForDay.length) {
|
||||
viewEntries = viewEntries.concat(
|
||||
createScheduleViewEntriesForNormalTasks(startTime, nonScheduledTasksForDay),
|
||||
);
|
||||
const strategy = scheduleConfig.taskPlacementStrategy;
|
||||
const allowSplitting = scheduleConfig.isAllowTaskSplitting;
|
||||
|
||||
if (strategy === 'BEST_FIT') {
|
||||
// Use best-fit bin packing algorithm for optimal gap filling
|
||||
const { viewEntries: placedEntries } = placeTasksInGaps(
|
||||
nonScheduledTasksForDay,
|
||||
blockedBlocksForDay,
|
||||
startTime,
|
||||
dayEndTime,
|
||||
allowSplitting,
|
||||
);
|
||||
viewEntries = viewEntries.concat(placedEntries);
|
||||
// Note: tasksForNextDay will be handled by task splitting prevention logic in create-schedule-days.ts
|
||||
} else {
|
||||
// Use strategy-based sequential placement
|
||||
const sortedTasks = sortTasksByStrategy(nonScheduledTasksForDay, strategy);
|
||||
|
||||
if (!allowSplitting) {
|
||||
// When splitting is not allowed, use gap-aware placement
|
||||
const { viewEntries: placedEntries } = placeTasksRespectingBlocks(
|
||||
sortedTasks,
|
||||
blockedBlocksForDay,
|
||||
startTime,
|
||||
dayEndTime,
|
||||
false,
|
||||
);
|
||||
viewEntries = viewEntries.concat(placedEntries);
|
||||
// Note: tasksForNextDay will be handled by task splitting prevention logic in create-schedule-days.ts
|
||||
} else {
|
||||
// When splitting is allowed, use simple sequential placement
|
||||
viewEntries = viewEntries.concat(
|
||||
createScheduleViewEntriesForNormalTasks(startTime, sortedTasks),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
insertBlockedBlocksViewEntriesForSchedule(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ScheduleLunchBreakCfg,
|
||||
ScheduleWorkStartEndCfg,
|
||||
} from '../schedule.model';
|
||||
import { ScheduleConfig } from '../../config/global-config.model';
|
||||
import { createScheduleDays } from './create-schedule-days';
|
||||
import { createBlockedBlocksByDayMap } from './create-blocked-blocks-by-day-map';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ export const mapToScheduleDays = (
|
|||
calenderWithItems: ScheduleCalendarMapEntry[],
|
||||
currentId: string | null,
|
||||
plannerDayMap: PlannerDayMap,
|
||||
scheduleConfig: ScheduleConfig,
|
||||
workStartEndCfg: ScheduleWorkStartEndCfg = {
|
||||
startTime: '0:00',
|
||||
endTime: '23:59',
|
||||
|
|
@ -90,6 +92,7 @@ export const mapToScheduleDays = (
|
|||
blockerBlocksDayMap,
|
||||
workStartEndCfg,
|
||||
now,
|
||||
scheduleConfig,
|
||||
);
|
||||
|
||||
return v;
|
||||
|
|
|
|||
|
|
@ -20,32 +20,36 @@ interface TaskPlacement {
|
|||
}
|
||||
|
||||
/**
|
||||
* Intelligently places tasks into gaps between blocked blocks to minimize/eliminate splitting.
|
||||
* Intelligently places tasks into gaps between blocked blocks using Best Fit bin packing.
|
||||
*
|
||||
* Algorithm Strategy (Best Fit Bin Packing):
|
||||
* 1. Calculate all available gaps between blocked blocks
|
||||
* 2. Sort tasks by remaining time (shortest first for gap optimization)
|
||||
* 3. For each task, find the smallest gap that can fit it completely
|
||||
* 4. Place task in that gap, updating gap availability
|
||||
* 5. Tasks that don't fit in any gap are placed sequentially after (may split)
|
||||
*
|
||||
* This approach ELIMINATES splits for tasks that can fit in gaps,
|
||||
* and only allows splitting for tasks that truly cannot fit anywhere.
|
||||
* 5. Tasks that don't fit in any gap:
|
||||
* - If splitting allowed: placed sequentially after last block (may split across blocks/days)
|
||||
* - If splitting NOT allowed: returned as tasksForNextDay
|
||||
*
|
||||
* @param tasks - Unscheduled tasks to place
|
||||
* @param blockedBlocks - Meetings, breaks, scheduled tasks, work boundaries
|
||||
* @param startTime - When to start scheduling (work start or current time)
|
||||
* @param endTime - Day boundary (usually end of work day)
|
||||
* @returns Schedule view entries with optimal placement
|
||||
* @param allowSplitting - Whether tasks can be split across blocked blocks
|
||||
* @returns Object with tasks that fit today and tasks to push to next day
|
||||
*/
|
||||
export const placeTasksInGaps = (
|
||||
tasks: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[],
|
||||
blockedBlocks: BlockedBlock[],
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): SVETask[] => {
|
||||
allowSplitting: boolean = true,
|
||||
): {
|
||||
viewEntries: SVETask[];
|
||||
tasksForNextDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[];
|
||||
} => {
|
||||
if (tasks.length === 0) {
|
||||
return [];
|
||||
return { viewEntries: [], tasksForNextDay: [] };
|
||||
}
|
||||
|
||||
// Step 1: Calculate available gaps between blocked blocks
|
||||
|
|
@ -64,6 +68,7 @@ export const placeTasksInGaps = (
|
|||
const placements: TaskPlacement[] = [];
|
||||
const remainingTasks: typeof tasksWithDuration = [];
|
||||
const availableGaps = [...gaps]; // Clone gaps to track remaining space
|
||||
const tasksForNextDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[] = [];
|
||||
|
||||
for (const { task, duration } of tasksWithDuration) {
|
||||
// Find the smallest gap that can fit this task completely
|
||||
|
|
@ -98,8 +103,14 @@ export const placeTasksInGaps = (
|
|||
availableGaps.splice(bestGapIndex, 1);
|
||||
}
|
||||
} else {
|
||||
// No gap can fit this task - it will be placed sequentially after
|
||||
remainingTasks.push({ task, duration });
|
||||
// No gap can fit this task
|
||||
if (allowSplitting) {
|
||||
// If splitting is allowed, it will be placed sequentially after
|
||||
remainingTasks.push({ task, duration });
|
||||
} else {
|
||||
// If splitting is NOT allowed, move to next day
|
||||
tasksForNextDay.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +152,7 @@ export const placeTasksInGaps = (
|
|||
// Sort by start time for consistent ordering
|
||||
viewEntries.sort((a, b) => a.start - b.start);
|
||||
|
||||
return viewEntries;
|
||||
return { viewEntries, tasksForNextDay };
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
import {
|
||||
TaskWithoutReminder,
|
||||
TaskWithPlannedForDayIndication,
|
||||
} from '../../tasks/task.model';
|
||||
import { BlockedBlock, SVETask } from '../schedule.model';
|
||||
import { SVEType } from '../schedule.const';
|
||||
import { getTimeLeftForTask } from '../../../util/get-time-left-for-task';
|
||||
|
||||
interface Gap {
|
||||
start: number;
|
||||
end: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places tasks while respecting blocked blocks (meetings, breaks, scheduled tasks).
|
||||
* When splitting is disabled, tasks will only be placed if they fit completely in gaps.
|
||||
* When splitting is enabled, tasks can span across blocked blocks.
|
||||
*
|
||||
* @param tasks - Unscheduled tasks to place
|
||||
* @param blockedBlocks - Meetings, breaks, scheduled tasks, work boundaries
|
||||
* @param startTime - When to start scheduling (work start or current time)
|
||||
* @param endTime - Day boundary (usually end of work day)
|
||||
* @param allowSplitting - Whether tasks can be split across blocked blocks
|
||||
* @returns Object with tasks that fit today and tasks to push to next day
|
||||
*/
|
||||
export const placeTasksRespectingBlocks = (
|
||||
tasks: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[],
|
||||
blockedBlocks: BlockedBlock[],
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
allowSplitting: boolean,
|
||||
): {
|
||||
viewEntries: SVETask[];
|
||||
tasksForNextDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[];
|
||||
} => {
|
||||
if (tasks.length === 0) {
|
||||
return { viewEntries: [], tasksForNextDay: [] };
|
||||
}
|
||||
|
||||
if (allowSplitting) {
|
||||
// When splitting is allowed, use simple sequential placement
|
||||
// Tasks will be placed one after another, ignoring blocked blocks
|
||||
// The existing split handling in create-schedule-days.ts will handle day boundaries
|
||||
return {
|
||||
viewEntries: placeTasksSequentially(tasks, startTime),
|
||||
tasksForNextDay: [],
|
||||
};
|
||||
}
|
||||
|
||||
// When splitting is NOT allowed, respect blocked blocks
|
||||
const gaps = calculateGaps(blockedBlocks, startTime, endTime);
|
||||
const viewEntries: SVETask[] = [];
|
||||
const tasksForNextDay: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[] = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
const duration = getTimeLeftForTask(task);
|
||||
|
||||
// Find the first gap that can fit this task completely
|
||||
let placed = false;
|
||||
for (let i = 0; i < gaps.length; i++) {
|
||||
const gap = gaps[i];
|
||||
|
||||
if (gap.duration >= duration) {
|
||||
// Task fits! Place it in this gap
|
||||
viewEntries.push({
|
||||
id: task.id,
|
||||
type: (task as TaskWithPlannedForDayIndication).plannedForDay
|
||||
? SVEType.TaskPlannedForDay
|
||||
: SVEType.Task,
|
||||
start: gap.start,
|
||||
data: task,
|
||||
duration,
|
||||
});
|
||||
|
||||
// Update gap: reduce available space
|
||||
gap.start += duration;
|
||||
gap.duration -= duration;
|
||||
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!placed) {
|
||||
// Task doesn't fit in any gap today - move to next day
|
||||
tasksForNextDay.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start time for consistent ordering
|
||||
viewEntries.sort((a, b) => a.start - b.start);
|
||||
|
||||
return { viewEntries, tasksForNextDay };
|
||||
};
|
||||
|
||||
/**
|
||||
* Places tasks sequentially without respecting blocked blocks.
|
||||
* Used when splitting is allowed.
|
||||
*/
|
||||
const placeTasksSequentially = (
|
||||
tasks: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[],
|
||||
startTime: number,
|
||||
): SVETask[] => {
|
||||
let currentTime = startTime;
|
||||
const viewEntries: SVETask[] = [];
|
||||
|
||||
for (const task of tasks) {
|
||||
const duration = getTimeLeftForTask(task);
|
||||
|
||||
viewEntries.push({
|
||||
id: task.id,
|
||||
type: (task as TaskWithPlannedForDayIndication).plannedForDay
|
||||
? SVEType.TaskPlannedForDay
|
||||
: SVEType.Task,
|
||||
start: currentTime,
|
||||
data: task,
|
||||
duration,
|
||||
});
|
||||
|
||||
currentTime += duration;
|
||||
}
|
||||
|
||||
return viewEntries;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates available time gaps between blocked blocks.
|
||||
*
|
||||
* @param blockedBlocks - Sorted array of blocked time periods
|
||||
* @param startTime - Start of scheduling window
|
||||
* @param endTime - End of scheduling window
|
||||
* @returns Array of gaps with their start time, end time, and duration
|
||||
*/
|
||||
const calculateGaps = (
|
||||
blockedBlocks: BlockedBlock[],
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): Gap[] => {
|
||||
const gaps: Gap[] = [];
|
||||
|
||||
if (blockedBlocks.length === 0) {
|
||||
// No blocks - entire period is available
|
||||
return [
|
||||
{
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
duration: endTime - startTime,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Sort blocks by start time
|
||||
const sortedBlocks = [...blockedBlocks].sort((a, b) => a.start - b.start);
|
||||
|
||||
// Gap before first block
|
||||
const firstBlock = sortedBlocks[0];
|
||||
if (startTime < firstBlock.start) {
|
||||
gaps.push({
|
||||
start: startTime,
|
||||
end: firstBlock.start,
|
||||
duration: firstBlock.start - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
// Gaps between blocks
|
||||
for (let i = 0; i < sortedBlocks.length - 1; i++) {
|
||||
const currentBlock = sortedBlocks[i];
|
||||
const nextBlock = sortedBlocks[i + 1];
|
||||
|
||||
if (currentBlock.end < nextBlock.start) {
|
||||
gaps.push({
|
||||
start: currentBlock.end,
|
||||
end: nextBlock.start,
|
||||
duration: nextBlock.start - currentBlock.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Gap after last block
|
||||
const lastBlock = sortedBlocks[sortedBlocks.length - 1];
|
||||
if (lastBlock.end < endTime) {
|
||||
gaps.push({
|
||||
start: lastBlock.end,
|
||||
end: endTime,
|
||||
duration: endTime - lastBlock.end,
|
||||
});
|
||||
}
|
||||
|
||||
return gaps;
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { TaskPlacementStrategy } from '../../config/global-config.model';
|
||||
import {
|
||||
TaskWithoutReminder,
|
||||
TaskWithPlannedForDayIndication,
|
||||
} from '../../tasks/task.model';
|
||||
import { getTimeLeftForTask } from '../../../util/get-time-left-for-task';
|
||||
|
||||
/**
|
||||
* Sorts tasks according to the specified placement strategy.
|
||||
* @param tasks - Array of tasks to sort
|
||||
* @param strategy - The placement strategy to use
|
||||
* @returns A new sorted array of tasks
|
||||
*/
|
||||
export const sortTasksByStrategy = (
|
||||
tasks: (TaskWithoutReminder | TaskWithPlannedForDayIndication)[],
|
||||
strategy: TaskPlacementStrategy,
|
||||
): (TaskWithoutReminder | TaskWithPlannedForDayIndication)[] => {
|
||||
const tasksCopy = [...tasks];
|
||||
|
||||
switch (strategy) {
|
||||
case 'SHORTEST_FIRST':
|
||||
return tasksCopy.sort((a, b) => getTimeLeftForTask(a) - getTimeLeftForTask(b));
|
||||
|
||||
case 'LONGEST_FIRST':
|
||||
return tasksCopy.sort((a, b) => getTimeLeftForTask(b) - getTimeLeftForTask(a));
|
||||
|
||||
case 'OLDEST_FIRST':
|
||||
return tasksCopy.sort((a, b) => a.created - b.created);
|
||||
|
||||
case 'NEWEST_FIRST':
|
||||
return tasksCopy.sort((a, b) => b.created - a.created);
|
||||
|
||||
case 'BEST_FIT':
|
||||
// Best-fit algorithm internally uses shortest-first for optimal gap filling
|
||||
return tasksCopy.sort((a, b) => getTimeLeftForTask(a) - getTimeLeftForTask(b));
|
||||
|
||||
case 'DEFAULT':
|
||||
default:
|
||||
// No sorting - use existing order (sequential placement)
|
||||
return tasksCopy;
|
||||
}
|
||||
};
|
||||
|
|
@ -13,6 +13,7 @@ import { PlannerDayMap } from '../planner/planner.model';
|
|||
import { TaskWithDueTime, TaskWithSubTasks } from '../tasks/task.model';
|
||||
import { TaskRepeatCfg } from '../task-repeat-cfg/task-repeat-cfg.model';
|
||||
import { ScheduleConfig } from '../config/global-config.model';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../config/default-global-config.const';
|
||||
import { mapToScheduleDays } from './map-schedule-data/map-to-schedule-days';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectTimelineTasks } from '../work-context/store/work-context.selectors';
|
||||
|
|
@ -84,6 +85,8 @@ export class ScheduleService {
|
|||
return [];
|
||||
}
|
||||
|
||||
const scheduleConfig = timelineCfg || DEFAULT_GLOBAL_CONFIG.schedule;
|
||||
|
||||
return mapToScheduleDays(
|
||||
now,
|
||||
daysToShow,
|
||||
|
|
@ -94,6 +97,7 @@ export class ScheduleService {
|
|||
icalEvents ?? [],
|
||||
currentTaskId,
|
||||
plannerDayMap,
|
||||
scheduleConfig,
|
||||
timelineCfg?.isWorkStartEndEnabled ? createWorkStartEndCfg(timelineCfg) : undefined,
|
||||
timelineCfg?.isLunchBreakEnabled ? createLunchBreakCfg(timelineCfg) : undefined,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue