From d2a883690777ec459904ae94a6a36b3102dd699e Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Fri, 19 Jun 2026 16:02:25 -0700 Subject: [PATCH] feat(scheduling): push flexible tasks to tomorrow queue --- .../V1_BLOCK_03_Scheduling_Engine.md | 12 ++ lib/src/scheduling_engine.dart | 154 +++++++++++++++++- test/scheduling_engine_test.dart | 89 ++++++++++ 3 files changed, 252 insertions(+), 3 deletions(-) diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_03_Scheduling_Engine.md b/Codex Documentation/Current Software Plan/V1_BLOCK_03_Scheduling_Engine.md index 2859f6e..b3c513f 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_03_Scheduling_Engine.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_03_Scheduling_Engine.md @@ -115,6 +115,8 @@ Execution notes: Recommended Codex level: high +Status: Completed + Tasks: Implement tomorrow behavior: @@ -128,6 +130,16 @@ Acceptance criteria: - Test: task receives tomorrow date/queue marker. - Test: task is before other flexible tomorrow tasks unless required/locked time blocks it. +Execution notes: + +- Added `SchedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue`. +- The input scheduling window represents tomorrow's flexible scheduling window. +- The selected planned flexible task is placed in the earliest available tomorrow slot and existing planned flexible tasks in that window shift later while preserving order. +- Locked and required visible time in the tomorrow window remains fixed. +- Successful tomorrow push returns updated tasks, movement notices, and exact `SchedulingChange` records. +- Added tests for tomorrow top-of-queue placement and locked/required blocking behavior. +- `dart analyze` and `dart test` passed. + BREAKPOINT: Stop here. The next chunk drops from high/extra high to medium. ## Chunk 3.5 — End-of-day rollover diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index 55bda2a..0f88610 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -295,6 +295,78 @@ class SchedulingEngine { input: input, pushedTask: task, placement: placement, + pushedTaskMessage: 'Flexible task pushed to next available slot.', + updatedAt: updatedAt ?? DateTime.now(), + ); + } + + /// Move a planned flexible task to the top of tomorrow's flexible queue. + /// + /// The input window represents tomorrow's scheduling window. Existing planned + /// flexible tasks in that window may shift later, preserving their order. + SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + }) { + final task = _taskById(input.tasks, taskId); + if (task == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + ), + ); + } + + if (!task.isFlexible || task.status != TaskStatus.planned) { + return _unchangedResult( + input, + SchedulingNotice( + 'Only planned flexible tasks can be moved to tomorrow.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + ), + ); + } + + final taskDuration = _scheduledIntervalFor(task)?.duration ?? + _durationFromMinutes(task.durationMinutes); + if (taskDuration == null || taskDuration.inMicroseconds <= 0) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a positive duration before moving to tomorrow.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + ), + ); + } + + final placement = _planTomorrowQueueInsertion( + input: input, + task: task, + taskDuration: taskDuration, + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'No available flexible slot tomorrow.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + ), + ); + } + + return _applyPushPlacement( + input: input, + pushedTask: task, + placement: placement, + pushedTaskMessage: 'Flexible task moved to tomorrow.', updatedAt: updatedAt ?? DateTime.now(), ); } @@ -631,6 +703,83 @@ _BacklogInsertionPlan? _planFlexiblePush({ return _BacklogInsertionPlan(placements: placements); } +_BacklogInsertionPlan? _planTomorrowQueueInsertion({ + required SchedulingInput input, + required Task task, + required Duration taskDuration, +}) { + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final queue = <_PlacementItem>[ + _PlacementItem( + task: task, + duration: taskDuration, + earliestStart: input.window.start, + ), + ]; + + final scheduledFlexibleTasks = input.flexibleTasks + .where((flexibleTask) => flexibleTask.id != task.id) + .toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? input.window.end; + final bStart = b.scheduledStart ?? input.window.end; + + return aStart.compareTo(bStart); + }); + + for (final flexibleTask in scheduledFlexibleTasks) { + final interval = _scheduledIntervalFor(flexibleTask); + if (interval == null) { + continue; + } + + final overlapsWindow = interval.overlaps(input.window.interval); + if (!overlapsWindow) { + continue; + } + + if (!input.window.contains(interval) || + flexibleTask.status != TaskStatus.planned) { + fixedBlocks.add(interval); + continue; + } + + queue.add( + _PlacementItem( + task: flexibleTask, + duration: interval.duration, + earliestStart: interval.start, + ), + ); + } + + fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); + + var cursor = input.window.start; + final placements = {}; + + for (final item in queue) { + final earliestStart = _laterOf(cursor, item.earliestStart); + final interval = _firstOpenIntervalFrom( + earliestStart: earliestStart, + windowEnd: input.window.end, + duration: item.duration, + blocked: fixedBlocks, + ); + + if (interval == null) { + return null; + } + + placements[item.task.id] = interval; + cursor = interval.end; + } + + return _BacklogInsertionPlan(placements: placements); +} + SchedulingResult _applyPlacement({ required SchedulingInput input, required Task insertedTask, @@ -700,6 +849,7 @@ SchedulingResult _applyPushPlacement({ required SchedulingInput input, required Task pushedTask, required _BacklogInsertionPlan placement, + required String pushedTaskMessage, required DateTime updatedAt, }) { final changes = []; @@ -743,9 +893,7 @@ SchedulingResult _applyPushPlacement({ ); notices.add( SchedulingNotice( - isPushedTask - ? 'Flexible task pushed to next available slot.' - : 'Flexible task moved to make room.', + isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.', type: SchedulingNoticeType.moved, taskId: task.id, ), diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index 586cf68..46fe06a 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -516,6 +516,95 @@ void main() { expect(result.changes.map((change) => change.taskId), ['task-1']); }); + test('push flexible task to tomorrow top of queue', () { + final task = flexibleTask().copyWith( + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 15), + ); + final tomorrowFlexible = flexibleTask().copyWith( + id: 'tomorrow-flexible-1', + durationMinutes: 30, + scheduledStart: DateTime(2026, 6, 20, 9), + scheduledEnd: DateTime(2026, 6, 20, 9, 30), + ); + final result = engine.pushFlexibleTaskToTomorrowTopOfQueue( + input: SchedulingInput( + tasks: [task, tomorrowFlexible], + window: SchedulingWindow( + start: DateTime(2026, 6, 20, 9), + end: DateTime(2026, 6, 20, 10), + ), + ), + taskId: 'task-1', + updatedAt: now, + ); + final movedToTomorrow = taskById(result.tasks, 'task-1'); + final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1'); + + expect(movedToTomorrow.scheduledStart, DateTime(2026, 6, 20, 9)); + expect(movedToTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 15)); + expect(movedToTomorrow.stats.manuallyPushedCount, 1); + expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 15)); + expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 45)); + expect(bumpedTomorrow.stats.autoPushedCount, 1); + expect(result.changes.map((change) => change.taskId), [ + 'task-1', + 'tomorrow-flexible-1', + ]); + expect(result.notices.first.message, 'Flexible task moved to tomorrow.'); + }); + + test('push flexible task to tomorrow respects required and locked time', + () { + final task = flexibleTask().copyWith( + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 15), + ); + final criticalTask = flexibleTask().copyWith( + id: 'critical-1', + type: TaskType.critical, + scheduledStart: DateTime(2026, 6, 20, 9, 30), + scheduledEnd: DateTime(2026, 6, 20, 10), + ); + final tomorrowFlexible = flexibleTask().copyWith( + id: 'tomorrow-flexible-1', + scheduledStart: DateTime(2026, 6, 20, 10), + scheduledEnd: DateTime(2026, 6, 20, 10, 15), + ); + final result = engine.pushFlexibleTaskToTomorrowTopOfQueue( + input: SchedulingInput( + tasks: [task, criticalTask, tomorrowFlexible], + window: SchedulingWindow( + start: DateTime(2026, 6, 20, 9), + end: DateTime(2026, 6, 20, 11), + ), + lockedIntervals: [ + TimeInterval( + start: DateTime(2026, 6, 20, 9), + end: DateTime(2026, 6, 20, 9, 30), + label: 'locked-block', + ), + ], + ), + taskId: 'task-1', + updatedAt: now, + ); + final movedToTomorrow = taskById(result.tasks, 'task-1'); + final critical = taskById(result.tasks, 'critical-1'); + final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1'); + + expect(movedToTomorrow.scheduledStart, DateTime(2026, 6, 20, 10)); + expect(movedToTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10, 15)); + expect(critical.scheduledStart, DateTime(2026, 6, 20, 9, 30)); + expect(critical.scheduledEnd, DateTime(2026, 6, 20, 10)); + expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 10, 15)); + expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10, 30)); + expect(result.changes.map((change) => change.taskId), [ + 'task-1', + 'tomorrow-flexible-1', + ]); + }); + test('moveToBacklog clears active schedule placement', () { final task = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 18),