forked from eva/focus-flow
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
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