focus-flow/lib/src/child_tasks.dart

752 lines
23 KiB
Dart

// 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<ChildTaskEntry> entries,
required String operationId,
}) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'),
entries = List<ChildTaskEntry>.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<ChildTaskEntry> 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<Task> tasks,
required this.parentTask,
required List<Task> childTasks,
required this.operationId,
List<String> createdChildTaskIds = const <String>[],
List<TaskActivity> activities = const <TaskActivity>[],
}) : tasks = List<Task>.unmodifiable(tasks),
childTasks = List<Task>.unmodifiable(childTasks),
createdChildTaskIds = List<String>.unmodifiable(createdChildTaskIds),
activities = List<TaskActivity>.unmodifiable(activities);
/// Replacement task list including newly created children.
final List<Task> tasks;
/// Parent that owns the created children.
final Task parentTask;
/// Created children in request order.
final List<Task> childTasks;
/// Idempotency key for the command.
final String operationId;
/// Child ids created by this command.
final List<String> 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<TaskActivity> 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<Task> 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 = <String>{};
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<Task>.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<Task> tasks,
}) : tasks = List<Task>.unmodifiable(tasks);
/// Source task list supplied by the caller.
final List<Task> tasks;
/// Direct children owned by [parent], preserving source-list order.
List<Task> childrenOf(Task parent) {
return List<Task>.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<Task> 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<Task>.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<Task> tasks,
required List<String> changedTaskIds,
this.completedChildId,
this.completedParentId,
List<String> forceCompletedChildIds = const <String>[],
List<TaskActivity> activities = const <TaskActivity>[],
List<TaskTransitionOutcomeCode> transitionOutcomeCodes =
const <TaskTransitionOutcomeCode>[],
this.autoCompletedParent = false,
this.canCompleteParentExplicitly = false,
}) : tasks = List<Task>.unmodifiable(tasks),
changedTaskIds = List<String>.unmodifiable(changedTaskIds),
forceCompletedChildIds =
List<String>.unmodifiable(forceCompletedChildIds),
activities = List<TaskActivity>.unmodifiable(activities),
transitionOutcomeCodes = List<TaskTransitionOutcomeCode>.unmodifiable(
transitionOutcomeCodes);
/// Replacement task list after the completion action.
final List<Task> tasks;
/// Task ids whose completion state changed.
final List<String> 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<String> forceCompletedChildIds;
/// Internal activities produced by parent/child completion propagation.
final List<TaskActivity> activities;
/// Transition outcomes returned while applying completion propagation.
final List<TaskTransitionOutcomeCode> 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<Task> tasks,
required String childTaskId,
DateTime? updatedAt,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
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 = <TaskActivity>[...childTransition.activities];
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
if (!shouldCompleteParent) {
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(withChildCompleted),
changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
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 = <String>[
if (childTransition.applied) child.id,
if (parentTransition.applied) parent.id,
];
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(completedTasks),
changedTaskIds: List<String>.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<Task> tasks,
required String childTaskId,
DateTime? updatedAt,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
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<Task> tasks,
required String parentTaskId,
DateTime? updatedAt,
String? operationId,
String? initiatingChildTaskId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
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 = <String>[];
final changedTaskIds = <String>[];
final activities = <TaskActivity>[];
final outcomes = <TaskTransitionOutcomeCode>[];
final updatedTasks = <Task>[];
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<Task>.unmodifiable(updatedTasks),
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
completedParentId: parent.id,
forceCompletedChildIds: List<String>.unmodifiable(forceCompletedChildIds),
activities: List<TaskActivity>.unmodifiable(activities),
transitionOutcomeCodes: List<TaskTransitionOutcomeCode>.unmodifiable(
outcomes,
),
);
}
TaskTransitionResult _completeWithTransition({
required Task task,
required String operationId,
required DateTime occurredAt,
required Iterable<TaskActivity> existingActivities,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> 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<Task> 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<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}
List<Task> _replaceTask(List<Task> 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;
}