focus-flow/lib/src/scheduling_engine.dart

835 lines
21 KiB
Dart

import 'models.dart';
/// Category for scheduler notices.
enum SchedulingNoticeType {
/// General informational notice.
info,
/// A task was moved by a scheduling operation.
moved,
/// A scheduled task overlaps blocked time.
overlap,
/// A task could not fit in the requested window.
noFit,
/// A task would need to move outside the requested window.
overflow,
}
/// Window of time available to a scheduling operation.
class SchedulingWindow {
const SchedulingWindow({
required this.start,
required this.end,
});
final DateTime start;
final DateTime end;
TimeInterval get interval => TimeInterval(start: start, end: end);
bool contains(TimeInterval interval) {
final startsInWindow =
interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start);
final endsInWindow =
interval.end.isBefore(end) || interval.end.isAtSameMomentAs(end);
return startsInWindow && endsInWindow;
}
}
/// In-memory input for scheduling operations.
class SchedulingInput {
const SchedulingInput({
required this.tasks,
required this.window,
this.lockedIntervals = const <TimeInterval>[],
this.requiredVisibleIntervals = const <TimeInterval>[],
});
final List<Task> tasks;
final SchedulingWindow window;
final List<TimeInterval> lockedIntervals;
final List<TimeInterval> requiredVisibleIntervals;
List<Task> get flexibleTasks {
return tasks.where((task) => task.isFlexible).toList(growable: false);
}
List<Task> get lockedTasks {
return tasks.where((task) => task.isLocked).toList(growable: false);
}
List<Task> get requiredVisibleTasks {
return tasks
.where((task) => task.isRequiredVisible)
.toList(growable: false);
}
List<TimeInterval> get flexibleIntervals {
return _scheduledIntervalsFor(flexibleTasks);
}
List<TimeInterval> get blockedIntervals {
final intervals = <TimeInterval>[
...lockedIntervals,
..._scheduledIntervalsFor(lockedTasks),
...requiredVisibleIntervals,
..._scheduledIntervalsFor(requiredVisibleTasks),
]..sort((a, b) => a.start.compareTo(b.start));
return List<TimeInterval>.unmodifiable(intervals);
}
}
/// Exact placement change made by a scheduling operation.
class SchedulingChange {
const SchedulingChange({
required this.taskId,
required this.previousStart,
required this.previousEnd,
required this.nextStart,
required this.nextEnd,
});
final String taskId;
final DateTime? previousStart;
final DateTime? previousEnd;
final DateTime? nextStart;
final DateTime? nextEnd;
}
/// Overlap between a scheduled task and blocked time.
class SchedulingOverlap {
const SchedulingOverlap({
required this.taskId,
required this.taskInterval,
required this.blockedInterval,
});
final String taskId;
final TimeInterval taskInterval;
final TimeInterval blockedInterval;
}
/// Starter notice type returned by scheduling operations.
class SchedulingNotice {
const SchedulingNotice(
this.message, {
this.type = SchedulingNoticeType.info,
this.taskId,
});
final String message;
final SchedulingNoticeType type;
final String? taskId;
}
/// Starter result wrapper for scheduling operations.
class SchedulingResult {
const SchedulingResult({
required this.tasks,
this.notices = const <SchedulingNotice>[],
this.changes = const <SchedulingChange>[],
this.overlaps = const <SchedulingOverlap>[],
});
final List<Task> tasks;
final List<SchedulingNotice> notices;
final List<SchedulingChange> changes;
final List<SchedulingOverlap> overlaps;
}
/// Starter scheduling engine.
///
/// This is intentionally small. Codex should expand this according to the V1
/// plan documents and add tests for every scheduling rule.
class SchedulingEngine {
const SchedulingEngine();
/// Insert a backlog task into the earliest available slot today.
///
/// Locked, inflexible, and critical time is treated as fixed. Planned
/// flexible tasks at or after the insertion point may shift later, preserving
/// their relative order.
SchedulingResult insertBacklogTaskIntoNextAvailableSlot({
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.isBacklog) {
return _unchangedResult(
input,
SchedulingNotice(
'Only backlog flexible tasks can be inserted.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
),
);
}
final taskDuration = _durationFromMinutes(task.durationMinutes);
if (taskDuration == null) {
return _unchangedResult(
input,
SchedulingNotice(
'Task needs a positive duration before scheduling.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
),
);
}
final placement = _planBacklogInsertion(
input: input,
task: task,
taskDuration: taskDuration,
);
if (placement == null) {
return _unchangedResult(
input,
SchedulingNotice(
'No available flexible slot today.',
type: SchedulingNoticeType.overflow,
taskId: task.id,
),
);
}
return _applyPlacement(
input: input,
insertedTask: task,
placement: placement,
updatedAt: updatedAt ?? DateTime.now(),
);
}
/// 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>[];
final notices = <SchedulingNotice>[];
final blockedIntervals = input.blockedIntervals;
for (final task in input.flexibleTasks) {
final taskInterval = _scheduledIntervalFor(task);
if (taskInterval == null || !input.window.contains(taskInterval)) {
continue;
}
for (final blockedInterval in blockedIntervals) {
if (!taskInterval.overlaps(blockedInterval)) {
continue;
}
overlaps.add(
SchedulingOverlap(
taskId: task.id,
taskInterval: taskInterval,
blockedInterval: blockedInterval,
),
);
notices.add(
SchedulingNotice(
'Flexible task overlaps blocked time.',
type: SchedulingNoticeType.overlap,
taskId: task.id,
),
);
}
}
return SchedulingResult(
tasks: input.tasks,
notices: List<SchedulingNotice>.unmodifiable(notices),
overlaps: List<SchedulingOverlap>.unmodifiable(overlaps),
);
}
/// Move a task to backlog.
///
/// Backlog does not preserve original schedule/order placement.
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
return task.copyWith(
status: TaskStatus.backlog,
updatedAt: updatedAt ?? DateTime.now(),
stats: task.stats.incrementMovedToBacklog(),
clearSchedule: true,
);
}
/// Mark a flexible task pushed manually.
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
return task.copyWith(
updatedAt: updatedAt ?? DateTime.now(),
stats: task.stats.incrementManualPush(),
);
}
/// Mark missed according to the current MVP rules.
///
/// Critical missed tasks go to backlog. Inflexible missed tasks stay in place.
Task markMissed(Task task, {DateTime? updatedAt}) {
final nextStats = task.stats.incrementMissed();
final now = updatedAt ?? DateTime.now();
if (task.type == TaskType.critical) {
return task.copyWith(
status: TaskStatus.backlog,
updatedAt: now,
stats: nextStats.incrementMovedToBacklog(),
clearSchedule: true,
);
}
return task.copyWith(
status: TaskStatus.missed,
updatedAt: now,
stats: nextStats,
);
}
/// Finds the first interval that can fit the requested duration while avoiding
/// blocked intervals.
///
/// This helper is deliberately simple. Full flexible bump behavior belongs in
/// later plan chunks.
TimeInterval? findFirstOpenInterval({
required DateTime windowStart,
required DateTime windowEnd,
required Duration duration,
required List<TimeInterval> blocked,
}) {
final sortedBlocked = [...blocked]
..sort((a, b) => a.start.compareTo(b.start));
var cursor = windowStart;
for (final interval in sortedBlocked) {
if (cursor.add(duration).isBefore(interval.start) ||
cursor.add(duration).isAtSameMomentAs(interval.start)) {
return TimeInterval(start: cursor, end: cursor.add(duration));
}
if (interval.end.isAfter(cursor)) {
cursor = interval.end;
}
}
final candidateEnd = cursor.add(duration);
if (candidateEnd.isBefore(windowEnd) ||
candidateEnd.isAtSameMomentAs(windowEnd)) {
return TimeInterval(start: cursor, end: candidateEnd);
}
return null;
}
}
TimeInterval? _scheduledIntervalFor(Task task) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return null;
}
return TimeInterval(start: start, end: end, label: task.id);
}
List<TimeInterval> _scheduledIntervalsFor(Iterable<Task> tasks) {
final intervals = <TimeInterval>[];
for (final task in tasks) {
final interval = _scheduledIntervalFor(task);
if (interval != null) {
intervals.add(interval);
}
}
return List<TimeInterval>.unmodifiable(intervals);
}
SchedulingResult _unchangedResult(
SchedulingInput input,
SchedulingNotice notice,
) {
return SchedulingResult(
tasks: input.tasks,
notices: [notice],
);
}
Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}
Duration? _durationFromMinutes(int? minutes) {
if (minutes == null || minutes <= 0) {
return null;
}
return Duration(minutes: minutes);
}
_BacklogInsertionPlan? _planBacklogInsertion({
required SchedulingInput input,
required Task task,
required Duration taskDuration,
}) {
final fixedBlocks = <TimeInterval>[
...input.blockedIntervals,
];
final queue = <_PlacementItem>[
_PlacementItem(
task: task,
duration: taskDuration,
earliestStart: input.window.start,
),
];
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 startsBeforeWindow = interval.start.isBefore(input.window.start);
final startsAfterWindow = interval.start.isAfter(input.window.end) ||
interval.start.isAtSameMomentAs(input.window.end);
if (startsBeforeWindow || 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 = input.window.start;
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);
}
_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,
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 isInsertedTask = task.id == insertedTask.id;
final moved = isInsertedTask ||
!_sameDateTime(task.scheduledStart, interval.start) ||
!_sameDateTime(task.scheduledEnd, interval.end);
if (!moved) {
updatedTasks.add(task);
continue;
}
final updatedTask = task.copyWith(
status: isInsertedTask ? TaskStatus.planned : task.status,
scheduledStart: interval.start,
scheduledEnd: interval.end,
updatedAt: updatedAt,
stats: isInsertedTask
? task.stats.incrementRestoredFromBacklog()
: 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(
isInsertedTask
? 'Backlog task inserted into schedule.'
: '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),
);
}
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;
}
return second;
}
bool _sameDateTime(DateTime? first, DateTime? second) {
if (first == null || second == null) {
return first == null && second == null;
}
return first.isAtSameMomentAs(second);
}
TimeInterval? _firstOpenIntervalFrom({
required DateTime earliestStart,
required DateTime windowEnd,
required Duration duration,
required List<TimeInterval> blocked,
}) {
var cursor = earliestStart;
while (true) {
final candidateEnd = cursor.add(duration);
if (candidateEnd.isAfter(windowEnd)) {
return null;
}
final candidate = TimeInterval(start: cursor, end: candidateEnd);
TimeInterval? overlappingBlock;
for (final block in blocked) {
if (block.end.isBefore(cursor) || block.end.isAtSameMomentAs(cursor)) {
continue;
}
if (block.start.isAfter(candidate.end) ||
block.start.isAtSameMomentAs(candidate.end)) {
break;
}
if (candidate.overlaps(block)) {
overlappingBlock = block;
break;
}
}
if (overlappingBlock == null) {
return candidate;
}
cursor = overlappingBlock.end;
}
}
class _PlacementItem {
const _PlacementItem({
required this.task,
required this.duration,
required this.earliestStart,
});
final Task task;
final Duration duration;
final DateTime earliestStart;
}
class _BacklogInsertionPlan {
const _BacklogInsertionPlan({required this.placements});
final Map<String, TimeInterval> placements;
}