feat(app): add atomic v1 command use cases

This commit is contained in:
Ashley Venn 2026-06-25 10:45:26 -07:00
parent 11f6eff85b
commit 60cad3814c
8 changed files with 1994 additions and 17 deletions

View file

@ -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 |

View file

@ -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.

View file

@ -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:

View file

@ -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

View file

@ -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';

File diff suppressed because it is too large Load diff

View file

@ -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(

View file

@ -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<Task> tasks = const <Task>[],
}) {
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<Task> tasks, String taskId) {
return tasks.singleWhere((task) => task.id == taskId);
}