feat(scheduling): push flexible tasks to tomorrow queue

This commit is contained in:
Ashley Venn 2026-06-19 16:02:25 -07:00
parent ae346c4ad0
commit d2a8836907
3 changed files with 252 additions and 3 deletions

View file

@ -115,6 +115,8 @@ Execution notes:
Recommended Codex level: high Recommended Codex level: high
Status: Completed
Tasks: Tasks:
Implement tomorrow behavior: Implement tomorrow behavior:
@ -128,6 +130,16 @@ Acceptance criteria:
- Test: task receives tomorrow date/queue marker. - Test: task receives tomorrow date/queue marker.
- Test: task is before other flexible tomorrow tasks unless required/locked time blocks it. - 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. BREAKPOINT: Stop here. The next chunk drops from high/extra high to medium.
## Chunk 3.5 — End-of-day rollover ## Chunk 3.5 — End-of-day rollover

View file

@ -295,6 +295,78 @@ class SchedulingEngine {
input: input, input: input,
pushedTask: task, pushedTask: task,
placement: placement, 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(), updatedAt: updatedAt ?? DateTime.now(),
); );
} }
@ -631,6 +703,83 @@ _BacklogInsertionPlan? _planFlexiblePush({
return _BacklogInsertionPlan(placements: placements); return _BacklogInsertionPlan(placements: placements);
} }
_BacklogInsertionPlan? _planTomorrowQueueInsertion({
required SchedulingInput input,
required Task task,
required Duration taskDuration,
}) {
final fixedBlocks = <TimeInterval>[
...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 = <String, TimeInterval>{};
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({ SchedulingResult _applyPlacement({
required SchedulingInput input, required SchedulingInput input,
required Task insertedTask, required Task insertedTask,
@ -700,6 +849,7 @@ SchedulingResult _applyPushPlacement({
required SchedulingInput input, required SchedulingInput input,
required Task pushedTask, required Task pushedTask,
required _BacklogInsertionPlan placement, required _BacklogInsertionPlan placement,
required String pushedTaskMessage,
required DateTime updatedAt, required DateTime updatedAt,
}) { }) {
final changes = <SchedulingChange>[]; final changes = <SchedulingChange>[];
@ -743,9 +893,7 @@ SchedulingResult _applyPushPlacement({
); );
notices.add( notices.add(
SchedulingNotice( SchedulingNotice(
isPushedTask isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.',
? 'Flexible task pushed to next available slot.'
: 'Flexible task moved to make room.',
type: SchedulingNoticeType.moved, type: SchedulingNoticeType.moved,
taskId: task.id, taskId: task.id,
), ),

View file

@ -516,6 +516,95 @@ void main() {
expect(result.changes.map((change) => change.taskId), ['task-1']); 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', () { test('moveToBacklog clears active schedule placement', () {
final task = flexibleTask().copyWith( final task = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 18), scheduledStart: DateTime(2026, 6, 19, 18),