algorithmic implementation of scheduling feature

This commit is contained in:
overcuriousity 2025-11-07 11:01:35 +01:00
parent 2814d28f95
commit 19b1daa883
8 changed files with 357 additions and 17 deletions

4
.gitignore vendored
View file

@ -111,6 +111,4 @@ e2e-webdav-data
playwright-report/
electron-builder-appx.yaml
SCHEDULING_FEATURE_IMPLEMENTATION.md
SCHEDULING_ROADMAP.md
electron-builder-appx.yaml

View file

@ -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) => {

View file

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

View file

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

View file

@ -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 };
};
/**

View file

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

View file

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

View file

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