// Parent/child task ownership helpers. // // Child tasks are owned by one parent through `Task.parentTaskId`. This file // keeps that relationship queryable without adding dependency scheduling, DAG // traversal, or UI-specific state. import 'models.dart'; import 'task_lifecycle.dart'; import 'time_contracts.dart'; /// Row-style input for creating one child task. /// /// Priority is nullable on purpose. A null priority means the user did not set /// one, so callers should preserve row insertion order instead of treating the /// child as medium priority. class ChildTaskEntry { const ChildTaskEntry({ required this.id, required this.title, required this.createdAt, this.priority, this.reward = RewardLevel.notSet, this.difficulty = DifficultyLevel.notSet, this.durationMinutes, this.projectId, this.updatedAt, }); /// Caller-generated child id. final String id; /// User-entered child title. final String title; /// Creation timestamp supplied by the caller. final DateTime createdAt; /// Optional explicit child priority. final PriorityLevel? priority; /// Optional explicit child reward. Missing reward stays `notSet`. final RewardLevel reward; /// Optional explicit child difficulty. final DifficultyLevel difficulty; /// Optional child duration estimate. final int? durationMinutes; /// Optional project override. Null means inherit the parent project. final String? projectId; /// Optional update timestamp. final DateTime? updatedAt; /// Convert this entry into a child [Task] owned by [parent]. Task toTask({ required Task parent, }) { final trimmedTitle = title.trim(); if (trimmedTitle.isEmpty) { throw ArgumentError.value(title, 'title', 'Title is required.'); } return Task( id: id, title: trimmedTitle, projectId: projectId ?? parent.projectId, type: TaskType.flexible, status: TaskStatus.backlog, priority: priority, reward: reward, difficulty: difficulty, durationMinutes: durationMinutes, parentTaskId: parent.id, createdAt: createdAt, updatedAt: updatedAt ?? createdAt, ); } } /// Command input for breaking one parent task into direct children. class ChildTaskBreakUpRequest { ChildTaskBreakUpRequest({ required String parentTaskId, required List entries, required String operationId, }) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'), entries = List.unmodifiable(entries), operationId = _requiredTrimmed(operationId, 'operationId'); /// Parent task that will own every created child. final String parentTaskId; /// Ordered child rows supplied by the caller. final List entries; /// Idempotency key for the application command that creates the children. final String operationId; } /// Result from breaking a parent task into direct children. class ChildTaskBreakUpResult { ChildTaskBreakUpResult({ required List tasks, required this.parentTask, required List childTasks, required this.operationId, List createdChildTaskIds = const [], List activities = const [], }) : tasks = List.unmodifiable(tasks), childTasks = List.unmodifiable(childTasks), createdChildTaskIds = List.unmodifiable(createdChildTaskIds), activities = List.unmodifiable(activities); /// Replacement task list including newly created children. final List tasks; /// Parent that owns the created children. final Task parentTask; /// Created children in request order. final List childTasks; /// Idempotency key for the command. final String operationId; /// Child ids created by this command. final List createdChildTaskIds; /// Activity records produced by this command. /// /// V1 child creation is represented by task inserts, so this is currently /// empty while still giving application use cases one atomic mutation shape. final List activities; } /// Creates direct child tasks from ordered child-entry rows. class ChildTaskBreakUpService { const ChildTaskBreakUpService(); /// Break [request.parentTaskId] into direct child tasks. /// /// This service validates the full requested set before returning any /// mutations. Persistence and transaction wiring are handled by later blocks. ChildTaskBreakUpResult breakUp({ required List tasks, required ChildTaskBreakUpRequest request, }) { final parent = _taskById(tasks, request.parentTaskId); if (parent == null) { throw ArgumentError.value( request.parentTaskId, 'parentTaskId', 'Parent not found.', ); } if (request.entries.isEmpty) { throw ArgumentError.value( request.entries, 'entries', 'At least one child task is required.', ); } final existingIds = tasks.map((task) => task.id).toSet(); final requestedIds = {}; for (final entry in request.entries) { final childId = entry.id.trim(); if (childId.isEmpty) { throw ArgumentError.value(entry.id, 'id', 'Child id is required.'); } if (childId == parent.id) { throw ArgumentError.value( entry.id, 'id', 'Child id cannot match the parent id.', ); } if (!requestedIds.add(childId)) { throw ArgumentError.value( entry.id, 'id', 'Child ids must be unique within one break-up command.', ); } if (existingIds.contains(childId)) { throw ArgumentError.value( entry.id, 'id', 'Child id is already used by another task.', ); } } final childTasks = request.entries .map((entry) => entry.toTask(parent: parent)) .toList(growable: false); return ChildTaskBreakUpResult( tasks: List.unmodifiable([...tasks, ...childTasks]), parentTask: parent, childTasks: childTasks, operationId: request.operationId, createdChildTaskIds: childTasks.map((child) => child.id).toList(growable: false), ); } } /// Read-only parent/child projection over a task list. /// /// This is intentionally not a scheduler. It answers ownership questions such /// as "which tasks belong to this parent?" while leaving task placement and /// completion rules to other domain services. class ChildTaskView { ChildTaskView({ required List tasks, }) : tasks = List.unmodifiable(tasks); /// Source task list supplied by the caller. final List tasks; /// Direct children owned by [parent], preserving source-list order. List childrenOf(Task parent) { return List.unmodifiable( tasks.where((task) => task.parentTaskId == parent.id), ); } /// Direct children sorted by explicit priority while preserving row order ties. /// /// Children without priority sort after explicit priorities. Within each /// priority group, including the no-priority group, source-list order is kept. List childrenOfSortedByPriority(Task parent) { final indexedChildren = childrenOf(parent) .asMap() .entries .map( (entry) => _IndexedChild( task: entry.value, originalIndex: entry.key, ), ) .toList(growable: false); indexedChildren.sort((a, b) { final priorityComparison = _priorityRank(b.task.priority) .compareTo(_priorityRank(a.task.priority)); if (priorityComparison != 0) { return priorityComparison; } return a.originalIndex.compareTo(b.originalIndex); }); return List.unmodifiable( indexedChildren.map((child) => child.task), ); } /// Parent task for [child], or null when the parent is not in [tasks]. Task? parentOf(Task child) { final parentId = child.parentTaskId; if (parentId == null) { return null; } for (final task in tasks) { if (task.id == parentId) { return task; } } return null; } /// Whether [child] is directly owned by [parent]. bool isChildOf({ required Task child, required Task parent, }) { return child.parentTaskId == parent.id; } /// Aggregate direct child status counts for [parent]. ChildTaskSummary summaryFor(Task parent) { return ChildTaskSummary.fromChildren(childrenOf(parent)); } /// Whether this helper would auto-complete [parent]. bool parentShouldAutoComplete(Task parent) { return summaryFor(parent).allChildrenCompleted; } } /// Result from child/parent completion propagation. class ChildTaskCompletionResult { ChildTaskCompletionResult({ required List tasks, required List changedTaskIds, this.completedChildId, this.completedParentId, List forceCompletedChildIds = const [], List activities = const [], List transitionOutcomeCodes = const [], this.autoCompletedParent = false, this.canCompleteParentExplicitly = false, }) : tasks = List.unmodifiable(tasks), changedTaskIds = List.unmodifiable(changedTaskIds), forceCompletedChildIds = List.unmodifiable(forceCompletedChildIds), activities = List.unmodifiable(activities), transitionOutcomeCodes = List.unmodifiable( transitionOutcomeCodes); /// Replacement task list after the completion action. final List tasks; /// Task ids whose completion state changed. final List changedTaskIds; /// Child id completed by the initial child-complete action, when applicable. final String? completedChildId; /// Parent id completed by the action, when applicable. final String? completedParentId; /// Direct child ids completed by explicit parent-complete behavior. final List forceCompletedChildIds; /// Internal activities produced by parent/child completion propagation. final List activities; /// Transition outcomes returned while applying completion propagation. final List transitionOutcomeCodes; /// Whether the parent was completed because all direct children are complete. final bool autoCompletedParent; /// Whether callers may offer an explicit parent-complete action. final bool canCompleteParentExplicitly; } /// Applies direct parent/child completion rules. /// /// This service intentionally handles only one ownership level. It does not walk /// dependency graphs, and it only completes sibling children when the caller /// explicitly completes the parent. class ChildTaskCompletionService { const ChildTaskCompletionService({ this.clock = const SystemClock(), this.transitionService = const TaskTransitionService(), }); /// Clock boundary used when a completion timestamp is not supplied. final Clock clock; /// Canonical lifecycle transition dependency. final TaskTransitionService transitionService; /// Complete one child and auto-complete its parent only when all children are complete. ChildTaskCompletionResult completeChild({ required List tasks, required String childTaskId, DateTime? updatedAt, String? operationId, Iterable existingActivities = const [], Iterable appliedActivityIds = const [], }) { final now = updatedAt ?? clock.now(); final child = _taskById(tasks, childTaskId); if (child == null) { throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); } final parentId = child.parentTaskId; if (parentId == null) { throw ArgumentError.value( childTaskId, 'childTaskId', 'Task is not a child.'); } final parent = _taskById(tasks, parentId); if (parent == null) { throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.'); } final opId = operationId ?? _defaultOperationId('complete-child', child.id, now); final childTransition = _completeWithTransition( task: child, operationId: opId, occurredAt: now, existingActivities: existingActivities, appliedActivityIds: appliedActivityIds, metadata: { 'completionSource': 'child', 'parentTaskId': parent.id, }, ); final withChildCompleted = _replaceTask(tasks, childTransition.task); final view = ChildTaskView(tasks: withChildCompleted); final shouldCompleteParent = parent.status != TaskStatus.completed && view.parentShouldAutoComplete(parent); final activities = [...childTransition.activities]; final outcomes = [childTransition.outcomeCode]; if (!shouldCompleteParent) { return ChildTaskCompletionResult( tasks: List.unmodifiable(withChildCompleted), changedTaskIds: childTransition.applied ? [child.id] : const [], completedChildId: child.id, canCompleteParentExplicitly: parent.status != TaskStatus.completed, activities: activities, transitionOutcomeCodes: outcomes, ); } final parentTransition = _completeWithTransition( task: parent, operationId: opId, occurredAt: now, existingActivities: existingActivities, appliedActivityIds: appliedActivityIds, metadata: { 'completionSource': 'autoParentAfterLastChild', 'completedChildTaskId': child.id, }, ); activities.addAll(parentTransition.activities); outcomes.add(parentTransition.outcomeCode); final updatedParent = parentTransition.task; final completedTasks = _replaceTask(withChildCompleted, updatedParent); final changedTaskIds = [ if (childTransition.applied) child.id, if (parentTransition.applied) parent.id, ]; return ChildTaskCompletionResult( tasks: List.unmodifiable(completedTasks), changedTaskIds: List.unmodifiable(changedTaskIds), completedChildId: child.id, completedParentId: parent.id, autoCompletedParent: true, activities: activities, transitionOutcomeCodes: outcomes, ); } /// Complete a parent from one of its direct children and force-complete siblings. ChildTaskCompletionResult completeParentFromChild({ required List tasks, required String childTaskId, DateTime? updatedAt, String? operationId, Iterable existingActivities = const [], Iterable appliedActivityIds = const [], }) { final child = _taskById(tasks, childTaskId); if (child == null) { throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.'); } final parentId = child.parentTaskId; if (parentId == null) { throw ArgumentError.value( childTaskId, 'childTaskId', 'Task is not a child.', ); } return completeParent( tasks: tasks, parentTaskId: parentId, updatedAt: updatedAt, operationId: operationId, initiatingChildTaskId: child.id, existingActivities: existingActivities, appliedActivityIds: appliedActivityIds, ); } /// Explicitly complete a parent and any direct children not already completed. ChildTaskCompletionResult completeParent({ required List tasks, required String parentTaskId, DateTime? updatedAt, String? operationId, String? initiatingChildTaskId, Iterable existingActivities = const [], Iterable appliedActivityIds = const [], }) { final now = updatedAt ?? clock.now(); final parent = _taskById(tasks, parentTaskId); if (parent == null) { throw ArgumentError.value( parentTaskId, 'parentTaskId', 'Parent not found.'); } final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent); if (initiatingChildTaskId != null && !directChildren.any((child) => child.id == initiatingChildTaskId)) { throw ArgumentError.value( initiatingChildTaskId, 'initiatingChildTaskId', 'Initiating task must be a direct child of the parent.', ); } final opId = operationId ?? _defaultOperationId('complete-parent', parent.id, now); final forceCompletedChildIds = []; final changedTaskIds = []; final activities = []; final outcomes = []; final updatedTasks = []; for (final task in tasks) { if (task.id == parent.id) { final transition = _completeWithTransition( task: task, operationId: opId, occurredAt: now, existingActivities: existingActivities, appliedActivityIds: appliedActivityIds, metadata: { 'completionSource': initiatingChildTaskId == null ? 'parent' : 'parentFromChild', if (initiatingChildTaskId != null) 'initiatingChildTaskId': initiatingChildTaskId, }, ); final completedParent = transition.task; updatedTasks.add(completedParent); activities.addAll(transition.activities); outcomes.add(transition.outcomeCode); if (transition.applied) { changedTaskIds.add(task.id); } continue; } final isDirectChild = directChildren.any((child) => child.id == task.id); if (isDirectChild && task.status != TaskStatus.completed) { final transition = _completeWithTransition( task: task, operationId: opId, occurredAt: now, existingActivities: existingActivities, appliedActivityIds: appliedActivityIds, metadata: { 'completionSource': initiatingChildTaskId == null ? 'parentForce' : 'childForceParent', 'parentTaskId': parent.id, if (initiatingChildTaskId != null) 'initiatingChildTaskId': initiatingChildTaskId, }, ); updatedTasks.add(transition.task); activities.addAll(transition.activities); outcomes.add(transition.outcomeCode); if (transition.applied) { forceCompletedChildIds.add(task.id); changedTaskIds.add(task.id); } continue; } updatedTasks.add(task); } return ChildTaskCompletionResult( tasks: List.unmodifiable(updatedTasks), changedTaskIds: List.unmodifiable(changedTaskIds), completedParentId: parent.id, forceCompletedChildIds: List.unmodifiable(forceCompletedChildIds), activities: List.unmodifiable(activities), transitionOutcomeCodes: List.unmodifiable( outcomes, ), ); } TaskTransitionResult _completeWithTransition({ required Task task, required String operationId, required DateTime occurredAt, required Iterable existingActivities, required Iterable appliedActivityIds, required Map metadata, }) { return transitionService.apply( task: task, transitionCode: TaskTransitionCode.complete, operationId: operationId, activityId: _activityId( operationId, task.id, TaskActivityCode.completed, ), occurredAt: occurredAt, existingActivities: existingActivities, appliedActivityIds: appliedActivityIds, metadata: metadata, ); } } /// Status counts for a parent's direct children. class ChildTaskSummary { const ChildTaskSummary({ required this.totalCount, required this.plannedCount, required this.activeCount, required this.completedCount, required this.missedCount, required this.cancelledCount, required this.noLongerRelevantCount, required this.backlogCount, }); /// Build counts from [children]. factory ChildTaskSummary.fromChildren(List children) { var plannedCount = 0; var activeCount = 0; var completedCount = 0; var missedCount = 0; var cancelledCount = 0; var noLongerRelevantCount = 0; var backlogCount = 0; for (final child in children) { switch (child.status) { case TaskStatus.planned: plannedCount += 1; case TaskStatus.active: activeCount += 1; case TaskStatus.completed: completedCount += 1; case TaskStatus.missed: missedCount += 1; case TaskStatus.cancelled: cancelledCount += 1; case TaskStatus.noLongerRelevant: noLongerRelevantCount += 1; case TaskStatus.backlog: backlogCount += 1; } } return ChildTaskSummary( totalCount: children.length, plannedCount: plannedCount, activeCount: activeCount, completedCount: completedCount, missedCount: missedCount, cancelledCount: cancelledCount, noLongerRelevantCount: noLongerRelevantCount, backlogCount: backlogCount, ); } /// Total direct children counted. final int totalCount; /// Children in planned status. final int plannedCount; /// Children in active status. final int activeCount; /// Children in completed status. final int completedCount; /// Children in missed status. final int missedCount; /// Children in cancelled status. final int cancelledCount; /// Children marked no longer relevant. final int noLongerRelevantCount; /// Children currently in backlog. final int backlogCount; /// Whether at least one direct child exists. bool get hasChildren => totalCount > 0; /// Whether every direct child is completed. bool get allChildrenCompleted => hasChildren && completedCount == totalCount; } class _IndexedChild { const _IndexedChild({ required this.task, required this.originalIndex, }); final Task task; final int originalIndex; } int _priorityRank(PriorityLevel? priority) { return switch (priority) { PriorityLevel.veryLow => 0, PriorityLevel.low => 1, PriorityLevel.medium => 2, PriorityLevel.high => 3, PriorityLevel.veryHigh => 4, null => -1, }; } Task? _taskById(List tasks, String taskId) { for (final task in tasks) { if (task.id == taskId) { return task; } } return null; } List _replaceTask(List tasks, Task replacement) { return tasks .map((task) => task.id == replacement.id ? replacement : task) .toList(growable: false); } String _defaultOperationId(String actionName, String taskId, DateTime at) { return '$actionName:$taskId:${at.toIso8601String()}'; } String _activityId( String operationId, String taskId, TaskActivityCode activityCode, ) { return '$operationId:$taskId:${activityCode.name}'; } String _requiredTrimmed(String value, String name) { final trimmed = value.trim(); if (trimmed.isEmpty) { throw ArgumentError.value(value, name, 'Value is required.'); } return trimmed; }