diff --git a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md index 4ca9d7e..8ea8505 100644 --- a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md @@ -38,7 +38,7 @@ passed. The current suite has 143 passing tests. | Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 | | Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, and exactly-once task-stat accounting | Atomic application use cases and persisted activity/stat transaction boundaries | 13, 14 | | Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, and push-before-completion values | Project aggregates, child completion patterns, and atomic persistence | 13, 14 | -| Child tasks | Entry conversion, ownership views, parent completion propagation | Atomic break-up workflow, cycle/direct-child validation, activity/stat updates, application use case | 13, 14 | +| Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, and idempotent force completion | Application use cases that persist all child-task mutations atomically | 13, 14 | | Project defaults | Configured static defaults and reminder profile on project | Per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution | 13 | | Reminder profiles | Gentle/persistent/strict/silent enum on project | Task override, effective-profile resolver, free-slot suppression directive; platform delivery remains outside the pure core | 13 | | Timeline state | UI-independent item mapper, locked overlay state, compact selection | Complete Today query/read model, stable per-occurrence IDs, status metadata, correct “next flexible” exclusion, rollover notices | 14 | diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md b/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md index c57cd08..38f4ff4 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md @@ -118,6 +118,8 @@ orchestration. Recommended Codex level: high +Status: Complete on 2026-06-25. + Tasks: - Add a pure domain command/result for breaking one parent into an ordered set of @@ -154,6 +156,13 @@ Acceptance criteria: - Direct-child/cycle constraints are tested. - Result objects contain enough mutation data for atomic persistence. +Verification on 2026-06-25: + +- `dart format lib test`: passed, 0 files changed after final format +- `dart analyze`: passed, no issues found +- `dart test`: passed, 225 tests +- `git diff --check`: passed + BREAKPOINT: Stop here. Confirm `extra high` mode before adding learned project statistics and suggestions. diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index 15820ec..2847ac3 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -55,7 +55,7 @@ changes from accidental API drift. |---|---| | `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` | | `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` | -| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` | +| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` | | `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` | | `src/free_slots.dart` | `ProtectedFreeSlotConflict`, `RequiredCommitmentScheduleResult`, `FreeSlotService` | | `src/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` | @@ -101,7 +101,8 @@ Current notable model fields: difficulty/reminder profile/duration fields - `TimeInterval`: `start`, `end`, optional `label` - `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour, - completed-after-shield, push-before-completion, and parent-child counters + completed-after-shield, and push-before-completion counters. Parent/child + completion patterns are carried as activity metadata. Known baseline gaps to preserve as explicit later work: @@ -209,6 +210,19 @@ Chunk 13.2 intentionally changed the public API: - Added `SurpriseTaskLogResult.activities` and routed surprise completion through the same accounting boundary. +Chunk 13.3 intentionally changed the public API: + +- Added `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, and + `ChildTaskBreakUpService`. +- Added `activities` and `transitionOutcomeCodes` to + `ChildTaskCompletionResult`. +- Added `transitionService` injection to `ChildTaskCompletionService`. +- Added `operationId`, existing-activity, and applied-activity parameters to + child and parent completion methods. +- Added `ChildTaskCompletionService.completeParentFromChild`. +- Routed parent/child completion through canonical lifecycle transitions and + parent/child completion metadata. + ## Repository contracts The current repository surface is pure Dart and in-memory only: diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index bd5b4b2..2df66b7 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -42,6 +42,13 @@ Latest Block 13.2 verification result on 2026-06-25: - `dart test`: passed, 218 tests - `git diff --check`: passed +Latest Block 13.3 verification result on 2026-06-25: + +- `dart format lib test`: passed, 0 files changed after final format +- `dart analyze`: passed, no issues found +- `dart test`: passed, 225 tests +- `git diff --check`: passed + Status values: - `complete`: Current backend APIs and tests cover the requirement. @@ -66,10 +73,10 @@ Status values: | MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot blockers, structured outcomes, and invariant coverage. | | MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Domain movement and structured rollover notices exist; durable next-open rollover notice/read model remains in Block 14. | | MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunks 13.1-13.2 add completion timestamps and surprise completion accounting/locked-hour statistics; application use cases remain in Block 14. | -| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskView`, child creation helpers | `test/child_tasks_test.dart` | complete | Atomic application use case remains in Block 14. | -| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers | `test/child_tasks_test.dart` | complete | Activity/stat updates remain in Block 13. | +| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart` | complete | Chunk 13.3 adds deterministic break-up mutation results; persistence transaction wiring remains in Block 14. | +| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers, `TaskActivity` | `test/child_tasks_test.dart` | complete | Chunk 13.3 routes parent/child completion through lifecycle activities and preserves already-terminal child facts. | | MVP-AC-13 | Display backlog staleness icons without per-task stale prompts. | `BacklogStalenessMarker`, `BacklogStalenessSettings`, `BacklogView` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Backend marker exists; settings persistence and UI display remain in Blocks 14, 15, and 18. | -| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, statistics increment helpers, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Chunks 13.1-13.2 add canonical activities plus exactly-once task-stat accounting for movement, late, locked-hour, and push-before-completion values; project aggregates and atomic persistence remain in Blocks 13.4 and 14. | +| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, statistics increment helpers, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart`, `test/child_tasks_test.dart` | incomplete | Chunks 13.1-13.3 add canonical activities, exactly-once task-stat accounting, and parent/child completion metadata; project aggregates and atomic persistence remain in Blocks 13.4 and 14. | ## Additional MVP backend requirements diff --git a/lib/src/child_tasks.dart b/lib/src/child_tasks.dart index c1f0191..5320bd2 100644 --- a/lib/src/child_tasks.dart +++ b/lib/src/child_tasks.dart @@ -5,6 +5,7 @@ // traversal, or UI-specific state. import 'models.dart'; +import 'task_lifecycle.dart'; import 'time_contracts.dart'; /// Row-style input for creating one child task. @@ -78,6 +79,135 @@ class ChildTaskEntry { } } +/// Command input for breaking one parent task into direct children. +class ChildTaskBreakUpRequest { + ChildTaskBreakUpRequest({ + required String parentTaskId, + required List entries, + required String operationId, + }) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'), + entries = List.unmodifiable(entries), + operationId = _requiredTrimmed(operationId, 'operationId'); + + /// Parent task that will own every created child. + final String parentTaskId; + + /// Ordered child rows supplied by the caller. + final List entries; + + /// Idempotency key for the application command that creates the children. + final String operationId; +} + +/// Result from breaking a parent task into direct children. +class ChildTaskBreakUpResult { + ChildTaskBreakUpResult({ + required List tasks, + required this.parentTask, + required List childTasks, + required this.operationId, + List createdChildTaskIds = const [], + List activities = const [], + }) : tasks = List.unmodifiable(tasks), + childTasks = List.unmodifiable(childTasks), + createdChildTaskIds = List.unmodifiable(createdChildTaskIds), + activities = List.unmodifiable(activities); + + /// Replacement task list including newly created children. + final List tasks; + + /// Parent that owns the created children. + final Task parentTask; + + /// Created children in request order. + final List childTasks; + + /// Idempotency key for the command. + final String operationId; + + /// Child ids created by this command. + final List createdChildTaskIds; + + /// Activity records produced by this command. + /// + /// V1 child creation is represented by task inserts, so this is currently + /// empty while still giving application use cases one atomic mutation shape. + final List activities; +} + +/// Creates direct child tasks from ordered child-entry rows. +class ChildTaskBreakUpService { + const ChildTaskBreakUpService(); + + /// Break [request.parentTaskId] into direct child tasks. + /// + /// This service validates the full requested set before returning any + /// mutations. Persistence and transaction wiring are handled by later blocks. + ChildTaskBreakUpResult breakUp({ + required List tasks, + required ChildTaskBreakUpRequest request, + }) { + final parent = _taskById(tasks, request.parentTaskId); + if (parent == null) { + throw ArgumentError.value( + request.parentTaskId, + 'parentTaskId', + 'Parent not found.', + ); + } + if (request.entries.isEmpty) { + throw ArgumentError.value( + request.entries, + 'entries', + 'At least one child task is required.', + ); + } + + final existingIds = tasks.map((task) => task.id).toSet(); + final requestedIds = {}; + for (final entry in request.entries) { + final childId = entry.id.trim(); + if (childId.isEmpty) { + throw ArgumentError.value(entry.id, 'id', 'Child id is required.'); + } + if (childId == parent.id) { + throw ArgumentError.value( + entry.id, + 'id', + 'Child id cannot match the parent id.', + ); + } + if (!requestedIds.add(childId)) { + throw ArgumentError.value( + entry.id, + 'id', + 'Child ids must be unique within one break-up command.', + ); + } + if (existingIds.contains(childId)) { + throw ArgumentError.value( + entry.id, + 'id', + 'Child id is already used by another task.', + ); + } + } + + final childTasks = request.entries + .map((entry) => entry.toTask(parent: parent)) + .toList(growable: false); + + return ChildTaskBreakUpResult( + tasks: List.unmodifiable([...tasks, ...childTasks]), + parentTask: parent, + childTasks: childTasks, + operationId: request.operationId, + createdChildTaskIds: + childTasks.map((child) => child.id).toList(growable: false), + ); + } +} + /// Read-only parent/child projection over a task list. /// /// This is intentionally not a scheduler. It answers ownership questions such @@ -172,12 +302,18 @@ class ChildTaskCompletionResult { this.completedChildId, this.completedParentId, List forceCompletedChildIds = const [], + List activities = const [], + List transitionOutcomeCodes = + const [], this.autoCompletedParent = false, this.canCompleteParentExplicitly = false, }) : tasks = List.unmodifiable(tasks), changedTaskIds = List.unmodifiable(changedTaskIds), forceCompletedChildIds = - List.unmodifiable(forceCompletedChildIds); + List.unmodifiable(forceCompletedChildIds), + activities = List.unmodifiable(activities), + transitionOutcomeCodes = List.unmodifiable( + transitionOutcomeCodes); /// Replacement task list after the completion action. final List tasks; @@ -194,6 +330,12 @@ class ChildTaskCompletionResult { /// Direct child ids completed by explicit parent-complete behavior. final List forceCompletedChildIds; + /// Internal activities produced by parent/child completion propagation. + final List activities; + + /// Transition outcomes returned while applying completion propagation. + final List transitionOutcomeCodes; + /// Whether the parent was completed because all direct children are complete. final bool autoCompletedParent; @@ -209,16 +351,23 @@ class ChildTaskCompletionResult { class ChildTaskCompletionService { const ChildTaskCompletionService({ this.clock = const SystemClock(), + this.transitionService = const TaskTransitionService(), }); /// Clock boundary used when a completion timestamp is not supplied. final Clock clock; + /// Canonical lifecycle transition dependency. + final TaskTransitionService transitionService; + /// Complete one child and auto-complete its parent only when all children are complete. ChildTaskCompletionResult completeChild({ required List tasks, required String childTaskId, DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], }) { final now = updatedAt ?? clock.now(); final child = _taskById(tasks, childTaskId); @@ -237,27 +386,56 @@ class ChildTaskCompletionService { throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.'); } - final updatedChild = _completedTask(child, updatedAt: now); - final withChildCompleted = _replaceTask(tasks, updatedChild); + final opId = + operationId ?? _defaultOperationId('complete-child', child.id, now); + final childTransition = _completeWithTransition( + task: child, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': 'child', + 'parentTaskId': parent.id, + }, + ); + final withChildCompleted = _replaceTask(tasks, childTransition.task); final view = ChildTaskView(tasks: withChildCompleted); final shouldCompleteParent = parent.status != TaskStatus.completed && view.parentShouldAutoComplete(parent); + final activities = [...childTransition.activities]; + final outcomes = [childTransition.outcomeCode]; if (!shouldCompleteParent) { return ChildTaskCompletionResult( tasks: List.unmodifiable(withChildCompleted), - changedTaskIds: - child.status == TaskStatus.completed ? const [] : [child.id], + changedTaskIds: childTransition.applied ? [child.id] : const [], completedChildId: child.id, canCompleteParentExplicitly: parent.status != TaskStatus.completed, + activities: activities, + transitionOutcomeCodes: outcomes, ); } - final updatedParent = _completedTask(parent, updatedAt: now); + final parentTransition = _completeWithTransition( + task: parent, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': 'autoParentAfterLastChild', + 'completedChildTaskId': child.id, + }, + ); + activities.addAll(parentTransition.activities); + outcomes.add(parentTransition.outcomeCode); + + final updatedParent = parentTransition.task; final completedTasks = _replaceTask(withChildCompleted, updatedParent); final changedTaskIds = [ - if (child.status != TaskStatus.completed) child.id, - parent.id, + if (childTransition.applied) child.id, + if (parentTransition.applied) parent.id, ]; return ChildTaskCompletionResult( @@ -266,6 +444,41 @@ class ChildTaskCompletionService { completedChildId: child.id, completedParentId: parent.id, autoCompletedParent: true, + activities: activities, + transitionOutcomeCodes: outcomes, + ); + } + + /// Complete a parent from one of its direct children and force-complete siblings. + ChildTaskCompletionResult completeParentFromChild({ + required List tasks, + required String childTaskId, + DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], + }) { + 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.', + ); + } + + return completeParent( + tasks: tasks, + parentTaskId: parentId, + updatedAt: updatedAt, + operationId: operationId, + initiatingChildTaskId: child.id, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, ); } @@ -274,6 +487,10 @@ class ChildTaskCompletionService { required List tasks, required String parentTaskId, DateTime? updatedAt, + String? operationId, + String? initiatingChildTaskId, + Iterable existingActivities = const [], + Iterable appliedActivityIds = const [], }) { final now = updatedAt ?? clock.now(); final parent = _taskById(tasks, parentTaskId); @@ -283,15 +500,43 @@ class ChildTaskCompletionService { } final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent); + if (initiatingChildTaskId != null && + !directChildren.any((child) => child.id == initiatingChildTaskId)) { + throw ArgumentError.value( + initiatingChildTaskId, + 'initiatingChildTaskId', + 'Initiating task must be a direct child of the parent.', + ); + } + + final opId = + operationId ?? _defaultOperationId('complete-parent', parent.id, now); final forceCompletedChildIds = []; final changedTaskIds = []; + final activities = []; + final outcomes = []; final updatedTasks = []; for (final task in tasks) { if (task.id == parent.id) { - final completedParent = _completedTask(task, updatedAt: now); + final transition = _completeWithTransition( + task: task, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': + initiatingChildTaskId == null ? 'parent' : 'parentFromChild', + if (initiatingChildTaskId != null) + 'initiatingChildTaskId': initiatingChildTaskId, + }, + ); + final completedParent = transition.task; updatedTasks.add(completedParent); - if (task.status != TaskStatus.completed) { + activities.addAll(transition.activities); + outcomes.add(transition.outcomeCode); + if (transition.applied) { changedTaskIds.add(task.id); } continue; @@ -299,9 +544,28 @@ class ChildTaskCompletionService { 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); + final transition = _completeWithTransition( + task: task, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: { + 'completionSource': initiatingChildTaskId == null + ? 'parentForce' + : 'childForceParent', + 'parentTaskId': parent.id, + if (initiatingChildTaskId != null) + 'initiatingChildTaskId': initiatingChildTaskId, + }, + ); + updatedTasks.add(transition.task); + activities.addAll(transition.activities); + outcomes.add(transition.outcomeCode); + if (transition.applied) { + forceCompletedChildIds.add(task.id); + changedTaskIds.add(task.id); + } continue; } @@ -313,6 +577,34 @@ class ChildTaskCompletionService { changedTaskIds: List.unmodifiable(changedTaskIds), completedParentId: parent.id, forceCompletedChildIds: List.unmodifiable(forceCompletedChildIds), + activities: List.unmodifiable(activities), + transitionOutcomeCodes: List.unmodifiable( + outcomes, + ), + ); + } + + TaskTransitionResult _completeWithTransition({ + required Task task, + required String operationId, + required DateTime occurredAt, + required Iterable existingActivities, + required Iterable appliedActivityIds, + required Map metadata, + }) { + return transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: operationId, + activityId: _activityId( + operationId, + task.id, + TaskActivityCode.completed, + ), + occurredAt: occurredAt, + existingActivities: existingActivities, + appliedActivityIds: appliedActivityIds, + metadata: metadata, ); } } @@ -439,13 +731,22 @@ List _replaceTask(List tasks, Task replacement) { .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, - ); +String _defaultOperationId(String actionName, String taskId, DateTime at) { + return '$actionName:$taskId:${at.toIso8601String()}'; +} + +String _activityId( + String operationId, + String taskId, + TaskActivityCode activityCode, +) { + return '$operationId:$taskId:${activityCode.name}'; +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; } diff --git a/test/child_tasks_test.dart b/test/child_tasks_test.dart index 1cdcb7e..0354ae2 100644 --- a/test/child_tasks_test.dart +++ b/test/child_tasks_test.dart @@ -217,6 +217,172 @@ void main() { }); }); + group('Child task break-up command', () { + final now = DateTime(2026, 6, 19, 12); + const service = ChildTaskBreakUpService(); + + test('creates ordered direct children for one parent', () { + final parent = task( + id: 'parent', + title: 'Clean kitchen', + createdAt: now, + ).copyWith(projectId: 'home'); + final request = ChildTaskBreakUpRequest( + parentTaskId: parent.id, + operationId: 'break-parent-1', + entries: [ + childEntry( + id: 'first', + title: 'Clear sink', + createdAt: now, + ), + childEntry( + id: 'second', + title: 'Wipe counter', + createdAt: now, + priority: PriorityLevel.high, + ), + ], + ); + + final result = service.breakUp(tasks: [parent], request: request); + + expect(result.parentTask, parent); + expect(result.operationId, 'break-parent-1'); + expect(result.createdChildTaskIds, ['first', 'second']); + expect(result.childTasks.map((child) => child.id), ['first', 'second']); + expect( + result.childTasks.every((child) => child.parentTaskId == parent.id), + isTrue); + expect(result.childTasks.every((child) => child.projectId == 'home'), + isTrue); + expect(result.tasks.map((task) => task.id), [ + 'parent', + 'first', + 'second', + ]); + expect(result.activities, isEmpty); + }); + + test('rejects empty child sets duplicate ids and reused task ids', () { + final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now); + final existingChild = task( + id: 'existing', + title: 'Existing child', + createdAt: now, + parentTaskId: parent.id, + ); + + expect( + () => service.breakUp( + tasks: [parent], + request: ChildTaskBreakUpRequest( + parentTaskId: parent.id, + operationId: 'empty', + entries: const [], + ), + ), + throwsArgumentError, + ); + expect( + () => service.breakUp( + tasks: [parent], + request: ChildTaskBreakUpRequest( + parentTaskId: parent.id, + operationId: 'duplicate', + entries: [ + childEntry(id: 'same', title: 'One', createdAt: now), + childEntry(id: 'same', title: 'Two', createdAt: now), + ], + ), + ), + throwsArgumentError, + ); + expect( + () => service.breakUp( + tasks: [parent, existingChild], + request: ChildTaskBreakUpRequest( + parentTaskId: parent.id, + operationId: 'reused', + entries: [ + childEntry(id: existingChild.id, title: 'Again', createdAt: now), + ], + ), + ), + throwsArgumentError, + ); + }); + + test('rejects self-parenting and invalid child row fields', () { + final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now); + + expect( + () => service.breakUp( + tasks: [parent], + request: ChildTaskBreakUpRequest( + parentTaskId: parent.id, + operationId: 'self', + entries: [ + childEntry(id: parent.id, title: 'Self', createdAt: now), + ], + ), + ), + throwsArgumentError, + ); + expect( + () => service.breakUp( + tasks: [parent], + request: ChildTaskBreakUpRequest( + parentTaskId: parent.id, + operationId: 'blank-title', + entries: [ + childEntry(id: 'child', title: ' ', createdAt: now), + ], + ), + ), + throwsArgumentError, + ); + expect( + () => service.breakUp( + tasks: [parent], + request: ChildTaskBreakUpRequest( + parentTaskId: parent.id, + operationId: 'bad-duration', + entries: [ + ChildTaskEntry( + id: 'child', + title: 'Clear sink', + createdAt: now, + durationMinutes: 0, + ), + ], + ), + ), + throwsA(isA()), + ); + }); + + test('request snapshots entry lists', () { + final entries = [ + childEntry(id: 'child', title: 'Clear sink', createdAt: now), + ]; + + final request = ChildTaskBreakUpRequest( + parentTaskId: 'parent', + operationId: 'break', + entries: entries, + ); + entries.clear(); + + expect(request.entries.map((entry) => entry.id), ['child']); + expect( + () => request.entries.add( + childEntry(id: 'other', title: 'Other', createdAt: now), + ), + throwsUnsupportedError); + }); + }); + group('Child task edge-case regressions', () { final now = DateTime(2026, 6, 19, 12); @@ -438,10 +604,11 @@ void main() { status: TaskStatus.completed, parentTaskId: parent.id, ); - final lastChild = task( + final lastChild = scheduledTask( id: 'last-child', title: 'Wipe counter', createdAt: now, + start: DateTime(2026, 6, 19, 11), parentTaskId: parent.id, ); @@ -456,6 +623,13 @@ void main() { expect(result.autoCompletedParent, isTrue); expect(result.completedParentId, parent.id); expect(result.forceCompletedChildIds, isEmpty); + expect(result.activities.map((activity) => activity.taskId), [ + 'last-child', + 'parent', + ]); + expect(result.activities.last.metadata['completionSource'], + 'autoParentAfterLastChild'); + expect(taskById(result.tasks, 'last-child').stats.completedLateCount, 1); }); test('parent complete force-completes remaining direct children', () { @@ -492,6 +666,15 @@ void main() { expect(result.forceCompletedChildIds, ['planned-child']); expect(result.autoCompletedParent, isFalse); expect(result.completedParentId, parent.id); + expect(result.activities.map((activity) => activity.taskId), [ + 'parent', + 'planned-child', + ]); + expect(result.activities.first.metadata['completionSource'], 'parent'); + expect( + result.activities.last.metadata['completionSource'], + 'parentForce', + ); }); test('partial child completion does not complete parent', () { @@ -576,6 +759,116 @@ void main() { expect(result.changedTaskIds, ['parent', 'first-child', 'second-child']); expect(result.canCompleteParentExplicitly, isFalse); }); + + test('parent can be completed from a direct child', () { + final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now); + final initiatingChild = task( + id: 'initiating-child', + title: 'Clear sink', + createdAt: now, + parentTaskId: parent.id, + ); + final sibling = task( + id: 'sibling', + title: 'Wipe counter', + createdAt: now, + parentTaskId: parent.id, + ); + + final result = service.completeParentFromChild( + tasks: [parent, initiatingChild, sibling], + childTaskId: initiatingChild.id, + updatedAt: now, + operationId: 'complete-from-child', + ); + + expect(taskById(result.tasks, parent.id).status, TaskStatus.completed); + expect( + taskById(result.tasks, initiatingChild.id).status, + TaskStatus.completed, + ); + expect(taskById(result.tasks, sibling.id).status, TaskStatus.completed); + expect(result.forceCompletedChildIds, ['initiating-child', 'sibling']); + expect(result.activities.map((activity) => activity.taskId), [ + 'parent', + 'initiating-child', + 'sibling', + ]); + expect( + result.activities.first.metadata['initiatingChildTaskId'], + initiatingChild.id, + ); + }); + + test('completion retry with existing activities does not duplicate facts', + () { + final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now); + final child = task( + id: 'child', + title: 'Clear sink', + createdAt: now, + parentTaskId: parent.id, + ); + final first = service.completeParent( + tasks: [parent, child], + parentTaskId: parent.id, + updatedAt: now, + operationId: 'complete-parent-once', + ); + + final retry = service.completeParent( + tasks: first.tasks, + parentTaskId: parent.id, + updatedAt: now.add(const Duration(minutes: 10)), + operationId: 'complete-parent-once', + existingActivities: first.activities, + ); + + expect(retry.changedTaskIds, isEmpty); + expect(retry.activities, isEmpty); + expect(retry.transitionOutcomeCodes, [ + TaskTransitionOutcomeCode.duplicateOperation, + ]); + expect(taskById(retry.tasks, parent.id).completedAt, now); + expect(taskById(retry.tasks, child.id).completedAt, now); + }); + + test('force completion preserves already terminal child facts', () { + final completedAt = now.subtract(const Duration(hours: 1)); + 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, + ).copyWith( + updatedAt: completedAt, + completedAt: completedAt, + ); + final cancelledChild = task( + id: 'cancelled-child', + title: 'Wipe counter', + createdAt: now, + status: TaskStatus.cancelled, + parentTaskId: parent.id, + ); + + final result = service.completeParent( + tasks: [parent, completedChild, cancelledChild], + parentTaskId: parent.id, + updatedAt: now, + ); + + expect(result.forceCompletedChildIds, isEmpty); + expect( + taskById(result.tasks, completedChild.id).completedAt, completedAt); + expect( + taskById(result.tasks, cancelledChild.id).status, + TaskStatus.cancelled, + ); + expect(result.activities.map((activity) => activity.taskId), ['parent']); + }); }); }