feat(tasks): add child task ownership helpers
This commit is contained in:
parent
6612a5629a
commit
92c05d8c1e
5 changed files with 319 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
151
lib/src/child_tasks.dart
Normal file
151
lib/src/child_tasks.dart
Normal 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
141
test/child_tasks_test.dart
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue