feat(scheduling): roll over unfinished flexible tasks

This commit is contained in:
Ashley Venn 2026-06-19 16:05:24 -07:00
parent d2a8836907
commit 89267551b7
3 changed files with 306 additions and 7 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 03 — Scheduling Engine
Status: Planned
Status: Completed
Purpose: Implement the core behavior that makes this product valuable: flexible tasks move safely without destroying the user's intended order.
@ -146,6 +146,8 @@ BREAKPOINT: Stop here. The next chunk drops from high/extra high to medium.
Recommended Codex level: medium
Status: Completed
Tasks:
Implement rollover behavior:
@ -168,6 +170,17 @@ Acceptance criteria:
- Tests cover all included and excluded task types.
- Notice text/count is generated.
Execution notes:
- Added `SchedulingEngine.rollOverUnfinishedFlexibleTasks`.
- Rollover moves planned and active flexible tasks that are not already in the tomorrow window.
- Rolled tasks are placed at the top of tomorrow's flexible queue, preserving current order.
- Existing planned flexible tasks in the tomorrow window shift later while preserving order.
- Locked, inflexible, critical, completed, and cancelled tasks remain unchanged.
- Rollover returns exact `SchedulingChange` records and a count notice.
- Added tests for included flexible tasks, excluded task types/statuses, order preservation, downstream tomorrow bumps, and notice count generation.
- `dart analyze` and `dart test` passed.
Commit suggestion:
```text

View file

@ -371,6 +371,75 @@ class SchedulingEngine {
);
}
/// Move unfinished flexible tasks to the top of tomorrow's flexible queue.
///
/// The input window represents tomorrow's scheduling window. Only planned and
/// active flexible tasks are rolled; required, locked, completed, and
/// cancelled tasks remain unchanged.
SchedulingResult rollOverUnfinishedFlexibleTasks({
required SchedulingInput input,
DateTime? updatedAt,
}) {
final rolledItems = <_PlacementItem>[];
final rolloverTasks = input.flexibleTasks
.where((task) => _shouldRollOver(task, input.window))
.toList()
..sort((a, b) {
final aStart = a.scheduledStart ?? input.window.start;
final bStart = b.scheduledStart ?? input.window.start;
return aStart.compareTo(bStart);
});
for (final task in rolloverTasks) {
final duration = _scheduledIntervalFor(task)?.duration ??
_durationFromMinutes(task.durationMinutes);
if (duration == null || duration.inMicroseconds <= 0) {
continue;
}
rolledItems.add(
_PlacementItem(
task: task,
duration: duration,
earliestStart: input.window.start,
),
);
}
if (rolledItems.isEmpty) {
return SchedulingResult(
tasks: input.tasks,
notices: const [
SchedulingNotice('No unfinished flexible tasks to roll over.'),
],
);
}
final placement = _planQueueAtWindowStart(
input: input,
queue: rolledItems,
excludeTaskIds: rolledItems.map((item) => item.task.id).toSet(),
);
if (placement == null) {
return _unchangedResult(
input,
const SchedulingNotice(
'Unfinished flexible tasks could not fit tomorrow.',
type: SchedulingNoticeType.overflow,
),
);
}
return _applyRolloverPlacement(
input: input,
rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(),
placement: placement,
updatedAt: updatedAt ?? DateTime.now(),
);
}
/// Analyze the current in-memory schedule without moving tasks.
SchedulingResult analyzeSchedule(SchedulingInput input) {
final overlaps = <SchedulingOverlap>[];
@ -708,9 +777,6 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({
required Task task,
required Duration taskDuration,
}) {
final fixedBlocks = <TimeInterval>[
...input.blockedIntervals,
];
final queue = <_PlacementItem>[
_PlacementItem(
task: task,
@ -719,8 +785,27 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({
),
];
return _planQueueAtWindowStart(
input: input,
queue: queue,
excludeTaskIds: {task.id},
);
}
_BacklogInsertionPlan? _planQueueAtWindowStart({
required SchedulingInput input,
required List<_PlacementItem> queue,
required Set<String> excludeTaskIds,
}) {
final fixedBlocks = <TimeInterval>[
...input.blockedIntervals,
];
final placementQueue = <_PlacementItem>[
...queue,
];
final scheduledFlexibleTasks = input.flexibleTasks
.where((flexibleTask) => flexibleTask.id != task.id)
.where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id))
.toList(growable: false)
..sort((a, b) {
final aStart = a.scheduledStart ?? input.window.end;
@ -746,7 +831,7 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({
continue;
}
queue.add(
placementQueue.add(
_PlacementItem(
task: flexibleTask,
duration: interval.duration,
@ -760,7 +845,7 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({
var cursor = input.window.start;
final placements = <String, TimeInterval>{};
for (final item in queue) {
for (final item in placementQueue) {
final earliestStart = _laterOf(cursor, item.earliestStart);
final interval = _firstOpenIntervalFrom(
earliestStart: earliestStart,
@ -907,6 +992,90 @@ SchedulingResult _applyPushPlacement({
);
}
SchedulingResult _applyRolloverPlacement({
required SchedulingInput input,
required Set<String> rolledTaskIds,
required _BacklogInsertionPlan placement,
required DateTime updatedAt,
}) {
final changes = <SchedulingChange>[];
final notices = <SchedulingNotice>[];
final updatedTasks = <Task>[];
var rolledCount = 0;
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 isRolledTask = rolledTaskIds.contains(task.id);
final updatedTask = task.copyWith(
status: isRolledTask ? TaskStatus.planned : task.status,
scheduledStart: interval.start,
scheduledEnd: interval.end,
updatedAt: updatedAt,
stats: task.stats.incrementAutoPush(),
);
updatedTasks.add(updatedTask);
changes.add(
SchedulingChange(
taskId: task.id,
previousStart: task.scheduledStart,
previousEnd: task.scheduledEnd,
nextStart: interval.start,
nextEnd: interval.end,
),
);
if (isRolledTask) {
rolledCount += 1;
} else {
notices.add(
SchedulingNotice(
'Flexible task moved to make room.',
type: SchedulingNoticeType.moved,
taskId: task.id,
),
);
}
}
notices.insert(
0,
SchedulingNotice(
'$rolledCount unfinished flexible tasks were moved to tomorrow.',
type: SchedulingNoticeType.moved,
),
);
return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks),
notices: List<SchedulingNotice>.unmodifiable(notices),
changes: List<SchedulingChange>.unmodifiable(changes),
);
}
bool _shouldRollOver(Task task, SchedulingWindow tomorrowWindow) {
final interval = _scheduledIntervalFor(task);
final isTomorrowTask =
interval != null && interval.overlaps(tomorrowWindow.interval);
return task.isFlexible &&
!isTomorrowTask &&
(task.status == TaskStatus.planned || task.status == TaskStatus.active);
}
DateTime _laterOf(DateTime first, DateTime second) {
if (first.isAfter(second)) {
return first;

View file

@ -605,6 +605,123 @@ void main() {
]);
});
test('rollover moves unfinished flexible tasks to tomorrow 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',
status: TaskStatus.active,
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 14, 30),
);
final tomorrowFlexible = flexibleTask().copyWith(
id: 'tomorrow-flexible-1',
durationMinutes: 15,
scheduledStart: DateTime(2026, 6, 20, 9),
scheduledEnd: DateTime(2026, 6, 20, 9, 15),
);
final result = engine.rollOverUnfinishedFlexibleTasks(
input: SchedulingInput(
tasks: [first, second, tomorrowFlexible],
window: SchedulingWindow(
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 10, 30),
),
),
updatedAt: now,
);
final rolledFirst = taskById(result.tasks, 'flexible-1');
final rolledSecond = taskById(result.tasks, 'flexible-2');
final bumpedTomorrow = taskById(result.tasks, 'tomorrow-flexible-1');
expect(rolledFirst.status, TaskStatus.planned);
expect(rolledFirst.scheduledStart, DateTime(2026, 6, 20, 9));
expect(rolledFirst.scheduledEnd, DateTime(2026, 6, 20, 9, 15));
expect(rolledSecond.status, TaskStatus.planned);
expect(rolledSecond.scheduledStart, DateTime(2026, 6, 20, 9, 15));
expect(rolledSecond.scheduledEnd, DateTime(2026, 6, 20, 9, 45));
expect(bumpedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 45));
expect(bumpedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 10));
expect(result.changes.map((change) => change.taskId), [
'flexible-1',
'flexible-2',
'tomorrow-flexible-1',
]);
expect(
result.notices.first.message,
'2 unfinished flexible tasks were moved to tomorrow.',
);
});
test('rollover excludes locked required completed and cancelled tasks', () {
final flexible = flexibleTask().copyWith(
id: 'flexible-1',
scheduledStart: DateTime(2026, 6, 19, 13),
scheduledEnd: DateTime(2026, 6, 19, 13, 15),
);
final locked = flexibleTask().copyWith(
id: 'locked-1',
type: TaskType.locked,
scheduledStart: DateTime(2026, 6, 19, 14),
scheduledEnd: DateTime(2026, 6, 19, 15),
);
final inflexible = flexibleTask().copyWith(
id: 'inflexible-1',
type: TaskType.inflexible,
scheduledStart: DateTime(2026, 6, 19, 15),
scheduledEnd: DateTime(2026, 6, 19, 15, 30),
);
final critical = flexibleTask().copyWith(
id: 'critical-1',
type: TaskType.critical,
scheduledStart: DateTime(2026, 6, 19, 16),
scheduledEnd: DateTime(2026, 6, 19, 16, 30),
);
final completed = flexibleTask().copyWith(
id: 'completed-1',
status: TaskStatus.completed,
scheduledStart: DateTime(2026, 6, 19, 17),
scheduledEnd: DateTime(2026, 6, 19, 17, 15),
);
final cancelled = flexibleTask().copyWith(
id: 'cancelled-1',
status: TaskStatus.cancelled,
scheduledStart: DateTime(2026, 6, 19, 18),
scheduledEnd: DateTime(2026, 6, 19, 18, 15),
);
final result = engine.rollOverUnfinishedFlexibleTasks(
input: SchedulingInput(
tasks: [flexible, locked, inflexible, critical, completed, cancelled],
window: SchedulingWindow(
start: DateTime(2026, 6, 20, 9),
end: DateTime(2026, 6, 20, 10),
),
),
updatedAt: now,
);
expect(taskById(result.tasks, 'flexible-1').scheduledStart,
DateTime(2026, 6, 20, 9));
expect(taskById(result.tasks, 'locked-1').scheduledStart,
DateTime(2026, 6, 19, 14));
expect(taskById(result.tasks, 'inflexible-1').scheduledStart,
DateTime(2026, 6, 19, 15));
expect(taskById(result.tasks, 'critical-1').scheduledStart,
DateTime(2026, 6, 19, 16));
expect(taskById(result.tasks, 'completed-1').scheduledStart,
DateTime(2026, 6, 19, 17));
expect(taskById(result.tasks, 'cancelled-1').scheduledStart,
DateTime(2026, 6, 19, 18));
expect(result.changes.map((change) => change.taskId), ['flexible-1']);
expect(
result.notices.first.message,
'1 unfinished flexible tasks were moved to tomorrow.',
);
});
test('moveToBacklog clears active schedule placement', () {
final task = flexibleTask().copyWith(
scheduledStart: DateTime(2026, 6, 19, 18),