feat(domain): centralize task transitions

This commit is contained in:
Ashley Venn 2026-06-25 09:30:18 -07:00
parent 18a34778b6
commit a5b72634fd
16 changed files with 1300 additions and 90 deletions

View file

@ -29,14 +29,14 @@ passed. The current suite has 143 passing tests.
| Area | Completed in Blocks 0110 | 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 specifications `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 |

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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;
}

View file

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

View file

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

View file

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

View file

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

View 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,
);
}