feat(domain): centralize task transitions
This commit is contained in:
parent
18a34778b6
commit
a5b72634fd
16 changed files with 1300 additions and 90 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ class Task {
|
|||
this.scheduledEnd,
|
||||
this.actualStart,
|
||||
this.actualEnd,
|
||||
this.completedAt,
|
||||
String? parentTaskId,
|
||||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||
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<BacklogTag>? 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<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||
ReminderProfile? reminderOverride,
|
||||
|
|
@ -606,6 +614,7 @@ class ProjectProfile {
|
|||
scheduledEnd: scheduledEnd,
|
||||
actualStart: actualStart,
|
||||
actualEnd: actualEnd,
|
||||
completedAt: completedAt,
|
||||
parentTaskId: parentTaskId,
|
||||
backlogTags: backlogTags,
|
||||
reminderOverride: reminderOverride,
|
||||
|
|
|
|||
|
|
@ -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 = <String>{
|
||||
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 = <Set<String>>[
|
||||
TaskDocumentFields.all,
|
||||
TaskActivityDocumentFields.all,
|
||||
TaskStatisticsDocumentFields.all,
|
||||
ProjectDocumentFields.all,
|
||||
ClockTimeDocumentFields.all,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <PushDestination>[],
|
||||
this.startsChildTaskFlow = false,
|
||||
});
|
||||
List<TaskActivity> activities = const <TaskActivity>[],
|
||||
this.transitionOutcomeCode,
|
||||
}) : activities = List<TaskActivity>.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<TaskActivity> 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<TaskActivity> activities = const <TaskActivity>[],
|
||||
}) : activities = List<TaskActivity>.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<TaskActivity> 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<TaskActivity> activities = const <TaskActivity>[],
|
||||
this.transitionOutcomeCode,
|
||||
}) : activities = List<TaskActivity>.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<TaskActivity> 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<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
}) {
|
||||
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<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
}) {
|
||||
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<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
}) {
|
||||
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<TaskActivity> _activitiesForPushDestination({
|
||||
required PushDestination destination,
|
||||
required SchedulingInput input,
|
||||
required SchedulingResult result,
|
||||
required String taskId,
|
||||
required String operationId,
|
||||
required DateTime occurredAt,
|
||||
required Iterable<TaskActivity> existingActivities,
|
||||
}) {
|
||||
if (result.outcomeCode != SchedulingOutcomeCode.success) {
|
||||
return const <TaskActivity>[];
|
||||
}
|
||||
|
||||
final activities = <TaskActivity>[];
|
||||
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<TaskActivity>.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<TaskActivity> 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<Task> tasks, String taskId) {
|
||||
for (final task in tasks) {
|
||||
|
|
|
|||
683
lib/src/task_lifecycle.dart
Normal file
683
lib/src/task_lifecycle.dart
Normal file
|
|
@ -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<String, Object?> metadata = const <String, Object?>{},
|
||||
}) : id = _requiredTrimmed(id, 'id'),
|
||||
operationId = _requiredTrimmed(operationId, 'operationId'),
|
||||
taskId = _requiredTrimmed(taskId, 'taskId'),
|
||||
projectId = _requiredTrimmed(projectId, 'projectId'),
|
||||
metadata = Map<String, Object?>.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<String, Object?> metadata;
|
||||
}
|
||||
|
||||
/// Result of applying one canonical transition.
|
||||
class TaskTransitionResult {
|
||||
TaskTransitionResult({
|
||||
required this.transitionCode,
|
||||
required this.outcomeCode,
|
||||
required this.task,
|
||||
List<TaskActivity> activities = const <TaskActivity>[],
|
||||
this.duplicateOfActivityId,
|
||||
}) : activities = List<TaskActivity>.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<TaskActivity> 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<TaskActivity> existingActivities = const <TaskActivity>[],
|
||||
Map<String, Object?> metadata = const <String, Object?>{},
|
||||
}) {
|
||||
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<String, Object?> 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<String, Object?> 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 = <TaskActivity>[
|
||||
_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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<TaskActivity> 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<String, Object?> 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<TaskActivity> 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<BacklogTag> backlogTags = const {},
|
||||
ReminderProfile? reminderOverride,
|
||||
|
|
@ -348,6 +353,7 @@ Task _task({
|
|||
scheduledEnd: scheduledEnd,
|
||||
actualStart: actualStart,
|
||||
actualEnd: actualEnd,
|
||||
completedAt: completedAt,
|
||||
parentTaskId: parentTaskId,
|
||||
backlogTags: backlogTags,
|
||||
reminderOverride: reminderOverride,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
280
test/task_lifecycle_test.dart
Normal file
280
test/task_lifecycle_test.dart
Normal file
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue