// 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 'scheduling_engine.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 { const FlexibleTaskActionResult({ required this.action, required this.task, this.pushDestinations = const [], this.startsChildTaskFlow = false, }); /// 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; /// 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 { const PushDestinationResult({ required this.destination, required this.schedulingResult, }); /// Destination that was applied. final PushDestination destination; /// Full scheduler output: updated tasks, notices, changes, and overlaps. final SchedulingResult schedulingResult; /// 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 { const RequiredTaskActionResult({ required this.action, required this.task, required this.removedFromActivePlan, }); /// 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; } /// 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. 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(), }); /// Scheduling dependency used for actions that need timeline changes. final SchedulingEngine schedulingEngine; /// 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, }) { if (!task.isFlexible) { throw ArgumentError.value( task.type, 'task.type', 'Task must be flexible.'); } switch (action) { case FlexibleTaskQuickAction.done: return FlexibleTaskActionResult( action: action, task: task.copyWith( status: TaskStatus.completed, updatedAt: updatedAt ?? DateTime.now(), ), ); case FlexibleTaskQuickAction.push: return FlexibleTaskActionResult( action: action, task: task, pushDestinations: const [ PushDestination.nextAvailableSlot, PushDestination.tomorrowTopOfQueue, PushDestination.backlog, ], ); case FlexibleTaskQuickAction.backlog: return FlexibleTaskActionResult( action: action, task: schedulingEngine.moveToBacklog(task, updatedAt: updatedAt), ); 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, }) { final result = switch (destination) { PushDestination.nextAvailableSlot => schedulingEngine.pushFlexibleTaskToNextAvailableSlot( input: input, taskId: taskId, updatedAt: updatedAt, ), PushDestination.tomorrowTopOfQueue => schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( input: input, taskId: taskId, updatedAt: updatedAt, ), PushDestination.backlog => _moveTaskToBacklog( input: input, taskId: taskId, updatedAt: updatedAt, ), }; return PushDestinationResult( destination: destination, schedulingResult: result, ); } /// 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, notices: [ SchedulingNotice( 'Task was not found.', type: SchedulingNoticeType.noFit, taskId: taskId, ), ], ); } if (!task.isFlexible || task.status != TaskStatus.planned) { return SchedulingResult( tasks: input.tasks, notices: [ SchedulingNotice( 'Only planned flexible tasks can be moved to backlog.', type: SchedulingNoticeType.noFit, taskId: task.id, ), ], ); } 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; }), ), notices: [ SchedulingNotice( 'Flexible task moved to backlog.', type: SchedulingNoticeType.moved, taskId: task.id, ), ], 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(), }); /// Scheduling dependency used for required missed behavior. final SchedulingEngine schedulingEngine; /// Apply one required-card state transition. RequiredTaskActionResult apply({ required Task task, required RequiredTaskAction action, DateTime? updatedAt, }) { if (!task.isRequiredVisible) { throw ArgumentError.value( task.type, 'task.type', 'Task must be critical or inflexible.', ); } final now = updatedAt ?? DateTime.now(); return switch (action) { RequiredTaskAction.done => RequiredTaskActionResult( action: action, task: task.copyWith( status: TaskStatus.completed, updatedAt: now, ), removedFromActivePlan: false, ), RequiredTaskAction.missed => _markMissed( task: task, action: action, updatedAt: now, ), RequiredTaskAction.cancel => RequiredTaskActionResult( action: action, task: task.copyWith( status: TaskStatus.cancelled, updatedAt: now, stats: task.stats.incrementCancelled(), clearSchedule: true, ), removedFromActivePlan: true, ), RequiredTaskAction.noLongerRelevant => RequiredTaskActionResult( action: action, task: task.copyWith( status: TaskStatus.noLongerRelevant, updatedAt: now, clearSchedule: true, ), removedFromActivePlan: true, ), }; } RequiredTaskActionResult _markMissed({ required Task task, required RequiredTaskAction action, required DateTime updatedAt, }) { final missed = schedulingEngine.markMissed(task, updatedAt: updatedAt); return RequiredTaskActionResult( action: action, task: missed, removedFromActivePlan: missed.status == TaskStatus.backlog, ); } } /// 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(), }); /// Scheduling dependency used to move overlapping flexible tasks. final SchedulingEngine schedulingEngine; /// Log one surprise task against a scheduling snapshot. SurpriseTaskLogResult log({ required SurpriseTaskLogRequest request, required SchedulingInput input, DateTime? updatedAt, }) { final now = updatedAt ?? DateTime.now(); final surpriseTask = _createSurpriseTask(request, updatedAt: now); final surpriseInterval = _scheduledIntervalForTask(surpriseTask); var currentTasks = [surpriseTask, ...input.tasks]; if (surpriseInterval == null) { return SurpriseTaskLogResult( surpriseTask: surpriseTask, schedulingResult: SchedulingResult( tasks: List.unmodifiable(currentTasks), notices: const [ SchedulingNotice('Surprise task logged.'), ], ), ); } final requiredOverlaps = _requiredOverlaps( tasks: input.requiredVisibleTasks, surpriseInterval: surpriseInterval, ); final lockedOverlaps = _lockedOverlaps( input: input, surpriseInterval: surpriseInterval, ); final flexibleTaskIds = _overlappingFlexibleTaskIds( input.flexibleTasks, 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, surpriseInterval, ], 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, ), ) .toList(growable: false); return SurpriseTaskLogResult( surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, schedulingResult: SchedulingResult( tasks: List.unmodifiable(currentTasks), notices: List.unmodifiable([ const SchedulingNotice('Surprise task logged.'), ...movementNotices, ...overlapNotices, ]), changes: List.unmodifiable(changes), overlaps: List.unmodifiable(requiredOverlaps), ), requiredOverlaps: List.unmodifiable(requiredOverlaps), lockedOverlaps: List.unmodifiable(lockedOverlaps), usedNormalPushRules: usedNormalPushRules, ); } 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, createdAt: request.createdAt, updatedAt: updatedAt, ); } } /// 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); } /// Required visible overlaps with a surprise interval. List _requiredOverlaps({ required List tasks, required TimeInterval surpriseInterval, }) { final overlaps = []; for (final task in tasks) { final interval = _scheduledIntervalForTask(task); if (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, }) { final lockedIntervals = [ ...input.lockedIntervals, ...input.lockedTasks .map(_scheduledIntervalForTask) .whereType(), ]; return lockedIntervals .where((interval) => interval.overlaps(surpriseInterval)) .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); }