feat(scheduling): push flexible tasks to tomorrow queue
This commit is contained in:
parent
ae346c4ad0
commit
d2a8836907
3 changed files with 252 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = <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({
|
||||
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 = <SchedulingChange>[];
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue