feat(tasks): support parent-owned child tasks

This commit is contained in:
Ashley Venn 2026-06-24 12:14:32 -07:00
parent 0b6270a8e7
commit 89faecdee9
6 changed files with 365 additions and 11 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 07 — Child Tasks
Status: Planned
Status: Completed
Purpose: Support breaking a large task into smaller owned child tasks without adding dependency complexity.
@ -138,6 +138,8 @@ BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion p
Recommended Codex level: high
Status: Completed
Tasks:
Implement completion rules:
@ -166,3 +168,15 @@ Commit suggestion:
```text
feat(tasks): support parent-owned child tasks
```
Execution notes:
- Added `ChildTaskCompletionService` and `ChildTaskCompletionResult`.
- Completing a child now completes its parent only when all direct children are complete.
- Completing one child does not complete siblings.
- Partial child completion leaves the parent incomplete and exposes `canCompleteParentExplicitly`.
- Explicit parent completion force-completes remaining direct children and records `forceCompletedChildIds`.
- Parent completion is direct-child only; no generalized dependency graph behavior was added.
- Completion propagation updates task status and `updatedAt` without adding duplicate statistics events.
- Added tests for all-child auto-completion, parent force-completion, partial completion, sibling safety, and explicit parent-complete result metadata.
- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed.

View file

@ -1,6 +1,6 @@
# V1 Block 07 — Child Tasks
Status: Planned
Status: Completed
Purpose: Support breaking a large task into smaller owned child tasks without adding dependency complexity.
@ -138,6 +138,8 @@ BREAKPOINT: Stop here. Confirm `high` mode before implementing auto-completion p
Recommended Codex level: high
Status: Completed
Tasks:
Implement completion rules:
@ -166,3 +168,15 @@ Commit suggestion:
```text
feat(tasks): support parent-owned child tasks
```
Execution notes:
- Added `ChildTaskCompletionService` and `ChildTaskCompletionResult`.
- Completing a child now completes its parent only when all direct children are complete.
- Completing one child does not complete siblings.
- Partial child completion leaves the parent incomplete and exposes `canCompleteParentExplicitly`.
- Explicit parent completion force-completes remaining direct children and records `forceCompletedChildIds`.
- Parent completion is direct-child only; no generalized dependency graph behavior was added.
- Completion propagation updates task status and `updatedAt` without adding duplicate statistics events.
- Added tests for all-child auto-completion, parent force-completion, partial completion, sibling safety, and explicit parent-complete result metadata.
- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed.

View file

@ -1,7 +1,7 @@
# Current Software Plan
Execute active V1 block documents in numeric order. Blocks 01-06 are completed
and archived; the next active block is Block 07.
Execute active V1 block documents in numeric order. Blocks 01-07 are completed
and archived; the next active block is Block 08.
## Execution rules
@ -32,7 +32,7 @@ Do not infer a new level name.
4. `V1_BLOCK_04_Backlog_Quick_Capture.md`
5. `V1_BLOCK_05_Recurring_Locked_Blocks.md`
6. `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md` — archived
7. `V1_BLOCK_07_Child_Tasks.md`
7. `V1_BLOCK_07_Child_Tasks.md` — archived
8. `V1_BLOCK_08_Today_Timeline_State.md`
9. `V1_BLOCK_09_Persistence_Preparation.md`
10. `V1_BLOCK_10_Testing_Documentation_Handoff.md`

View file

@ -45,6 +45,11 @@ is completed immediately. If it has a time interval, overlapping flexible tasks
are pushed by the normal scheduler, required visible overlaps are reported, and
locked overlaps are tracked as hidden data rather than rendered as normal tasks.
Child tasks are direct parent-owned tasks, not dependency graph nodes. Completing
all direct children can complete the parent, and explicitly completing a parent
can force-complete its direct children. Scheduling still treats child ownership
as metadata and does not block placement based on parent/child state.
## Persistence direction
MongoDB is the committed persistence target. The V1 scheduling core should still

