feat(tasks): support parent-owned child tasks
This commit is contained in:
parent
0b6270a8e7
commit
89faecdee9
6 changed files with 365 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue