From a5b72634fdce643766cc9dd1577ef0be967d17bb Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 25 Jun 2026 09:30:18 -0700 Subject: [PATCH] feat(domain): centralize task transitions --- .../V1_BACKEND_COMPLETION_GAP_MATRIX.md | 4 +- ..._Lifecycle_Statistics_Project_Reminders.md | 11 +- .../V1_PUBLIC_API_BASELINE.md | 20 +- .../V1_REQUIREMENTS_TRACEABILITY_MATRIX.md | 21 +- lib/scheduler_core.dart | 1 + lib/src/document_mapping.dart | 2 + lib/src/models.dart | 9 + lib/src/persistence_contract.dart | 24 + lib/src/scheduling_engine.dart | 50 +- lib/src/task_actions.dart | 266 +++++-- lib/src/task_lifecycle.dart | 683 ++++++++++++++++++ test/document_mapping_test.dart | 2 + test/domain_invariants_test.dart | 6 + test/persistence_edge_cases_test.dart | 10 + test/surprise_task_logging_test.dart | 1 + test/task_lifecycle_test.dart | 280 +++++++ 16 files changed, 1300 insertions(+), 90 deletions(-) create mode 100644 lib/src/task_lifecycle.dart create mode 100644 test/task_lifecycle_test.dart 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 d67cebb..5cda54f 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 @@ -29,14 +29,14 @@ passed. The current suite has 143 passing tests. | Area | Completed in Blocks 01–10 | Remaining V1 backend work | Planned block | |---|---|---|---| | Pure Dart foundation | Package layout, exports, lints, tests, documentation structure | Fresh verification, traceability matrix, and current API baseline | 11 | -| Core task model | Task types, lifecycle statuses, basic metadata, immutable copy pattern | Strong invariants, explicit nullable clearing, completion metadata, task reminder override, backlog-entered time, typed validation | 11, 13 | +| Core task model | Task types, lifecycle statuses, basic metadata, immutable copy pattern, strong invariants, explicit nullable clearing, completion timestamp, task reminder override, typed validation | Backlog-entered time and remaining persistence/query support | 14, 15 | | Status semantics | Planned/active/completed/missed/cancelled/no-longer-relevant/backlog | Reconcile the human specification’s `pushed` and `skipped` labels with event/stat semantics without importing V2 burnout behavior | 11, 13 | | 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 | Application-facing 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 and timeline token exist | Creation/update rules, protection from flexible scheduling, reminder suppression policy, overlap tests | 12, 13, 14 | | Surprise work | Completed surprise logging and immediate flexible-task repair | Persist actual occupancy, prevent later overlap/regression, idempotent replays, completion/locked-hour statistics | 12, 13, 14 | -| Task actions | Flexible and required action services | One canonical transition layer, idempotent operation records, completion timestamps/actual intervals, consistent errors | 13, 14 | +| Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals | Atomic application use cases and exactly-once stat persistence | 13, 14 | | Internal task statistics | Baseline counters and increment helpers | Automatic exactly-once updates, late/locked completion calculation, push-before-completion aggregates, child patterns | 13 | | Child tasks | Entry conversion, ownership views, parent completion propagation | Atomic break-up workflow, cycle/direct-child validation, activity/stat updates, application use case | 13, 14 | | Project defaults | Configured static defaults and reminder profile on project | Per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution | 13 | diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md b/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md index 0580dc2..f2ce83b 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md @@ -1,6 +1,6 @@ # V1 Block 13 — Lifecycle, Statistics, Project Defaults, and Reminder Policy -Status: Planned +Status: In progress Purpose: Centralize task transitions, update internal statistics exactly once, add project-level learned suggestions, and expose reminder-policy decisions @@ -10,6 +10,8 @@ without implementing V2 history UI or platform notification delivery. Recommended Codex level: high +Status: Complete on 2026-06-25. + Tasks: - Introduce one canonical transition service for completion, miss, cancel, @@ -48,6 +50,13 @@ Acceptance criteria: - Required-task missed behavior matches the product specification. - Existing action tests are migrated without losing coverage. +Verification on 2026-06-25: + +- `dart format lib test`: passed, 0 files changed +- `dart analyze`: passed, no issues found +- `dart test`: passed, 211 tests +- `git diff --check`: passed + BREAKPOINT: Stop here. Confirm `extra high` mode before implementing completion accounting and locked-hour statistics. 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 ead3ce4..4f97340 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -25,6 +25,7 @@ following source files: - `src/repositories.dart` - `src/scheduling_engine.dart` - `src/task_actions.dart` +- `src/task_lifecycle.dart` - `src/task_statistics.dart` - `src/time_contracts.dart` - `src/timeline_state.dart` @@ -44,6 +45,7 @@ changes from accidental API drift. | `src/quick_capture.dart` | `QuickCaptureStatus` | | `src/scheduling_engine.dart` | `SchedulingNoticeType`, `SchedulingOperationCode`, `SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, `SchedulingConflictCode` | | `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` | +| `src/task_lifecycle.dart` | `TaskTransitionCode`, `TaskActivityCode`, `TaskTransitionOutcomeCode` | | `src/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` | | `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` | @@ -63,6 +65,7 @@ changes from accidental API drift. | `src/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` | | `src/scheduling_engine.dart` | `SchedulingWindow`, `SchedulingInput`, `SchedulingChange`, `SchedulingOverlap`, `SchedulingNotice`, `SchedulingResult`, `SchedulingEngine` | | `src/task_actions.dart` | `FlexibleTaskActionResult`, `PushDestinationResult`, `RequiredTaskActionResult`, `SurpriseTaskLogRequest`, `SurpriseTaskLogResult`, `FlexibleTaskActionService`, `RequiredTaskActionService`, `SurpriseTaskLogService` | +| `src/task_lifecycle.dart` | `TaskActivity`, `TaskTransitionResult`, `TaskTransitionService` | | `src/task_statistics.dart` | `TaskStatistics` | | `src/time_contracts.dart` | `Clock`, `SystemClock`, `FixedClock`, `IdGenerator`, `SequentialIdGenerator`, `CivilDate`, `WallTime`, `TimeZoneResolutionOptions`, `ResolvedLocalDateTime`, `TimeZoneResolver`, `FixedOffsetTimeZoneResolver` | | `src/timeline_state.dart` | `TimelineItem`, `CompactTimelineState`, `TimelineItemMapper` | @@ -92,7 +95,7 @@ Current notable model fields: - `Task`: `id`, `title`, `projectId`, `type`, `status`, `priority`, `reward`, `difficulty`, `durationMinutes`, `scheduledStart`, `scheduledEnd`, - `actualStart`, `actualEnd`, `parentTaskId`, `backlogTags`, + `actualStart`, `actualEnd`, `completedAt`, `parentTaskId`, `backlogTags`, `reminderOverride`, `createdAt`, `updatedAt`, `stats` - `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/ difficulty/reminder profile/duration fields @@ -104,8 +107,7 @@ Known baseline gaps to preserve as explicit later work: - `TaskStatus` does not include `pushed` or `skipped`; Chunk 11.2 resolves the status/event distinction. -- `Task` does not yet have explicit completion timestamp or backlog-entered - timestamp fields. +- `Task` does not yet have an explicit backlog-entered timestamp field. - Instant ranges still use `DateTime`; locked recurrence dates, wall-clock rules, clocks, IDs, and timezone resolution are explicit Chunk 11.4 contracts. @@ -180,6 +182,18 @@ Chunk 12.4 intentionally changed the public API: - Added a `SchedulingResult` assertion that each notice uses at most one issue, movement, or conflict code. +Chunk 13.1 intentionally changed the public API: + +- Added `src/task_lifecycle.dart` and exported it from `scheduler_core.dart`. +- Added `TaskTransitionCode`, `TaskActivityCode`, `TaskTransitionOutcomeCode`, + `TaskActivity`, `TaskTransitionResult`, and `TaskTransitionService`. +- Added `Task.completedAt` and `Task.copyWith(clearCompletion: true)`. +- Added `TaskActivityDocumentFields` and `TaskDocumentFields.completedAt`. +- Added activity and transition outcome fields to flexible, push-destination, and + required task action results. +- Routed flexible/required direct action services through the canonical + transition service while preserving existing scheduling helper compatibility. + ## 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 e42bdb0..34bc03e 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -28,6 +28,13 @@ Latest Block 12 verification result on 2026-06-25: - `dart test`: passed, 203 tests - `git diff --check`: passed +Latest Block 13.1 verification result on 2026-06-25: + +- `dart format lib test`: passed, 0 files changed +- `dart analyze`: passed, no issues found +- `dart test`: passed, 211 tests +- `git diff --check`: passed + Status values: - `complete`: Current backend APIs and tests cover the requirement. @@ -48,28 +55,28 @@ Status values: | 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. | `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart` | incomplete | Backend read-model tokens exist; actual Flutter rendering is Block 18. | | MVP-AC-06 | Use compact Today mode manually. | `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart` | complete | Complete at backend/read-model level; 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 | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Exactly-once activity/stat records remain in Block 13. | +| 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-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Domain movement and structured rollover notices exist; durable next-open rollover notice/read model remains in Block 14. | -| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Block 12 covers actual occupancy, same-day blockers, privacy-safe locked overlap reporting, typed outcomes, and idempotent retry; statistics/application use cases remain in Blocks 13 and 14. | +| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunk 13.1 adds completion timestamps; statistics/application use cases remain in Blocks 13 and 14. | | MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskView`, child creation helpers | `test/child_tasks_test.dart` | complete | Atomic application use case remains in Block 14. | | MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers | `test/child_tasks_test.dart` | complete | Activity/stat updates remain in Block 13. | | MVP-AC-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`, statistics increment helpers, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart` | incomplete | Automatic exactly-once activity/stat updates remain in Block 13. | +| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `TaskActivity`, statistics increment helpers, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Chunk 13.1 adds canonical activities; exactly-once counter accounting remains in Chunk 13.2. | ## Additional MVP backend requirements | ID | Requirement | Production API surface | Representative tests | Status | Active-plan reference or issue | |---|---|---|---|---|---| | MVP-SUP-01 | Flexible tasks move while locked, inflexible, and critical time does not move automatically. | `OccupancyPolicy`, `SchedulingInput.blockedIntervals`, `SchedulingEngine` placement methods | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 centralizes occupancy and adds structured outcomes plus invariant coverage for immovable placements. | -| MVP-SUP-02 | Critical missed tasks are marked missed and moved to backlog. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.critical` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Durable completion/missed metadata remains in Block 13. | -| MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_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` | `test/required_task_actions_test.dart` | complete | Completion/cancellation timestamps remain in Block 13. | +| 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`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Block 12 covers scheduling protection, helpers, explicit required conflicts, and typed outcomes; reminder directives remain in Block 13. | | MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart` | incomplete | Task override storage exists; effective resolver, learned suggestions, and reminder directives remain in Block 13. | | MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, 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` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. | -| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `SchedulingChange`, `SchedulingMovementCode`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Structured movement codes exist; canonical transition/activity layer remains in Block 13. | +| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Chunk 13.1 adds canonical transition/activity records; exactly-once statistic accounting remains in Chunk 13.2. | | 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 6705cd4..17ee9b9 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -28,6 +28,7 @@ export 'src/quick_capture.dart'; export 'src/repositories.dart'; export 'src/scheduling_engine.dart'; export 'src/task_actions.dart'; +export 'src/task_lifecycle.dart'; export 'src/task_statistics.dart'; export 'src/time_contracts.dart'; export 'src/timeline_state.dart'; diff --git a/lib/src/document_mapping.dart b/lib/src/document_mapping.dart index 20c8609..76ff9e8 100644 --- a/lib/src/document_mapping.dart +++ b/lib/src/document_mapping.dart @@ -89,6 +89,7 @@ abstract final class TaskDocumentMapping { TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd), TaskDocumentFields.actualStart: _dateTimeToStored(task.actualStart), TaskDocumentFields.actualEnd: _dateTimeToStored(task.actualEnd), + TaskDocumentFields.completedAt: _dateTimeToStored(task.completedAt), TaskDocumentFields.parentTaskId: task.parentTaskId, TaskDocumentFields.backlogTags: task.backlogTags.map((tag) => tag.persistenceName).toList(), @@ -131,6 +132,7 @@ abstract final class TaskDocumentMapping { _nullableDateTime(document, TaskDocumentFields.scheduledEnd), actualStart: _nullableDateTime(document, TaskDocumentFields.actualStart), actualEnd: _nullableDateTime(document, TaskDocumentFields.actualEnd), + completedAt: _nullableDateTime(document, TaskDocumentFields.completedAt), parentTaskId: _nullableString(document, TaskDocumentFields.parentTaskId), backlogTags: _backlogTagsFromDocument(document), reminderOverride: PersistenceEnumMapping.decodeNullableReminderProfile( diff --git a/lib/src/models.dart b/lib/src/models.dart index 3bdd67d..923cb62 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -258,6 +258,7 @@ class Task { this.scheduledEnd, this.actualStart, this.actualEnd, + this.completedAt, String? parentTaskId, Set backlogTags = const {}, this.reminderOverride, @@ -397,6 +398,9 @@ class Task { /// Actual work end time, when known after the fact. final DateTime? actualEnd; + /// Explicit completion timestamp, distinct from the generic update timestamp. + final DateTime? completedAt; + /// Parent task id when this task is a child/subtask. Null means top-level task. final String? parentTaskId; @@ -457,6 +461,7 @@ class Task { DateTime? scheduledEnd, DateTime? actualStart, DateTime? actualEnd, + DateTime? completedAt, String? parentTaskId, Set? backlogTags, ReminderProfile? reminderOverride, @@ -467,6 +472,7 @@ class Task { bool clearDuration = false, bool clearSchedule = false, bool clearActualInterval = false, + bool clearCompletion = false, bool clearParentTask = false, bool clearReminderOverride = false, }) { @@ -487,6 +493,7 @@ class Task { actualStart: clearActualInterval ? null : (actualStart ?? this.actualStart), actualEnd: clearActualInterval ? null : (actualEnd ?? this.actualEnd), + completedAt: clearCompletion ? null : (completedAt ?? this.completedAt), parentTaskId: clearParentTask ? null : (parentTaskId ?? this.parentTaskId), backlogTags: backlogTags ?? this.backlogTags, @@ -587,6 +594,7 @@ class ProjectProfile { DateTime? scheduledEnd, DateTime? actualStart, DateTime? actualEnd, + DateTime? completedAt, String? parentTaskId, Set backlogTags = const {}, ReminderProfile? reminderOverride, @@ -606,6 +614,7 @@ class ProjectProfile { scheduledEnd: scheduledEnd, actualStart: actualStart, actualEnd: actualEnd, + completedAt: completedAt, parentTaskId: parentTaskId, backlogTags: backlogTags, reminderOverride: reminderOverride, diff --git a/lib/src/persistence_contract.dart b/lib/src/persistence_contract.dart index 6d9cbab..1858999 100644 --- a/lib/src/persistence_contract.dart +++ b/lib/src/persistence_contract.dart @@ -89,6 +89,7 @@ abstract final class TaskDocumentFields { static const scheduledEnd = 'scheduledEnd'; static const actualStart = 'actualStart'; static const actualEnd = 'actualEnd'; + static const completedAt = 'completedAt'; static const parentTaskId = 'parentTaskId'; static const backlogTags = 'backlogTags'; static const reminderOverride = 'reminderOverride'; @@ -110,6 +111,7 @@ abstract final class TaskDocumentFields { scheduledEnd, actualStart, actualEnd, + completedAt, parentTaskId, backlogTags, reminderOverride, @@ -119,6 +121,27 @@ abstract final class TaskDocumentFields { }; } +/// Document field names for internal [TaskActivity] documents. +abstract final class TaskActivityDocumentFields { + static const id = DocumentFields.id; + static const operationId = 'operationId'; + static const code = 'code'; + static const taskId = 'taskId'; + static const projectId = 'projectId'; + static const occurredAt = 'occurredAt'; + static const metadata = 'metadata'; + + static const all = { + id, + operationId, + code, + taskId, + projectId, + occurredAt, + metadata, + }; +} + /// Document field names for [TaskStatistics] embedded documents. abstract final class TaskStatisticsDocumentFields { static const skippedDuringBurnoutCount = 'skippedDuringBurnoutCount'; @@ -345,6 +368,7 @@ abstract final class SchedulingOverlapDocumentFields { abstract final class PersistenceDocumentFieldSets { static const all = >[ TaskDocumentFields.all, + TaskActivityDocumentFields.all, TaskStatisticsDocumentFields.all, ProjectDocumentFields.all, ClockTimeDocumentFields.all, diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index 31f814c..c24a33c 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -14,9 +14,11 @@ import 'models.dart'; import 'occupancy_policy.dart'; +import 'task_lifecycle.dart'; import 'time_contracts.dart'; const OccupancyPolicy _occupancyPolicy = OccupancyPolicy(); +const TaskTransitionService _transitionService = TaskTransitionService(); /// Category for scheduler notices. /// @@ -899,12 +901,16 @@ class SchedulingEngine { /// schedule is cleared and its moved-to-backlog counter is incremented so /// reports can distinguish this from a task that was never scheduled. Task moveToBacklog(Task task, {DateTime? updatedAt}) { - return task.copyWith( - status: TaskStatus.backlog, - updatedAt: updatedAt ?? clock.now(), - stats: task.stats.incrementMovedToBacklog(), - clearSchedule: true, + final now = updatedAt ?? clock.now(); + final result = _transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.moveToBacklog, + operationId: 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}', + activityId: + 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity', + occurredAt: now, ); + return result.applied ? result.task : task; } /// Mark a flexible task pushed manually. @@ -912,10 +918,16 @@ class SchedulingEngine { /// This updates statistics only. Use the push methods above when the task's /// actual scheduled slot should change. Task markManuallyPushed(Task task, {DateTime? updatedAt}) { - return task.copyWith( - updatedAt: updatedAt ?? clock.now(), - stats: task.stats.incrementManualPush(), + final now = updatedAt ?? clock.now(); + final result = _transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.manualPush, + operationId: 'legacy-manual-push:${task.id}:${now.toIso8601String()}', + activityId: + 'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity', + occurredAt: now, ); + return result.applied ? result.task : task; } /// Mark missed according to the current MVP rules. @@ -924,23 +936,15 @@ class SchedulingEngine { /// missed tasks stay in place as missed because they represented a fixed event /// or time block that cannot simply be rescheduled automatically. Task markMissed(Task task, {DateTime? updatedAt}) { - final nextStats = task.stats.incrementMissed(); final now = updatedAt ?? clock.now(); - - if (task.type == TaskType.critical) { - return task.copyWith( - status: TaskStatus.backlog, - updatedAt: now, - stats: nextStats.incrementMovedToBacklog(), - clearSchedule: true, - ); - } - - return task.copyWith( - status: TaskStatus.missed, - updatedAt: now, - stats: nextStats, + final result = _transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.miss, + operationId: 'legacy-miss:${task.id}:${now.toIso8601String()}', + activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity', + occurredAt: now, ); + return result.applied ? result.task : task; } /// Finds the first interval that can fit the requested duration while avoiding diff --git a/lib/src/task_actions.dart b/lib/src/task_actions.dart index 9c50006..188b22c 100644 --- a/lib/src/task_actions.dart +++ b/lib/src/task_actions.dart @@ -8,6 +8,7 @@ import 'models.dart'; import 'occupancy_policy.dart'; import 'scheduling_engine.dart'; +import 'task_lifecycle.dart'; import 'time_contracts.dart'; /// Quick actions available from a flexible task card. @@ -70,12 +71,14 @@ enum PushDestination { /// must choose a push destination, or the UI should start the child-task flow. /// That keeps card code from guessing how to interpret each action. class FlexibleTaskActionResult { - const FlexibleTaskActionResult({ + FlexibleTaskActionResult({ required this.action, required this.task, this.pushDestinations = const [], this.startsChildTaskFlow = false, - }); + List activities = const [], + this.transitionOutcomeCode, + }) : activities = List.unmodifiable(activities); /// Action the user selected. final FlexibleTaskQuickAction action; @@ -89,6 +92,12 @@ class FlexibleTaskActionResult { /// Whether the UI should open a child-task creation flow. final bool startsChildTaskFlow; + /// Internal activities produced by direct lifecycle actions. + final List activities; + + /// Typed transition outcome for direct lifecycle actions. + final TaskTransitionOutcomeCode? transitionOutcomeCode; + /// True when the action directly produced an updated [task]. bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty; } @@ -99,10 +108,11 @@ class FlexibleTaskActionResult { /// tests can distinguish "moved later today" from "moved to tomorrow" even if /// the low-level scheduling change shape is similar. class PushDestinationResult { - const PushDestinationResult({ + PushDestinationResult({ required this.destination, required this.schedulingResult, - }); + List activities = const [], + }) : activities = List.unmodifiable(activities); /// Destination that was applied. final PushDestination destination; @@ -110,6 +120,9 @@ class PushDestinationResult { /// Full scheduler output: updated tasks, notices, changes, and overlaps. final SchedulingResult schedulingResult; + /// Internal activities produced by applying the selected destination. + final List activities; + /// Convenience flag for UI copy or persistence behavior that cares about the /// tomorrow queue specifically. bool get placesAtTomorrowTopOfQueue { @@ -119,11 +132,13 @@ class PushDestinationResult { /// Result from applying a required task state transition. class RequiredTaskActionResult { - const RequiredTaskActionResult({ + RequiredTaskActionResult({ required this.action, required this.task, required this.removedFromActivePlan, - }); + List activities = const [], + this.transitionOutcomeCode, + }) : activities = List.unmodifiable(activities); /// Action the user selected. final RequiredTaskAction action; @@ -137,6 +152,12 @@ class RequiredTaskActionResult { /// and no-longer-relevant tasks also return true because they are no longer /// active planned work. final bool removedFromActivePlan; + + /// Internal activities produced by the transition. + final List activities; + + /// Typed transition outcome. + final TaskTransitionOutcomeCode? transitionOutcomeCode; } /// Input for logging work the user already did outside the plan. @@ -222,12 +243,16 @@ class SurpriseTaskLogResult { class FlexibleTaskActionService { const FlexibleTaskActionService({ this.schedulingEngine = const SchedulingEngine(), + this.transitionService = const TaskTransitionService(), this.clock = const SystemClock(), }); /// Scheduling dependency used for actions that need timeline changes. final SchedulingEngine schedulingEngine; + /// Canonical lifecycle transition dependency. + final TaskTransitionService transitionService; + /// Clock boundary used when an action timestamp is not supplied. final Clock clock; @@ -240,6 +265,8 @@ class FlexibleTaskActionService { required Task task, required FlexibleTaskQuickAction action, DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], }) { if (!task.isFlexible) { throw ArgumentError.value( @@ -249,12 +276,25 @@ class FlexibleTaskActionService { switch (action) { case FlexibleTaskQuickAction.done: final now = updatedAt ?? clock.now(); + final opId = + operationId ?? _defaultOperationId(action.name, task.id, now); + final transition = transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: opId, + activityId: _activityId( + opId, + task.id, + TaskActivityCode.completed, + ), + occurredAt: now, + existingActivities: existingActivities, + ); return FlexibleTaskActionResult( action: action, - task: task.copyWith( - status: TaskStatus.completed, - updatedAt: now, - ), + task: transition.task, + activities: transition.activities, + transitionOutcomeCode: transition.outcomeCode, ); case FlexibleTaskQuickAction.push: return FlexibleTaskActionResult( @@ -268,9 +308,25 @@ class FlexibleTaskActionService { ); case FlexibleTaskQuickAction.backlog: final now = updatedAt ?? clock.now(); + final opId = + operationId ?? _defaultOperationId(action.name, task.id, now); + final transition = transitionService.apply( + task: task, + transitionCode: TaskTransitionCode.moveToBacklog, + operationId: opId, + activityId: _activityId( + opId, + task.id, + TaskActivityCode.movedToBacklog, + ), + occurredAt: now, + existingActivities: existingActivities, + ); return FlexibleTaskActionResult( action: action, - task: schedulingEngine.moveToBacklog(task, updatedAt: now), + task: transition.task, + activities: transition.activities, + transitionOutcomeCode: transition.outcomeCode, ); case FlexibleTaskQuickAction.breakUp: return FlexibleTaskActionResult( @@ -290,8 +346,12 @@ class FlexibleTaskActionService { required SchedulingInput input, required String taskId, DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], }) { final now = updatedAt ?? clock.now(); + final opId = operationId ?? + _defaultOperationId('push-${destination.name}', taskId, now); final result = switch (destination) { PushDestination.nextAvailableSlot => schedulingEngine.pushFlexibleTaskToNextAvailableSlot( @@ -315,6 +375,15 @@ class FlexibleTaskActionService { return PushDestinationResult( destination: destination, schedulingResult: result, + activities: _activitiesForPushDestination( + destination: destination, + input: input, + result: result, + taskId: taskId, + operationId: opId, + occurredAt: now, + existingActivities: existingActivities, + ), ); } @@ -412,12 +481,16 @@ class FlexibleTaskActionService { class RequiredTaskActionService { const RequiredTaskActionService({ this.schedulingEngine = const SchedulingEngine(), + this.transitionService = const TaskTransitionService(), this.clock = const SystemClock(), }); /// Scheduling dependency used for required missed behavior. final SchedulingEngine schedulingEngine; + /// Canonical lifecycle transition dependency. + final TaskTransitionService transitionService; + /// Clock boundary used when an action timestamp is not supplied. final Clock clock; @@ -426,6 +499,8 @@ class RequiredTaskActionService { required Task task, required RequiredTaskAction action, DateTime? updatedAt, + String? operationId, + Iterable existingActivities = const [], }) { if (!task.isRequiredVisible) { throw ArgumentError.value( @@ -437,53 +512,37 @@ class RequiredTaskActionService { final now = updatedAt ?? clock.now(); - return switch (action) { - RequiredTaskAction.done => RequiredTaskActionResult( - action: action, - task: task.copyWith( - status: TaskStatus.completed, - updatedAt: now, - ), - removedFromActivePlan: false, - ), - RequiredTaskAction.missed => _markMissed( - task: task, - action: action, - updatedAt: now, - ), - RequiredTaskAction.cancel => RequiredTaskActionResult( - action: action, - task: task.copyWith( - status: TaskStatus.cancelled, - updatedAt: now, - stats: task.stats.incrementCancelled(), - clearSchedule: true, - ), - removedFromActivePlan: true, - ), - RequiredTaskAction.noLongerRelevant => RequiredTaskActionResult( - action: action, - task: task.copyWith( - status: TaskStatus.noLongerRelevant, - updatedAt: now, - clearSchedule: true, - ), - removedFromActivePlan: true, - ), + final transitionCode = switch (action) { + RequiredTaskAction.done => TaskTransitionCode.complete, + RequiredTaskAction.missed => TaskTransitionCode.miss, + RequiredTaskAction.cancel => TaskTransitionCode.cancel, + RequiredTaskAction.noLongerRelevant => + TaskTransitionCode.noLongerRelevant, }; - } - - RequiredTaskActionResult _markMissed({ - required Task task, - required RequiredTaskAction action, - required DateTime updatedAt, - }) { - final missed = schedulingEngine.markMissed(task, updatedAt: updatedAt); + final activityCode = switch (action) { + RequiredTaskAction.done => TaskActivityCode.completed, + RequiredTaskAction.missed => TaskActivityCode.missed, + RequiredTaskAction.cancel => TaskActivityCode.cancelled, + RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant, + }; + final opId = operationId ?? _defaultOperationId(action.name, task.id, now); + final transition = transitionService.apply( + task: task, + transitionCode: transitionCode, + operationId: opId, + activityId: _activityId(opId, task.id, activityCode), + occurredAt: now, + existingActivities: existingActivities, + ); return RequiredTaskActionResult( action: action, - task: missed, - removedFromActivePlan: missed.status == TaskStatus.backlog, + task: transition.task, + removedFromActivePlan: transition.task.status == TaskStatus.backlog || + transition.task.status == TaskStatus.cancelled || + transition.task.status == TaskStatus.noLongerRelevant, + activities: transition.activities, + transitionOutcomeCode: transition.outcomeCode, ); } } @@ -714,12 +773,111 @@ class SurpriseTaskLogService { actualStart: hasScheduledTime ? start : null, actualEnd: hasScheduledTime ? start.add(Duration(minutes: duration)) : null, + completedAt: updatedAt, createdAt: request.createdAt, updatedAt: updatedAt, ); } } +List _activitiesForPushDestination({ + required PushDestination destination, + required SchedulingInput input, + required SchedulingResult result, + required String taskId, + required String operationId, + required DateTime occurredAt, + required Iterable existingActivities, +}) { + if (result.outcomeCode != SchedulingOutcomeCode.success) { + return const []; + } + + final activities = []; + for (final change in result.changes) { + if (_hasDuplicateActivity( + existingActivities: existingActivities, + operationId: operationId, + taskId: change.taskId, + )) { + continue; + } + + final task = _taskById(input.tasks, change.taskId); + if (task == null) { + continue; + } + + final code = _activityCodeForPushChange( + destination: destination, + isSelectedTask: change.taskId == taskId, + ); + activities.add( + TaskActivity( + id: _activityId(operationId, change.taskId, code), + operationId: operationId, + code: code, + taskId: change.taskId, + projectId: task.projectId, + occurredAt: occurredAt, + metadata: { + 'previousStatus': task.status.name, + 'nextStatus': code == TaskActivityCode.movedToBacklog + ? TaskStatus.backlog.name + : task.status.name, + 'previousStart': change.previousStart, + 'previousEnd': change.previousEnd, + 'nextStart': change.nextStart, + 'nextEnd': change.nextEnd, + 'destination': destination.name, + }, + ), + ); + } + + return List.unmodifiable(activities); +} + +TaskActivityCode _activityCodeForPushChange({ + required PushDestination destination, + required bool isSelectedTask, +}) { + if (destination == PushDestination.backlog) { + return TaskActivityCode.movedToBacklog; + } + + return isSelectedTask + ? TaskActivityCode.manuallyPushed + : TaskActivityCode.automaticallyPushed; +} + +bool _hasDuplicateActivity({ + required Iterable existingActivities, + required String operationId, + required String taskId, +}) { + for (final activity in existingActivities) { + if (activity.operationId == operationId && activity.taskId == taskId) { + return true; + } + } + + return false; +} + +String _defaultOperationId( + String actionName, String taskId, DateTime occurredAt) { + return '$actionName:$taskId:${occurredAt.toIso8601String()}'; +} + +String _activityId( + String operationId, + String taskId, + TaskActivityCode activityCode, +) { + return '$operationId:$taskId:${activityCode.name}'; +} + /// Find one task by id in a list. Task? _taskById(List tasks, String taskId) { for (final task in tasks) { diff --git a/lib/src/task_lifecycle.dart b/lib/src/task_lifecycle.dart new file mode 100644 index 0000000..1a01b40 --- /dev/null +++ b/lib/src/task_lifecycle.dart @@ -0,0 +1,683 @@ +// Canonical task lifecycle transitions and internal activity records. +// +// This module keeps task state changes separate from UI buttons and scheduling +// placement. Application use cases can persist the returned task and activities +// atomically in later blocks. + +import 'models.dart'; +import 'task_statistics.dart'; +import 'time_contracts.dart'; + +/// Canonical task transition commands. +enum TaskTransitionCode { + complete, + miss, + cancel, + noLongerRelevant, + manualPush, + automaticPush, + moveToBacklog, + restoreFromBacklog, + activate, +} + +/// Stable internal activity categories derived from transitions. +enum TaskActivityCode { + completed, + missed, + cancelled, + noLongerRelevant, + manuallyPushed, + automaticallyPushed, + movedToBacklog, + restoredFromBacklog, + activated, +} + +/// Typed result categories for expected transition states. +enum TaskTransitionOutcomeCode { + applied, + noOp, + duplicateOperation, + invalidState, +} + +/// Immutable internal activity fact. +/// +/// Activities are backend/application data, not a visible per-task history UI. +/// They carry enough stable structure for idempotency, statistics, and future +/// persistence adapters. +class TaskActivity { + TaskActivity({ + required String id, + required String operationId, + required this.code, + required String taskId, + required String projectId, + required this.occurredAt, + Map metadata = const {}, + }) : id = _requiredTrimmed(id, 'id'), + operationId = _requiredTrimmed(operationId, 'operationId'), + taskId = _requiredTrimmed(taskId, 'taskId'), + projectId = _requiredTrimmed(projectId, 'projectId'), + metadata = Map.unmodifiable(metadata); + + /// Stable activity id supplied by the application boundary. + final String id; + + /// Idempotency key for the user/application operation that created this fact. + final String operationId; + + /// Stable activity category. + final TaskActivityCode code; + + /// Task this activity belongs to. + final String taskId; + + /// Project the task belonged to when the activity occurred. + final String projectId; + + /// When the activity happened. + final DateTime occurredAt; + + /// Small structured payload for later statistics and application use cases. + final Map metadata; +} + +/// Result of applying one canonical transition. +class TaskTransitionResult { + TaskTransitionResult({ + required this.transitionCode, + required this.outcomeCode, + required this.task, + List activities = const [], + this.duplicateOfActivityId, + }) : activities = List.unmodifiable(activities); + + /// Requested transition. + final TaskTransitionCode transitionCode; + + /// Typed outcome for callers. + final TaskTransitionOutcomeCode outcomeCode; + + /// Original or updated task. + final Task task; + + /// New internal activity records. Empty for no-op, invalid, and duplicates. + final List activities; + + /// Existing activity id matched by a duplicate operation, when available. + final String? duplicateOfActivityId; + + /// Convenience flag for mutation persistence. + bool get applied => outcomeCode == TaskTransitionOutcomeCode.applied; +} + +/// Canonical lifecycle transition service. +class TaskTransitionService { + const TaskTransitionService({ + this.clock = const SystemClock(), + }); + + /// Clock boundary used when no explicit occurrence time is supplied. + final Clock clock; + + /// Apply one transition to [task]. + /// + /// [operationId] is the idempotency key. If an existing activity for the same + /// operation and task is supplied, the command is treated as an exact duplicate + /// and no additional changes are returned. + TaskTransitionResult apply({ + required Task task, + required TaskTransitionCode transitionCode, + required String operationId, + required String activityId, + DateTime? occurredAt, + DateTime? actualStart, + DateTime? actualEnd, + Iterable existingActivities = const [], + Map metadata = const {}, + }) { + final now = occurredAt ?? clock.now(); + final duplicate = _duplicateActivity( + existingActivities: existingActivities, + taskId: task.id, + operationId: operationId, + ); + if (duplicate != null) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.duplicateOperation, + task: task, + duplicateOfActivityId: duplicate.id, + ); + } + + if (!_actualIntervalShapeIsValid(actualStart, actualEnd)) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.invalidState, + task: task, + ); + } + + if (_isNoOpTerminalRepeat(task, transitionCode)) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.noOp, + task: task, + ); + } + + if (_isBlockedByTerminalState(task, transitionCode)) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.invalidState, + task: task, + ); + } + + return switch (transitionCode) { + TaskTransitionCode.complete => _complete( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + actualStart: actualStart, + actualEnd: actualEnd, + metadata: metadata, + ), + TaskTransitionCode.miss => _miss( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + ), + TaskTransitionCode.cancel => _cancel( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + ), + TaskTransitionCode.noLongerRelevant => _noLongerRelevant( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + ), + TaskTransitionCode.manualPush => _movementOnly( + task: task, + transitionCode: transitionCode, + activityCode: TaskActivityCode.manuallyPushed, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + updatedStats: task.stats.incrementManualPush(), + ), + TaskTransitionCode.automaticPush => _movementOnly( + task: task, + transitionCode: transitionCode, + activityCode: TaskActivityCode.automaticallyPushed, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + updatedStats: task.stats.incrementAutoPush(), + ), + TaskTransitionCode.moveToBacklog => _moveToBacklog( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + ), + TaskTransitionCode.restoreFromBacklog => _restoreFromBacklog( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + ), + TaskTransitionCode.activate => _activate( + task: task, + operationId: operationId, + activityId: activityId, + occurredAt: now, + metadata: metadata, + ), + }; + } + + TaskTransitionResult _complete({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required DateTime? actualStart, + required DateTime? actualEnd, + required Map metadata, + }) { + if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { + return _invalid(task, TaskTransitionCode.complete); + } + + final completed = task.copyWith( + status: TaskStatus.completed, + updatedAt: occurredAt, + completedAt: occurredAt, + actualStart: actualStart, + actualEnd: actualEnd, + ); + + return _applied( + transitionCode: TaskTransitionCode.complete, + task: completed, + activities: [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.completed, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: completed.status, + ), + ], + ); + } + + TaskTransitionResult _miss({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Map metadata, + }) { + if (task.status != TaskStatus.planned && task.status != TaskStatus.active) { + return _invalid(task, TaskTransitionCode.miss); + } + if (!task.isFlexible && !task.isRequiredVisible) { + return _invalid(task, TaskTransitionCode.miss); + } + + final missedStats = task.stats.incrementMissed(); + final updatedTask = task.type == TaskType.critical + ? task.copyWith( + status: TaskStatus.backlog, + updatedAt: occurredAt, + stats: missedStats.incrementMovedToBacklog(), + clearSchedule: true, + ) + : task.copyWith( + status: TaskStatus.missed, + updatedAt: occurredAt, + stats: missedStats, + ); + final activities = [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.missed, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: updatedTask.status, + ), + ]; + + if (task.type == TaskType.critical) { + activities.add( + _activity( + id: '$activityId:movedToBacklog', + operationId: operationId, + code: TaskActivityCode.movedToBacklog, + task: task, + occurredAt: occurredAt, + metadata: { + ...metadata, + 'reason': 'criticalMissed', + }, + nextStatus: updatedTask.status, + ), + ); + } + + return _applied( + transitionCode: TaskTransitionCode.miss, + task: updatedTask, + activities: activities, + ); + } + + TaskTransitionResult _cancel({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Map metadata, + }) { + if (task.status == TaskStatus.cancelled) { + return _noOp(task, TaskTransitionCode.cancel); + } + if (!_canRemoveFromActivePlan(task)) { + return _invalid(task, TaskTransitionCode.cancel); + } + + final cancelled = task.copyWith( + status: TaskStatus.cancelled, + updatedAt: occurredAt, + stats: task.stats.incrementCancelled(), + clearSchedule: true, + ); + + return _applied( + transitionCode: TaskTransitionCode.cancel, + task: cancelled, + activities: [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.cancelled, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: cancelled.status, + ), + ], + ); + } + + TaskTransitionResult _noLongerRelevant({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Map metadata, + }) { + if (task.status == TaskStatus.noLongerRelevant) { + return _noOp(task, TaskTransitionCode.noLongerRelevant); + } + if (!_canRemoveFromActivePlan(task)) { + return _invalid(task, TaskTransitionCode.noLongerRelevant); + } + + final dismissed = task.copyWith( + status: TaskStatus.noLongerRelevant, + updatedAt: occurredAt, + clearSchedule: true, + ); + + return _applied( + transitionCode: TaskTransitionCode.noLongerRelevant, + task: dismissed, + activities: [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.noLongerRelevant, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: dismissed.status, + ), + ], + ); + } + + TaskTransitionResult _movementOnly({ + required Task task, + required TaskTransitionCode transitionCode, + required TaskActivityCode activityCode, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Map metadata, + required TaskStatistics updatedStats, + }) { + if (!task.isFlexible || task.status != TaskStatus.planned) { + return _invalid(task, transitionCode); + } + + final updated = task.copyWith(updatedAt: occurredAt, stats: updatedStats); + return _applied( + transitionCode: transitionCode, + task: updated, + activities: [ + _activity( + id: activityId, + operationId: operationId, + code: activityCode, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: updated.status, + ), + ], + ); + } + + TaskTransitionResult _moveToBacklog({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Map metadata, + }) { + final canMove = (task.isFlexible || task.type == TaskType.critical) && + !_isTerminal(task.status); + if (!canMove) { + return _invalid(task, TaskTransitionCode.moveToBacklog); + } + + final moved = task.copyWith( + status: TaskStatus.backlog, + updatedAt: occurredAt, + stats: task.stats.incrementMovedToBacklog(), + clearSchedule: true, + ); + + return _applied( + transitionCode: TaskTransitionCode.moveToBacklog, + task: moved, + activities: [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.movedToBacklog, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: moved.status, + ), + ], + ); + } + + TaskTransitionResult _restoreFromBacklog({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Map metadata, + }) { + if (task.status != TaskStatus.backlog || + (!task.isFlexible && task.type != TaskType.critical)) { + return _invalid(task, TaskTransitionCode.restoreFromBacklog); + } + + final restored = task.copyWith( + status: TaskStatus.planned, + updatedAt: occurredAt, + stats: task.stats.incrementRestoredFromBacklog(), + ); + + return _applied( + transitionCode: TaskTransitionCode.restoreFromBacklog, + task: restored, + activities: [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.restoredFromBacklog, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: restored.status, + ), + ], + ); + } + + TaskTransitionResult _activate({ + required Task task, + required String operationId, + required String activityId, + required DateTime occurredAt, + required Map metadata, + }) { + if (task.status != TaskStatus.planned || + task.isLocked || + task.type == TaskType.freeSlot || + task.type == TaskType.surprise) { + return _invalid(task, TaskTransitionCode.activate); + } + + final activated = task.copyWith( + status: TaskStatus.active, + updatedAt: occurredAt, + ); + + return _applied( + transitionCode: TaskTransitionCode.activate, + task: activated, + activities: [ + _activity( + id: activityId, + operationId: operationId, + code: TaskActivityCode.activated, + task: task, + occurredAt: occurredAt, + metadata: metadata, + nextStatus: activated.status, + ), + ], + ); + } +} + +TaskTransitionResult _applied({ + required TaskTransitionCode transitionCode, + required Task task, + required List activities, +}) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.applied, + task: task, + activities: activities, + ); +} + +TaskTransitionResult _invalid(Task task, TaskTransitionCode transitionCode) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.invalidState, + task: task, + ); +} + +TaskTransitionResult _noOp(Task task, TaskTransitionCode transitionCode) { + return TaskTransitionResult( + transitionCode: transitionCode, + outcomeCode: TaskTransitionOutcomeCode.noOp, + task: task, + ); +} + +TaskActivity _activity({ + required String id, + required String operationId, + required TaskActivityCode code, + required Task task, + required DateTime occurredAt, + required Map metadata, + required TaskStatus nextStatus, +}) { + return TaskActivity( + id: id, + operationId: operationId, + code: code, + taskId: task.id, + projectId: task.projectId, + occurredAt: occurredAt, + metadata: { + ...metadata, + 'previousStatus': task.status.name, + 'nextStatus': nextStatus.name, + }, + ); +} + +TaskActivity? _duplicateActivity({ + required Iterable existingActivities, + required String taskId, + required String operationId, +}) { + for (final activity in existingActivities) { + if (activity.taskId == taskId && activity.operationId == operationId) { + return activity; + } + } + + return null; +} + +bool _actualIntervalShapeIsValid(DateTime? actualStart, DateTime? actualEnd) { + if (actualStart == null && actualEnd == null) { + return true; + } + return actualStart != null && + actualEnd != null && + actualStart.isBefore(actualEnd); +} + +bool _isNoOpTerminalRepeat(Task task, TaskTransitionCode transitionCode) { + return (task.status == TaskStatus.completed && + transitionCode == TaskTransitionCode.complete) || + (task.status == TaskStatus.cancelled && + transitionCode == TaskTransitionCode.cancel) || + (task.status == TaskStatus.noLongerRelevant && + transitionCode == TaskTransitionCode.noLongerRelevant); +} + +bool _isBlockedByTerminalState(Task task, TaskTransitionCode transitionCode) { + if (!_isTerminal(task.status)) { + return false; + } + return !_isNoOpTerminalRepeat(task, transitionCode); +} + +bool _isTerminal(TaskStatus status) { + return status == TaskStatus.completed || + status == TaskStatus.cancelled || + status == TaskStatus.noLongerRelevant; +} + +bool _canRemoveFromActivePlan(Task task) { + return task.status == TaskStatus.planned || + task.status == TaskStatus.active || + task.status == TaskStatus.backlog || + task.status == TaskStatus.missed; +} + +String _requiredTrimmed(String value, String name) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(value, name, 'Value is required.'); + } + return trimmed; +} diff --git a/test/document_mapping_test.dart b/test/document_mapping_test.dart index 956dd78..49bddcc 100644 --- a/test/document_mapping_test.dart +++ b/test/document_mapping_test.dart @@ -38,6 +38,7 @@ void main() { expect(restored.scheduledEnd, task.scheduledEnd); expect(restored.actualStart, task.actualStart); expect(restored.actualEnd, task.actualEnd); + expect(restored.completedAt, task.completedAt); expect(restored.parentTaskId, task.parentTaskId); expect(restored.backlogTags, task.backlogTags); expect(restored.reminderOverride, task.reminderOverride); @@ -148,6 +149,7 @@ Task scheduledTask({ scheduledEnd: DateTime.utc(2026, 6, 23, 9, 30), actualStart: DateTime.utc(2026, 6, 23, 9, 5), actualEnd: DateTime.utc(2026, 6, 23, 9, 25), + completedAt: DateTime.utc(2026, 6, 23, 9, 30), parentTaskId: 'parent-1', backlogTags: const {BacklogTag.wishlist}, reminderOverride: ReminderProfile.persistent, diff --git a/test/domain_invariants_test.dart b/test/domain_invariants_test.dart index e5b590b..f0ef43b 100644 --- a/test/domain_invariants_test.dart +++ b/test/domain_invariants_test.dart @@ -74,6 +74,7 @@ void main() { scheduledEnd: DateTime(2026, 6, 24, 9, 25), actualStart: DateTime(2026, 6, 24, 9, 5), actualEnd: DateTime(2026, 6, 24, 9, 20), + completedAt: DateTime(2026, 6, 24, 9, 25), parentTaskId: 'parent-1', reminderOverride: ReminderProfile.strict, ); @@ -84,6 +85,7 @@ void main() { clearDuration: true, clearSchedule: true, clearActualInterval: true, + clearCompletion: true, clearParentTask: true, clearReminderOverride: true, ); @@ -92,6 +94,7 @@ void main() { expect(preserved.durationMinutes, 25); expect(preserved.scheduledStart, DateTime(2026, 6, 24, 9)); expect(preserved.actualStart, DateTime(2026, 6, 24, 9, 5)); + expect(preserved.completedAt, DateTime(2026, 6, 24, 9, 25)); expect(preserved.parentTaskId, 'parent-1'); expect(preserved.reminderOverride, ReminderProfile.strict); @@ -101,6 +104,7 @@ void main() { expect(cleared.scheduledEnd, isNull); expect(cleared.actualStart, isNull); expect(cleared.actualEnd, isNull); + expect(cleared.completedAt, isNull); expect(cleared.parentTaskId, isNull); expect(cleared.reminderOverride, isNull); }); @@ -332,6 +336,7 @@ Task _task({ DateTime? scheduledEnd, DateTime? actualStart, DateTime? actualEnd, + DateTime? completedAt, String? parentTaskId, Set backlogTags = const {}, ReminderProfile? reminderOverride, @@ -348,6 +353,7 @@ Task _task({ scheduledEnd: scheduledEnd, actualStart: actualStart, actualEnd: actualEnd, + completedAt: completedAt, parentTaskId: parentTaskId, backlogTags: backlogTags, reminderOverride: reminderOverride, diff --git a/test/persistence_edge_cases_test.dart b/test/persistence_edge_cases_test.dart index cf65d13..74c27db 100644 --- a/test/persistence_edge_cases_test.dart +++ b/test/persistence_edge_cases_test.dart @@ -217,6 +217,7 @@ void main() { 'scheduledEnd', 'actualStart', 'actualEnd', + 'completedAt', 'parentTaskId', 'backlogTags', 'reminderOverride', @@ -224,6 +225,15 @@ void main() { 'updatedAt', 'stats', }); + expect(TaskActivityDocumentFields.all, { + '_id', + 'operationId', + 'code', + 'taskId', + 'projectId', + 'occurredAt', + 'metadata', + }); expect(TaskStatisticsDocumentFields.all, { 'skippedDuringBurnoutCount', 'manuallyPushedCount', diff --git a/test/surprise_task_logging_test.dart b/test/surprise_task_logging_test.dart index 981951b..f2fe611 100644 --- a/test/surprise_task_logging_test.dart +++ b/test/surprise_task_logging_test.dart @@ -24,6 +24,7 @@ void main() { expect(result.surpriseTask.type, TaskType.surprise); expect(result.surpriseTask.status, TaskStatus.completed); + expect(result.surpriseTask.completedAt, now); expect(result.surpriseTask.title, 'Answered urgent text'); expect(result.surpriseTask.projectId, 'relationships'); expect(result.surpriseTask.priority, PriorityLevel.high); diff --git a/test/task_lifecycle_test.dart b/test/task_lifecycle_test.dart new file mode 100644 index 0000000..3f208d1 --- /dev/null +++ b/test/task_lifecycle_test.dart @@ -0,0 +1,280 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Task transition service', () { + final now = DateTime(2026, 6, 25, 9); + const service = TaskTransitionService(); + + test('completion records completedAt actual interval and activity', () { + final task = scheduledTask(id: 'task-1', now: now); + final actualStart = DateTime(2026, 6, 25, 9, 5); + final actualEnd = DateTime(2026, 6, 25, 9, 20); + + final result = service.apply( + task: task, + transitionCode: TaskTransitionCode.complete, + operationId: 'op-complete', + activityId: 'activity-complete', + occurredAt: now.add(const Duration(minutes: 30)), + actualStart: actualStart, + actualEnd: actualEnd, + ); + + expect(result.outcomeCode, TaskTransitionOutcomeCode.applied); + expect(result.task.status, TaskStatus.completed); + expect(result.task.completedAt, now.add(const Duration(minutes: 30))); + expect(result.task.actualStart, actualStart); + expect(result.task.actualEnd, actualEnd); + expect(result.activities.single.id, 'activity-complete'); + expect(result.activities.single.operationId, 'op-complete'); + expect(result.activities.single.code, TaskActivityCode.completed); + expect( + result.activities.single.metadata, + containsPair('previousStatus', 'planned'), + ); + expect( + result.activities.single.metadata, + containsPair('nextStatus', 'completed'), + ); + }); + + test('duplicate operation ids do not create activities or mutate again', + () { + final task = scheduledTask(id: 'task-1', now: now); + final first = service.apply( + task: task, + transitionCode: TaskTransitionCode.manualPush, + operationId: 'op-push', + activityId: 'activity-push', + occurredAt: now, + ); + + final duplicate = service.apply( + task: first.task, + transitionCode: TaskTransitionCode.manualPush, + operationId: 'op-push', + activityId: 'activity-push-again', + occurredAt: now.add(const Duration(minutes: 5)), + existingActivities: first.activities, + ); + + expect(first.task.stats.manuallyPushedCount, 1); + expect(first.activities.single.code, TaskActivityCode.manuallyPushed); + expect( + duplicate.outcomeCode, TaskTransitionOutcomeCode.duplicateOperation); + expect(duplicate.duplicateOfActivityId, 'activity-push'); + expect(duplicate.activities, isEmpty); + expect(duplicate.task.stats.manuallyPushedCount, 1); + }); + + test('terminal states no-op for same transition and reject reopening', () { + final completed = scheduledTask(id: 'done', now: now).copyWith( + status: TaskStatus.completed, + completedAt: now, + ); + + final repeatComplete = service.apply( + task: completed, + transitionCode: TaskTransitionCode.complete, + operationId: 'op-repeat', + activityId: 'activity-repeat', + occurredAt: now, + ); + final activateCompleted = service.apply( + task: completed, + transitionCode: TaskTransitionCode.activate, + operationId: 'op-activate', + activityId: 'activity-activate', + occurredAt: now, + ); + + expect(repeatComplete.outcomeCode, TaskTransitionOutcomeCode.noOp); + expect(repeatComplete.task, completed); + expect(repeatComplete.activities, isEmpty); + expect(activateCompleted.outcomeCode, + TaskTransitionOutcomeCode.invalidState); + expect(activateCompleted.task.status, TaskStatus.completed); + }); + + test('critical missed records miss and backlog movement activities', () { + final task = requiredTask( + id: 'meds', + type: TaskType.critical, + now: now, + ); + + final result = service.apply( + task: task, + transitionCode: TaskTransitionCode.miss, + operationId: 'op-missed', + activityId: 'activity-missed', + occurredAt: now, + ); + + expect(result.outcomeCode, TaskTransitionOutcomeCode.applied); + expect(result.task.status, TaskStatus.backlog); + expect(result.task.scheduledStart, isNull); + expect(result.task.stats.missedCount, 1); + expect(result.task.stats.movedToBacklogCount, 1); + expect(result.activities.map((activity) => activity.code), [ + TaskActivityCode.missed, + TaskActivityCode.movedToBacklog, + ]); + }); + + test( + 'restore and activation use activity records without movement statuses', + () { + final backlog = Task.quickCapture( + id: 'backlog', + title: 'Backlog', + createdAt: now, + ); + + final restored = service.apply( + task: backlog, + transitionCode: TaskTransitionCode.restoreFromBacklog, + operationId: 'op-restore', + activityId: 'activity-restore', + occurredAt: now, + ); + final activated = service.apply( + task: restored.task, + transitionCode: TaskTransitionCode.activate, + operationId: 'op-activate', + activityId: 'activity-activate', + occurredAt: now, + ); + + expect(restored.task.status, TaskStatus.planned); + expect(restored.task.stats.restoredFromBacklogCount, 1); + expect(restored.activities.single.code, + TaskActivityCode.restoredFromBacklog); + expect(activated.task.status, TaskStatus.active); + expect(activated.activities.single.code, TaskActivityCode.activated); + expect(TaskStatus.values.map((status) => status.name), + isNot(contains('pushed'))); + }); + }); + + group('Action services use canonical transitions', () { + final now = DateTime(2026, 6, 25, 9); + + test('flexible done returns completion activity', () { + const service = FlexibleTaskActionService(); + final task = scheduledTask(id: 'flex', now: now); + + final result = service.apply( + task: task, + action: FlexibleTaskQuickAction.done, + updatedAt: now, + operationId: 'op-flex-done', + ); + + expect(result.transitionOutcomeCode, TaskTransitionOutcomeCode.applied); + expect(result.task.completedAt, now); + expect(result.activities.single.code, TaskActivityCode.completed); + expect(result.activities.single.operationId, 'op-flex-done'); + }); + + test('required missed returns canonical activities', () { + const service = RequiredTaskActionService(); + final task = requiredTask( + id: 'critical', + type: TaskType.critical, + now: now, + ); + + final result = service.apply( + task: task, + action: RequiredTaskAction.missed, + updatedAt: now, + operationId: 'op-required-missed', + ); + + expect(result.transitionOutcomeCode, TaskTransitionOutcomeCode.applied); + expect(result.task.status, TaskStatus.backlog); + expect(result.activities.map((activity) => activity.code), [ + TaskActivityCode.missed, + TaskActivityCode.movedToBacklog, + ]); + }); + + test('push destination returns movement activities for changed tasks', () { + const service = FlexibleTaskActionService(); + final first = scheduledTask(id: 'first', now: now); + final second = scheduledTask( + id: 'second', + now: now, + start: DateTime(2026, 6, 25, 9, 20), + ); + + final result = service.applyPushDestination( + destination: PushDestination.nextAvailableSlot, + input: SchedulingInput( + tasks: [first, second], + window: SchedulingWindow( + start: DateTime(2026, 6, 25, 9), + end: DateTime(2026, 6, 25, 11), + ), + ), + taskId: first.id, + updatedAt: now, + operationId: 'op-push', + ); + + expect( + result.schedulingResult.outcomeCode, SchedulingOutcomeCode.success); + expect(result.activities.map((activity) => activity.code), [ + TaskActivityCode.manuallyPushed, + TaskActivityCode.automaticallyPushed, + ]); + expect( + result.activities.map((activity) => activity.operationId).toSet(), { + 'op-push', + }); + }); + }); +} + +Task scheduledTask({ + required String id, + required DateTime now, + DateTime? start, +}) { + final scheduledStart = start ?? DateTime(2026, 6, 25, 9); + return Task( + id: id, + title: id, + projectId: 'project', + type: TaskType.flexible, + status: TaskStatus.planned, + priority: PriorityLevel.medium, + durationMinutes: 15, + scheduledStart: scheduledStart, + scheduledEnd: scheduledStart.add(const Duration(minutes: 15)), + createdAt: now, + updatedAt: now, + ); +} + +Task requiredTask({ + required String id, + required TaskType type, + required DateTime now, +}) { + return Task( + id: id, + title: id, + projectId: 'required', + type: type, + status: TaskStatus.planned, + priority: PriorityLevel.high, + durationMinutes: 30, + scheduledStart: DateTime(2026, 6, 25, 9), + scheduledEnd: DateTime(2026, 6, 25, 9, 30), + createdAt: now, + updatedAt: now, + ); +}