forked from eva/focus-flow
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
|
||||
|
||||
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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue