feat(scheduling): push flexible tasks to next slot

This commit is contained in:
Ashley Venn 2026-06-19 15:59:04 -07:00
parent 5b20e46987
commit ae346c4ad0
3 changed files with 382 additions and 4 deletions

View file

@ -39,8 +39,8 @@ Execution notes:
- Expanded `SchedulingResult` and `SchedulingNotice` so operation results can report changed task placements, overlap details, and typed notices. - 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 `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. - 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. - Initial Dart verification was blocked while the SDK was unavailable; after repair, `dart analyze` and `dart test` passed.
- Commit was deferred until the repository was initialized and repaired. - Committed after the repository was initialized and repaired.
## Chunk 3.2 — Insert backlog task into next available flexible slot ## 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. - 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. - 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. - 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. - Initial Dart verification was blocked while the SDK was unavailable; after repair, `dart analyze` and `dart test` passed.
- Commit was deferred until the repository was initialized and repaired. - Committed after the repository was initialized and repaired.
BREAKPOINT: Stop here. Confirm `extra high` mode before starting this chunk if not already set. 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 Recommended Codex level: extra high
Status: Completed
Tasks: Tasks:
Implement push behavior: Implement push behavior:
@ -99,6 +101,16 @@ Acceptance criteria:
- Test: locked intervals are respected. - Test: locked intervals are respected.
- Test: required items remain fixed and visible. - 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 ## Chunk 3.4 — Push flexible task to tomorrow top of queue
Recommended Codex level: high Recommended Codex level: high

View file

@ -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. /// Analyze the current in-memory schedule without moving tasks.
SchedulingResult analyzeSchedule(SchedulingInput input) { SchedulingResult analyzeSchedule(SchedulingInput input) {
final overlaps = <SchedulingOverlap>[]; final overlaps = <SchedulingOverlap>[];
@ -471,6 +551,86 @@ _BacklogInsertionPlan? _planBacklogInsertion({
return _BacklogInsertionPlan(placements: placements); 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({ SchedulingResult _applyPlacement({
required SchedulingInput input, required SchedulingInput input,
required Task insertedTask, 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) { DateTime _laterOf(DateTime first, DateTime second) {
if (first.isAfter(second)) { if (first.isAfter(second)) {
return first; return first;

View file

@ -373,6 +373,149 @@ void main() {
expect(backlogTask.status, TaskStatus.backlog); 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', () { 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),