// Flexible task card actions. // // The main scheduling engine moves tasks through time. This file models the // small user actions that appear on a flexible task card, such as done, push, // backlog, and break-up. Keeping these in a service makes UI button handlers // thin and keeps task-type safety checks in one place. import 'models.dart'; import 'occupancy_policy.dart'; import 'scheduling_engine.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; /// Quick actions available from a flexible task card. /// /// These are the low-friction card controls the UI can expose directly on a /// planned flexible task. The service below translates each button into either a /// direct task update, a scheduling operation, or a follow-up flow. enum FlexibleTaskQuickAction { /// Mark the task completed. done, /// Ask the user where the task should be pushed. push, /// Move the task out of today's plan and into backlog. backlog, /// Start a flow that splits the task into child tasks. breakUp, } /// State transitions available from required visible task cards. /// /// Required visible tasks are critical or inflexible items. They are not moved by /// flexible scheduling, but the user still needs simple lifecycle actions for /// what happened to the commitment. enum RequiredTaskAction { /// Mark the required task completed. done, /// Mark that the required task did not happen. missed, /// Intentionally remove it from the active plan. cancel, /// Dismiss it because it no longer applies, distinct from cancellation. noLongerRelevant, } /// Explicit push destinations shown after choosing the push quick action. /// /// Push starts as a simple quick action, but the actual destination requires one /// more choice. Keeping destinations as a separate enum prevents the initial card /// action list from becoming too crowded. enum PushDestination { /// Move the task later within the current planning window. nextAvailableSlot, /// Move the task to the beginning of the supplied tomorrow/future window. tomorrowTopOfQueue, /// Remove the task from the active timeline and store it for later. backlog, } /// Domain result for a flexible task quick action. /// /// This result deliberately supports three outcomes: the task changed, the user /// must choose a push destination, or the UI should start the child-task flow. /// That keeps card code from guessing how to interpret each action. class FlexibleTaskActionResult { FlexibleTaskActionResult({ required this.action, required this.task, this.pushDestinations = const [], this.startsChildTaskFlow = false, List activities = const [], this.transitionOutcomeCode, }) : activities = List.unmodifiable(activities); /// Action the user selected. final FlexibleTaskQuickAction action; /// Current or updated task, depending on the action. final Task task; /// Destination choices to show after `push`; empty for direct actions. final List pushDestinations; /// Whether the UI should open a child-task creation flow. final bool startsChildTaskFlow; /// Internal activities produced by direct lifecycle actions. final List activities; /// Typed transition outcome for direct lifecycle actions. final TaskTransitionOutcomeCode? transitionOutcomeCode; /// True when the action directly produced an updated [task]. bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty; } /// Result from applying a selected push destination. /// /// The selected destination is included next to the [SchedulingResult] so UI and /// tests can distinguish "moved later today" from "moved to tomorrow" even if /// the low-level scheduling change shape is similar. class PushDestinationResult { PushDestinationResult({ required this.destination, required this.schedulingResult, List activities = const [], }) : activities = List.unmodifiable(activities); /// Destination that was applied. final PushDestination destination; /// Full scheduler output: updated tasks, notices, changes, and overlaps. final SchedulingResult schedulingResult; /// Internal activities produced by applying the selected destination. final List activities; /// Convenience flag for UI copy or persistence behavior that cares about the /// tomorrow queue specifically. bool get placesAtTomorrowTopOfQueue { return destination == PushDestination.tomorrowTopOfQueue; } } /// Result from applying a required task state transition. class RequiredTaskActionResult { RequiredTaskActionResult({ required this.action, required this.task, required this.removedFromActivePlan, List activities = const [], this.transitionOutcomeCode, }) : activities = List.unmodifiable(activities); /// Action the user selected. final RequiredTaskAction action; /// Updated task after the transition. final Task task; /// Whether the transition removes the task from the active timeline. /// /// Critical missed tasks return true because they move to backlog. Cancelled /// and no-longer-relevant tasks also return true because they are no longer /// active planned work. final bool removedFromActivePlan; /// Internal activities produced by the transition. final List activities; /// Typed transition outcome. final TaskTransitionOutcomeCode? transitionOutcomeCode; } /// Input for logging work the user already did outside the plan. /// /// The user may know only a title, or may also know when it happened and how /// long it took. When time data is present, the resulting surprise task occupies /// that interval and flexible work overlapping it is pushed out of the way. class SurpriseTaskLogRequest { const SurpriseTaskLogRequest({ required this.id, required this.title, required this.createdAt, this.startedAt, this.timeUsedMinutes, this.projectId = 'inbox', this.priority, this.reward = RewardLevel.notSet, this.difficulty = DifficultyLevel.notSet, }); /// Caller-generated id for the new surprise task. /// /// This id is also the idempotency key for retrying the same log operation. final String id; /// User-entered title. final String title; /// Creation/log timestamp supplied by the caller for testability. final DateTime createdAt; /// When the surprise work started, if known. final DateTime? startedAt; /// Minutes spent on the surprise work, if known. final int? timeUsedMinutes; /// Project id to assign; defaults to inbox when omitted. final String projectId; /// Optional priority. Null means the user did not set one. final PriorityLevel? priority; /// Optional reward estimate. final RewardLevel reward; /// Optional difficulty estimate. final DifficultyLevel difficulty; } /// Result from logging a surprise task. class SurpriseTaskLogResult { const SurpriseTaskLogResult({ required this.surpriseTask, required this.schedulingResult, this.requiredOverlaps = const [], this.lockedOverlaps = const [], this.usedNormalPushRules = false, }); /// Newly created completed surprise task. final Task surpriseTask; /// Updated task list, movement notices, changes, and visible overlaps. final SchedulingResult schedulingResult; /// Critical/inflexible overlaps with the surprise interval. final List requiredOverlaps; /// Locked overlaps tracked separately so they stay hidden by default. final List lockedOverlaps; /// Whether flexible movement was delegated to normal push behavior. final bool usedNormalPushRules; } /// Applies low-friction quick actions for flexible task cards. /// /// This service is the adapter between small UI button presses and domain logic. /// It intentionally only accepts flexible tasks; required/locked/surprise items /// should have their own action rules so the UI cannot accidentally apply a /// flexible-only behavior to a fixed commitment. class FlexibleTaskActionService { const FlexibleTaskActionService({ this.schedulingEngine = const SchedulingEngine(), this.transitionService = const TaskTransitionService(), this.clock = const SystemClock(), }); /// Scheduling dependency used for actions that need timeline changes. final SchedulingEngine schedulingEngine; /// Canonical lifecycle transition dependency. final TaskTransitionService transitionService; /// Clock boundary used when an action timestamp is not supplied. final Clock clock; /// Apply the first-stage quick action. /// /// Direct actions (`done`, `backlog`) return a changed task. `push` returns the /// list of destinations the UI should present. `breakUp` signals that the UI /// should start a child-task flow rather than changing the task immediately. FlexibleTaskActionResult apply({ required Task task, required FlexibleTaskQuickAction action, DateTime? updatedAt, String? operationId, Iterable existingActivities = const [], }) { if (!task.isFlexible) { throw ArgumentError.value( task.type, 'task.type', 'Task must be flexible.'); } switch (action) { case FlexibleTaskQuickAction.done: final now = updatedAt ?? clock.now(); final opId = operationId ?? _defaultOperationId(action.name, task.id, now); final transition = transitionService.apply( task: task, transitionCode: TaskTransitionCode.complete, operationId: opId, activityId: _activityId( opId, task.id, TaskActivityCode.completed, ), occurredAt: now, existingActivities: existingActivities, ); return FlexibleTaskActionResult( action: action, task: transition.task, activities: transition.activities, transitionOutcomeCode: transition.outcomeCode, ); case FlexibleTaskQuickAction.push: return FlexibleTaskActionResult( action: action, task: task, pushDestinations: const [ PushDestination.nextAvailableSlot, PushDestination.tomorrowTopOfQueue, PushDestination.backlog, ], ); case FlexibleTaskQuickAction.backlog: final now = updatedAt ?? clock.now(); final opId = operationId ?? _defaultOperationId(action.name, task.id, now); final transition = transitionService.apply( task: task, transitionCode: TaskTransitionCode.moveToBacklog, operationId: opId, activityId: _activityId( opId, task.id, TaskActivityCode.movedToBacklog, ), occurredAt: now, existingActivities: existingActivities, ); return FlexibleTaskActionResult( action: action, task: transition.task, activities: transition.activities, transitionOutcomeCode: transition.outcomeCode, ); case FlexibleTaskQuickAction.breakUp: return FlexibleTaskActionResult( action: action, task: task, startsChildTaskFlow: true, ); } } /// Apply the second-stage destination selected after the `push` action. /// /// This needs the full [SchedulingInput] because pushing can shift other /// flexible tasks and must avoid locked/required intervals. PushDestinationResult applyPushDestination({ required PushDestination destination, required SchedulingInput input, required String taskId, DateTime? updatedAt, String? operationId, Iterable existingActivities = const [], }) { final now = updatedAt ?? clock.now(); final opId = operationId ?? _defaultOperationId('push-${destination.name}', taskId, now); final result = switch (destination) { PushDestination.nextAvailableSlot => schedulingEngine.pushFlexibleTaskToNextAvailableSlot( input: input, taskId: taskId, updatedAt: now, ), PushDestination.tomorrowTopOfQueue => schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( input: input, taskId: taskId, updatedAt: now, ), PushDestination.backlog => _moveTaskToBacklog( input: input, taskId: taskId, updatedAt: now, ), }; return PushDestinationResult( destination: destination, schedulingResult: result, activities: _activitiesForPushDestination( destination: destination, input: input, result: result, taskId: taskId, operationId: opId, occurredAt: now, existingActivities: existingActivities, ), ); } /// Move one planned flexible task to backlog inside a scheduling result. /// /// This mirrors the shape of other push destination results so callers can /// handle every destination through the same `SchedulingResult` interface. SchedulingResult _moveTaskToBacklog({ required SchedulingInput input, required String taskId, DateTime? updatedAt, }) { final task = _taskById(input.tasks, taskId); if (task == null) { return SchedulingResult( tasks: input.tasks, operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, outcomeCode: SchedulingOutcomeCode.notFound, notices: [ SchedulingNotice( 'Task was not found.', type: SchedulingNoticeType.noFit, taskId: taskId, issueCode: SchedulingIssueCode.taskNotFound, ), ], ); } if (!task.isFlexible || task.status != TaskStatus.planned) { return SchedulingResult( tasks: input.tasks, operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, outcomeCode: SchedulingOutcomeCode.invalidState, notices: [ SchedulingNotice( 'Only planned flexible tasks can be moved to backlog.', type: SchedulingNoticeType.noFit, taskId: task.id, issueCode: SchedulingIssueCode.invalidTaskState, ), ], ); } final movedTask = schedulingEngine.moveToBacklog( task, updatedAt: updatedAt, ); return SchedulingResult( tasks: List.unmodifiable( input.tasks.map((current) { if (current.id == task.id) { return movedTask; } return current; }), ), operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, outcomeCode: SchedulingOutcomeCode.success, notices: [ SchedulingNotice( 'Flexible task moved to backlog.', type: SchedulingNoticeType.moved, taskId: task.id, movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog, parameters: { 'previousStart': task.scheduledStart, 'previousEnd': task.scheduledEnd, 'nextStart': null, 'nextEnd': null, }, ), ], changes: [ SchedulingChange( taskId: task.id, previousStart: task.scheduledStart, previousEnd: task.scheduledEnd, nextStart: null, nextEnd: null, ), ], ); } } /// Applies lifecycle actions for required visible task cards. /// /// This service only handles critical and inflexible tasks. Locked blocks are /// scheduling constraints, not normal task cards, and flexible tasks use /// [FlexibleTaskActionService]. class RequiredTaskActionService { const RequiredTaskActionService({ this.schedulingEngine = const SchedulingEngine(), this.transitionService = const TaskTransitionService(), this.clock = const SystemClock(), }); /// Scheduling dependency used for required missed behavior. final SchedulingEngine schedulingEngine; /// Canonical lifecycle transition dependency. final TaskTransitionService transitionService; /// Clock boundary used when an action timestamp is not supplied. final Clock clock; /// Apply one required-card state transition. RequiredTaskActionResult apply({ required Task task, required RequiredTaskAction action, DateTime? updatedAt, String? operationId, Iterable existingActivities = const [], }) { if (!task.isRequiredVisible) { throw ArgumentError.value( task.type, 'task.type', 'Task must be critical or inflexible.', ); } final now = updatedAt ?? clock.now(); final transitionCode = switch (action) { RequiredTaskAction.done => TaskTransitionCode.complete, RequiredTaskAction.missed => TaskTransitionCode.miss, RequiredTaskAction.cancel => TaskTransitionCode.cancel, RequiredTaskAction.noLongerRelevant => TaskTransitionCode.noLongerRelevant, }; final activityCode = switch (action) { RequiredTaskAction.done => TaskActivityCode.completed, RequiredTaskAction.missed => TaskActivityCode.missed, RequiredTaskAction.cancel => TaskActivityCode.cancelled, RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant, }; final opId = operationId ?? _defaultOperationId(action.name, task.id, now); final transition = transitionService.apply( task: task, transitionCode: transitionCode, operationId: opId, activityId: _activityId(opId, task.id, activityCode), occurredAt: now, existingActivities: existingActivities, ); return RequiredTaskActionResult( action: action, task: transition.task, removedFromActivePlan: transition.task.status == TaskStatus.backlog || transition.task.status == TaskStatus.cancelled || transition.task.status == TaskStatus.noLongerRelevant, activities: transition.activities, transitionOutcomeCode: transition.outcomeCode, ); } } /// Logs surprise completed work and repairs affected flexible tasks. /// /// Surprise work is already done, so the new task is created as completed. When /// it has a concrete interval, overlapping planned flexible tasks are moved by /// the existing push scheduler with the surprise interval treated as blocked /// time. Required visible and locked time is never moved. class SurpriseTaskLogService { const SurpriseTaskLogService({ this.schedulingEngine = const SchedulingEngine(), this.clock = const SystemClock(), this.idGenerator, }); /// Scheduling dependency used to move overlapping flexible tasks. final SchedulingEngine schedulingEngine; /// Clock boundary used when a log timestamp is not supplied. final Clock clock; /// Optional ID boundary for outer convenience entry points. final IdGenerator? idGenerator; /// Log one surprise task against a scheduling snapshot. SurpriseTaskLogResult log({ required SurpriseTaskLogRequest request, required SchedulingInput input, DateTime? updatedAt, bool revealHiddenLockedDetails = false, }) { final now = updatedAt ?? clock.now(); final existingTask = _taskById(input.tasks, request.id); if (existingTask != null) { if (existingTask.type != TaskType.surprise) { throw ArgumentError.value( request.id, 'request.id', 'Surprise task id is already used by another task.', ); } return SurpriseTaskLogResult( surpriseTask: existingTask, schedulingResult: SchedulingResult( tasks: input.tasks, operationCode: SchedulingOperationCode.logSurpriseTask, outcomeCode: SchedulingOutcomeCode.noOp, notices: [ SchedulingNotice( 'Surprise task already logged.', issueCode: SchedulingIssueCode.duplicateSurpriseLog, taskId: existingTask.id, ), ], ), ); } final surpriseTask = _createSurpriseTask(request, updatedAt: now); final surpriseInterval = _occupyingIntervalForTask(surpriseTask); var currentTasks = [surpriseTask, ...input.tasks]; if (surpriseInterval == null) { return SurpriseTaskLogResult( surpriseTask: surpriseTask, schedulingResult: SchedulingResult( tasks: List.unmodifiable(currentTasks), operationCode: SchedulingOperationCode.logSurpriseTask, outcomeCode: SchedulingOutcomeCode.success, notices: [ SchedulingNotice('Surprise task logged.'), ], ), ); } final requiredOverlaps = _requiredOverlaps( input: input, surpriseInterval: surpriseInterval, ); final lockedOverlaps = _lockedOverlaps( input: input, surpriseInterval: surpriseInterval, revealHiddenLockedDetails: revealHiddenLockedDetails, ); final flexibleTaskIds = _overlappingFlexibleTaskIds( input.movablePlannedFlexibleTasks, surpriseInterval, ); final changes = []; final movementNotices = []; var usedNormalPushRules = false; for (final taskId in flexibleTaskIds) { final currentTask = _taskById(currentTasks, taskId); final currentInterval = currentTask == null ? null : _scheduledIntervalForTask(currentTask); if (currentTask == null || currentInterval == null || !currentInterval.overlaps(surpriseInterval)) { continue; } final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot( input: SchedulingInput( tasks: currentTasks, window: input.window, lockedIntervals: input.lockedIntervals, requiredVisibleIntervals: input.requiredVisibleIntervals, ), taskId: taskId, updatedAt: now, countAsManualPush: false, ); usedNormalPushRules = true; currentTasks = pushResult.tasks; changes.addAll(pushResult.changes); movementNotices.addAll(pushResult.notices); } final overlapNotices = requiredOverlaps .map( (overlap) => SchedulingNotice( 'Surprise task overlaps required visible time.', type: SchedulingNoticeType.overlap, taskId: overlap.taskId, conflictCode: SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, parameters: { 'blockedStart': overlap.blockedInterval.start, 'blockedEnd': overlap.blockedInterval.end, }, ), ) .toList(growable: false); return SurpriseTaskLogResult( surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, schedulingResult: SchedulingResult( tasks: List.unmodifiable(currentTasks), operationCode: SchedulingOperationCode.logSurpriseTask, outcomeCode: requiredOverlaps.isEmpty ? SchedulingOutcomeCode.success : SchedulingOutcomeCode.conflict, notices: List.unmodifiable([ SchedulingNotice('Surprise task logged.'), ...movementNotices, ...overlapNotices, ]), changes: List.unmodifiable(changes), overlaps: List.unmodifiable(requiredOverlaps), ), requiredOverlaps: List.unmodifiable(requiredOverlaps), lockedOverlaps: List.unmodifiable(lockedOverlaps), usedNormalPushRules: usedNormalPushRules, ); } /// Convenience outer-boundary wrapper that supplies id and creation time. SurpriseTaskLogResult logNew({ required String title, required SchedulingInput input, DateTime? startedAt, int? timeUsedMinutes, String projectId = 'inbox', PriorityLevel? priority, RewardLevel reward = RewardLevel.notSet, DifficultyLevel difficulty = DifficultyLevel.notSet, DateTime? createdAt, DateTime? updatedAt, bool revealHiddenLockedDetails = false, }) { final generator = idGenerator; if (generator == null) { throw StateError('Surprise task logging needs an id generator.'); } final now = createdAt ?? updatedAt ?? clock.now(); return log( request: SurpriseTaskLogRequest( id: generator.nextId(), title: title, createdAt: now, startedAt: startedAt, timeUsedMinutes: timeUsedMinutes, projectId: projectId, priority: priority, reward: reward, difficulty: difficulty, ), input: input, updatedAt: updatedAt ?? now, revealHiddenLockedDetails: revealHiddenLockedDetails, ); } Task _createSurpriseTask( SurpriseTaskLogRequest request, { required DateTime updatedAt, }) { final trimmedTitle = request.title.trim(); if (trimmedTitle.isEmpty) { throw ArgumentError.value(request.title, 'title', 'Title is required.'); } final duration = request.timeUsedMinutes; final start = request.startedAt; final hasScheduledTime = start != null && duration != null && duration > 0; return Task( id: request.id, title: trimmedTitle, projectId: request.projectId, type: TaskType.surprise, status: TaskStatus.completed, priority: request.priority, reward: request.reward, difficulty: request.difficulty, durationMinutes: duration != null && duration > 0 ? duration : null, scheduledStart: hasScheduledTime ? start : null, scheduledEnd: hasScheduledTime ? start.add(Duration(minutes: duration)) : null, actualStart: hasScheduledTime ? start : null, actualEnd: hasScheduledTime ? start.add(Duration(minutes: duration)) : null, completedAt: updatedAt, createdAt: request.createdAt, updatedAt: updatedAt, ); } } List _activitiesForPushDestination({ required PushDestination destination, required SchedulingInput input, required SchedulingResult result, required String taskId, required String operationId, required DateTime occurredAt, required Iterable existingActivities, }) { if (result.outcomeCode != SchedulingOutcomeCode.success) { return const []; } final activities = []; for (final change in result.changes) { if (_hasDuplicateActivity( existingActivities: existingActivities, operationId: operationId, taskId: change.taskId, )) { continue; } final task = _taskById(input.tasks, change.taskId); if (task == null) { continue; } final code = _activityCodeForPushChange( destination: destination, isSelectedTask: change.taskId == taskId, ); activities.add( TaskActivity( id: _activityId(operationId, change.taskId, code), operationId: operationId, code: code, taskId: change.taskId, projectId: task.projectId, occurredAt: occurredAt, metadata: { 'previousStatus': task.status.name, 'nextStatus': code == TaskActivityCode.movedToBacklog ? TaskStatus.backlog.name : task.status.name, 'previousStart': change.previousStart, 'previousEnd': change.previousEnd, 'nextStart': change.nextStart, 'nextEnd': change.nextEnd, 'destination': destination.name, }, ), ); } return List.unmodifiable(activities); } TaskActivityCode _activityCodeForPushChange({ required PushDestination destination, required bool isSelectedTask, }) { if (destination == PushDestination.backlog) { return TaskActivityCode.movedToBacklog; } return isSelectedTask ? TaskActivityCode.manuallyPushed : TaskActivityCode.automaticallyPushed; } bool _hasDuplicateActivity({ required Iterable existingActivities, required String operationId, required String taskId, }) { for (final activity in existingActivities) { if (activity.operationId == operationId && activity.taskId == taskId) { return true; } } return false; } String _defaultOperationId( String actionName, String taskId, DateTime occurredAt) { return '$actionName:$taskId:${occurredAt.toIso8601String()}'; } String _activityId( String operationId, String taskId, TaskActivityCode activityCode, ) { return '$operationId:$taskId:${activityCode.name}'; } /// Find one task by id in a list. Task? _taskById(List tasks, String taskId) { for (final task in tasks) { if (task.id == taskId) { return task; } } return null; } /// Build an interval for a task, or null when it has no usable placement. TimeInterval? _scheduledIntervalForTask(Task task) { final start = task.scheduledStart; final end = task.scheduledEnd; if (start == null || end == null || !start.isBefore(end)) { return null; } return TimeInterval(start: start, end: end, label: task.id); } /// Build an actual-first occupancy interval for completed/active task facts. TimeInterval? _occupyingIntervalForTask(Task task) { final actualStart = task.actualStart; final actualEnd = task.actualEnd; if (actualStart != null && actualEnd != null && actualStart.isBefore(actualEnd)) { return TimeInterval(start: actualStart, end: actualEnd, label: task.id); } return _scheduledIntervalForTask(task); } /// Required visible overlaps with a surprise interval. List _requiredOverlaps({ required SchedulingInput input, required TimeInterval surpriseInterval, }) { final overlaps = []; for (final occupancy in input.occupancyEntries) { final task = occupancy.task; final interval = occupancy.interval; final isRequiredOccupancy = occupancy.category == OccupancyCategory.requiredVisibleCommitment || (occupancy.category == OccupancyCategory.activeWork && task != null && task.isRequiredVisible); if (task == null || !isRequiredOccupancy || interval == null || !interval.overlaps(surpriseInterval)) { continue; } overlaps.add( SchedulingOverlap( taskId: task.id, taskInterval: interval, blockedInterval: surpriseInterval, ), ); } return overlaps; } /// Locked overlaps with a surprise interval, kept hidden from normal notices. List _lockedOverlaps({ required SchedulingInput input, required TimeInterval surpriseInterval, required bool revealHiddenLockedDetails, }) { return input.occupancyEntries .where( (occupancy) => occupancy.category == OccupancyCategory.hiddenLockedConstraint, ) .map((occupancy) => occupancy.interval) .whereType() .where((interval) => interval.overlaps(surpriseInterval)) .map((interval) { if (revealHiddenLockedDetails) { return interval; } return TimeInterval(start: interval.start, end: interval.end); }).toList(growable: false); } /// Planned flexible task ids overlapping a surprise interval in timeline order. List _overlappingFlexibleTaskIds( List tasks, TimeInterval surpriseInterval, ) { final overlappingTasks = tasks.where((task) { if (task.status != TaskStatus.planned) { return false; } final interval = _scheduledIntervalForTask(task); return interval != null && interval.overlaps(surpriseInterval); }).toList(growable: false) ..sort((a, b) { final aStart = a.scheduledStart ?? surpriseInterval.end; final bStart = b.scheduledStart ?? surpriseInterval.end; return aStart.compareTo(bStart); }); return overlappingTasks.map((task) => task.id).toList(growable: false); }