From 89faecdee979adca371e7f41c089ea44b6fc3125 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 24 Jun 2026 12:14:32 -0700 Subject: [PATCH] feat(tasks): support parent-owned child tasks --- .../V1_BLOCK_07_Child_Tasks.md | 16 +- .../V1_BLOCK_07_Child_Tasks_UPDATED.md} | 16 +- .../Current Software Plan/README.md | 6 +- .../Starter Architecture Notes.md | 5 + lib/src/child_tasks.dart | 178 +++++++++++++++++- test/child_tasks_test.dart | 155 ++++++++++++++- 6 files changed, 365 insertions(+), 11 deletions(-) rename Codex Documentation/{Current Software Plan/V1_BLOCKS_07_10_UPDATED => Archived plans}/V1_BLOCK_07_Child_Tasks.md (88%) rename Codex Documentation/{Current Software Plan/V1_BLOCK_07_Child_Tasks.md => Archived plans/V1_BLOCK_07_Child_Tasks_UPDATED.md} (88%) diff --git a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md b/Codex Documentation/Archived plans/V1_BLOCK_07_Child_Tasks.md similarity index 88% rename from Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md rename to Codex Documentation/Archived plans/V1_BLOCK_07_Child_Tasks.md index 131a275..395f3f7 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md +++ b/Codex Documentation/Archived plans/V1_BLOCK_07_Child_Tasks.md @@ -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. diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md b/Codex Documentation/Archived plans/V1_BLOCK_07_Child_Tasks_UPDATED.md similarity index 88% rename from Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md rename to Codex Documentation/Archived plans/V1_BLOCK_07_Child_Tasks_UPDATED.md index 131a275..395f3f7 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md +++ b/Codex Documentation/Archived plans/V1_BLOCK_07_Child_Tasks_UPDATED.md @@ -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. diff --git a/Codex Documentation/Current Software Plan/README.md b/Codex Documentation/Current Software Plan/README.md index 414a9c2..80a1f64 100644 --- a/Codex Documentation/Current Software Plan/README.md +++ b/Codex Documentation/Current Software Plan/README.md @@ -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` diff --git a/Human Documentation/Starter Architecture Notes.md b/Human Documentation/Starter Architecture Notes.md index 182bace..4c1f4e8 100644 --- a/Human Documentation/Starter Architecture Notes.md +++ b/Human Documentation/Starter Architecture Notes.md @@ -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 diff --git a/lib/src/child_tasks.dart b/lib/src/child_tasks.dart index 202e450..9af68d3 100644 --- a/lib/src/child_tasks.dart +++ b/lib/src/child_tasks.dart @@ -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 [], + this.autoCompletedParent = false, + this.canCompleteParentExplicitly = false, + }); + + /// 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; + + /// 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 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.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 = [ + if (child.status != TaskStatus.completed) child.id, + parent.id, + ]; + + return ChildTaskCompletionResult( + tasks: List.unmodifiable(completedTasks), + changedTaskIds: List.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 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 = []; + final changedTaskIds = []; + final updatedTasks = []; + + 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.unmodifiable(updatedTasks), + changedTaskIds: List.unmodifiable(changedTaskIds), + completedParentId: parent.id, + forceCompletedChildIds: List.unmodifiable(forceCompletedChildIds), + ); } } @@ -272,3 +413,30 @@ int _priorityRank(PriorityLevel? priority) { 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); +} + +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, + ); +} diff --git a/test/child_tasks_test.dart b/test/child_tasks_test.dart index 761e5ea..1cdcb7e 100644 --- a/test/child_tasks_test.dart +++ b/test/child_tasks_test.dart @@ -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({