View file

@ -158,12 +158,153 @@ class ChildTaskView {
}
/// Whether this helper would auto-complete [parent].
///
/// Block 07.1 only defines ownership. Parent auto-completion is implemented in
/// a later chunk, so this deliberately returns false even when every child is
/// complete.
bool parentShouldAutoComplete(Task parent) {
return false;
return summaryFor(parent).allChildrenCompleted;
}
}
/// Result from child/parent completion propagation.
class ChildTaskCompletionResult {
const ChildTaskCompletionResult({
required this.tasks,
required this.changedTaskIds,
this.completedChildId,
this.completedParentId,
this.forceCompletedChildIds = const <String>[],
this.autoCompletedParent = false,
this.canCompleteParentExplicitly = false,
});
/// 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;
/// 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();
/// 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,
}) {
final now = updatedAt ?? DateTime.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 updatedChild = _completedTask(child, updatedAt: now);
final withChildCompleted = _replaceTask(tasks, updatedChild);
final view = ChildTaskView(tasks: withChildCompleted);
final shouldCompleteParent = parent.status != TaskStatus.completed &&
view.parentShouldAutoComplete(parent);
if (!shouldCompleteParent) {
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(withChildCompleted),
changedTaskIds:
child.status == TaskStatus.completed ? const [] : [child.id],
completedChildId: child.id,
canCompleteParentExplicitly: parent.status != TaskStatus.completed,
);
}
final updatedParent = _completedTask(parent, updatedAt: now);
final completedTasks = _replaceTask(withChildCompleted, updatedParent);
final changedTaskIds = <String>[
if (child.status != TaskStatus.completed) child.id,
parent.id,
];
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(completedTasks),
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
completedChildId: child.id,
completedParentId: parent.id,
autoCompletedParent: true,
);
}
/// Explicitly complete a parent and any direct children not already completed.
ChildTaskCompletionResult completeParent({
required List<Task> tasks,
required String parentTaskId,
DateTime? updatedAt,
}) {
final now = updatedAt ?? DateTime.now();
final parent = _taskById(tasks, parentTaskId);
if (parent == null) {
throw ArgumentError.value(
parentTaskId, 'parentTaskId', 'Parent not found.');
}
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
final forceCompletedChildIds = <String>[];
final changedTaskIds = <String>[];
final updatedTasks = <Task>[];
for (final task in tasks) {
if (task.id == parent.id) {
final completedParent = _completedTask(task, updatedAt: now);
updatedTasks.add(completedParent);
if (task.status != TaskStatus.completed) {
changedTaskIds.add(task.id);
}
continue;
}
final isDirectChild = directChildren.any((child) => child.id == task.id);
if (isDirectChild && task.status != TaskStatus.completed) {
updatedTasks.add(_completedTask(task, updatedAt: now));
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),
);
}
}
@ -272,3 +413,30 @@ int _priorityRank(PriorityLevel? priority) {
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);
}
Task _completedTask(Task task, {required DateTime updatedAt}) {
if (task.status == TaskStatus.completed && task.updatedAt == updatedAt) {
return task;
}
return task.copyWith(
status: TaskStatus.completed,
updatedAt: updatedAt,
);
}

View file

@ -71,7 +71,7 @@ void main() {
expect(parent.status, TaskStatus.planned);
expect(view.summaryFor(parent).allChildrenCompleted, isTrue);
expect(view.parentShouldAutoComplete(parent), isFalse);
expect(view.parentShouldAutoComplete(parent), isTrue);
});
test('child ownership does not block normal scheduling behavior', () {
@ -424,6 +424,159 @@ void main() {
expect(view.childrenOf(child).map((task) => task.id), ['grandchild']);
});
});
group('Parent child completion rules', () {
final now = DateTime(2026, 6, 19, 12);
const service = ChildTaskCompletionService();
test('all children completed completes parent', () {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final completedChild = task(
id: 'completed-child',
title: 'Clear sink',
createdAt: now,
status: TaskStatus.completed,
parentTaskId: parent.id,
);
final lastChild = task(
id: 'last-child',
title: 'Wipe counter',
createdAt: now,
parentTaskId: parent.id,
);
final result = service.completeChild(
tasks: [parent, completedChild, lastChild],
childTaskId: lastChild.id,
updatedAt: now,
);
expect(taskById(result.tasks, 'last-child').status, TaskStatus.completed);
expect(taskById(result.tasks, 'parent').status, TaskStatus.completed);
expect(result.autoCompletedParent, isTrue);
expect(result.completedParentId, parent.id);
expect(result.forceCompletedChildIds, isEmpty);
});
test('parent complete force-completes remaining direct children', () {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final plannedChild = task(
id: 'planned-child',
title: 'Clear sink',
createdAt: now,
parentTaskId: parent.id,
);
final completedChild = task(
id: 'completed-child',
title: 'Wipe counter',
createdAt: now,
status: TaskStatus.completed,
parentTaskId: parent.id,
);
final result = service.completeParent(
tasks: [parent, plannedChild, completedChild],
parentTaskId: parent.id,
updatedAt: now,
);
expect(taskById(result.tasks, 'parent').status, TaskStatus.completed);
expect(
taskById(result.tasks, 'planned-child').status,
TaskStatus.completed,
);
expect(
taskById(result.tasks, 'completed-child').status,
TaskStatus.completed,
);
expect(result.forceCompletedChildIds, ['planned-child']);
expect(result.autoCompletedParent, isFalse);
expect(result.completedParentId, parent.id);
});
test('partial child completion does not complete parent', () {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final firstChild = task(
id: 'first-child',
title: 'Clear sink',
createdAt: now,
parentTaskId: parent.id,
);
final secondChild = task(
id: 'second-child',
title: 'Wipe counter',
createdAt: now,
parentTaskId: parent.id,
);
final result = service.completeChild(
tasks: [parent, firstChild, secondChild],
childTaskId: firstChild.id,
updatedAt: now,
);
expect(
taskById(result.tasks, 'first-child').status, TaskStatus.completed);
expect(taskById(result.tasks, 'parent').status, TaskStatus.planned);
expect(result.autoCompletedParent, isFalse);
expect(result.canCompleteParentExplicitly, isTrue);
});
test('completing one child does not complete siblings', () {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final firstChild = task(
id: 'first-child',
title: 'Clear sink',
createdAt: now,
parentTaskId: parent.id,
);
final secondChild = task(
id: 'second-child',
title: 'Wipe counter',
createdAt: now,
parentTaskId: parent.id,
);
final result = service.completeChild(
tasks: [parent, firstChild, secondChild],
childTaskId: firstChild.id,
updatedAt: now,
);
expect(
taskById(result.tasks, 'first-child').status, TaskStatus.completed);
expect(taskById(result.tasks, 'second-child').status, TaskStatus.planned);
expect(result.forceCompletedChildIds, isEmpty);
});
test('explicit parent-complete action records parent and child completion',
() {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final firstChild = task(
id: 'first-child',
title: 'Clear sink',
createdAt: now,
parentTaskId: parent.id,
);
final secondChild = task(
id: 'second-child',
title: 'Wipe counter',
createdAt: now,
parentTaskId: parent.id,
);
final result = service.completeParent(
tasks: [parent, firstChild, secondChild],
parentTaskId: parent.id,
updatedAt: now,
);
expect(result.completedParentId, parent.id);
expect(result.forceCompletedChildIds, ['first-child', 'second-child']);
expect(result.changedTaskIds, ['parent', 'first-child', 'second-child']);
expect(result.canCompleteParentExplicitly, isFalse);
});
});
}
Task task({