forked from eva/focus-flow
1399 lines
42 KiB
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;
|
|
}
|