feat(scheduling): push flexible tasks to next slot
This commit is contained in:
parent
5b20e46987
commit
ae346c4ad0
3 changed files with 382 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = <SchedulingOverlap>[];
|
||||
|
|
@ -471,6 +551,86 @@ _BacklogInsertionPlan? _planBacklogInsertion({
|
|||
return _BacklogInsertionPlan(placements: placements);
|
||||
}
|
||||
|
||||
_BacklogInsertionPlan? _planFlexiblePush({
|
||||
required SchedulingInput input,
|
||||
required Task task,
|
||||
required TimeInterval currentInterval,
|
||||
}) {
|
||||
final fixedBlocks = <TimeInterval>[
|
||||
...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 = <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,
|
||||
|
|
@ -536,6 +696,69 @@ SchedulingResult _applyPlacement({
|
|||
);
|
||||
}
|
||||
|
||||
SchedulingResult _applyPushPlacement({
|
||||
required SchedulingInput input,
|
||||
required Task pushedTask,
|
||||
required _BacklogInsertionPlan placement,
|
||||
required DateTime updatedAt,
|
||||
}) {
|
||||
final changes = <SchedulingChange>[];
|
||||
final notices = <SchedulingNotice>[];
|
||||
final updatedTasks = <Task>[];
|
||||
|
||||
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<Task>.unmodifiable(updatedTasks),
|
||||
notices: List<SchedulingNotice>.unmodifiable(notices),
|
||||
changes: List<SchedulingChange>.unmodifiable(changes),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime _laterOf(DateTime first, DateTime second) {
|
||||
if (first.isAfter(second)) {
|
||||
return first;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue