feat(tasks): add child task ownership helpers

This commit is contained in:
Ashley Venn 2026-06-24 11:51:13 -07:00
parent 6612a5629a
commit 92c05d8c1e
5 changed files with 319 additions and 0 deletions

View file

@ -8,6 +8,8 @@ Purpose: Support breaking a large task into smaller owned child tasks without ad
Recommended Codex level: medium Recommended Codex level: medium
Status: Completed
Tasks: Tasks:
Implement parent/child ownership fields and helpers: Implement parent/child ownership fields and helpers:
@ -30,6 +32,17 @@ Acceptance criteria:
- Test: parent can query/aggregate children through helper. - Test: parent can query/aggregate children through helper.
- Test: child ownership does not create dependency/blocking behavior. - 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 ## Chunk 7.2 — Child entry defaults
Recommended Codex level: medium Recommended Codex level: medium

View file

@ -8,6 +8,8 @@ Purpose: Support breaking a large task into smaller owned child tasks without ad
Recommended Codex level: medium Recommended Codex level: medium
Status: Completed
Tasks: Tasks:
Implement parent/child ownership fields and helpers: Implement parent/child ownership fields and helpers:
@ -30,6 +32,17 @@ Acceptance criteria:
- Test: parent can query/aggregate children through helper. - Test: parent can query/aggregate children through helper.
- Test: child ownership does not create dependency/blocking behavior. - 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 ## Chunk 7.2 — Child entry defaults
Recommended Codex level: medium Recommended Codex level: medium

View file

@ -18,6 +18,7 @@
export 'src/models.dart'; export 'src/models.dart';
export 'src/backlog.dart'; export 'src/backlog.dart';
export 'src/child_tasks.dart';
export 'src/locked_time.dart'; export 'src/locked_time.dart';
export 'src/quick_capture.dart'; export 'src/quick_capture.dart';
export 'src/scheduling_engine.dart'; export 'src/scheduling_engine.dart';

151
lib/src/child_tasks.dart Normal file
View file

@ -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<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),
);
}
/// 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<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;
}

141
test/child_tasks_test.dart Normal file
View file

@ -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<Task> tasks, String id) {
return tasks.singleWhere((task) => task.id == id);
}