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 5695b4d..a10eece 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 @@ -34,16 +34,16 @@ passed. The current suite has 143 passing tests. | Time model | DateTime-based schedule intervals and recurring wall-clock helpers | Civil-date/wall-time/time-zone semantics, DST policy, deterministic clocks, and safe date-only persistence | 11 | | Scheduling engine | Flexible insertion, next-slot push, tomorrow push, backlog push, rollover, overlap analysis, centralized occupancy, free-slot protection, surprise/actual occupancy, structured non-localized result codes, invariant coverage, Today pending-notice read model | Application-facing command orchestration and final backend acceptance scenarios | 14, 17 | | Locked time | Recurring blocks, one-day remove/replace/add overrides, hidden overlay mapping | Strong override validation, date/time-zone safety, complete persistence mapping, date-scoped repository queries | 11, 15 | -| Free slots | Task type, timeline token, creation/update rules, protection from flexible scheduling, reminder suppression policy, and overlap tests | Application use cases that persist resulting changes atomically | 12, 13, 14 | -| 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, push-before-completion values, project aggregates, and learned suggestion inputs | Atomic persistence | 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 | +| Free slots | Task type, timeline token, creation/update rules, protection from flexible scheduling, reminder suppression policy, overlap tests, and atomic create/update/remove application commands | Management/settings queries and future UI rendering | 12, 13, 14, 18 | +| Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, completion/locked-hour accounting, and atomic application command persistence | Final backend acceptance scenarios | 12, 13, 14, 17 | +| Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, exactly-once task-stat accounting, and atomic V1 application commands | Management queries and final backend acceptance scenarios | 13, 14, 17 | +| Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, push-before-completion values, project aggregates, learned suggestion inputs, and atomic command persistence | MongoDB adapter persistence and final backend acceptance scenarios | 14, 15, 16, 17 | +| Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, idempotent force completion, and atomic application commands | Final backend acceptance scenarios | 13, 14, 17 | | Project defaults | Configured static defaults, reminder profile on project, per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution, and effective reminder-profile resolver | Application-layer persistence/query orchestration | 13, 14 | | Reminder profiles | Gentle/persistent/strict/silent enum on project, task override, effective-profile resolver, typed reminder directives, and Free Slot suppression policy | 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, and pending notice projection | Flutter rendering and later management/read-model queries | 14, 18 | | Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 | -| Application layer | Application operation context, typed result/failure contracts, centralized scheduler input loader, side-effect-free read boundary, complete Today query/read model, and in-memory atomic unit-of-work foundation | V1 command use cases that invoke domain rules and return UI-ready mutation results | 14 | +| Application layer | Application operation context, typed result/failure contracts, centralized scheduler input loader, side-effect-free read boundary, complete Today query/read model, V1 atomic command use cases, and in-memory atomic unit-of-work foundation | Management queries/settings commands and explicit rollover/startup orchestration | 14 | | Repository contracts | Basic task/project/locked/snapshot interfaces, activity/settings/project-statistics/operation repository contracts, and in-memory staged unit of work | Date/project/parent queries, archive/delete behavior, revisions, complete persistence codecs, and adapter conformance suite | 14, 15 | | Document mapping | Task, task-statistics, and project-statistics map round trips; field-name constants for other entities | Complete codecs for every repository entity, explicit stable codes, schema version, civil-time encoding, migration fixtures | 15 | | MongoDB runtime | Committed target documented; no driver/runtime yet | Trusted runtime decision, actual adapter, indexes, transactions/optimistic concurrency, integration tests, secret handling | 16 | diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md b/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md index 102d889..9edcafb 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md @@ -152,6 +152,8 @@ Verification: Recommended Codex level: extra high +Status: Complete + Tasks: Implement application commands for: @@ -196,6 +198,40 @@ Acceptance criteria: - Failure tests prove rollback. - Command outputs are sufficient for a UI to refresh predictably. +Completed implementation: + +- Added `src/application_commands.dart` with `V1ApplicationCommandUseCases`, + stable `ApplicationCommandCode` values, child draft input, structured command + results, and read refresh hints. +- Added atomic commands for quick capture to backlog, quick capture to next + available slot, backlog item scheduling, flexible next-slot push, flexible + tomorrow/top push, flexible move to backlog, flexible completion, required + done/missed/cancel/no-longer-relevant actions, surprise logging with flexible + repair, protected Free Slot create/update/remove, child task break-up, child + completion, parent completion, and parent-from-child completion. +- Commands load authoritative repository state, expand locked intervals through + the explicit owner time-zone context where scheduling or completion accounting + needs it, and invoke the existing pure domain services. +- Commands enforce idempotency through the operation record boundary and use + optional `expectedUpdatedAt` guards for V1 stale-write protection until Block + 15 adds explicit persisted revisions. +- Commands persist changed tasks, new activities, project aggregate updates, and + operation records in one `ApplicationUnitOfWork.run` boundary. +- Command results include changed tasks, activities, project statistics, notices, + scheduling changes/overlaps, child ids, protected-rest conflicts, and read + hints so UI callers do not need to infer what changed. +- Added command tests for quick capture, backlog scheduling, stale revisions, + commit rollback, flexible pushes, backlog movement, completion/project stats, + surprise repair, Free Slot create/update/remove, child break-up, and + parent/child completion propagation. + +Verification: + +- `dart format lib test`: passed, 0 files changed after final format +- `dart analyze`: passed, no issues found +- `dart test`: passed, 268 tests +- `git diff --check`: passed + BREAKPOINT: Stop here. Confirm `high` mode before adding management queries and startup/rollover orchestration. 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 6fccc8a..60e0c39 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -14,6 +14,7 @@ The public library entry point is `lib/scheduler_core.dart`. It exports the following source files: - `src/models.dart` +- `src/application_commands.dart` - `src/application_layer.dart` - `src/backlog.dart` - `src/child_tasks.dart` @@ -41,6 +42,7 @@ changes from accidental API drift. | File | Enums | |---|---| +| `src/application_commands.dart` | `ApplicationCommandCode` | | `src/application_layer.dart` | `ApplicationFailureCode` | | `src/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` | | `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` | @@ -61,6 +63,7 @@ changes from accidental API drift. | File | Public API | |---|---| +| `src/application_commands.dart` | `ApplicationChildTaskDraft`, `ApplicationCommandReadHint`, `ApplicationCommandResult`, `V1ApplicationCommandUseCases` | | `src/application_layer.dart` | `OwnerTimeZoneContext`, `ApplicationOperationContext`, `ApplicationFailure`, `ApplicationResult`, `ApplicationFailureException`, `ApplicationPersistenceException`, `ApplicationOperationRecord`, `OwnerSettings`, `TaskActivityRepository`, `ProjectStatisticsRepository`, `OwnerSettingsRepository`, `ApplicationOperationRepository`, `ApplicationUnitOfWorkRepositories`, `ApplicationUnitOfWork`, `ApplicationSchedulingLoader`, `InMemoryApplicationUnitOfWork` | | `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` | | `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` | @@ -289,6 +292,23 @@ Chunk 14.2 intentionally changed the public API: - Updated compact next-flexible selection so the current flexible item is not also returned as `nextFlexibleItem`. +Chunk 14.3 intentionally changed the public API: + +- Added `src/application_commands.dart` and exported it from + `scheduler_core.dart`. +- Added `ApplicationCommandCode`, `ApplicationChildTaskDraft`, + `ApplicationCommandReadHint`, `ApplicationCommandResult`, and + `V1ApplicationCommandUseCases`. +- Added atomic V1 commands for quick capture, backlog scheduling, flexible + push/backlog/completion, required task actions, surprise logging, protected + Free Slot create/update/remove, child break-up, and parent/child completion. +- Command results return changed tasks, persisted activities, project aggregate + updates, scheduler notices/changes/overlaps, child ids, and read refresh + hints. +- Application command failures use stable `ApplicationFailureCode` values for + not found, stale revision, no slot, validation, persistence, and duplicate + operation outcomes. + ## 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 53e37a8..e69530b 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -77,6 +77,13 @@ Latest Block 14.2 verification result on 2026-06-25: - `dart test`: passed, 257 tests - `git diff --check`: passed +Latest Block 14.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, 268 tests +- `git diff --check`: passed + Status values: - `complete`: Current backend APIs and tests cover the requirement. @@ -91,20 +98,20 @@ Status values: | ID | Acceptance criterion | Production API surface | Representative tests | Status | Active-plan reference or issue | |---|---|---|---|---|---| -| MVP-AC-01 | Create a task through quick capture with only a title. | `Task.quickCapture`, `QuickCaptureRequest`, `QuickCaptureService.capture` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Keep constructor invariants under Chunk 11.3. | +| MVP-AC-01 | Create a task through quick capture with only a title. | `V1ApplicationCommandUseCases.quickCaptureToBacklog`, `Task.quickCapture`, `QuickCaptureRequest`, `QuickCaptureService.capture` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds the atomic application command boundary. | | MVP-AC-02 | Store quick-capture tasks in Backlog with neutral defaults. | `Task.quickCapture`, `QuickCaptureService.capture`, `TaskStatus.backlog`, `PriorityLevel.medium`, `RewardLevel.notSet`, inbox project default | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Backlog-entered timestamp remains incomplete; see Blocks 11 and 15. | -| MVP-AC-03 | Optionally schedule a quick-capture task into the next available slot after entering duration. | `QuickCaptureRequest.scheduleImmediately`, `QuickCaptureService.capture`, `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot avoidance, structured no-slot outcomes, and invariant coverage. | +| MVP-AC-03 | Optionally schedule a quick-capture task into the next available slot after entering duration. | `V1ApplicationCommandUseCases.quickCaptureToNextAvailableSlot`, `QuickCaptureRequest.scheduleImmediately`, `QuickCaptureService.capture`, `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart`, `test/application_commands_test.dart` | complete | Block 12 covers scheduling rules; Chunk 14.3 adds the atomic application command boundary. | | MVP-AC-04 | Create recurring hidden locked blocks and one-day overrides. | `LockedBlock`, `LockedBlockRecurrence.weekly`, `LockedBlockOverride.remove`, `LockedBlockOverride.replace`, `LockedBlockOverride.add`, `expandLockedBlocksForDay` | `test/scheduling_engine_test.dart`, `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Date-only/time-zone hardening remains in Chunk 11.4 and Block 15. | | MVP-AC-05 | Render Today as a timeline with project border, task-type background, task name, reward icon, and difficulty icon. | `GetTodayStateQuery`, `TodayState`, `TodayTimelineItem`, `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart`, `test/today_state_test.dart` | incomplete | Backend Today read model and tokens are complete; actual Flutter rendering is Block 18. | | MVP-AC-06 | Use compact Today mode manually. | `GetTodayStateQuery`, `TodayCompactState`, `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart`, `test/today_state_test.dart` | complete | Complete at backend/read-model level, including current/next deduplication; Flutter UI remains Block 18. | -| MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods, `TaskActivityCode` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 adds internal activity records; exactly-once statistics/accounting continues in Chunk 13.2. | -| 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-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `V1ApplicationCommandUseCases.pushFlexibleToNextAvailableSlot`, `V1ApplicationCommandUseCases.pushFlexibleToTomorrowTopOfQueue`, `V1ApplicationCommandUseCases.moveFlexibleToBacklog`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods, `TaskActivityCode` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic application commands with persisted activities and operation records. | +| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `V1ApplicationCommandUseCases.scheduleBacklogItemToNextAvailableSlot`, `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`, `test/application_commands_test.dart` | complete | Block 12 covers scheduling rules; Chunk 14.3 adds the atomic application command boundary. | | MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks`, `SchedulingNotice`, `TodayPendingNotice`, `GetTodayStateQuery` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart`, `test/today_state_test.dart` | incomplete | Domain movement, structured rollover notices, and Today pending-notice read model exist; explicit durable app-open rollover orchestration remains in Chunk 14.5. | -| 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`, `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-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `V1ApplicationCommandUseCases.logSurpriseTask`, `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic surprise logging use case with flexible repair, activity persistence, project stats, and operation records. | +| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `V1ApplicationCommandUseCases.breakUpTask`, `ApplicationChildTaskDraft`, `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic child break-up command wiring. | +| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `V1ApplicationCommandUseCases.completeChildTask`, `V1ApplicationCommandUseCases.completeParentTask`, `V1ApplicationCommandUseCases.completeParentFromChild`, `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers, `TaskActivity` | `test/child_tasks_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic parent/child completion command wiring. | | 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`, `ProjectStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `ProjectStatisticsAggregationService`, `ApplicationUnitOfWork`, 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`, `test/project_statistics_test.dart`, `test/application_layer_test.dart` | incomplete | Chunks 13.1-13.4 add canonical activities, exactly-once task/project statistic accounting, parent/child completion metadata, and learned suggestion aggregates; Chunk 14.1 adds the atomic in-memory unit-of-work foundation; concrete command integration remains in Chunk 14.3. | +| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `ProjectStatisticsAggregationService`, `ApplicationUnitOfWork`, `V1ApplicationCommandUseCases`, 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`, `test/project_statistics_test.dart`, `test/application_layer_test.dart`, `test/application_commands_test.dart` | complete | Chunks 13.1-13.4 add canonical statistics/accounting; Chunk 14.3 persists command activities and project aggregate updates atomically in memory. MongoDB adapter persistence remains tracked separately under MVP-SUP-07. | ## Additional MVP backend requirements @@ -114,11 +121,11 @@ Status values: | MVP-SUP-02 | Critical missed tasks are marked missed and moved to backlog. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskTransitionService`, `TaskType.critical` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 records both missed and moved-to-backlog activities. | | MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskTransitionService`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. | | MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction`, `TaskActivityCode` | `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 centralizes both transitions and emits distinct activity codes. | -| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, `ReminderPolicyService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart`, `test/reminder_policy_test.dart` | complete | Blocks 12 and 13 cover scheduling protection, explicit required conflicts, and reminder suppression/directives at the backend policy level. | +| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `V1ApplicationCommandUseCases.createProtectedFreeSlot`, `V1ApplicationCommandUseCases.updateProtectedFreeSlot`, `V1ApplicationCommandUseCases.removeProtectedFreeSlot`, `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, `ReminderPolicyService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart`, `test/reminder_policy_test.dart`, `test/application_commands_test.dart` | complete | Blocks 12 and 13 cover scheduling/reminder policy; Chunk 14.3 adds atomic protected Free Slot commands. | | MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ProjectStatistics`, `ProjectSuggestionService`, `ProjectDefaultResolution`, `ReminderPolicyService`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart`, `test/project_statistics_test.dart`, `test/reminder_policy_test.dart` | complete | Chunk 13.5 adds effective reminder resolution without using learned suggestions as silent configuration. | -| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, `ApplicationUnitOfWork`, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart`, `test/application_layer_test.dart` | incomplete | Chunk 14.1 adds pure Dart application transaction contracts and staged in-memory unit-of-work behavior; complete codecs, revisions, and runtime adapter remain in Blocks 15 and 16. | +| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, `ApplicationUnitOfWork`, `V1ApplicationCommandUseCases`, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart`, `test/application_layer_test.dart`, `test/application_commands_test.dart` | incomplete | Chunks 14.1-14.3 add pure Dart transaction/read/command contracts; complete codecs, durable revisions, and runtime adapter remain in Blocks 15 and 16. | | MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence`, `GetTodayStateQuery`, `TodayTimelineItem` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart`, `test/today_state_test.dart` | complete | Chunk 14.2 adds hidden-by-default Today omission, explicit reveal mode, and date-stable locked overlay IDs. | -| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, `ApplicationUnitOfWork`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart` | incomplete | Chunks 13.1-13.4 add canonical transition/activity records plus exactly-once task/project statistic accounting; Chunk 14.1 adds atomic transaction contracts; concrete action use-case persistence remains in Chunk 14.3. | +| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, `ApplicationUnitOfWork`, `V1ApplicationCommandUseCases`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart`, `test/application_commands_test.dart` | complete | Chunk 14.3 adds atomic command persistence for movement activities, task stats, project stats, and operation records. | | MVP-SUP-10 | The human spec lists `pushed` and `skipped` as statuses. | `TaskStatus` excludes both; movement and burnout compatibility are modeled as activity/stat metadata. | `test/domain_contracts_test.dart` | contradictory | Resolved by `V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md`; the contradiction is kept visible so later chunks do not add movement labels as statuses. | ## Intentionally deferred human-spec items diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index d56c423..1b4de42 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -17,6 +17,7 @@ // Keep this file small. Implementation details belong in `lib/src/`. export 'src/models.dart'; +export 'src/application_commands.dart'; export 'src/application_layer.dart'; export 'src/backlog.dart'; export 'src/child_tasks.dart'; diff --git a/lib/src/application_commands.dart b/lib/src/application_commands.dart new file mode 100644 index 0000000..63efcee --- /dev/null +++ b/lib/src/application_commands.dart @@ -0,0 +1,1382 @@ +// Atomic V1 application commands. +// +// These use cases are the UI-facing mutation boundary. They load authoritative +// repository state, invoke pure domain services, and persist all resulting task, +// activity, project-statistic, and operation-record changes in one unit of work. + +import 'application_layer.dart'; +import 'child_tasks.dart'; +import 'free_slots.dart'; +import 'locked_time.dart'; +import 'models.dart'; +import 'project_statistics.dart'; +import 'quick_capture.dart'; +import 'scheduling_engine.dart'; +import 'task_actions.dart'; +import 'task_lifecycle.dart'; +import 'time_contracts.dart'; + +/// Stable V1 application command identifiers. +enum ApplicationCommandCode { + quickCaptureToBacklog, + quickCaptureToNextAvailableSlot, + scheduleBacklogItemToNextAvailableSlot, + pushFlexibleToNextAvailableSlot, + pushFlexibleToTomorrowTopOfQueue, + moveFlexibleToBacklog, + completeFlexibleTask, + applyRequiredTaskAction, + logSurpriseTask, + createProtectedFreeSlot, + updateProtectedFreeSlot, + removeProtectedFreeSlot, + breakUpTask, + completeChildTask, + completeParentTask, + completeParentFromChild, +} + +/// Draft row for creating one child task from an application command. +class ApplicationChildTaskDraft { + const ApplicationChildTaskDraft({ + required this.title, + this.priority, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + this.durationMinutes, + this.projectId, + }); + + /// User-entered child title. + final String title; + + /// Optional child priority. + final PriorityLevel? priority; + + /// Optional reward estimate. + final RewardLevel reward; + + /// Optional difficulty estimate. + final DifficultyLevel difficulty; + + /// Optional duration estimate. + final int? durationMinutes; + + /// Optional project override. Null inherits from the parent. + final String? projectId; +} + +/// Hint for read models that should be refreshed after a command. +class ApplicationCommandReadHint { + ApplicationCommandReadHint({ + CivilDate? localDate, + Iterable affectedDates = const [], + Iterable affectedTaskIds = const [], + this.refreshToday = true, + }) : localDate = localDate, + affectedDates = List.unmodifiable( + { + if (localDate != null) localDate, + ...affectedDates, + }, + ), + affectedTaskIds = List.unmodifiable(affectedTaskIds); + + /// Primary local date for a Today refresh, when known. + final CivilDate? localDate; + + /// All local dates touched by the command. + final List affectedDates; + + /// Task ids changed or created by the command. + final List affectedTaskIds; + + /// Whether Today-style read models should be refreshed. + final bool refreshToday; +} + +/// Structured result returned by V1 application commands. +class ApplicationCommandResult { + ApplicationCommandResult({ + required this.commandCode, + required String operationId, + required List changedTasks, + required this.readHint, + List activities = const [], + List projectStatistics = const [], + List notices = const [], + List changes = const [], + List overlaps = const [], + List protectedFreeSlotConflicts = + const [], + List childTaskIds = const [], + this.schedulingResult, + }) : operationId = _requiredTrimmed(operationId, 'operationId'), + changedTasks = List.unmodifiable(changedTasks), + activities = List.unmodifiable(activities), + projectStatistics = List.unmodifiable( + projectStatistics, + ), + notices = List.unmodifiable(notices), + changes = List.unmodifiable(changes), + overlaps = List.unmodifiable(overlaps), + protectedFreeSlotConflicts = + List.unmodifiable( + protectedFreeSlotConflicts, + ), + childTaskIds = List.unmodifiable(childTaskIds); + + /// Command that produced this result. + final ApplicationCommandCode commandCode; + + /// Exactly-once operation id. + final String operationId; + + /// Tasks inserted or changed by the command. + final List changedTasks; + + /// New internal activities persisted with the command. + final List activities; + + /// Project aggregates updated by completion activities. + final List projectStatistics; + + /// Scheduling notices returned by domain services. + final List notices; + + /// Scheduling movement records returned by domain services. + final List changes; + + /// Scheduling overlaps returned by domain services. + final List overlaps; + + /// Protected rest conflicts caused by an explicit required placement. + final List protectedFreeSlotConflicts; + + /// Child task ids created or affected by child-task commands. + final List childTaskIds; + + /// Full scheduling result when the command invoked the scheduler. + final SchedulingResult? schedulingResult; + + /// Read-model refresh hint for UI/state management. + final ApplicationCommandReadHint readHint; +} + +/// UI-facing V1 command use cases. +class V1ApplicationCommandUseCases { + const V1ApplicationCommandUseCases({ + required this.applicationStore, + required this.timeZoneResolver, + this.resolutionOptions = const TimeZoneResolutionOptions(), + this.quickCaptureService = const QuickCaptureService(), + this.flexibleTaskActionService = const FlexibleTaskActionService(), + this.requiredTaskActionService = const RequiredTaskActionService(), + this.surpriseTaskLogService = const SurpriseTaskLogService(), + this.freeSlotService = const FreeSlotService(), + this.childTaskBreakUpService = const ChildTaskBreakUpService(), + this.childTaskCompletionService = const ChildTaskCompletionService(), + this.projectStatisticsAggregationService = + const ProjectStatisticsAggregationService(), + }); + + /// Atomic repository boundary. + final ApplicationUnitOfWork applicationStore; + + /// Explicit local-time resolver for planning windows and locked expansion. + final TimeZoneResolver timeZoneResolver; + + /// DST/nonexistent/repeated local time policy. + final TimeZoneResolutionOptions resolutionOptions; + + final QuickCaptureService quickCaptureService; + final FlexibleTaskActionService flexibleTaskActionService; + final RequiredTaskActionService requiredTaskActionService; + final SurpriseTaskLogService surpriseTaskLogService; + final FreeSlotService freeSlotService; + final ChildTaskBreakUpService childTaskBreakUpService; + final ChildTaskCompletionService childTaskCompletionService; + final ProjectStatisticsAggregationService projectStatisticsAggregationService; + + /// Quick capture a task into backlog. + Future> quickCaptureToBacklog({ + required ApplicationOperationContext context, + required String title, + String projectId = 'inbox', + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + TaskType type = TaskType.flexible, + int? durationMinutes, + Set backlogTags = const {}, + }) { + const commandCode = ApplicationCommandCode.quickCaptureToBacklog; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final beforeTasks = await repositories.tasks.findAll(); + final result = quickCaptureService.capture( + QuickCaptureRequest( + id: context.idGenerator.nextId(), + title: title, + createdAt: context.now, + addToNextAvailableSlot: false, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + type: type, + durationMinutes: durationMinutes, + backlogTags: backlogTags, + ), + updatedAt: context.now, + ); + if (!result.isValid) { + return _failure( + ApplicationFailureCode.validation, + detailCode: QuickCaptureStatus.validationError.name, + ); + } + + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: beforeTasks, + afterTasks: [...beforeTasks, result.task], + activities: const [], + readHint: ApplicationCommandReadHint( + affectedTaskIds: [result.task.id], + refreshToday: false, + ), + ); + }, + ); + } + + /// Quick capture a flexible task and schedule it into the next available slot. + Future> + quickCaptureToNextAvailableSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String title, + required int durationMinutes, + String projectId = 'inbox', + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + }) { + const commandCode = ApplicationCommandCode.quickCaptureToNextAvailableSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final taskId = context.idGenerator.nextId(); + final result = quickCaptureService.capture( + QuickCaptureRequest( + id: taskId, + title: title, + createdAt: context.now, + addToNextAvailableSlot: true, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + durationMinutes: durationMinutes, + ), + schedulingInput: state.input, + updatedAt: context.now, + ); + if (!result.isValid) { + return _failureForQuickCapture(result); + } + + final activities = _activitiesForSchedulingChanges( + result: result.schedulingResult, + beforeTasks: [result.task, ...state.tasks], + operationId: context.operationId, + occurredAt: context.now, + selectedTaskId: result.task.id, + selectedActivityCode: TaskActivityCode.restoredFromBacklog, + movedActivityCode: TaskActivityCode.automaticallyPushed, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: result.schedulingResult?.tasks ?? [result.task], + activities: activities, + schedulingResult: result.schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _taskIdsFromTasks([result.task]), + ), + ); + }, + ); + } + + /// Schedule an existing backlog item into the next available flexible slot. + Future> + scheduleBacklogItemToNextAvailableSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = + ApplicationCommandCode.scheduleBacklogItemToNextAvailableSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final task = _taskById(state.tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + + final schedulingResult = + const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot( + input: state.input, + taskId: taskId, + updatedAt: context.now, + ); + final schedulingFailure = _failureForSchedulingResult( + schedulingResult, + entityId: taskId, + ); + if (schedulingFailure != null) { + return ApplicationResult.failure(schedulingFailure); + } + + final activities = _activitiesForSchedulingChanges( + result: schedulingResult, + beforeTasks: state.tasks, + operationId: context.operationId, + occurredAt: context.now, + selectedTaskId: taskId, + selectedActivityCode: TaskActivityCode.restoredFromBacklog, + movedActivityCode: TaskActivityCode.automaticallyPushed, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: schedulingResult.tasks, + activities: activities, + schedulingResult: schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _taskIdsFromChanges(schedulingResult.changes), + ), + ); + }, + ); + } + + /// Push a planned flexible task later in the same local day. + Future> + pushFlexibleToNextAvailableSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + return _pushFlexible( + commandCode: ApplicationCommandCode.pushFlexibleToNextAvailableSlot, + context: context, + localDate: localDate, + taskId: taskId, + destination: PushDestination.nextAvailableSlot, + expectedUpdatedAt: expectedUpdatedAt, + ); + } + + /// Push a planned flexible task to the top of a future/tomorrow queue. + Future> + pushFlexibleToTomorrowTopOfQueue({ + required ApplicationOperationContext context, + required CivilDate targetDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + return _pushFlexible( + commandCode: ApplicationCommandCode.pushFlexibleToTomorrowTopOfQueue, + context: context, + localDate: targetDate, + taskId: taskId, + destination: PushDestination.tomorrowTopOfQueue, + expectedUpdatedAt: expectedUpdatedAt, + ); + } + + /// Move a planned flexible task to backlog. + Future> moveFlexibleToBacklog({ + required ApplicationOperationContext context, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.moveFlexibleToBacklog; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final task = _taskById(tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final actionResult = flexibleTaskActionService.apply( + task: task, + action: FlexibleTaskQuickAction.backlog, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ); + if (actionResult.transitionOutcomeCode == + TaskTransitionOutcomeCode.invalidState) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + final afterTasks = _replaceTask(tasks, actionResult.task); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: afterTasks, + activities: actionResult.activities, + readHint: ApplicationCommandReadHint( + affectedTaskIds: [taskId], + refreshToday: true, + ), + ); + }, + ); + } + + /// Complete a flexible task. + Future> completeFlexibleTask({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + DateTime? actualStart, + DateTime? actualEnd, + }) { + const commandCode = ApplicationCommandCode.completeFlexibleTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final task = _taskById(state.tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final actionResult = flexibleTaskActionService.apply( + task: task, + action: FlexibleTaskQuickAction.done, + updatedAt: context.now, + operationId: context.operationId, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: state.lockedIntervals, + existingActivities: existingActivities, + ); + if (actionResult.transitionOutcomeCode == + TaskTransitionOutcomeCode.invalidState) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: _replaceTask(state.tasks, actionResult.task), + activities: actionResult.activities, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Apply a required visible task lifecycle action. + Future> applyRequiredTaskAction({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + required RequiredTaskAction action, + DateTime? expectedUpdatedAt, + DateTime? actualStart, + DateTime? actualEnd, + }) { + const commandCode = ApplicationCommandCode.applyRequiredTaskAction; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final task = _taskById(state.tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final actionResult = requiredTaskActionService.apply( + task: task, + action: action, + updatedAt: context.now, + operationId: context.operationId, + actualStart: actualStart, + actualEnd: actualEnd, + lockedIntervals: state.lockedIntervals, + existingActivities: existingActivities, + ); + if (actionResult.transitionOutcomeCode == + TaskTransitionOutcomeCode.invalidState) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: _replaceTask(state.tasks, actionResult.task), + activities: actionResult.activities, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Log completed surprise work and repair overlapping flexible work. + Future> logSurpriseTask({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String title, + DateTime? startedAt, + int? timeUsedMinutes, + String projectId = 'inbox', + PriorityLevel? priority, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + bool revealHiddenLockedDetails = false, + }) { + const commandCode = ApplicationCommandCode.logSurpriseTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final logResult = surpriseTaskLogService.log( + request: SurpriseTaskLogRequest( + id: context.operationId, + title: title, + createdAt: context.now, + startedAt: startedAt, + timeUsedMinutes: timeUsedMinutes, + projectId: projectId, + priority: priority, + reward: reward, + difficulty: difficulty, + ), + input: state.input, + updatedAt: context.now, + revealHiddenLockedDetails: revealHiddenLockedDetails, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: logResult.schedulingResult.tasks, + activities: logResult.activities, + schedulingResult: logResult.schedulingResult, + protectedFreeSlotConflicts: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _changedTaskIds( + state.tasks, + logResult.schedulingResult.tasks, + ), + ), + ); + }, + ); + } + + /// Create a protected Free Slot task. + Future> createProtectedFreeSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String title, + required DateTime start, + required DateTime end, + String projectId = 'inbox', + }) { + const commandCode = ApplicationCommandCode.createProtectedFreeSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final freeSlot = freeSlotService.create( + id: context.idGenerator.nextId(), + title: title, + start: start, + end: end, + createdAt: context.now, + updatedAt: context.now, + projectId: projectId, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: [...tasks, freeSlot], + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [freeSlot.id], + ), + ); + }, + ); + } + + /// Update a protected Free Slot task. + Future> updateProtectedFreeSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + required DateTime start, + required DateTime end, + DateTime? expectedUpdatedAt, + String? title, + String? projectId, + }) { + const commandCode = ApplicationCommandCode.updateProtectedFreeSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final task = _taskById(tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final updated = freeSlotService.update( + freeSlot: task, + start: start, + end: end, + updatedAt: context.now, + title: title, + projectId: projectId, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: _replaceTask(tasks, updated), + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Remove a protected Free Slot from the active timeline. + Future> removeProtectedFreeSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.removeProtectedFreeSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final task = _taskById(tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + if (task.type != TaskType.freeSlot) { + return _failure( + ApplicationFailureCode.validation, + entityId: taskId, + detailCode: 'invalidFreeSlotType', + ); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final removed = task.copyWith( + status: TaskStatus.cancelled, + updatedAt: context.now, + clearSchedule: true, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: _replaceTask(tasks, removed), + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + + /// Break a parent task into direct child tasks. + Future> breakUpTask({ + required ApplicationOperationContext context, + required String parentTaskId, + required List children, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.breakUpTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final parent = _taskById(tasks, parentTaskId); + if (parent == null) { + return _failure( + ApplicationFailureCode.notFound, + entityId: parentTaskId, + ); + } + final staleFailure = _staleFailure(parent, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final entries = children.map((draft) { + return ChildTaskEntry( + id: context.idGenerator.nextId(), + title: draft.title, + createdAt: context.now, + priority: draft.priority, + reward: draft.reward, + difficulty: draft.difficulty, + durationMinutes: draft.durationMinutes, + projectId: draft.projectId, + updatedAt: context.now, + ); + }).toList(growable: false); + final breakUpResult = childTaskBreakUpService.breakUp( + tasks: tasks, + request: ChildTaskBreakUpRequest( + parentTaskId: parentTaskId, + entries: entries, + operationId: context.operationId, + ), + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: breakUpResult.tasks, + activities: breakUpResult.activities, + childTaskIds: breakUpResult.createdChildTaskIds, + readHint: ApplicationCommandReadHint( + affectedTaskIds: [ + parentTaskId, + ...breakUpResult.createdChildTaskIds + ], + refreshToday: false, + ), + ); + }, + ); + } + + /// Complete one child task and auto-complete its parent if appropriate. + Future> completeChildTask({ + required ApplicationOperationContext context, + required String childTaskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.completeChildTask; + return _completeChildOrParent( + commandCode: commandCode, + context: context, + taskId: childTaskId, + expectedUpdatedAt: expectedUpdatedAt, + apply: (tasks, existingActivities) => + childTaskCompletionService.completeChild( + tasks: tasks, + childTaskId: childTaskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ), + ); + } + + /// Complete a parent and force-complete unfinished direct children. + Future> completeParentTask({ + required ApplicationOperationContext context, + required String parentTaskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.completeParentTask; + return _completeChildOrParent( + commandCode: commandCode, + context: context, + taskId: parentTaskId, + expectedUpdatedAt: expectedUpdatedAt, + apply: (tasks, existingActivities) => + childTaskCompletionService.completeParent( + tasks: tasks, + parentTaskId: parentTaskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ), + ); + } + + /// Complete a parent from one of its direct children. + Future> completeParentFromChild({ + required ApplicationOperationContext context, + required String childTaskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.completeParentFromChild; + return _completeChildOrParent( + commandCode: commandCode, + context: context, + taskId: childTaskId, + expectedUpdatedAt: expectedUpdatedAt, + apply: (tasks, existingActivities) => + childTaskCompletionService.completeParentFromChild( + tasks: tasks, + childTaskId: childTaskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ), + ); + } + + Future> _pushFlexible({ + required ApplicationCommandCode commandCode, + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + required PushDestination destination, + DateTime? expectedUpdatedAt, + }) { + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final state = + await _loadPlanningState(repositories, context, localDate); + final task = _taskById(state.tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final pushResult = flexibleTaskActionService.applyPushDestination( + destination: destination, + input: state.input, + taskId: taskId, + updatedAt: context.now, + operationId: context.operationId, + existingActivities: existingActivities, + ); + final schedulingFailure = _failureForSchedulingResult( + pushResult.schedulingResult, + entityId: taskId, + ); + if (schedulingFailure != null) { + return ApplicationResult.failure(schedulingFailure); + } + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: pushResult.schedulingResult.tasks, + activities: pushResult.activities, + schedulingResult: pushResult.schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: + _taskIdsFromChanges(pushResult.schedulingResult.changes), + ), + ); + }, + ); + } + + Future> _completeChildOrParent({ + required ApplicationCommandCode commandCode, + required ApplicationOperationContext context, + required String taskId, + required ChildTaskCompletionResult Function( + List tasks, + List existingActivities, + ) apply, + DateTime? expectedUpdatedAt, + }) { + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + final tasks = await repositories.tasks.findAll(); + final task = _taskById(tasks, taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + final existingActivities = await repositories.taskActivities.findAll(); + final result = apply(tasks, existingActivities); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: tasks, + afterTasks: result.tasks, + activities: result.activities, + childTaskIds: [ + if (result.completedChildId != null) result.completedChildId!, + ...result.forceCompletedChildIds, + ], + readHint: ApplicationCommandReadHint( + affectedTaskIds: result.changedTaskIds, + refreshToday: true, + ), + ); + }, + ); + } + + Future<_PlanningState> _loadPlanningState( + ApplicationUnitOfWorkRepositories repositories, + ApplicationOperationContext context, + CivilDate localDate, + ) async { + final ownerId = context.ownerTimeZone.ownerId; + final settings = await repositories.ownerSettings.findByOwnerId(ownerId) ?? + OwnerSettings( + ownerId: ownerId, + timeZoneId: context.ownerTimeZone.timeZoneId, + ); + final dayStart = _resolve( + date: localDate, + wallTime: settings.dayStart, + timeZoneId: settings.timeZoneId, + ); + final dayEnd = _resolve( + date: localDate, + wallTime: settings.dayEnd, + timeZoneId: settings.timeZoneId, + ); + final window = SchedulingWindow(start: dayStart, end: dayEnd); + final tasks = await repositories.tasks.findAll(); + final lockedExpansion = expandLockedBlocksForDay( + blocks: await repositories.lockedBlocks.findAllBlocks(), + overrides: await repositories.lockedBlocks.findAllOverrides(), + date: localDate, + timeZoneId: settings.timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + + return _PlanningState( + tasks: tasks, + window: window, + lockedIntervals: lockedExpansion.schedulingIntervals, + ); + } + + DateTime _resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + }) { + return timeZoneResolver + .resolve( + date: date, + wallTime: wallTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ) + .instant; + } + + Future> _persist({ + required ApplicationUnitOfWorkRepositories repositories, + required ApplicationCommandCode commandCode, + required ApplicationOperationContext context, + required List beforeTasks, + required List afterTasks, + required List activities, + required ApplicationCommandReadHint readHint, + SchedulingResult? schedulingResult, + List protectedFreeSlotConflicts = + const [], + List childTaskIds = const [], + }) async { + final changedTasks = _changedTasks(beforeTasks, afterTasks); + await repositories.tasks.saveAll(changedTasks); + await repositories.taskActivities.saveAll(activities); + final projectStats = await _saveProjectStatistics( + repositories: repositories, + activities: activities, + tasks: afterTasks, + ); + + return ApplicationResult.success( + ApplicationCommandResult( + commandCode: commandCode, + operationId: context.operationId, + changedTasks: changedTasks, + activities: activities, + projectStatistics: projectStats, + notices: schedulingResult?.notices ?? const [], + changes: schedulingResult?.changes ?? const [], + overlaps: schedulingResult?.overlaps ?? const [], + protectedFreeSlotConflicts: protectedFreeSlotConflicts, + childTaskIds: childTaskIds, + schedulingResult: schedulingResult, + readHint: readHint, + ), + ); + } + + Future> _saveProjectStatistics({ + required ApplicationUnitOfWorkRepositories repositories, + required List activities, + required List tasks, + }) async { + final projectIds = activities + .where((activity) => activity.code == TaskActivityCode.completed) + .map((activity) => activity.projectId) + .toSet(); + final updated = []; + + for (final projectId in projectIds) { + final current = await repositories.projectStatistics.findByProjectId( + projectId, + ) ?? + ProjectStatistics(projectId: projectId); + final result = projectStatisticsAggregationService.apply( + statistics: current, + activities: activities, + tasks: tasks, + ); + if (result.appliedActivityIds.isEmpty) { + continue; + } + await repositories.projectStatistics.save(result.statistics); + updated.add(result.statistics); + } + + return List.unmodifiable(updated); + } +} + +class _PlanningState { + _PlanningState({ + required List tasks, + required this.window, + required List lockedIntervals, + }) : tasks = List.unmodifiable(tasks), + lockedIntervals = List.unmodifiable(lockedIntervals); + + final List tasks; + final SchedulingWindow window; + final List lockedIntervals; + + SchedulingInput get input { + return SchedulingInput( + tasks: tasks, + window: window, + lockedIntervals: lockedIntervals, + ); + } +} + +ApplicationResult _failure( + ApplicationFailureCode code, { + String? entityId, + String? detailCode, +}) { + return ApplicationResult.failure( + ApplicationFailure( + code: code, + entityId: entityId, + detailCode: detailCode, + ), + ); +} + +ApplicationResult _failureForQuickCapture( + QuickCaptureResult result, +) { + final schedulingResult = result.schedulingResult; + if (schedulingResult != null) { + final failure = _failureForSchedulingResult( + schedulingResult, + entityId: result.task.id, + ); + if (failure != null) { + return ApplicationResult.failure(failure); + } + } + + return _failure( + ApplicationFailureCode.validation, + entityId: result.task.id, + detailCode: result.status.name, + ); +} + +ApplicationFailure? _failureForSchedulingResult( + SchedulingResult result, { + required String entityId, +}) { + return switch (result.outcomeCode) { + SchedulingOutcomeCode.success || SchedulingOutcomeCode.noOp => null, + SchedulingOutcomeCode.conflict => null, + SchedulingOutcomeCode.notFound => ApplicationFailure( + code: ApplicationFailureCode.notFound, + entityId: entityId, + detailCode: result.outcomeCode.name, + ), + SchedulingOutcomeCode.noSlot || + SchedulingOutcomeCode.overflow => + ApplicationFailure( + code: ApplicationFailureCode.noSlot, + entityId: entityId, + detailCode: result.outcomeCode.name, + ), + SchedulingOutcomeCode.invalidState => ApplicationFailure( + code: ApplicationFailureCode.conflict, + entityId: entityId, + detailCode: result.outcomeCode.name, + ), + }; +} + +ApplicationFailure? _staleFailure(Task task, DateTime? expectedUpdatedAt) { + if (expectedUpdatedAt == null || + _sameNullableInstant(task.updatedAt, expectedUpdatedAt)) { + return null; + } + + return ApplicationFailure( + code: ApplicationFailureCode.staleRevision, + entityId: task.id, + ); +} + +List _activitiesForSchedulingChanges({ + required SchedulingResult? result, + required List beforeTasks, + required String operationId, + required DateTime occurredAt, + required String selectedTaskId, + required TaskActivityCode selectedActivityCode, + required TaskActivityCode movedActivityCode, +}) { + if (result == null || result.outcomeCode != SchedulingOutcomeCode.success) { + return const []; + } + + final activities = []; + for (final change in result.changes) { + final original = _taskById(beforeTasks, change.taskId); + if (original == null) { + continue; + } + final code = change.taskId == selectedTaskId + ? selectedActivityCode + : movedActivityCode; + activities.add( + TaskActivity( + id: '$operationId:${change.taskId}:${code.name}', + operationId: operationId, + code: code, + taskId: change.taskId, + projectId: original.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': original.status.name, + 'nextStatus': _nextStatusForChange(original, code).name, + 'previousStart': change.previousStart, + 'previousEnd': change.previousEnd, + 'nextStart': change.nextStart, + 'nextEnd': change.nextEnd, + }, + ), + ); + } + + return List.unmodifiable(activities); +} + +TaskStatus _nextStatusForChange(Task task, TaskActivityCode code) { + if (code == TaskActivityCode.restoredFromBacklog) { + return TaskStatus.planned; + } + if (code == TaskActivityCode.movedToBacklog) { + return TaskStatus.backlog; + } + + return task.status; +} + +List _changedTasks(List beforeTasks, List afterTasks) { + final beforeById = { + for (final task in beforeTasks) task.id: task, + }; + final changed = []; + + for (final task in afterTasks) { + final before = beforeById[task.id]; + if (before == null || _taskChanged(before, task)) { + changed.add(task); + } + } + + return List.unmodifiable(changed); +} + +List _changedTaskIds(List beforeTasks, List afterTasks) { + return _taskIdsFromTasks(_changedTasks(beforeTasks, afterTasks)); +} + +bool _taskChanged(Task before, Task after) { + return before.title != after.title || + before.projectId != after.projectId || + before.type != after.type || + before.status != after.status || + before.priority != after.priority || + before.reward != after.reward || + before.difficulty != after.difficulty || + before.durationMinutes != after.durationMinutes || + !_sameNullableInstant(before.scheduledStart, after.scheduledStart) || + !_sameNullableInstant(before.scheduledEnd, after.scheduledEnd) || + !_sameNullableInstant(before.actualStart, after.actualStart) || + !_sameNullableInstant(before.actualEnd, after.actualEnd) || + !_sameNullableInstant(before.completedAt, after.completedAt) || + before.parentTaskId != after.parentTaskId || + !_sameNullableInstant(before.updatedAt, after.updatedAt) || + before.stats != after.stats; +} + +bool _sameNullableInstant(DateTime? first, DateTime? second) { + if (first == null || second == null) { + return first == null && second == null; + } + + return first.isAtSameMomentAs(second); +} + +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; +} + +List _replaceTask(List tasks, Task replacement) { + return tasks + .map((task) => task.id == replacement.id ? replacement : task) + .toList(growable: false); +} + +List _taskIdsFromChanges(List changes) { + return List.unmodifiable( + changes.map((change) => change.taskId).toSet(), + ); +} + +List _taskIdsFromTasks(List tasks) { + return List.unmodifiable(tasks.map((task) => task.id).toSet()); +} + +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/lib/src/application_layer.dart b/lib/src/application_layer.dart index 0fbaa0e..926a68d 100644 --- a/lib/src/application_layer.dart +++ b/lib/src/application_layer.dart @@ -478,6 +478,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { detailCode: error.code.name, ), ); + } on ArgumentError catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.name, + ), + ); } on ApplicationPersistenceException { return ApplicationResult.failure( const ApplicationFailure( @@ -511,6 +518,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { detailCode: error.code.name, ), ); + } on ArgumentError catch (error) { + return ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: error.name, + ), + ); } on ApplicationPersistenceException { return ApplicationResult.failure( const ApplicationFailure( diff --git a/test/application_commands_test.dart b/test/application_commands_test.dart new file mode 100644 index 0000000..d8397f1 --- /dev/null +++ b/test/application_commands_test.dart @@ -0,0 +1,517 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('V1 application commands', () { + final day = CivilDate(2026, 6, 25); + final tomorrow = CivilDate(2026, 6, 26); + final createdAt = DateTime.utc(2026, 6, 20, 8); + + test('quick capture to backlog is atomic and idempotent by operation id', + () async { + final store = storeWithSettings(); + final useCases = commands(store); + final context = + appContext('capture-backlog', DateTime.utc(2026, 6, 25, 8)); + + final result = await useCases.quickCaptureToBacklog( + context: context, + title: ' Write down idea ', + ); + final duplicate = await useCases.quickCaptureToBacklog( + context: context, + title: 'Write down idea again', + ); + + expect(result.isSuccess, isTrue); + expect(result.requireValue.changedTasks.single.title, 'Write down idea'); + expect( + result.requireValue.changedTasks.single.status, TaskStatus.backlog); + expect(store.currentTasks, hasLength(1)); + expect(store.currentOperations.single.operationId, 'capture-backlog'); + expect( + duplicate.failure!.code, ApplicationFailureCode.duplicateOperation); + expect(store.currentTasks, hasLength(1)); + }); + + test('quick capture to next available slot schedules through the app layer', + () async { + final store = storeWithSettings(); + + final result = await commands(store).quickCaptureToNextAvailableSlot( + context: appContext('capture-schedule', DateTime.utc(2026, 6, 25, 8)), + localDate: day, + title: 'Scheduled capture', + durationMinutes: 30, + ); + + expect(result.isSuccess, isTrue); + final captured = result.requireValue.changedTasks.single; + expect(captured.status, TaskStatus.planned); + expect(captured.scheduledStart, DateTime.utc(2026, 6, 25, 9)); + expect(captured.scheduledEnd, DateTime.utc(2026, 6, 25, 9, 30)); + expect(store.currentTaskActivities.single.code, + TaskActivityCode.restoredFromBacklog); + }); + + test('schedules backlog item and persists movement activities', () async { + final backlog = task( + id: 'backlog', + title: 'Backlog', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: createdAt, + durationMinutes: 30, + ); + final planned = task( + id: 'planned', + title: 'Planned', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [backlog, planned]); + + final result = + await commands(store).scheduleBacklogItemToNextAvailableSlot( + context: appContext('schedule-backlog', DateTime.utc(2026, 6, 25, 8)), + localDate: day, + taskId: backlog.id, + expectedUpdatedAt: backlog.updatedAt, + ); + + expect(result.isSuccess, isTrue); + expect(result.requireValue.changedTasks.map((task) => task.id).toSet(), { + 'backlog', + 'planned', + }); + expect( + taskById(store.currentTasks, 'backlog').status, + TaskStatus.planned, + ); + expect( + taskById(store.currentTasks, 'backlog').scheduledStart, + DateTime.utc(2026, 6, 25, 9), + ); + expect( + taskById(store.currentTasks, 'planned').scheduledStart, + DateTime.utc(2026, 6, 25, 9, 30), + ); + expect(store.currentTaskActivities.map((activity) => activity.code), [ + TaskActivityCode.restoredFromBacklog, + TaskActivityCode.automaticallyPushed, + ]); + }); + + test('stale expected update leaves repositories unchanged', () async { + final planned = task( + id: 'planned', + title: 'Planned', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [planned]); + + final result = await commands(store).moveFlexibleToBacklog( + context: appContext('stale-move', DateTime.utc(2026, 6, 25, 8)), + taskId: planned.id, + expectedUpdatedAt: + planned.updatedAt.subtract(const Duration(minutes: 1)), + ); + + expect(result.failure!.code, ApplicationFailureCode.staleRevision); + expect( + taskById(store.currentTasks, planned.id).status, TaskStatus.planned); + expect(store.currentOperations, isEmpty); + }); + + test('commit persistence failure rolls back command-side task and activity', + () async { + final planned = task( + id: 'planned', + title: 'Planned', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [planned]) + ..failNextCommitForTesting(); + + final result = await commands(store).completeFlexibleTask( + context: appContext('rollback-complete', DateTime.utc(2026, 6, 25, 9)), + localDate: day, + taskId: planned.id, + ); + + expect(result.failure!.code, ApplicationFailureCode.persistenceFailure); + expect( + taskById(store.currentTasks, planned.id).status, TaskStatus.planned); + expect(store.currentTaskActivities, isEmpty); + expect(store.currentProjectStatistics, isEmpty); + expect(store.currentOperations, isEmpty); + }); + + test('push commands and backlog move persist expected command outputs', + () async { + final first = task( + id: 'first', + title: 'First', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final second = task( + id: 'second', + title: 'Second', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9, 30), + end: DateTime.utc(2026, 6, 25, 10), + ); + final tomorrowTask = task( + id: 'tomorrow-existing', + title: 'Tomorrow existing', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 26, 9), + end: DateTime.utc(2026, 6, 26, 9, 30), + ); + final store = storeWithSettings(tasks: [first, second, tomorrowTask]); + final useCases = commands(store); + + final pushToday = await useCases.pushFlexibleToNextAvailableSlot( + context: appContext('push-today', DateTime.utc(2026, 6, 25, 8)), + localDate: day, + taskId: first.id, + ); + final pushTomorrow = await useCases.pushFlexibleToTomorrowTopOfQueue( + context: appContext('push-tomorrow', DateTime.utc(2026, 6, 25, 8, 5)), + targetDate: tomorrow, + taskId: second.id, + ); + final moveBacklog = await useCases.moveFlexibleToBacklog( + context: appContext('move-backlog', DateTime.utc(2026, 6, 25, 8, 10)), + taskId: first.id, + ); + + expect(pushToday.isSuccess, isTrue); + expect(pushToday.requireValue.activities.first.code, + TaskActivityCode.manuallyPushed); + expect(pushTomorrow.isSuccess, isTrue); + expect( + taskById(store.currentTasks, second.id).scheduledStart, + DateTime.utc(2026, 6, 26, 9), + ); + expect(moveBacklog.isSuccess, isTrue); + expect(taskById(store.currentTasks, first.id).status, TaskStatus.backlog); + expect( + store.currentTaskActivities + .map((activity) => activity.operationId) + .toSet(), + containsAll({'push-today', 'push-tomorrow', 'move-backlog'}), + ); + }); + + test('completion commands persist activities and project statistics', + () async { + final flexible = task( + id: 'flexible', + title: 'Flexible', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final critical = task( + id: 'critical', + title: 'Critical', + type: TaskType.critical, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 10), + end: DateTime.utc(2026, 6, 25, 10, 30), + ); + final store = storeWithSettings(tasks: [flexible, critical]); + final useCases = commands(store); + + final done = await useCases.completeFlexibleTask( + context: + appContext('complete-flexible', DateTime.utc(2026, 6, 25, 9, 20)), + localDate: day, + taskId: flexible.id, + actualStart: DateTime.utc(2026, 6, 25, 9), + actualEnd: DateTime.utc(2026, 6, 25, 9, 20), + ); + final missed = await useCases.applyRequiredTaskAction( + context: appContext('miss-critical', DateTime.utc(2026, 6, 25, 10, 45)), + localDate: day, + taskId: critical.id, + action: RequiredTaskAction.missed, + ); + + expect(done.isSuccess, isTrue); + expect(taskById(store.currentTasks, flexible.id).status, + TaskStatus.completed); + expect(missed.isSuccess, isTrue); + expect( + taskById(store.currentTasks, critical.id).status, TaskStatus.backlog); + expect( + store.currentTaskActivities.map((activity) => activity.code).toSet(), + { + TaskActivityCode.completed, + TaskActivityCode.missed, + TaskActivityCode.movedToBacklog, + }, + ); + expect(store.currentProjectStatistics.single.completedTaskCount, 1); + }); + + test('surprise logging creates completed work and repairs flexible overlap', + () async { + final planned = task( + id: 'planned', + title: 'Planned', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 10), + ); + final store = storeWithSettings(tasks: [planned]); + + final result = await commands(store).logSurpriseTask( + context: appContext('surprise-op', DateTime.utc(2026, 6, 25, 9, 30)), + localDate: day, + title: 'Unexpected call', + startedAt: DateTime.utc(2026, 6, 25, 9), + timeUsedMinutes: 30, + ); + + expect(result.isSuccess, isTrue); + expect(taskById(store.currentTasks, 'surprise-op').status, + TaskStatus.completed); + expect( + taskById(store.currentTasks, planned.id).scheduledStart, + isNot(DateTime.utc(2026, 6, 25, 9)), + ); + expect( + store.currentTaskActivities.single.code, TaskActivityCode.completed); + expect(store.currentProjectStatistics.single.completedTaskCount, 1); + }); + + test('protected Free Slot create update and remove are atomic commands', + () async { + final store = storeWithSettings(); + final useCases = commands(store); + final create = await useCases.createProtectedFreeSlot( + context: appContext('free-create', DateTime.utc(2026, 6, 25, 8)), + localDate: day, + title: 'Rest', + start: DateTime.utc(2026, 6, 25, 10), + end: DateTime.utc(2026, 6, 25, 11), + ); + final freeSlotId = create.requireValue.changedTasks.single.id; + final update = await useCases.updateProtectedFreeSlot( + context: appContext('free-update', DateTime.utc(2026, 6, 25, 8, 5)), + localDate: day, + taskId: freeSlotId, + title: 'Quiet rest', + start: DateTime.utc(2026, 6, 25, 11), + end: DateTime.utc(2026, 6, 25, 12), + ); + final remove = await useCases.removeProtectedFreeSlot( + context: appContext('free-remove', DateTime.utc(2026, 6, 25, 8, 10)), + localDate: day, + taskId: freeSlotId, + ); + + expect(create.isSuccess, isTrue); + expect(update.isSuccess, isTrue); + expect(taskById(store.currentTasks, freeSlotId).title, 'Quiet rest'); + expect(remove.isSuccess, isTrue); + final removed = taskById(store.currentTasks, freeSlotId); + expect(removed.status, TaskStatus.cancelled); + expect(removed.scheduledStart, isNull); + expect(store.currentOperations.map((operation) => operation.operationId), + ['free-create', 'free-update', 'free-remove']); + }); + + test('child task commands create children and propagate completion', + () async { + final parent = task( + id: 'parent', + title: 'Parent', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 10), + ); + final child = task( + id: 'child', + title: 'Child', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + parentTaskId: parent.id, + ); + final sibling = task( + id: 'sibling', + title: 'Sibling', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + parentTaskId: parent.id, + ); + final store = storeWithSettings(tasks: [parent, child, sibling]); + final useCases = commands(store); + + final breakUp = await useCases.breakUpTask( + context: appContext('break-up', DateTime.utc(2026, 6, 25, 8)), + parentTaskId: parent.id, + children: const [ + ApplicationChildTaskDraft(title: 'New child'), + ], + ); + final completeParent = await useCases.completeParentTask( + context: + appContext('complete-parent', DateTime.utc(2026, 6, 25, 8, 30)), + parentTaskId: parent.id, + ); + + expect(breakUp.isSuccess, isTrue); + expect(breakUp.requireValue.childTaskIds, hasLength(1)); + expect( + taskById(store.currentTasks, breakUp.requireValue.childTaskIds.single) + .parentTaskId, + parent.id, + ); + expect(completeParent.isSuccess, isTrue); + expect( + taskById(store.currentTasks, parent.id).status, TaskStatus.completed); + expect( + taskById(store.currentTasks, child.id).status, TaskStatus.completed); + expect(taskById(store.currentTasks, sibling.id).status, + TaskStatus.completed); + expect(store.currentProjectStatistics.single.completedTaskCount, 3); + }); + + test('complete child and complete parent from child commands are exposed', + () async { + final parent = task( + id: 'parent', + title: 'Parent', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + ); + final child = task( + id: 'child', + title: 'Child', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + parentTaskId: parent.id, + ); + final store = storeWithSettings(tasks: [parent, child]); + final useCases = commands(store); + + final completeChild = await useCases.completeChildTask( + context: appContext('complete-child', DateTime.utc(2026, 6, 25, 8)), + childTaskId: child.id, + ); + final secondStore = storeWithSettings(tasks: [parent, child]); + final completeFromChild = + await commands(secondStore).completeParentFromChild( + context: appContext( + 'complete-parent-from-child', DateTime.utc(2026, 6, 25, 8)), + childTaskId: child.id, + ); + + expect(completeChild.isSuccess, isTrue); + expect( + taskById(store.currentTasks, parent.id).status, TaskStatus.completed); + expect(completeFromChild.isSuccess, isTrue); + expect(taskById(secondStore.currentTasks, parent.id).status, + TaskStatus.completed); + expect(taskById(secondStore.currentTasks, child.id).status, + TaskStatus.completed); + }); + }); +} + +V1ApplicationCommandUseCases commands(InMemoryApplicationUnitOfWork store) { + return V1ApplicationCommandUseCases( + applicationStore: store, + timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + ); +} + +InMemoryApplicationUnitOfWork storeWithSettings({ + List tasks = const [], +}) { + return InMemoryApplicationUnitOfWork( + initialTasks: tasks, + initialOwnerSettings: [ + OwnerSettings( + ownerId: 'owner-1', + timeZoneId: 'UTC', + dayStart: WallTime(hour: 9, minute: 0), + dayEnd: WallTime(hour: 17, minute: 0), + ), + ], + ); +} + +ApplicationOperationContext appContext(String operationId, DateTime now) { + return ApplicationOperationContext.start( + operationId: operationId, + ownerTimeZone: OwnerTimeZoneContext(ownerId: 'owner-1', timeZoneId: 'UTC'), + clock: FixedClock(now), + idGenerator: SequentialIdGenerator(prefix: '$operationId-task'), + ); +} + +Task task({ + required String id, + required String title, + required TaskType type, + required TaskStatus status, + required DateTime createdAt, + DateTime? start, + DateTime? end, + int? durationMinutes, + String projectId = 'home', + String? parentTaskId, +}) { + return Task( + id: id, + title: title, + projectId: projectId, + type: type, + status: status, + durationMinutes: durationMinutes ?? + (start != null && end != null ? end.difference(start).inMinutes : 30), + scheduledStart: start, + scheduledEnd: end, + parentTaskId: parentTaskId, + createdAt: createdAt, + updatedAt: createdAt, + ); +} + +Task taskById(List tasks, String taskId) { + return tasks.singleWhere((task) => task.id == taskId); +}