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