forked from eva/focus-flow
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
|
# V1 Block 07 — Child Tasks
|
||||||
|
|
||||||
Status: Planned
|
Status: Completed
|
||||||
|
|
||||||
Purpose: Support breaking a large task into smaller owned child tasks without adding dependency complexity.
|
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
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Implement completion rules:
|
Implement completion rules:
|
||||||
|
|
@ -166,3 +168,15 @@ Commit suggestion:
|
||||||
```text
|
```text
|
||||||
feat(tasks): support parent-owned child tasks
|
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
|
# V1 Block 07 — Child Tasks
|
||||||
|
|
||||||
Status: Planned
|
Status: Completed
|
||||||
|
|
||||||
Purpose: Support breaking a large task into smaller owned child tasks without adding dependency complexity.
|
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
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Implement completion rules:
|
Implement completion rules:
|
||||||
|
|
@ -166,3 +168,15 @@ Commit suggestion:
|
||||||
```text
|
```text
|
||||||
feat(tasks): support parent-owned child tasks
|
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
|
# Current Software Plan
|
||||||
|
|
||||||
Execute active V1 block documents in numeric order. Blocks 01-06 are completed
|
Execute active V1 block documents in numeric order. Blocks 01-07 are completed
|
||||||
and archived; the next active block is Block 07.
|
and archived; the next active block is Block 08.
|
||||||
|
|
||||||
## Execution rules
|
## Execution rules
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ Do not infer a new level name.
|
||||||
4. `V1_BLOCK_04_Backlog_Quick_Capture.md`
|
4. `V1_BLOCK_04_Backlog_Quick_Capture.md`
|
||||||
5. `V1_BLOCK_05_Recurring_Locked_Blocks.md`
|
5. `V1_BLOCK_05_Recurring_Locked_Blocks.md`
|
||||||
6. `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md` — archived
|
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`
|
8. `V1_BLOCK_08_Today_Timeline_State.md`
|
||||||
9. `V1_BLOCK_09_Persistence_Preparation.md`
|
9. `V1_BLOCK_09_Persistence_Preparation.md`
|
||||||
10. `V1_BLOCK_10_Testing_Documentation_Handoff.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
|
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.
|
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
|
## Persistence direction
|
||||||
|
|
||||||
MongoDB is the committed persistence target. The V1 scheduling core should still
|
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].
|
/// 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) {
|
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,
|
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(parent.status, TaskStatus.planned);
|
||||||
expect(view.summaryFor(parent).allChildrenCompleted, isTrue);
|
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', () {
|
test('child ownership does not block normal scheduling behavior', () {
|
||||||
|
|
@ -424,6 +424,159 @@ void main() {
|
||||||
expect(view.childrenOf(child).map((task) => task.id), ['grandchild']);
|
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({
|
Task task({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue