From 92c05d8c1e6639a9c476f0806f0a17795608df3f Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Wed, 24 Jun 2026 11:51:13 -0700 Subject: [PATCH] feat(tasks): add child task ownership helpers --- .../V1_BLOCK_07_Child_Tasks.md | 13 ++ .../V1_BLOCK_07_Child_Tasks.md | 13 ++ lib/scheduler_core.dart | 1 + lib/src/child_tasks.dart | 151 ++++++++++++++++++ test/child_tasks_test.dart | 141 ++++++++++++++++ 5 files changed, 319 insertions(+) create mode 100644 lib/src/child_tasks.dart create mode 100644 test/child_tasks_test.dart diff --git a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md index 7767658..85e9f82 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCKS_07_10_UPDATED/V1_BLOCK_07_Child_Tasks.md @@ -8,6 +8,8 @@ Purpose: Support breaking a large task into smaller owned child tasks without ad Recommended Codex level: medium +Status: Completed + Tasks: Implement parent/child ownership fields and helpers: @@ -30,6 +32,17 @@ Acceptance criteria: - Test: parent can query/aggregate children through helper. - Test: child ownership does not create dependency/blocking behavior. +Execution notes: + +- Added `ChildTaskView` as a domain-only read projection over task lists. +- Added `ChildTaskSummary` for direct child status counts. +- Exported child-task helpers through `lib/scheduler_core.dart`. +- Parent/child ownership uses existing `Task.parentTaskId`. +- Helpers query direct children and parent relationships without adding dependency/DAG behavior. +- Parent auto-completion is explicitly not implemented in this chunk. +- Added `test/child_tasks_test.dart` for ownership, aggregation, incomplete parent behavior, and scheduling non-blocking behavior. +- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed. + ## Chunk 7.2 — Child entry defaults Recommended Codex level: medium diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md b/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md index 7767658..85e9f82 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_07_Child_Tasks.md @@ -8,6 +8,8 @@ Purpose: Support breaking a large task into smaller owned child tasks without ad Recommended Codex level: medium +Status: Completed + Tasks: Implement parent/child ownership fields and helpers: @@ -30,6 +32,17 @@ Acceptance criteria: - Test: parent can query/aggregate children through helper. - Test: child ownership does not create dependency/blocking behavior. +Execution notes: + +- Added `ChildTaskView` as a domain-only read projection over task lists. +- Added `ChildTaskSummary` for direct child status counts. +- Exported child-task helpers through `lib/scheduler_core.dart`. +- Parent/child ownership uses existing `Task.parentTaskId`. +- Helpers query direct children and parent relationships without adding dependency/DAG behavior. +- Parent auto-completion is explicitly not implemented in this chunk. +- Added `test/child_tasks_test.dart` for ownership, aggregation, incomplete parent behavior, and scheduling non-blocking behavior. +- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed. + ## Chunk 7.2 — Child entry defaults Recommended Codex level: medium diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 1f423b0..dd9f472 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -18,6 +18,7 @@ export 'src/models.dart'; export 'src/backlog.dart'; +export 'src/child_tasks.dart'; export 'src/locked_time.dart'; export 'src/quick_capture.dart'; export 'src/scheduling_engine.dart'; diff --git a/lib/src/child_tasks.dart b/lib/src/child_tasks.dart new file mode 100644 index 0000000..f94d949 --- /dev/null +++ b/lib/src/child_tasks.dart @@ -0,0 +1,151 @@ +// 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'; + +/// 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 { + const ChildTaskView({ + required this.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), + ); + } + + /// 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]. + /// + /// 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; + } +} + +/// 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; +} diff --git a/test/child_tasks_test.dart b/test/child_tasks_test.dart new file mode 100644 index 0000000..fab4c36 --- /dev/null +++ b/test/child_tasks_test.dart @@ -0,0 +1,141 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Child task ownership', () { + final now = DateTime(2026, 6, 19, 12); + + test('child references parent through parentTaskId', () { + final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now); + final child = task( + id: 'child', + title: 'Clear sink', + createdAt: now, + parentTaskId: parent.id, + ); + final view = ChildTaskView(tasks: [parent, child]); + + expect(child.parentTaskId, parent.id); + expect(view.isChildOf(child: child, parent: parent), isTrue); + expect(view.parentOf(child), same(parent)); + }); + + test('parent can query and aggregate 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 unrelatedChild = task( + id: 'unrelated-child', + title: 'Sort drawer', + createdAt: now, + parentTaskId: 'other-parent', + ); + final view = ChildTaskView( + tasks: [parent, plannedChild, unrelatedChild, completedChild], + ); + + expect(view.childrenOf(parent).map((child) => child.id), [ + 'planned-child', + 'completed-child', + ]); + + final summary = view.summaryFor(parent); + expect(summary.totalCount, 2); + expect(summary.plannedCount, 1); + expect(summary.completedCount, 1); + expect(summary.allChildrenCompleted, isFalse); + }); + + test('parent can remain incomplete while children are planned or completed', + () { + 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 view = ChildTaskView(tasks: [parent, completedChild]); + + expect(parent.status, TaskStatus.planned); + expect(view.summaryFor(parent).allChildrenCompleted, isTrue); + expect(view.parentShouldAutoComplete(parent), isFalse); + }); + + test('child ownership does not block normal scheduling behavior', () { + const engine = SchedulingEngine(); + final parent = task( + id: 'parent', + title: 'Clean kitchen', + createdAt: now, + status: TaskStatus.backlog, + ); + final child = task( + id: 'child', + title: 'Clear sink', + createdAt: now, + status: TaskStatus.backlog, + durationMinutes: 15, + parentTaskId: parent.id, + ); + + final result = engine.insertBacklogTaskIntoNextAvailableSlot( + input: SchedulingInput( + tasks: [parent, child], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 10), + ), + ), + taskId: child.id, + updatedAt: now, + ); + final scheduledChild = taskById(result.tasks, child.id); + final unchangedParent = taskById(result.tasks, parent.id); + + expect(scheduledChild.status, TaskStatus.planned); + expect(scheduledChild.parentTaskId, parent.id); + expect(scheduledChild.scheduledStart, DateTime(2026, 6, 19, 9)); + expect(unchangedParent.status, TaskStatus.backlog); + expect(unchangedParent.scheduledStart, isNull); + }); + }); +} + +Task task({ + required String id, + required String title, + required DateTime createdAt, + TaskStatus status = TaskStatus.planned, + int? durationMinutes, + String? parentTaskId, +}) { + return Task( + id: id, + title: title, + projectId: 'home', + type: TaskType.flexible, + status: status, + priority: PriorityLevel.medium, + durationMinutes: durationMinutes, + parentTaskId: parentTaskId, + createdAt: createdAt, + updatedAt: createdAt, + ); +} + +Task taskById(List tasks, String id) { + return tasks.singleWhere((task) => task.id == id); +}