focus-flow/lib/src/scheduling_engine.dart

1760 lines
56 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';
import 'occupancy_policy.dart';
import 'task_lifecycle.dart';
import 'time_contracts.dart';
const OccupancyPolicy _occupancyPolicy = OccupancyPolicy();
const TaskTransitionService _transitionService = TaskTransitionService();
const TaskActivityAccountingService _activityAccountingService =
TaskActivityAccountingService();
/// 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,
}
/// Stable scheduler operation identifiers.
enum SchedulingOperationCode {
/// Operation was built outside the core scheduler or is not specified.
unspecified,
/// Insert a backlog task into the next available slot.
insertBacklogTaskIntoNextAvailableSlot,
/// Push a flexible task later in the current window.
pushFlexibleTaskToNextAvailableSlot,
/// Move a flexible task to the top of a future/tomorrow queue.
pushFlexibleTaskToTomorrowTopOfQueue,
/// Roll unfinished flexible tasks into a target window.
rollOverUnfinishedFlexibleTasks,
/// Analyze overlaps without moving tasks.
analyzeSchedule,
/// Move a flexible task to backlog through the action service.
moveFlexibleTaskToBacklog,
/// Log completed surprise work.
logSurpriseTask,
/// Explicitly schedule a critical or inflexible commitment.
scheduleRequiredCommitment,
}
/// Stable high-level outcome categories for scheduling operations.
enum SchedulingOutcomeCode {
/// Operation completed without conflicts.
success,
/// Operation intentionally made no changes.
noOp,
/// Requested record was not found.
notFound,
/// Requested operation does not apply to the current state.
invalidState,
/// No placement slot exists for the requested item.
noSlot,
/// The requested queue would extend beyond the planning window.
overflow,
/// Operation completed or analyzed with a conflict.
conflict,
}
/// Stable issue identifiers for validation, no-slot, and no-op notices.
enum SchedulingIssueCode {
taskNotFound,
invalidTaskState,
missingDuration,
missingScheduledSlot,
nonPositiveDuration,
noAvailableSlot,
unfinishedTasksCouldNotFit,
noUnfinishedFlexibleTasks,
duplicateSurpriseLog,
}
/// Stable movement identifiers for task placement changes.
enum SchedulingMovementCode {
backlogTaskInserted,
flexibleTaskMovedToMakeRoom,
flexibleTaskPushedToNextAvailableSlot,
flexibleTaskMovedToTomorrow,
unfinishedFlexibleTasksRolledOver,
flexibleTaskMovedToBacklog,
requiredCommitmentScheduled,
}
/// Stable conflict identifiers for overlap/interrupt notices.
enum SchedulingConflictCode {
flexibleTaskOverlapsBlockedTime,
surpriseTaskOverlapsRequiredVisibleTime,
requiredCommitmentOverlapsProtectedFreeSlot,
}
/// 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 {
SchedulingWindow({
required this.start,
required this.end,
}) {
if (!start.isBefore(end)) {
throw DomainValidationException(
code: DomainValidationCode.invalidSchedulingWindow,
invalidValue: {'start': start, 'end': end},
name: 'SchedulingWindow',
message: 'Scheduling window end must be after start.',
);
}
}
/// 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 {
SchedulingInput({
required List<Task> tasks,
required this.window,
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
List<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
}) : tasks = List<Task>.unmodifiable(tasks),
lockedIntervals = List<TimeInterval>.unmodifiable(lockedIntervals),
requiredVisibleIntervals =
List<TimeInterval>.unmodifiable(requiredVisibleIntervals);
/// 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);
}
/// Central occupancy classification for this scheduling snapshot.
List<OccupancyEntry> get occupancyEntries {
return _occupancyPolicy.classify(
tasks: tasks,
lockedIntervals: lockedIntervals,
requiredVisibleIntervals: requiredVisibleIntervals,
);
}
/// Planned flexible task records that automatic scheduling may move.
List<Task> get movablePlannedFlexibleTasks {
return occupancyEntries
.where((entry) => entry.isMovablePlannedFlexible)
.map((entry) => entry.task)
.whereType<Task>()
.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 is derived from the central occupancy policy rather than from UI card
/// categories. The result is sorted to make interval scanning deterministic.
List<TimeInterval> get blockedIntervals {
final intervals = occupancyEntries
.where((entry) => entry.blocksAutomaticFlexiblePlacement)
.map((entry) => entry.interval)
.whereType<TimeInterval>()
.toList(growable: false)
..sort((a, b) => a.start.compareTo(b.start));
return List<TimeInterval>.unmodifiable(intervals);
}
/// Occupancy entries that should be surfaced as conflicts when overlapped.
List<OccupancyEntry> get conflictReportingOccupancies {
return occupancyEntries
.where((entry) => entry.reportsConflict)
.toList(growable: false);
}
}
/// 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 {
SchedulingNotice(
this.message, {
this.type = SchedulingNoticeType.info,
this.taskId,
this.issueCode,
this.movementCode,
this.conflictCode,
Map<String, Object?> parameters = const <String, Object?>{},
}) : parameters = Map<String, Object?>.unmodifiable(parameters);
/// 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;
/// Stable issue code for validation/no-slot/no-op outcomes.
final SchedulingIssueCode? issueCode;
/// Stable movement code for placement changes.
final SchedulingMovementCode? movementCode;
/// Stable conflict code for overlap outcomes.
final SchedulingConflictCode? conflictCode;
/// Structured notice parameters for callers that need more context.
final Map<String, Object?> parameters;
}
/// 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 {
SchedulingResult({
required List<Task> tasks,
this.operationCode = SchedulingOperationCode.unspecified,
this.outcomeCode = SchedulingOutcomeCode.success,
List<SchedulingNotice> notices = const <SchedulingNotice>[],
List<SchedulingChange> changes = const <SchedulingChange>[],
List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[],
}) : tasks = List<Task>.unmodifiable(tasks),
assert(
_noticeCodesAreSinglePurpose(notices),
'Each scheduling notice may have only one issue/movement/conflict code.',
),
notices = List<SchedulingNotice>.unmodifiable(notices),
changes = List<SchedulingChange>.unmodifiable(changes),
overlaps = List<SchedulingOverlap>.unmodifiable(overlaps);
/// Replacement task list after the operation.
final List<Task> tasks;
/// Stable operation identifier.
final SchedulingOperationCode operationCode;
/// Stable high-level outcome category.
final SchedulingOutcomeCode outcomeCode;
/// 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;
}
bool _noticeCodesAreSinglePurpose(Iterable<SchedulingNotice> notices) {
for (final notice in notices) {
final codeCount = [
notice.issueCode,
notice.movementCode,
notice.conflictCode,
].where((code) => code != null).length;
if (codeCount > 1) {
return false;
}
}
return true;
}
/// 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({
this.clock = const SystemClock(),
});
/// Clock boundary used when an operation timestamp is not supplied.
final Clock clock;
/// 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,
issueCode: SchedulingIssueCode.taskNotFound,
),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.notFound,
);
}
if (!task.isFlexible || !task.isBacklog) {
return _unchangedResult(
input,
SchedulingNotice(
'Only backlog flexible tasks can be inserted.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
issueCode: SchedulingIssueCode.invalidTaskState,
),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
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,
issueCode: SchedulingIssueCode.missingDuration,
),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
// 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,
issueCode: SchedulingIssueCode.noAvailableSlot,
),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.noSlot,
);
}
return _applyPlacement(
input: input,
insertedTask: task,
placement: placement,
updatedAt: updatedAt ?? clock.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,
issueCode: SchedulingIssueCode.taskNotFound,
),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.notFound,
);
}
if (!task.isFlexible || task.status != TaskStatus.planned) {
return _unchangedResult(
input,
SchedulingNotice(
'Only planned flexible tasks can be pushed.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
issueCode: SchedulingIssueCode.invalidTaskState,
),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
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,
issueCode: SchedulingIssueCode.missingScheduledSlot,
),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
if (currentInterval.duration.inMicroseconds <= 0) {
return _unchangedResult(
input,
SchedulingNotice(
'Task needs a positive scheduled duration before pushing.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
issueCode: SchedulingIssueCode.nonPositiveDuration,
),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
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,
issueCode: SchedulingIssueCode.noAvailableSlot,
),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.noSlot,
);
}
return _applyPushPlacement(
input: input,
pushedTask: task,
placement: placement,
pushedTaskMessage: 'Flexible task pushed to next available slot.',
pushedTaskMovementCode:
SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot,
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
updatedAt: updatedAt ?? clock.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,
issueCode: SchedulingIssueCode.taskNotFound,
),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
outcomeCode: SchedulingOutcomeCode.notFound,
);
}
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,
issueCode: SchedulingIssueCode.invalidTaskState,
),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
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,
issueCode: SchedulingIssueCode.missingDuration,
),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
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,
issueCode: SchedulingIssueCode.noAvailableSlot,
),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
outcomeCode: SchedulingOutcomeCode.noSlot,
);
}
return _applyPushPlacement(
input: input,
pushedTask: task,
placement: placement,
pushedTaskMessage: 'Flexible task moved to tomorrow.',
pushedTaskMovementCode:
SchedulingMovementCode.flexibleTaskMovedToTomorrow,
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
updatedAt: updatedAt ?? clock.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,
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
outcomeCode: SchedulingOutcomeCode.noOp,
notices: [
SchedulingNotice(
'No unfinished flexible tasks to roll over.',
issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks,
),
],
);
}
final placement = _planQueueAtWindowStart(
input: input,
queue: rolledItems,
excludeTaskIds: rolledItems.map((item) => item.task.id).toSet(),
);
if (placement == null) {
return _unchangedResult(
input,
SchedulingNotice(
'Unfinished flexible tasks could not fit tomorrow.',
type: SchedulingNoticeType.overflow,
issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit,
),
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
outcomeCode: SchedulingOutcomeCode.overflow,
);
}
return _applyRolloverPlacement(
input: input,
rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(),
placement: placement,
updatedAt: updatedAt ?? clock.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 conflictOccupancies = input.conflictReportingOccupancies;
for (final task in input.movablePlannedFlexibleTasks) {
final taskInterval = _scheduledIntervalFor(task);
if (taskInterval == null || !input.window.contains(taskInterval)) {
continue;
}
for (final occupancy in conflictOccupancies) {
final blockedInterval = occupancy.interval;
if (blockedInterval == null) {
continue;
}
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,
conflictCode:
SchedulingConflictCode.flexibleTaskOverlapsBlockedTime,
parameters: {
'blockedStart': blockedInterval.start,
'blockedEnd': blockedInterval.end,
},
),
);
}
}
return SchedulingResult(
tasks: input.tasks,
operationCode: SchedulingOperationCode.analyzeSchedule,
outcomeCode: overlaps.isEmpty
? SchedulingOutcomeCode.success
: SchedulingOutcomeCode.conflict,
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}) {
final now = updatedAt ?? clock.now();
final result = _transitionService.apply(
task: task,
transitionCode: TaskTransitionCode.moveToBacklog,
operationId: 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}',
activityId:
'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity',
occurredAt: now,
);
return result.applied ? result.task : task;
}
/// 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}) {
final now = updatedAt ?? clock.now();
final result = _transitionService.apply(
task: task,
transitionCode: TaskTransitionCode.manualPush,
operationId: 'legacy-manual-push:${task.id}:${now.toIso8601String()}',
activityId:
'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity',
occurredAt: now,
);
return result.applied ? result.task : task;
}
/// 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 now = updatedAt ?? clock.now();
final result = _transitionService.apply(
task: task,
transitionCode: TaskTransitionCode.miss,
operationId: 'legacy-miss:${task.id}:${now.toIso8601String()}',
activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity',
occurredAt: now,
);
return result.applied ? result.task : task;
}
/// 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, {
SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified,
SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState,
}) {
return SchedulingResult(
tasks: input.tasks,
operationCode: operationCode,
outcomeCode: outcomeCode,
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.movablePlannedFlexibleTasks
.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;
}
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.movablePlannedFlexibleTasks
.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;
}
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.movablePlannedFlexibleTasks
.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)) {
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 placedTask = task.copyWith(
status: isInsertedTask ? TaskStatus.planned : task.status,
scheduledStart: interval.start,
scheduledEnd: interval.end,
updatedAt: updatedAt,
);
final updatedTask = _applySchedulingActivity(
originalTask: task,
updatedTask: placedTask,
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
activityCode: isInsertedTask
? TaskActivityCode.restoredFromBacklog
: TaskActivityCode.automaticallyPushed,
occurredAt: updatedAt,
previousStart: task.scheduledStart,
previousEnd: task.scheduledEnd,
nextStart: interval.start,
nextEnd: interval.end,
);
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,
movementCode: isInsertedTask
? SchedulingMovementCode.backlogTaskInserted
: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
parameters: {
'previousStart': task.scheduledStart,
'previousEnd': task.scheduledEnd,
'nextStart': interval.start,
'nextEnd': interval.end,
},
),
);
}
return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.success,
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 SchedulingMovementCode pushedTaskMovementCode,
required SchedulingOperationCode operationCode,
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 placedTask = task.copyWith(
scheduledStart: interval.start,
scheduledEnd: interval.end,
updatedAt: updatedAt,
);
final updatedTask = _applySchedulingActivity(
originalTask: task,
updatedTask: placedTask,
operationCode: operationCode,
activityCode: isPushedTask && countPushedTaskAsManual
? TaskActivityCode.manuallyPushed
: TaskActivityCode.automaticallyPushed,
occurredAt: updatedAt,
previousStart: task.scheduledStart,
previousEnd: task.scheduledEnd,
nextStart: interval.start,
nextEnd: interval.end,
);
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,
movementCode: isPushedTask
? pushedTaskMovementCode
: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
parameters: {
'previousStart': task.scheduledStart,
'previousEnd': task.scheduledEnd,
'nextStart': interval.start,
'nextEnd': interval.end,
},
),
);
}
return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks),
operationCode: operationCode,
outcomeCode: SchedulingOutcomeCode.success,
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 placedTask = task.copyWith(
status: isRolledTask ? TaskStatus.planned : task.status,
scheduledStart: interval.start,
scheduledEnd: interval.end,
updatedAt: updatedAt,
);
final updatedTask = _applySchedulingActivity(
originalTask: task,
updatedTask: placedTask,
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
activityCode: TaskActivityCode.automaticallyPushed,
occurredAt: updatedAt,
previousStart: task.scheduledStart,
previousEnd: task.scheduledEnd,
nextStart: interval.start,
nextEnd: interval.end,
);
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,
movementCode: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
parameters: {
'previousStart': task.scheduledStart,
'previousEnd': task.scheduledEnd,
'nextStart': interval.start,
'nextEnd': interval.end,
},
),
);
}
}
notices.insert(
0,
SchedulingNotice(
'$rolledCount unfinished flexible tasks were moved to tomorrow.',
type: SchedulingNoticeType.moved,
movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
parameters: {
'rolledCount': rolledCount,
},
),
);
return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks),
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
outcomeCode: SchedulingOutcomeCode.success,
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);
}
Task _applySchedulingActivity({
required Task originalTask,
required Task updatedTask,
required SchedulingOperationCode operationCode,
required TaskActivityCode activityCode,
required DateTime occurredAt,
required DateTime? previousStart,
required DateTime? previousEnd,
required DateTime? nextStart,
required DateTime? nextEnd,
}) {
final operationId =
'${operationCode.name}:${originalTask.id}:${occurredAt.toIso8601String()}';
final activity = TaskActivity(
id: '$operationId:${activityCode.name}',
operationId: operationId,
code: activityCode,
taskId: originalTask.id,
projectId: originalTask.projectId,
occurredAt: occurredAt,
metadata: {
'previousStatus': originalTask.status.name,
'nextStatus': updatedTask.status.name,
'previousStart': previousStart,
'previousEnd': previousEnd,
'nextStart': nextStart,
'nextEnd': nextEnd,
},
);
return _activityAccountingService
.apply(task: updatedTask, activities: [activity]).task;
}
/// 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;
}