From 89267551b7c4ffb7c3f7e05c1fe3a1dff920ca26 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Fri, 19 Jun 2026 16:05:24 -0700 Subject: [PATCH] feat(scheduling): roll over unfinished flexible tasks --- .../V1_BLOCK_03_Scheduling_Engine.md | 15 +- lib/src/scheduling_engine.dart | 181 +++++++++++++++++- test/scheduling_engine_test.dart | 117 +++++++++++ 3 files changed, 306 insertions(+), 7 deletions(-) rename Codex Documentation/{Current Software Plan => Archived plans}/V1_BLOCK_03_Scheduling_Engine.md (90%) diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_03_Scheduling_Engine.md b/Codex Documentation/Archived plans/V1_BLOCK_03_Scheduling_Engine.md similarity index 90% rename from Codex Documentation/Current Software Plan/V1_BLOCK_03_Scheduling_Engine.md rename to Codex Documentation/Archived plans/V1_BLOCK_03_Scheduling_Engine.md index b3c513f..df43c36 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_03_Scheduling_Engine.md +++ b/Codex Documentation/Archived plans/V1_BLOCK_03_Scheduling_Engine.md @@ -1,6 +1,6 @@ # V1 Block 03 — Scheduling Engine -Status: Planned +Status: Completed Purpose: Implement the core behavior that makes this product valuable: flexible tasks move safely without destroying the user's intended order. @@ -146,6 +146,8 @@ BREAKPOINT: Stop here. The next chunk drops from high/extra high to medium. Recommended Codex level: medium +Status: Completed + Tasks: Implement rollover behavior: @@ -168,6 +170,17 @@ Acceptance criteria: - Tests cover all included and excluded task types. - Notice text/count is generated. +Execution notes: + +- Added `SchedulingEngine.rollOverUnfinishedFlexibleTasks`. +- Rollover moves planned and active flexible tasks that are not already in the tomorrow window. +- Rolled tasks are placed at the top of tomorrow's flexible queue, preserving current order. +- Existing planned flexible tasks in the tomorrow window shift later while preserving order. +- Locked, inflexible, critical, completed, and cancelled tasks remain unchanged. +- Rollover returns exact `SchedulingChange` records and a count notice. +- Added tests for included flexible tasks, excluded task types/statuses, order preservation, downstream tomorrow bumps, and notice count generation. +- `dart analyze` and `dart test` passed. + Commit suggestion: ```text diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index 0f88610..5c4c081 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -371,6 +371,75 @@ class SchedulingEngine { ); } + /// Move unfinished flexible tasks to the top of tomorrow's flexible queue. + /// + /// The input window represents tomorrow's scheduling window. Only planned and + /// active flexible tasks are rolled; required, locked, completed, and + /// cancelled tasks remain unchanged. + SchedulingResult rollOverUnfinishedFlexibleTasks({ + required SchedulingInput input, + DateTime? updatedAt, + }) { + final rolledItems = <_PlacementItem>[]; + final rolloverTasks = input.flexibleTasks + .where((task) => _shouldRollOver(task, input.window)) + .toList() + ..sort((a, b) { + final aStart = a.scheduledStart ?? input.window.start; + final bStart = b.scheduledStart ?? input.window.start; + + return aStart.compareTo(bStart); + }); + + for (final task in rolloverTasks) { + final duration = _scheduledIntervalFor(task)?.duration ?? + _durationFromMinutes(task.durationMinutes); + if (duration == null || duration.inMicroseconds <= 0) { + continue; + } + + rolledItems.add( + _PlacementItem( + task: task, + duration: duration, + earliestStart: input.window.start, + ), + ); + } + + if (rolledItems.isEmpty) { + return SchedulingResult( + tasks: input.tasks, + notices: const [ + SchedulingNotice('No unfinished flexible tasks to roll over.'), + ], + ); + } + + final placement = _planQueueAtWindowStart( + input: input, + queue: rolledItems, + excludeTaskIds: rolledItems.map((item) => item.task.id).toSet(), + ); + + if (placement == null) { + return _unchangedResult( + input, + const SchedulingNotice( + 'Unfinished flexible tasks could not fit tomorrow.', + type: SchedulingNoticeType.overflow, + ), + ); + } + + return _applyRolloverPlacement( + input: input, + rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(), + placement: placement, + updatedAt: updatedAt ?? DateTime.now(), + ); + } + /// Analyze the current in-memory schedule without moving tasks. SchedulingResult analyzeSchedule(SchedulingInput input) { final overlaps = []; @@ -708,9 +777,6 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({ required Task task, required Duration taskDuration, }) { - final fixedBlocks = [ - ...input.blockedIntervals, - ]; final queue = <_PlacementItem>[ _PlacementItem( task: task, @@ -719,8 +785,27 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({ ), ]; + return _planQueueAtWindowStart( + input: input, + queue: queue, + excludeTaskIds: {task.id}, + ); +} + +_BacklogInsertionPlan? _planQueueAtWindowStart({ + required SchedulingInput input, + required List<_PlacementItem> queue, + required Set excludeTaskIds, +}) { + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final placementQueue = <_PlacementItem>[ + ...queue, + ]; + final scheduledFlexibleTasks = input.flexibleTasks - .where((flexibleTask) => flexibleTask.id != task.id) + .where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id)) .toList(growable: false) ..sort((a, b) { final aStart = a.scheduledStart ?? input.window.end; @@ -746,7 +831,7 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({ continue; } - queue.add( + placementQueue.add( _PlacementItem( task: flexibleTask, duration: interval.duration, @@ -760,7 +845,7 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({ var cursor = input.window.start; final placements = {}; - for (final item in queue) { + for (final item in placementQueue) { final earliestStart = _laterOf(cursor, item.earliestStart); final interval = _firstOpenIntervalFrom( earliestStart: earliestStart, @@ -907,6 +992,90 @@ SchedulingResult _applyPushPlacement({ ); } +SchedulingResult _applyRolloverPlacement({ + required SchedulingInput input, + required Set rolledTaskIds, + required _BacklogInsertionPlan placement, + required DateTime updatedAt, +}) { + final changes = []; + final notices = []; + final updatedTasks = []; + var rolledCount = 0; + + 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 isRolledTask = rolledTaskIds.contains(task.id); + final updatedTask = task.copyWith( + status: isRolledTask ? TaskStatus.planned : task.status, + scheduledStart: interval.start, + scheduledEnd: interval.end, + updatedAt: updatedAt, + stats: task.stats.incrementAutoPush(), + ); + + updatedTasks.add(updatedTask); + changes.add( + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: interval.start, + nextEnd: interval.end, + ), + ); + + if (isRolledTask) { + rolledCount += 1; + } else { + notices.add( + SchedulingNotice( + 'Flexible task moved to make room.', + type: SchedulingNoticeType.moved, + taskId: task.id, + ), + ); + } + } + + notices.insert( + 0, + SchedulingNotice( + '$rolledCount unfinished flexible tasks were moved to tomorrow.', + type: SchedulingNoticeType.moved, + ), + ); + + return SchedulingResult( + tasks: List.unmodifiable(updatedTasks), + notices: List.unmodifiable(notices), + changes: List.unmodifiable(changes), + ); +} + +bool _shouldRollOver(Task task, SchedulingWindow tomorrowWindow) { + final interval = _scheduledIntervalFor(task); + final isTomorrowTask = + interval != null && interval.overlaps(tomorrowWindow.interval); + + return task.isFlexible && + !isTomorrowTask && + (task.status == TaskStatus.planned || task.status == TaskStatus.active); +} + 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 46fe06a..1e43b72 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -605,6 +605,123 @@ void main() { ]); }); + test('rollover moves unfinished flexible tasks to tomorrow 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', + status: TaskStatus.active, + scheduledStart: DateTime(2026, 6, 19, 14), + scheduledEnd: DateTime(2026, 6, 19, 14, 30), + ); + final tomorrowFlexible = flexibleTask().copyWith( + id: 'tomorrow-flexible-1', + durationMinutes: 15, + scheduledStart: DateTime(2026, 6, 20, 9), + scheduledEnd: DateTime(2026, 6, 20, 9, 15), + ); + final result = engine.rollOverUnfinishedFlexibleTasks( + input: SchedulingInput( + tasks: [first, second, tomorrowFlexible], + window: SchedulingWindow( + start: DateTime(2026, 6, 20, 9), + end: DateTime(2026, 6, 20, 10, 30), + ), + ), + updatedAt: now, + ); + final rolledFirst = taskById(result.tasks, 'flexible-1'); + final rolledSecond = taskById(result.tasks, 'flexible-2'); + final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1'); + + expect(rolledFirst.status, TaskStatus.planned); + expect(rolledFirst.scheduledStart, DateTime(2026, 6, 20, 9)); + expect(rolledFirst.scheduledEnd, DateTime(2026, 6, 20, 9, 15)); + expect(rolledSecond.status, TaskStatus.planned); + expect(rolledSecond.scheduledStart, DateTime(2026, 6, 20, 9, 15)); + expect(rolledSecond.scheduledEnd, DateTime(2026, 6, 20, 9, 45)); + expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 45)); + expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10)); + expect(result.changes.map((change) => change.taskId), [ + 'flexible-1', + 'flexible-2', + 'tomorrow-flexible-1', + ]); + expect( + result.notices.first.message, + '2 unfinished flexible tasks were moved to tomorrow.', + ); + }); + + test('rollover excludes locked required completed and cancelled tasks', () { + final flexible = flexibleTask().copyWith( + id: 'flexible-1', + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 15), + ); + final locked = flexibleTask().copyWith( + id: 'locked-1', + type: TaskType.locked, + scheduledStart: DateTime(2026, 6, 19, 14), + scheduledEnd: DateTime(2026, 6, 19, 15), + ); + final inflexible = flexibleTask().copyWith( + id: 'inflexible-1', + type: TaskType.inflexible, + scheduledStart: DateTime(2026, 6, 19, 15), + scheduledEnd: DateTime(2026, 6, 19, 15, 30), + ); + final critical = flexibleTask().copyWith( + id: 'critical-1', + type: TaskType.critical, + scheduledStart: DateTime(2026, 6, 19, 16), + scheduledEnd: DateTime(2026, 6, 19, 16, 30), + ); + final completed = flexibleTask().copyWith( + id: 'completed-1', + status: TaskStatus.completed, + scheduledStart: DateTime(2026, 6, 19, 17), + scheduledEnd: DateTime(2026, 6, 19, 17, 15), + ); + final cancelled = flexibleTask().copyWith( + id: 'cancelled-1', + status: TaskStatus.cancelled, + scheduledStart: DateTime(2026, 6, 19, 18), + scheduledEnd: DateTime(2026, 6, 19, 18, 15), + ); + final result = engine.rollOverUnfinishedFlexibleTasks( + input: SchedulingInput( + tasks: [flexible, locked, inflexible, critical, completed, cancelled], + window: SchedulingWindow( + start: DateTime(2026, 6, 20, 9), + end: DateTime(2026, 6, 20, 10), + ), + ), + updatedAt: now, + ); + + expect(taskById(result.tasks, 'flexible-1').scheduledStart, + DateTime(2026, 6, 20, 9)); + expect(taskById(result.tasks, 'locked-1').scheduledStart, + DateTime(2026, 6, 19, 14)); + expect(taskById(result.tasks, 'inflexible-1').scheduledStart, + DateTime(2026, 6, 19, 15)); + expect(taskById(result.tasks, 'critical-1').scheduledStart, + DateTime(2026, 6, 19, 16)); + expect(taskById(result.tasks, 'completed-1').scheduledStart, + DateTime(2026, 6, 19, 17)); + expect(taskById(result.tasks, 'cancelled-1').scheduledStart, + DateTime(2026, 6, 19, 18)); + expect(result.changes.map((change) => change.taskId), ['flexible-1']); + expect( + result.notices.first.message, + '1 unfinished flexible tasks were moved to tomorrow.', + ); + }); + test('moveToBacklog clears active schedule placement', () { final task = flexibleTask().copyWith( scheduledStart: DateTime(2026, 6, 19, 18),