feat(scheduling): roll over unfinished flexible tasks
This commit is contained in:
parent
d2a8836907
commit
89267551b7
3 changed files with 306 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# V1 Block 03 — Scheduling Engine
|
# 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.
|
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
|
Recommended Codex level: medium
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Implement rollover behavior:
|
Implement rollover behavior:
|
||||||
|
|
@ -168,6 +170,17 @@ Acceptance criteria:
|
||||||
- Tests cover all included and excluded task types.
|
- Tests cover all included and excluded task types.
|
||||||
- Notice text/count is generated.
|
- 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:
|
Commit suggestion:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -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.
|
/// Analyze the current in-memory schedule without moving tasks.
|
||||||
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
||||||
final overlaps = <SchedulingOverlap>[];
|
final overlaps = <SchedulingOverlap>[];
|
||||||
|
|
@ -708,9 +777,6 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({
|
||||||
required Task task,
|
required Task task,
|
||||||
required Duration taskDuration,
|
required Duration taskDuration,
|
||||||
}) {
|
}) {
|
||||||
final fixedBlocks = <TimeInterval>[
|
|
||||||
...input.blockedIntervals,
|
|
||||||
];
|
|
||||||
final queue = <_PlacementItem>[
|
final queue = <_PlacementItem>[
|
||||||
_PlacementItem(
|
_PlacementItem(
|
||||||
task: task,
|
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
|
final scheduledFlexibleTasks = input.flexibleTasks
|
||||||
.where((flexibleTask) => flexibleTask.id != task.id)
|
.where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id))
|
||||||
.toList(growable: false)
|
.toList(growable: false)
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
final aStart = a.scheduledStart ?? input.window.end;
|
final aStart = a.scheduledStart ?? input.window.end;
|
||||||
|
|
@ -746,7 +831,7 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.add(
|
placementQueue.add(
|
||||||
_PlacementItem(
|
_PlacementItem(
|
||||||
task: flexibleTask,
|
task: flexibleTask,
|
||||||
duration: interval.duration,
|
duration: interval.duration,
|
||||||
|
|
@ -760,7 +845,7 @@ _BacklogInsertionPlan? _planTomorrowQueueInsertion({
|
||||||
var cursor = input.window.start;
|
var cursor = input.window.start;
|
||||||
final placements = <String, TimeInterval>{};
|
final placements = <String, TimeInterval>{};
|
||||||
|
|
||||||
for (final item in queue) {
|
for (final item in placementQueue) {
|
||||||
final earliestStart = _laterOf(cursor, item.earliestStart);
|
final earliestStart = _laterOf(cursor, item.earliestStart);
|
||||||
final interval = _firstOpenIntervalFrom(
|
final interval = _firstOpenIntervalFrom(
|
||||||
earliestStart: earliestStart,
|
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) {
|
DateTime _laterOf(DateTime first, DateTime second) {
|
||||||
if (first.isAfter(second)) {
|
if (first.isAfter(second)) {
|
||||||
return first;
|
return first;
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
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