diff --git a/.gitignore b/.gitignore index 1548ac648..86ea1a7a0 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,4 @@ e2e-webdav-data playwright-report/ -electron-builder-appx.yaml -SCHEDULING_FEATURE_IMPLEMENTATION.md -SCHEDULING_ROADMAP.md +electron-builder-appx.yaml \ No newline at end of file diff --git a/src/app/features/schedule/map-schedule-data/create-schedule-days.ts b/src/app/features/schedule/map-schedule-data/create-schedule-days.ts index ba114f157..d9dcad284 100644 --- a/src/app/features/schedule/map-schedule-data/create-schedule-days.ts +++ b/src/app/features/schedule/map-schedule-data/create-schedule-days.ts @@ -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) => { diff --git a/src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts b/src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts index 91629579b..7cd74f453 100644 --- a/src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts +++ b/src/app/features/schedule/map-schedule-data/create-view-entries-for-day.ts @@ -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( diff --git a/src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts b/src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts index 27814c444..1d4ad0c69 100644 --- a/src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts +++ b/src/app/features/schedule/map-schedule-data/map-to-schedule-days.ts @@ -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; diff --git a/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.ts b/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.ts index c74436cca..4414cb9fd 100644 --- a/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.ts +++ b/src/app/features/schedule/map-schedule-data/place-tasks-in-gaps.ts @@ -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 }; }; /** diff --git a/src/app/features/schedule/map-schedule-data/place-tasks-respecting-blocks.ts b/src/app/features/schedule/map-schedule-data/place-tasks-respecting-blocks.ts new file mode 100644 index 000000000..2e85cb3cf --- /dev/null +++ b/src/app/features/schedule/map-schedule-data/place-tasks-respecting-blocks.ts @@ -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; +}; diff --git a/src/app/features/schedule/map-schedule-data/sort-tasks-by-strategy.ts b/src/app/features/schedule/map-schedule-data/sort-tasks-by-strategy.ts new file mode 100644 index 000000000..067def846 --- /dev/null +++ b/src/app/features/schedule/map-schedule-data/sort-tasks-by-strategy.ts @@ -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; + } +}; diff --git a/src/app/features/schedule/schedule.service.ts b/src/app/features/schedule/schedule.service.ts index 3c42ee567..fd6753be1 100644 --- a/src/app/features/schedule/schedule.service.ts +++ b/src/app/features/schedule/schedule.service.ts @@ -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, );