// 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 tasks, required this.window, List lockedIntervals = const [], List requiredVisibleIntervals = const [], }) : tasks = List.unmodifiable(tasks), lockedIntervals = List.unmodifiable(lockedIntervals), requiredVisibleIntervals = List.unmodifiable(requiredVisibleIntervals); /// All tasks available to this operation. The scheduler returns a replacement /// list rather than mutating this one. final List 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 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 requiredVisibleIntervals; /// Tasks that the flexible movement algorithms are allowed to consider. List get flexibleTasks { return tasks.where((task) => task.isFlexible).toList(growable: false); } /// Central occupancy classification for this scheduling snapshot. List get occupancyEntries { return _occupancyPolicy.classify( tasks: tasks, lockedIntervals: lockedIntervals, requiredVisibleIntervals: requiredVisibleIntervals, ); } /// Planned flexible task records that automatic scheduling may move. List get movablePlannedFlexibleTasks { return occupancyEntries .where((entry) => entry.isMovablePlannedFlexible) .map((entry) => entry.task) .whereType() .toList(growable: false); } /// Locked task records in [tasks], if the caller represents locked time as /// tasks instead of only passing [lockedIntervals]. List get lockedTasks { return tasks.where((task) => task.isLocked).toList(growable: false); } /// Critical and inflexible task records that should block flexible placement. List get requiredVisibleTasks { return tasks .where((task) => task.isRequiredVisible) .toList(growable: false); } /// Scheduled intervals for flexible tasks only. Useful for analysis/debugging. List 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 get blockedIntervals { final intervals = occupancyEntries .where((entry) => entry.blocksAutomaticFlexiblePlacement) .map((entry) => entry.interval) .whereType() .toList(growable: false) ..sort((a, b) => a.start.compareTo(b.start)); return List.unmodifiable(intervals); } /// Occupancy entries that should be surfaced as conflicts when overlapped. List 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 parameters = const {}, }) : parameters = Map.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 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 tasks, this.operationCode = SchedulingOperationCode.unspecified, this.outcomeCode = SchedulingOutcomeCode.success, List notices = const [], List changes = const [], List overlaps = const [], }) : tasks = List.unmodifiable(tasks), assert( _noticeCodesAreSinglePurpose(notices), 'Each scheduling notice may have only one issue/movement/conflict code.', ), notices = List.unmodifiable(notices), changes = List.unmodifiable(changes), overlaps = List.unmodifiable(overlaps); /// Replacement task list after the operation. final List tasks; /// Stable operation identifier. final SchedulingOperationCode operationCode; /// Stable high-level outcome category. final SchedulingOutcomeCode outcomeCode; /// Human-readable operation messages. final List notices; /// Machine-readable movements or schedule clears. final List changes; /// Analysis-only overlap findings. final List overlaps; } bool _noticeCodesAreSinglePurpose(Iterable 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 = []; final notices = []; 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.unmodifiable(notices), overlaps: List.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 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 _scheduledIntervalsFor(Iterable tasks) { final intervals = []; for (final task in tasks) { final interval = _scheduledIntervalFor(task); if (interval != null) { intervals.add(interval); } } return List.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 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 = [ ...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 = {}; 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 = [ ...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 = {}; 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 excludeTaskIds, }) { final fixedBlocks = [ ...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 = {}; 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 = []; final notices = []; final updatedTasks = []; 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.unmodifiable(updatedTasks), operationCode: SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, outcomeCode: SchedulingOutcomeCode.success, notices: List.unmodifiable(notices), changes: List.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 = []; final notices = []; final updatedTasks = []; 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.unmodifiable(updatedTasks), operationCode: operationCode, outcomeCode: SchedulingOutcomeCode.success, notices: List.unmodifiable(notices), changes: List.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 rolledTaskIds, required _BacklogInsertionPlan placement, required DateTime updatedAt, }) { final changes = []; final notices = []; final updatedTasks = []; 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.unmodifiable(updatedTasks), operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, outcomeCode: SchedulingOutcomeCode.success, notices: List.unmodifiable(notices), changes: List.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 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 placements; }