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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue