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 80a5730..2859f6e 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 @@ -39,8 +39,8 @@ Execution notes: - Expanded `SchedulingResult` and `SchedulingNotice` so operation results can report changed task placements, overlap details, and typed notices. - Added `SchedulingEngine.analyzeSchedule` for deterministic in-memory schedule analysis without moving tasks or touching persistence. - Added tests for in-memory task grouping, overlap reporting, and exact movement-change representation. -- Could not run `dart analyze` or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment. -- Commit was deferred until the repository was initialized and repaired. +- Initial Dart verification was blocked while the SDK was unavailable; after repair, `dart analyze` and `dart test` passed. +- Committed after the repository was initialized and repaired. ## Chunk 3.2 — Insert backlog task into next available flexible slot @@ -74,8 +74,8 @@ Execution notes: - Successful insertion returns updated tasks, movement notices, and exact `SchedulingChange` records. - No-fit and overflow cases return the original task list with a typed notice. - Added tests for open-slot insertion, bumping a later flexible task, skipping locked time, preserving inflexible/critical blocks, and overflow/no-fit. -- Could not run `dart analyze` or `dart test` because neither `dart` nor `flutter` is installed on `PATH` in this environment. -- Commit was deferred until the repository was initialized and repaired. +- Initial Dart verification was blocked while the SDK was unavailable; after repair, `dart analyze` and `dart test` passed. +- Committed after the repository was initialized and repaired. BREAKPOINT: Stop here. Confirm `extra high` mode before starting this chunk if not already set. @@ -83,6 +83,8 @@ BREAKPOINT: Stop here. Confirm `extra high` mode before starting this chunk if n Recommended Codex level: extra high +Status: Completed + Tasks: Implement push behavior: @@ -99,6 +101,16 @@ Acceptance criteria: - Test: locked intervals are respected. - Test: required items remain fixed and visible. +Execution notes: + +- Added `SchedulingEngine.pushFlexibleTaskToNextAvailableSlot`. +- Push requires a planned flexible task with an existing scheduled interval and positive duration. +- The selected task moves after its current slot; downstream planned flexible tasks shift later in their existing order. +- Locked, inflexible, critical, non-planned, and earlier flexible intervals remain fixed or untouched. +- Successful push returns updated tasks, movement notices, and exact `SchedulingChange` records. +- Added tests for moving after the current slot, bumping downstream flexible tasks, respecting locked intervals, and preserving required visible items. +- `dart analyze` and `dart test` passed. + ## Chunk 3.4 — Push flexible task to tomorrow top of queue Recommended Codex level: high diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index 8b399a4..55bda2a 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -219,6 +219,86 @@ class SchedulingEngine { ); } + /// Push a planned flexible task to the next available slot today. + /// + /// The selected task moves after its current slot. Planned flexible tasks + /// after it may shift later, preserving their relative order. + SchedulingResult pushFlexibleTaskToNextAvailableSlot({ + 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 pushed.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + ), + ); + } + + final currentInterval = _scheduledIntervalFor(task); + if (currentInterval == null || !input.window.contains(currentInterval)) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a current scheduled slot before pushing.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + ), + ); + } + + if (currentInterval.duration.inMicroseconds <= 0) { + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a positive scheduled duration before pushing.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + ), + ); + } + + final placement = _planFlexiblePush( + input: input, + task: task, + currentInterval: currentInterval, + ); + + if (placement == null) { + return _unchangedResult( + input, + SchedulingNotice( + 'No available flexible slot today.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + ), + ); + } + + return _applyPushPlacement( + input: input, + pushedTask: task, + placement: placement, + updatedAt: updatedAt ?? DateTime.now(), + ); + } + /// Analyze the current in-memory schedule without moving tasks. SchedulingResult analyzeSchedule(SchedulingInput input) { final overlaps = []; @@ -471,6 +551,86 @@ _BacklogInsertionPlan? _planBacklogInsertion({ return _BacklogInsertionPlan(placements: placements); } +_BacklogInsertionPlan? _planFlexiblePush({ + required SchedulingInput input, + required Task task, + required TimeInterval currentInterval, +}) { + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final queue = <_PlacementItem>[ + _PlacementItem( + task: task, + duration: currentInterval.duration, + earliestStart: currentInterval.end, + ), + ]; + + 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 startsBeforePushPoint = interval.start.isBefore(currentInterval.end); + final startsAfterWindow = interval.start.isAfter(input.window.end) || + interval.start.isAtSameMomentAs(input.window.end); + + if (startsBeforePushPoint || startsAfterWindow) { + fixedBlocks.add(interval); + continue; + } + + if (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 = currentInterval.end; + 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, @@ -536,6 +696,69 @@ SchedulingResult _applyPlacement({ ); } +SchedulingResult _applyPushPlacement({ + required SchedulingInput input, + required Task pushedTask, + required _BacklogInsertionPlan placement, + required DateTime updatedAt, +}) { + final changes = []; + final notices = []; + final updatedTasks = []; + + for (final task in input.tasks) { + final interval = placement.placements[task.id]; + if (interval == null) { + updatedTasks.add(task); + continue; + } + + final moved = !_sameDateTime(task.scheduledStart, interval.start) || + !_sameDateTime(task.scheduledEnd, interval.end); + + if (!moved) { + updatedTasks.add(task); + continue; + } + + final isPushedTask = task.id == pushedTask.id; + final updatedTask = task.copyWith( + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt, + stats: isPushedTask + ? task.stats.incrementManualPush() + : task.stats.incrementAutoPush(), + ); + + updatedTasks.add(updatedTask); + changes.add( + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ), + ); + notices.add( + SchedulingNotice( + isPushedTask + ? 'Flexible task pushed to next available slot.' + : 'Flexible task moved to make room.', + type: SchedulingNoticeType.moved, + taskId: task.id, + ), + ); + } + + return SchedulingResult( + tasks: List.unmodifiable(updatedTasks), + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + ); +} + DateTime _laterOf(DateTime first, DateTime second) { if (first.isAfter(second)) { return first; diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index 8ed4820..586cf68 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -373,6 +373,149 @@ void main() { expect(backlogTask.status, TaskStatus.backlog); }); + test('push flexible task moves it after current slot', () { + final task = flexibleTask().copyWith( + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 15), + ); + final result = engine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: [task], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14), + ), + ), + taskId: 'task-1', + updatedAt: now, + ); + final pushed = result.tasks.single; + + expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15)); + expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30)); + expect(pushed.stats.manuallyPushedCount, 1); + expect(result.changes.single.previousStart, DateTime(2026, 6, 19, 13)); + expect(result.changes.single.nextStart, DateTime(2026, 6, 19, 13, 15)); + expect(result.notices.single.type, SchedulingNoticeType.moved); + }); + + test('push flexible task bumps downstream flexible tasks in order', () { + final first = flexibleTask().copyWith( + id: 'flexible-1', + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 15), + ); + final second = flexibleTask().copyWith( + id: 'flexible-2', + durationMinutes: 30, + scheduledStart: DateTime(2026, 6, 19, 13, 15), + scheduledEnd: DateTime(2026, 6, 19, 13, 45), + ); + final third = flexibleTask().copyWith( + id: 'flexible-3', + scheduledStart: DateTime(2026, 6, 19, 13, 45), + scheduledEnd: DateTime(2026, 6, 19, 14), + ); + final result = engine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: [first, second, third], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14, 30), + ), + ), + taskId: 'flexible-1', + updatedAt: now, + ); + final pushed = taskById(result.tasks, 'flexible-1'); + final bumpedSecond = taskById(result.tasks, 'flexible-2'); + final bumpedThird = taskById(result.tasks, 'flexible-3'); + + expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 15)); + expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 30)); + expect(bumpedSecond.scheduledStart, DateTime(2026, 6, 19, 13, 30)); + expect(bumpedSecond.scheduledEnd, DateTime(2026, 6, 19, 14)); + expect(bumpedThird.scheduledStart, DateTime(2026, 6, 19, 14)); + expect(bumpedThird.scheduledEnd, DateTime(2026, 6, 19, 14, 15)); + expect(bumpedSecond.stats.autoPushedCount, 1); + expect(bumpedThird.stats.autoPushedCount, 1); + expect(result.changes.map((change) => change.taskId), [ + 'flexible-1', + 'flexible-2', + 'flexible-3', + ]); + }); + + test('push flexible task respects locked intervals', () { + final task = flexibleTask().copyWith( + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 15), + ); + final result = engine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: [task], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14), + ), + lockedIntervals: [ + TimeInterval( + start: DateTime(2026, 6, 19, 13, 15), + end: DateTime(2026, 6, 19, 13, 30), + label: 'locked-block', + ), + ], + ), + taskId: 'task-1', + updatedAt: now, + ); + final pushed = result.tasks.single; + + expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30)); + expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45)); + }); + + test('push flexible task leaves required items fixed and visible', () { + final task = flexibleTask().copyWith( + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 15), + ); + final inflexibleTask = flexibleTask().copyWith( + id: 'inflexible-1', + type: TaskType.inflexible, + scheduledStart: DateTime(2026, 6, 19, 13, 15), + scheduledEnd: DateTime(2026, 6, 19, 13, 45), + ); + final criticalTask = flexibleTask().copyWith( + id: 'critical-1', + type: TaskType.critical, + scheduledStart: DateTime(2026, 6, 19, 14), + scheduledEnd: DateTime(2026, 6, 19, 14, 30), + ); + final result = engine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: [task, inflexibleTask, criticalTask], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 15), + ), + ), + taskId: 'task-1', + updatedAt: now, + ); + final pushed = taskById(result.tasks, 'task-1'); + final inflexible = taskById(result.tasks, 'inflexible-1'); + final critical = taskById(result.tasks, 'critical-1'); + + expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 45)); + expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 14)); + expect(inflexible.scheduledStart, DateTime(2026, 6, 19, 13, 15)); + expect(inflexible.scheduledEnd, DateTime(2026, 6, 19, 13, 45)); + expect(critical.scheduledStart, DateTime(2026, 6, 19, 14)); + expect(critical.scheduledEnd, DateTime(2026, 6, 19, 14, 30)); + expect(result.changes.map((change) => change.taskId), ['task-1']); + }); + test('moveToBacklog clears active schedule placement', () { final task = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 18),