focus-flow/lib/src/scheduling_engine.dart

1399 lines
42 KiB
Dart

// Scheduling engine for the ADHD scheduling starter project.
//
// This file is the core timeline manipulation layer. It takes task data plus a
// planning window and returns a new task list, notices, changes, and analysis
// findings. The implementation is deliberately side-effect free so a first-time
// reader can trace each operation from input validation, to placement planning,
// to application of the plan.
//
// Human reading map:
// 1. Data wrappers: `SchedulingWindow`, `SchedulingInput`, result classes.
// 2. Public engine methods: the operations UI/actions can call.
// 3. Private planning helpers: calculate intervals without changing tasks.
// 4. Private apply helpers: convert plans into updated tasks and notices.
import 'models.dart';
/// Category for scheduler notices.
///
/// Notices are human-readable summaries attached to a [SchedulingResult]. They
/// are not exceptions. The scheduler returns them alongside the task list so UI
/// can explain what happened without losing the successfully computed output.
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.
///
/// Most engine methods operate on one planning window: "today", "tomorrow",
/// or any other bounded range supplied by the caller. The window constrains where
/// flexible tasks can be placed. Anything outside this range is treated as out of
/// scope for the operation.
class SchedulingWindow {
const SchedulingWindow({
required this.start,
required this.end,
});
/// Inclusive beginning of the scheduling range.
final DateTime start;
/// Exclusive ending of the scheduling range.
final DateTime end;
/// The window as a [TimeInterval], useful for overlap checks.
TimeInterval get interval => TimeInterval(start: start, end: end);
/// Whether [interval] is completely inside this window.
///
/// A task that starts before the window or ends after the window is considered
/// outside the operation. The engine may treat such tasks as fixed blocks
/// instead of moving them.
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.
///
/// This is the complete snapshot the pure scheduling engine needs. It contains
/// tasks plus the fixed intervals the scheduler must avoid. It deliberately does
/// not know where the data came from: UI state, a database, tests, or generated
/// examples can all build this same object.
class SchedulingInput {
const SchedulingInput({
required this.tasks,
required this.window,
this.lockedIntervals = const <TimeInterval>[],
this.requiredVisibleIntervals = const <TimeInterval>[],
});
/// All tasks available to this operation. The scheduler returns a replacement
/// list rather than mutating this one.
final List<Task> tasks;
/// Date/time range that the operation is allowed to plan inside.
final SchedulingWindow window;
/// External locked time intervals, usually produced by `locked_time.dart`.
final List<TimeInterval> lockedIntervals;
/// Extra fixed visible intervals supplied by the caller. This lets UI/backend
/// code reserve required time even when that time is not represented as a
/// [Task] in the current list.
final List<TimeInterval> requiredVisibleIntervals;
/// Tasks that the flexible movement algorithms are allowed to consider.
List<Task> get flexibleTasks {
return tasks.where((task) => task.isFlexible).toList(growable: false);
}
/// Locked task records in [tasks], if the caller represents locked time as
/// tasks instead of only passing [lockedIntervals].
List<Task> get lockedTasks {
return tasks.where((task) => task.isLocked).toList(growable: false);
}
/// Critical and inflexible task records that should block flexible placement.
List<Task> get requiredVisibleTasks {
return tasks
.where((task) => task.isRequiredVisible)
.toList(growable: false);
}
/// Scheduled intervals for flexible tasks only. Useful for analysis/debugging.
List<TimeInterval> get flexibleIntervals {
return _scheduledIntervalsFor(flexibleTasks);
}
/// All intervals that flexible scheduling must avoid.
///
/// This combines explicit locked intervals, locked task records, caller-supplied
/// required-visible intervals, and scheduled critical/inflexible tasks. The
/// result is sorted to make interval scanning deterministic.
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.
///
/// Changes are machine-readable before/after records. UI can use notices for
/// display text, but persistence, undo, analytics, or tests should inspect these
/// fields to know exactly which task moved and where.
class SchedulingChange {
const SchedulingChange({
required this.taskId,
required this.previousStart,
required this.previousEnd,
required this.nextStart,
required this.nextEnd,
});
/// Task that moved or had its schedule cleared.
final String taskId;
/// Previous scheduled start, or null if the task was previously unplaced.
final DateTime? previousStart;
/// Previous scheduled end, or null if the task was previously unplaced.
final DateTime? previousEnd;
/// New scheduled start, or null if the task was moved out of the timeline.
final DateTime? nextStart;
/// New scheduled end, or null if the task was moved out of the timeline.
final DateTime? nextEnd;
}
/// Overlap between a scheduled task and blocked time.
///
/// Analysis uses this to report problems without moving anything. This is useful
/// when loading persisted data, debugging imports, or validating a day before the
/// UI presents it as clean.
class SchedulingOverlap {
const SchedulingOverlap({
required this.taskId,
required this.taskInterval,
required this.blockedInterval,
});
/// Flexible task that overlaps blocked time.
final String taskId;
/// The task's scheduled interval.
final TimeInterval taskInterval;
/// The blocked interval it overlaps.
final TimeInterval blockedInterval;
}
/// Starter notice type returned by scheduling operations.
///
/// A notice is presentation-friendly context about an operation. It intentionally
/// carries both text and a structured [type] so the UI can decide whether to show
/// it as neutral info, movement, overlap, or failure.
class SchedulingNotice {
const SchedulingNotice(
this.message, {
this.type = SchedulingNoticeType.info,
this.taskId,
});
/// Human-readable message safe to surface in UI or logs.
final String message;
/// Structured category for UI styling and tests.
final SchedulingNoticeType type;
/// Optional task related to this notice. Null means the notice applies to the
/// whole operation.
final String? taskId;
}
/// Starter result wrapper for scheduling operations.
///
/// Every engine operation returns a [SchedulingResult], even when nothing moved.
/// This keeps the call pattern predictable: always inspect `tasks`, then surface
/// any `notices`, `changes`, or `overlaps` relevant to the UI.
class SchedulingResult {
const SchedulingResult({
required this.tasks,
this.notices = const <SchedulingNotice>[],
this.changes = const <SchedulingChange>[],
this.overlaps = const <SchedulingOverlap>[],
});
/// Replacement task list after the operation.
final List<Task> tasks;
/// Human-readable operation messages.
final List<SchedulingNotice> notices;
/// Machine-readable movements or schedule clears.
final List<SchedulingChange> changes;
/// Analysis-only overlap findings.
final List<SchedulingOverlap> overlaps;
}
/// Starter scheduling engine.
///
/// The engine is a pure domain service: it receives immutable-ish input values
/// and returns new values. It does not persist data, render UI, send reminders,
/// or read the clock except where an optional [updatedAt] timestamp is omitted.
///
/// Current V1 responsibilities:
/// - insert backlog tasks into the earliest available flexible slot;
/// - push flexible tasks later today;
/// - move flexible tasks to tomorrow's queue;
/// - roll unfinished flexible tasks into a new planning window;
/// - analyze overlaps against locked/required time;
/// - perform small state transitions such as missed/backlog handling.
///
/// Important rule vocabulary:
/// - `fixedBlocks` are intervals the engine will not move.
/// - `queue` is the ordered set of flexible tasks that may be placed or shifted.
/// - `placement` is a map from task id to the interval chosen by the planner.
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.
///
/// The selected task must already exist in [input.tasks], be flexible, be in
/// backlog status, and have a positive duration. If any precondition fails, the
/// original task list is returned with a no-fit/overflow notice.
SchedulingResult insertBacklogTaskIntoNextAvailableSlot({
required SchedulingInput input,
required String taskId,
DateTime? updatedAt,
}) {
// Step 1: resolve and validate the task. The engine does not create tasks;
// quick capture or persistence code is responsible for adding it to the list.
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,
),
);
}
// Step 2: compute placements without mutating any task. Planning returns null
// if the inserted task and shifted queue cannot fit inside the window.
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.
///
/// This is the "not now, later today" action. Anything before the pushed
/// task's current end time becomes fixed for this operation, while later
/// planned flexible tasks may be shifted if necessary.
SchedulingResult pushFlexibleTaskToNextAvailableSlot({
required SchedulingInput input,
required String taskId,
DateTime? updatedAt,
bool countAsManualPush = true,
}) {
// Resolve the selected task by id so UI code only needs to pass a stable
// identifier, not object references.
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,
pushedTaskMessage: 'Flexible task pushed to next available slot.',
updatedAt: updatedAt ?? DateTime.now(),
countPushedTaskAsManual: countAsManualPush,
);
}
/// Move a planned flexible task to the top of tomorrow's flexible queue.
///
/// The input window represents tomorrow's scheduling window. Existing planned
/// flexible tasks in that window may shift later, preserving their order.
///
/// The method name says "tomorrow" because that is the product action, but the
/// engine only trusts [input.window]. Tests can pass any future window.
SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({
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 moved to tomorrow.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
),
);
}
final taskDuration = _scheduledIntervalFor(task)?.duration ??
_durationFromMinutes(task.durationMinutes);
if (taskDuration == null || taskDuration.inMicroseconds <= 0) {
return _unchangedResult(
input,
SchedulingNotice(
'Task needs a positive duration before moving to tomorrow.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
),
);
}
final placement = _planTomorrowQueueInsertion(
input: input,
task: task,
taskDuration: taskDuration,
);
if (placement == null) {
return _unchangedResult(
input,
SchedulingNotice(
'No available flexible slot tomorrow.',
type: SchedulingNoticeType.overflow,
taskId: task.id,
),
);
}
return _applyPushPlacement(
input: input,
pushedTask: task,
placement: placement,
pushedTaskMessage: 'Flexible task moved to tomorrow.',
updatedAt: updatedAt ?? DateTime.now(),
);
}
/// 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.
///
/// Rollover is bulk push behavior for day-end recovery. It collects unfinished
/// flexible tasks outside the target window, preserves their relative order,
/// then places them at the start of the new window while shifting already
/// planned flexible tasks as needed. When [sourceWindow] is supplied, only
/// unfinished flexible tasks from that source window are rolled over. This
/// prevents future planned tasks from being pulled into tomorrow accidentally.
SchedulingResult rollOverUnfinishedFlexibleTasks({
required SchedulingInput input,
SchedulingWindow? sourceWindow,
DateTime? updatedAt,
}) {
// Build the explicit queue of tasks to roll before asking the planner to
// place anything. This keeps selection separate from placement.
final rolledItems = <_PlacementItem>[];
final rolloverTasks = input.flexibleTasks
.where((task) => _shouldRollOver(
task: task,
targetWindow: input.window,
sourceWindow: sourceWindow,
))
.toList()
..sort((a, b) {
final aStart =
a.scheduledStart ?? sourceWindow?.start ?? input.window.start;
final bStart =
b.scheduledStart ?? sourceWindow?.start ?? 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.
///
/// This is a validation/debugging helper. It scans scheduled flexible tasks and
/// reports any overlap with blocked intervals. It deliberately returns the
/// original task list unchanged.
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. The task's
/// schedule is cleared and its moved-to-backlog counter is incremented so
/// reports can distinguish this from a task that was never scheduled.
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.
///
/// This updates statistics only. Use the push methods above when the task's
/// actual scheduled slot should change.
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 so they remain actionable. Inflexible
/// missed tasks stay in place as missed because they represented a fixed event
/// or time block that cannot simply be rescheduled automatically.
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 public helper is deliberately simple and does not shift existing
/// flexible tasks. It is useful for UI previews or tests that only need to know
/// the first open gap. Full bump/queue behavior lives in the private planning
/// helpers below.
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;
}
}
/// Convert a scheduled task into an interval, or null if it is unplaced.
///
/// This helper does not validate positive duration; callers that require a valid
/// duration check that separately.
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);
}
/// Convert all placed tasks in [tasks] into intervals.
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);
}
/// Return the original task list with one explanatory notice.
///
/// Most validation failures use this so callers can keep rendering the existing
/// schedule while showing why the requested action did not apply.
SchedulingResult _unchangedResult(
SchedulingInput input,
SchedulingNotice notice,
) {
return SchedulingResult(
tasks: input.tasks,
notices: [notice],
);
}
/// Find a task by stable id.
Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}
/// Convert a nullable minute estimate into a positive [Duration].
Duration? _durationFromMinutes(int? minutes) {
if (minutes == null || minutes <= 0) {
return null;
}
return Duration(minutes: minutes);
}
/// Plan insertion of a backlog task plus any flexible tasks that must shift.
///
/// This function only calculates intervals. It does not update task objects. The
/// returned plan is later applied by [_applyPlacement], which creates notices,
/// changes, and updated task copies.
_BacklogInsertionPlan? _planBacklogInsertion({
required SchedulingInput input,
required Task task,
required Duration taskDuration,
}) {
// Start with intervals that the algorithm is not allowed to move.
final fixedBlocks = <TimeInterval>[
...input.blockedIntervals,
];
final queue = <_PlacementItem>[
_PlacementItem(
task: task,
duration: taskDuration,
earliestStart: input.window.start,
),
];
// Existing flexible tasks are inspected in timeline order. Planned tasks inside
// the movable portion become part of the placement queue; everything else is
// treated as fixed.
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));
// The cursor tracks the earliest point after the previously placed queue item.
// Each queued task is placed no earlier than both the cursor and its own
// original earliest 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);
}
/// Plan the "push later today" behavior for one flexible task.
///
/// Items before the pushed task's current end are fixed. The pushed task starts
/// the queue at its current end, followed by later planned flexible tasks that
/// may need to move to preserve order.
_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);
}
/// Plan putting a single task at the start of the supplied future window.
_BacklogInsertionPlan? _planTomorrowQueueInsertion({
required SchedulingInput input,
required Task task,
required Duration taskDuration,
}) {
final queue = <_PlacementItem>[
_PlacementItem(
task: task,
duration: taskDuration,
earliestStart: input.window.start,
),
];
return _planQueueAtWindowStart(
input: input,
queue: queue,
excludeTaskIds: {task.id},
);
}
/// Plan a queue of flexible tasks at the beginning of [input.window].
///
/// This is shared by tomorrow push and bulk rollover. [excludeTaskIds] identifies
/// tasks already represented in the incoming [queue] so they are not also pulled
/// from existing scheduled flexible tasks.
_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) => !excludeTaskIds.contains(flexibleTask.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 overlapsWindow = interval.overlaps(input.window.interval);
if (!overlapsWindow) {
continue;
}
if (!input.window.contains(interval) ||
flexibleTask.status != TaskStatus.planned) {
fixedBlocks.add(interval);
continue;
}
placementQueue.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 placementQueue) {
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);
}
/// Apply a backlog insertion plan to the task list.
///
/// The inserted backlog task becomes planned and increments
/// `restoredFromBacklogCount`; any existing flexible tasks moved to make room
/// increment `autoPushedCount`.
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),
);
}
/// Apply a push/tomorrow placement plan to the task list.
///
/// The explicitly pushed task increments `manuallyPushedCount`; other moved
/// flexible tasks increment `autoPushedCount` because the scheduler moved them as
/// a side effect.
SchedulingResult _applyPushPlacement({
required SchedulingInput input,
required Task pushedTask,
required _BacklogInsertionPlan placement,
required String pushedTaskMessage,
required DateTime updatedAt,
bool countPushedTaskAsManual = true,
}) {
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 && countPushedTaskAsManual
? 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 ? pushedTaskMessage : '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),
);
}
/// Apply a bulk rollover placement plan.
///
/// Rolled tasks are set back to planned status in the target window. Existing
/// tasks moved to make room receive normal movement notices.
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),
);
}
/// Whether [task] belongs in the rollover queue.
///
/// Planned/active flexible tasks already inside the target window are not rolled
/// again; they are handled as existing tasks that may shift to make room.
bool _shouldRollOver({
required Task task,
required SchedulingWindow targetWindow,
SchedulingWindow? sourceWindow,
}) {
final interval = _scheduledIntervalFor(task);
final isTomorrowTask =
interval != null && interval.overlaps(targetWindow.interval);
final isInSourceWindow = sourceWindow == null ||
(interval != null && sourceWindow.contains(interval));
return task.isFlexible &&
!isTomorrowTask &&
isInSourceWindow &&
(task.status == TaskStatus.planned || task.status == TaskStatus.active);
}
/// Return whichever timestamp is later.
DateTime _laterOf(DateTime first, DateTime second) {
if (first.isAfter(second)) {
return first;
}
return second;
}
/// Null-safe exact timestamp comparison.
bool _sameDateTime(DateTime? first, DateTime? second) {
if (first == null || second == null) {
return first == null && second == null;
}
return first.isAtSameMomentAs(second);
}
/// Find the first candidate interval at or after [earliestStart].
///
/// The scan assumes [blocked] is sorted by start time. When a candidate overlaps
/// a blocked interval, the cursor jumps to that blocked interval's end and tries
/// again. This makes the algorithm easy to follow and adequate for the starter
/// in-memory engine.
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;
}
}
/// One item in a placement queue.
///
/// [earliestStart] preserves a task's natural ordering constraint. For existing
/// scheduled tasks, this is usually their current start; for a pushed task, it is
/// the earliest time the push operation allows.
class _PlacementItem {
const _PlacementItem({
required this.task,
required this.duration,
required this.earliestStart,
});
/// Task represented by this queue entry.
final Task task;
/// Duration the planner must reserve.
final Duration duration;
/// Earliest allowed start time for this item.
final DateTime earliestStart;
}
/// Planned task intervals keyed by task id.
///
/// The name is historical from the first insertion feature; it now also supports
/// push and rollover placement plans. It remains private so it can be renamed or
/// expanded later without affecting callers.
class _BacklogInsertionPlan {
const _BacklogInsertionPlan({required this.placements});
/// Chosen interval for each task that should be scheduled or moved.
final Map<String, TimeInterval> placements;
}