feat(scheduling): add structured outcomes and invariants

This commit is contained in:
Ashley Venn 2026-06-25 09:19:16 -07:00
parent 6219706ab3
commit 18a34778b6
12 changed files with 910 additions and 33 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 12 — Occupancy Policy and Scheduling Correctness # V1 Block 12 — Occupancy Policy and Scheduling Correctness
Status: In progress Status: Complete on 2026-06-25.
Purpose: Make every scheduling operation use one explicit occupancy policy so Purpose: Make every scheduling operation use one explicit occupancy policy so
protected free slots, surprise work, required commitments, and hidden locked protected free slots, surprise work, required commitments, and hidden locked
@ -147,6 +147,8 @@ message contracts and adding invariant/property tests.
Recommended Codex level: extra high Recommended Codex level: extra high
Status: Complete on 2026-06-25.
Tasks: Tasks:
- Replace English notice text as the machine contract with stable scheduling - Replace English notice text as the machine contract with stable scheduling
@ -185,6 +187,13 @@ Acceptance criteria:
- No-slot and conflict results are non-destructive. - No-slot and conflict results are non-destructive.
- The standard verification suite and new scheduling matrix pass. - The standard verification suite and new scheduling matrix pass.
Verification on 2026-06-25:
- `dart format lib test`: passed, 0 files changed
- `dart analyze`: passed, no issues found
- `dart test`: passed, 203 tests
- `git diff --check`: passed
BREAKPOINT: Stop here. Confirm `high` mode before beginning Block 13. BREAKPOINT: Stop here. Confirm `high` mode before beginning Block 13.
Commit suggestion: Commit suggestion:

View file

@ -1,7 +1,7 @@
# Current Software Plan # Current Software Plan
Blocks 0110 are complete and remain historical records in Blocks 0112 are complete and remain historical records in
`Codex Documentation/Archived plans/`. The active path resumes at Block 11. `Codex Documentation/Archived plans/`. The active path resumes at Block 13.
The backend sequence is Blocks 1117. Block 18 is a deliberately limited UI The backend sequence is Blocks 1117. Block 18 is a deliberately limited UI
foundation block and is blocked until the backend completion gate in Chunk 17.4 foundation block and is blocked until the backend completion gate in Chunk 17.4
@ -46,7 +46,7 @@ runtime database dependency.
| Order | Plan | Status | Backend/UI | | Order | Plan | Status | Backend/UI |
|---|---|---|---| |---|---|---|---|
| 11 | `../Archived plans/V1_BLOCK_11_Backend_Baseline_Domain_Contracts.md` | Complete, archived | Backend | | 11 | `../Archived plans/V1_BLOCK_11_Backend_Baseline_Domain_Contracts.md` | Complete, archived | Backend |
| 12 | `V1_BLOCK_12_Occupancy_Scheduling_Correctness.md` | Planned | Backend | | 12 | `../Archived plans/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md` | Complete, archived | Backend |
| 13 | `V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md` | Planned | Backend | | 13 | `V1_BLOCK_13_Lifecycle_Statistics_Project_Reminders.md` | Planned | Backend |
| 14 | `V1_BLOCK_14_Application_Use_Cases_Read_Models.md` | Planned | Backend | | 14 | `V1_BLOCK_14_Application_Use_Cases_Read_Models.md` | Planned | Backend |
| 15 | `V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md` | Planned | Backend | | 15 | `V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md` | Planned | Backend |

View file

@ -32,7 +32,7 @@ passed. The current suite has 143 passing tests.
| 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 metadata, task reminder override, backlog-entered time, typed validation | 11, 13 |
| 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 | | 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 | | 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 | One centralized occupancy policy; free-slot protection; surprise/actual occupancy across every operation; structured non-localized result codes | 12 | | 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 | | 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 | | 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 | | 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 |

View file

@ -42,7 +42,7 @@ changes from accidental API drift.
| `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` | | `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` |
| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` | | `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` |
| `src/quick_capture.dart` | `QuickCaptureStatus` | | `src/quick_capture.dart` | `QuickCaptureStatus` |
| `src/scheduling_engine.dart` | `SchedulingNoticeType` | | `src/scheduling_engine.dart` | `SchedulingNoticeType`, `SchedulingOperationCode`, `SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, `SchedulingConflictCode` |
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` | | `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` |
| `src/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` | | `src/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` |
| `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` | | `src/timeline_state.dart` | `TimelineItemCategory`, `TimelineBackgroundToken`, `TimelineRewardIconToken`, `TimelineDifficultyIconToken`, `TimelineQuickAction` |
@ -167,6 +167,19 @@ Chunk 12.3 intentionally changed the public API:
- Surprise tasks with known time now populate `actualStart` and `actualEnd` in - Surprise tasks with known time now populate `actualStart` and `actualEnd` in
addition to the existing scheduled interval compatibility fields. addition to the existing scheduled interval compatibility fields.
Chunk 12.4 intentionally changed the public API:
- Added stable scheduler code enums: `SchedulingOperationCode`,
`SchedulingOutcomeCode`, `SchedulingIssueCode`, `SchedulingMovementCode`, and
`SchedulingConflictCode`.
- Added `SchedulingResult.operationCode` and `SchedulingResult.outcomeCode`.
- Added `SchedulingNotice.issueCode`, `SchedulingNotice.movementCode`,
`SchedulingNotice.conflictCode`, and immutable `SchedulingNotice.parameters`.
- Changed `SchedulingNotice` from a `const` constructor to a runtime constructor
so notice parameters can be defensively copied.
- Added a `SchedulingResult` assertion that each notice uses at most one issue,
movement, or conflict code.
## Repository contracts ## Repository contracts
The current repository surface is pure Dart and in-memory only: The current repository surface is pure Dart and in-memory only:

View file

@ -21,6 +21,13 @@ Verification result on 2026-06-24:
- `dart test`: passed, 143 tests - `dart test`: passed, 143 tests
- `git diff --check`: passed - `git diff --check`: passed
Latest Block 12 verification result on 2026-06-25:
- `dart format lib test`: passed, 0 files changed
- `dart analyze`: passed, no issues found
- `dart test`: passed, 203 tests
- `git diff --check`: passed
Status values: Status values:
- `complete`: Current backend APIs and tests cover the requirement. - `complete`: Current backend APIs and tests cover the requirement.
@ -37,14 +44,14 @@ Status values:
|---|---|---|---|---|---| |---|---|---|---|---|---|
| MVP-AC-01 | Create a task through quick capture with only a title. | `Task.quickCapture`, `QuickCaptureRequest`, `QuickCaptureService.capture` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Keep constructor invariants under Chunk 11.3. | | MVP-AC-01 | Create a task through quick capture with only a title. | `Task.quickCapture`, `QuickCaptureRequest`, `QuickCaptureService.capture` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Keep constructor invariants under Chunk 11.3. |
| MVP-AC-02 | Store quick-capture tasks in Backlog with neutral defaults. | `Task.quickCapture`, `QuickCaptureService.capture`, `TaskStatus.backlog`, `PriorityLevel.medium`, `RewardLevel.notSet`, inbox project default | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Backlog-entered timestamp remains incomplete; see Blocks 11 and 15. | | MVP-AC-02 | Store quick-capture tasks in Backlog with neutral defaults. | `Task.quickCapture`, `QuickCaptureService.capture`, `TaskStatus.backlog`, `PriorityLevel.medium`, `RewardLevel.notSet`, inbox project default | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Backlog-entered timestamp remains incomplete; see Blocks 11 and 15. |
| MVP-AC-03 | Optionally schedule a quick-capture task into the next available slot after entering duration. | `QuickCaptureRequest.scheduleImmediately`, `QuickCaptureService.capture`, `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | complete | Occupancy centralization and free-slot protection remain in Block 12. | | MVP-AC-03 | Optionally schedule a quick-capture task into the next available slot after entering duration. | `QuickCaptureRequest.scheduleImmediately`, `QuickCaptureService.capture`, `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot avoidance, structured no-slot outcomes, and invariant coverage. |
| MVP-AC-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-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-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-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 | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Exactly-once activity/stat records remain in Block 13. |
| 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` | complete | Chunk 12.1 centralized occupancy; Chunk 12.2 covers protected Free Slot blockers. | | 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` | incomplete | Domain movement exists; durable next-open rollover notice/read model remains in Block 14. | | 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` | incomplete | Chunk 12.3 covers actual occupancy, same-day blockers, privacy-safe locked overlap reporting, 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` | `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-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-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-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-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. |
@ -54,15 +61,15 @@ Status values:
| ID | Requirement | Production API surface | Representative tests | Status | Active-plan reference or issue | | 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` | complete | Centralized by Chunk 12.1; later Block 12 chunks add more outcome structure and edge-case coverage. | | 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-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-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-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-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` | incomplete | Chunk 12.2 covers scheduling protection, helpers, and explicit required conflicts; reminder directives remain in Block 13. | | 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-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-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-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`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | incomplete | Canonical transition/activity layer remains in Block 13. | | 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-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. | | 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 ## Intentionally deferred human-spec items

View file

@ -153,7 +153,9 @@ class FreeSlotService {
'Task was not found.', 'Task was not found.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: taskId, taskId: taskId,
issueCode: SchedulingIssueCode.taskNotFound,
), ),
outcomeCode: SchedulingOutcomeCode.notFound,
); );
} }
@ -165,7 +167,9 @@ class FreeSlotService {
'Only critical or inflexible tasks can be explicitly scheduled here.', 'Only critical or inflexible tasks can be explicitly scheduled here.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.invalidTaskState,
), ),
outcomeCode: SchedulingOutcomeCode.invalidState,
); );
} }
@ -208,6 +212,18 @@ class FreeSlotService {
? SchedulingNoticeType.moved ? SchedulingNoticeType.moved
: SchedulingNoticeType.overlap, : SchedulingNoticeType.overlap,
taskId: task.id, taskId: task.id,
movementCode: conflicts.isEmpty
? SchedulingMovementCode.requiredCommitmentScheduled
: null,
conflictCode: conflicts.isEmpty
? null
: SchedulingConflictCode
.requiredCommitmentOverlapsProtectedFreeSlot,
parameters: {
'requiredStart': requiredInterval.start,
'requiredEnd': requiredInterval.end,
if (conflicts.isNotEmpty) 'freeSlotCount': conflicts.length,
},
), ),
]; ];
final changes = <SchedulingChange>[ final changes = <SchedulingChange>[
@ -229,6 +245,10 @@ class FreeSlotService {
protectedFreeSlotConflicts: conflicts, protectedFreeSlotConflicts: conflicts,
schedulingResult: SchedulingResult( schedulingResult: SchedulingResult(
tasks: updatedTasks, tasks: updatedTasks,
operationCode: SchedulingOperationCode.scheduleRequiredCommitment,
outcomeCode: conflicts.isEmpty
? SchedulingOutcomeCode.success
: SchedulingOutcomeCode.conflict,
notices: notices, notices: notices,
changes: changes, changes: changes,
overlaps: overlapDetails, overlaps: overlapDetails,
@ -241,12 +261,15 @@ RequiredCommitmentScheduleResult _unchangedRequiredResult({
required SchedulingInput input, required SchedulingInput input,
required RequiredCommitmentScheduleStatus status, required RequiredCommitmentScheduleStatus status,
required SchedulingNotice notice, required SchedulingNotice notice,
required SchedulingOutcomeCode outcomeCode,
}) { }) {
return RequiredCommitmentScheduleResult( return RequiredCommitmentScheduleResult(
status: status, status: status,
protectedFreeSlotConflicts: const [], protectedFreeSlotConflicts: const [],
schedulingResult: SchedulingResult( schedulingResult: SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
operationCode: SchedulingOperationCode.scheduleRequiredCommitment,
outcomeCode: outcomeCode,
notices: [notice], notices: [notice],
), ),
); );

View file

@ -40,6 +40,91 @@ enum SchedulingNoticeType {
overflow, overflow,
} }
/// Stable scheduler operation identifiers.
enum SchedulingOperationCode {
/// Operation was built outside the core scheduler or is not specified.
unspecified,
/// Insert a backlog task into the next available slot.
insertBacklogTaskIntoNextAvailableSlot,
/// Push a flexible task later in the current window.
pushFlexibleTaskToNextAvailableSlot,
/// Move a flexible task to the top of a future/tomorrow queue.
pushFlexibleTaskToTomorrowTopOfQueue,
/// Roll unfinished flexible tasks into a target window.
rollOverUnfinishedFlexibleTasks,
/// Analyze overlaps without moving tasks.
analyzeSchedule,
/// Move a flexible task to backlog through the action service.
moveFlexibleTaskToBacklog,
/// Log completed surprise work.
logSurpriseTask,
/// Explicitly schedule a critical or inflexible commitment.
scheduleRequiredCommitment,
}
/// Stable high-level outcome categories for scheduling operations.
enum SchedulingOutcomeCode {
/// Operation completed without conflicts.
success,
/// Operation intentionally made no changes.
noOp,
/// Requested record was not found.
notFound,
/// Requested operation does not apply to the current state.
invalidState,
/// No placement slot exists for the requested item.
noSlot,
/// The requested queue would extend beyond the planning window.
overflow,
/// Operation completed or analyzed with a conflict.
conflict,
}
/// Stable issue identifiers for validation, no-slot, and no-op notices.
enum SchedulingIssueCode {
taskNotFound,
invalidTaskState,
missingDuration,
missingScheduledSlot,
nonPositiveDuration,
noAvailableSlot,
unfinishedTasksCouldNotFit,
noUnfinishedFlexibleTasks,
duplicateSurpriseLog,
}
/// Stable movement identifiers for task placement changes.
enum SchedulingMovementCode {
backlogTaskInserted,
flexibleTaskMovedToMakeRoom,
flexibleTaskPushedToNextAvailableSlot,
flexibleTaskMovedToTomorrow,
unfinishedFlexibleTasksRolledOver,
flexibleTaskMovedToBacklog,
requiredCommitmentScheduled,
}
/// Stable conflict identifiers for overlap/interrupt notices.
enum SchedulingConflictCode {
flexibleTaskOverlapsBlockedTime,
surpriseTaskOverlapsRequiredVisibleTime,
requiredCommitmentOverlapsProtectedFreeSlot,
}
/// Window of time available to a scheduling operation. /// Window of time available to a scheduling operation.
/// ///
/// Most engine methods operate on one planning window: "today", "tomorrow", /// Most engine methods operate on one planning window: "today", "tomorrow",
@ -239,11 +324,15 @@ class SchedulingOverlap {
/// carries both text and a structured [type] so the UI can decide whether to show /// carries both text and a structured [type] so the UI can decide whether to show
/// it as neutral info, movement, overlap, or failure. /// it as neutral info, movement, overlap, or failure.
class SchedulingNotice { class SchedulingNotice {
const SchedulingNotice( SchedulingNotice(
this.message, { this.message, {
this.type = SchedulingNoticeType.info, this.type = SchedulingNoticeType.info,
this.taskId, this.taskId,
}); this.issueCode,
this.movementCode,
this.conflictCode,
Map<String, Object?> parameters = const <String, Object?>{},
}) : parameters = Map<String, Object?>.unmodifiable(parameters);
/// Human-readable message safe to surface in UI or logs. /// Human-readable message safe to surface in UI or logs.
final String message; final String message;
@ -254,6 +343,18 @@ class SchedulingNotice {
/// Optional task related to this notice. Null means the notice applies to the /// Optional task related to this notice. Null means the notice applies to the
/// whole operation. /// whole operation.
final String? taskId; final String? taskId;
/// Stable issue code for validation/no-slot/no-op outcomes.
final SchedulingIssueCode? issueCode;
/// Stable movement code for placement changes.
final SchedulingMovementCode? movementCode;
/// Stable conflict code for overlap outcomes.
final SchedulingConflictCode? conflictCode;
/// Structured notice parameters for callers that need more context.
final Map<String, Object?> parameters;
} }
/// Starter result wrapper for scheduling operations. /// Starter result wrapper for scheduling operations.
@ -264,10 +365,16 @@ class SchedulingNotice {
class SchedulingResult { class SchedulingResult {
SchedulingResult({ SchedulingResult({
required List<Task> tasks, required List<Task> tasks,
this.operationCode = SchedulingOperationCode.unspecified,
this.outcomeCode = SchedulingOutcomeCode.success,
List<SchedulingNotice> notices = const <SchedulingNotice>[], List<SchedulingNotice> notices = const <SchedulingNotice>[],
List<SchedulingChange> changes = const <SchedulingChange>[], List<SchedulingChange> changes = const <SchedulingChange>[],
List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[], List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[],
}) : tasks = List<Task>.unmodifiable(tasks), }) : tasks = List<Task>.unmodifiable(tasks),
assert(
_noticeCodesAreSinglePurpose(notices),
'Each scheduling notice may have only one issue/movement/conflict code.',
),
notices = List<SchedulingNotice>.unmodifiable(notices), notices = List<SchedulingNotice>.unmodifiable(notices),
changes = List<SchedulingChange>.unmodifiable(changes), changes = List<SchedulingChange>.unmodifiable(changes),
overlaps = List<SchedulingOverlap>.unmodifiable(overlaps); overlaps = List<SchedulingOverlap>.unmodifiable(overlaps);
@ -275,6 +382,12 @@ class SchedulingResult {
/// Replacement task list after the operation. /// Replacement task list after the operation.
final List<Task> tasks; final List<Task> tasks;
/// Stable operation identifier.
final SchedulingOperationCode operationCode;
/// Stable high-level outcome category.
final SchedulingOutcomeCode outcomeCode;
/// Human-readable operation messages. /// Human-readable operation messages.
final List<SchedulingNotice> notices; final List<SchedulingNotice> notices;
@ -285,6 +398,21 @@ class SchedulingResult {
final List<SchedulingOverlap> overlaps; final List<SchedulingOverlap> overlaps;
} }
bool _noticeCodesAreSinglePurpose(Iterable<SchedulingNotice> notices) {
for (final notice in notices) {
final codeCount = [
notice.issueCode,
notice.movementCode,
notice.conflictCode,
].where((code) => code != null).length;
if (codeCount > 1) {
return false;
}
}
return true;
}
/// Starter scheduling engine. /// Starter scheduling engine.
/// ///
/// The engine is a pure domain service: it receives immutable-ish input values /// The engine is a pure domain service: it receives immutable-ish input values
@ -335,7 +463,11 @@ class SchedulingEngine {
'Task was not found.', 'Task was not found.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: taskId, taskId: taskId,
issueCode: SchedulingIssueCode.taskNotFound,
), ),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.notFound,
); );
} }
@ -346,7 +478,11 @@ class SchedulingEngine {
'Only backlog flexible tasks can be inserted.', 'Only backlog flexible tasks can be inserted.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.invalidTaskState,
), ),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
); );
} }
@ -358,7 +494,11 @@ class SchedulingEngine {
'Task needs a positive duration before scheduling.', 'Task needs a positive duration before scheduling.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.missingDuration,
), ),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
); );
} }
@ -377,7 +517,11 @@ class SchedulingEngine {
'No available flexible slot today.', 'No available flexible slot today.',
type: SchedulingNoticeType.overflow, type: SchedulingNoticeType.overflow,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.noAvailableSlot,
), ),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.noSlot,
); );
} }
@ -413,7 +557,11 @@ class SchedulingEngine {
'Task was not found.', 'Task was not found.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: taskId, taskId: taskId,
issueCode: SchedulingIssueCode.taskNotFound,
), ),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.notFound,
); );
} }
@ -424,7 +572,11 @@ class SchedulingEngine {
'Only planned flexible tasks can be pushed.', 'Only planned flexible tasks can be pushed.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.invalidTaskState,
), ),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
); );
} }
@ -436,7 +588,11 @@ class SchedulingEngine {
'Task needs a current scheduled slot before pushing.', 'Task needs a current scheduled slot before pushing.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.missingScheduledSlot,
), ),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
); );
} }
@ -447,7 +603,11 @@ class SchedulingEngine {
'Task needs a positive scheduled duration before pushing.', 'Task needs a positive scheduled duration before pushing.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.nonPositiveDuration,
), ),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
); );
} }
@ -464,7 +624,11 @@ class SchedulingEngine {
'No available flexible slot today.', 'No available flexible slot today.',
type: SchedulingNoticeType.overflow, type: SchedulingNoticeType.overflow,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.noAvailableSlot,
), ),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.noSlot,
); );
} }
@ -473,6 +637,10 @@ class SchedulingEngine {
pushedTask: task, pushedTask: task,
placement: placement, placement: placement,
pushedTaskMessage: 'Flexible task pushed to next available slot.', pushedTaskMessage: 'Flexible task pushed to next available slot.',
pushedTaskMovementCode:
SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot,
operationCode:
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
updatedAt: updatedAt ?? clock.now(), updatedAt: updatedAt ?? clock.now(),
countPushedTaskAsManual: countAsManualPush, countPushedTaskAsManual: countAsManualPush,
); );
@ -498,7 +666,11 @@ class SchedulingEngine {
'Task was not found.', 'Task was not found.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: taskId, taskId: taskId,
issueCode: SchedulingIssueCode.taskNotFound,
), ),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
outcomeCode: SchedulingOutcomeCode.notFound,
); );
} }
@ -509,7 +681,11 @@ class SchedulingEngine {
'Only planned flexible tasks can be moved to tomorrow.', 'Only planned flexible tasks can be moved to tomorrow.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.invalidTaskState,
), ),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
outcomeCode: SchedulingOutcomeCode.invalidState,
); );
} }
@ -522,7 +698,11 @@ class SchedulingEngine {
'Task needs a positive duration before moving to tomorrow.', 'Task needs a positive duration before moving to tomorrow.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.missingDuration,
), ),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
outcomeCode: SchedulingOutcomeCode.invalidState,
); );
} }
@ -539,7 +719,11 @@ class SchedulingEngine {
'No available flexible slot tomorrow.', 'No available flexible slot tomorrow.',
type: SchedulingNoticeType.overflow, type: SchedulingNoticeType.overflow,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.noAvailableSlot,
), ),
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
outcomeCode: SchedulingOutcomeCode.noSlot,
); );
} }
@ -548,6 +732,10 @@ class SchedulingEngine {
pushedTask: task, pushedTask: task,
placement: placement, placement: placement,
pushedTaskMessage: 'Flexible task moved to tomorrow.', pushedTaskMessage: 'Flexible task moved to tomorrow.',
pushedTaskMovementCode:
SchedulingMovementCode.flexibleTaskMovedToTomorrow,
operationCode:
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
updatedAt: updatedAt ?? clock.now(), updatedAt: updatedAt ?? clock.now(),
); );
} }
@ -607,8 +795,13 @@ class SchedulingEngine {
if (rolledItems.isEmpty) { if (rolledItems.isEmpty) {
return SchedulingResult( return SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
notices: const [ operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
SchedulingNotice('No unfinished flexible tasks to roll over.'), outcomeCode: SchedulingOutcomeCode.noOp,
notices: [
SchedulingNotice(
'No unfinished flexible tasks to roll over.',
issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks,
),
], ],
); );
} }
@ -622,10 +815,13 @@ class SchedulingEngine {
if (placement == null) { if (placement == null) {
return _unchangedResult( return _unchangedResult(
input, input,
const SchedulingNotice( SchedulingNotice(
'Unfinished flexible tasks could not fit tomorrow.', 'Unfinished flexible tasks could not fit tomorrow.',
type: SchedulingNoticeType.overflow, type: SchedulingNoticeType.overflow,
issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit,
), ),
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
outcomeCode: SchedulingOutcomeCode.overflow,
); );
} }
@ -675,6 +871,12 @@ class SchedulingEngine {
'Flexible task overlaps blocked time.', 'Flexible task overlaps blocked time.',
type: SchedulingNoticeType.overlap, type: SchedulingNoticeType.overlap,
taskId: task.id, taskId: task.id,
conflictCode:
SchedulingConflictCode.flexibleTaskOverlapsBlockedTime,
parameters: {
'blockedStart': blockedInterval.start,
'blockedEnd': blockedInterval.end,
},
), ),
); );
} }
@ -682,6 +884,10 @@ class SchedulingEngine {
return SchedulingResult( return SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
operationCode: SchedulingOperationCode.analyzeSchedule,
outcomeCode: overlaps.isEmpty
? SchedulingOutcomeCode.success
: SchedulingOutcomeCode.conflict,
notices: List<SchedulingNotice>.unmodifiable(notices), notices: List<SchedulingNotice>.unmodifiable(notices),
overlaps: List<SchedulingOverlap>.unmodifiable(overlaps), overlaps: List<SchedulingOverlap>.unmodifiable(overlaps),
); );
@ -810,10 +1016,14 @@ List<TimeInterval> _scheduledIntervalsFor(Iterable<Task> tasks) {
/// schedule while showing why the requested action did not apply. /// schedule while showing why the requested action did not apply.
SchedulingResult _unchangedResult( SchedulingResult _unchangedResult(
SchedulingInput input, SchedulingInput input,
SchedulingNotice notice, SchedulingNotice notice, {
) { SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified,
SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState,
}) {
return SchedulingResult( return SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
operationCode: operationCode,
outcomeCode: outcomeCode,
notices: [notice], notices: [notice],
); );
} }
@ -1162,12 +1372,24 @@ SchedulingResult _applyPlacement({
: 'Flexible task moved to make room.', : 'Flexible task moved to make room.',
type: SchedulingNoticeType.moved, type: SchedulingNoticeType.moved,
taskId: task.id, taskId: task.id,
movementCode: isInsertedTask
? SchedulingMovementCode.backlogTaskInserted
: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
parameters: {
'previousStart': task.scheduledStart,
'previousEnd': task.scheduledEnd,
'nextStart': interval.start,
'nextEnd': interval.end,
},
), ),
); );
} }
return SchedulingResult( return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks), tasks: List<Task>.unmodifiable(updatedTasks),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.success,
notices: List<SchedulingNotice>.unmodifiable(notices), notices: List<SchedulingNotice>.unmodifiable(notices),
changes: List<SchedulingChange>.unmodifiable(changes), changes: List<SchedulingChange>.unmodifiable(changes),
); );
@ -1183,6 +1405,8 @@ SchedulingResult _applyPushPlacement({
required Task pushedTask, required Task pushedTask,
required _BacklogInsertionPlan placement, required _BacklogInsertionPlan placement,
required String pushedTaskMessage, required String pushedTaskMessage,
required SchedulingMovementCode pushedTaskMovementCode,
required SchedulingOperationCode operationCode,
required DateTime updatedAt, required DateTime updatedAt,
bool countPushedTaskAsManual = true, bool countPushedTaskAsManual = true,
}) { }) {
@ -1230,12 +1454,23 @@ SchedulingResult _applyPushPlacement({
isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.', isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.',
type: SchedulingNoticeType.moved, type: SchedulingNoticeType.moved,
taskId: task.id, taskId: task.id,
movementCode: isPushedTask
? pushedTaskMovementCode
: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
parameters: {
'previousStart': task.scheduledStart,
'previousEnd': task.scheduledEnd,
'nextStart': interval.start,
'nextEnd': interval.end,
},
), ),
); );
} }
return SchedulingResult( return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks), tasks: List<Task>.unmodifiable(updatedTasks),
operationCode: operationCode,
outcomeCode: SchedulingOutcomeCode.success,
notices: List<SchedulingNotice>.unmodifiable(notices), notices: List<SchedulingNotice>.unmodifiable(notices),
changes: List<SchedulingChange>.unmodifiable(changes), changes: List<SchedulingChange>.unmodifiable(changes),
); );
@ -1299,6 +1534,13 @@ SchedulingResult _applyRolloverPlacement({
'Flexible task moved to make room.', 'Flexible task moved to make room.',
type: SchedulingNoticeType.moved, type: SchedulingNoticeType.moved,
taskId: task.id, taskId: task.id,
movementCode: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
parameters: {
'previousStart': task.scheduledStart,
'previousEnd': task.scheduledEnd,
'nextStart': interval.start,
'nextEnd': interval.end,
},
), ),
); );
} }
@ -1309,11 +1551,17 @@ SchedulingResult _applyRolloverPlacement({
SchedulingNotice( SchedulingNotice(
'$rolledCount unfinished flexible tasks were moved to tomorrow.', '$rolledCount unfinished flexible tasks were moved to tomorrow.',
type: SchedulingNoticeType.moved, type: SchedulingNoticeType.moved,
movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
parameters: {
'rolledCount': rolledCount,
},
), ),
); );
return SchedulingResult( return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks), tasks: List<Task>.unmodifiable(updatedTasks),
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
outcomeCode: SchedulingOutcomeCode.success,
notices: List<SchedulingNotice>.unmodifiable(notices), notices: List<SchedulingNotice>.unmodifiable(notices),
changes: List<SchedulingChange>.unmodifiable(changes), changes: List<SchedulingChange>.unmodifiable(changes),
); );

View file

@ -331,11 +331,14 @@ class FlexibleTaskActionService {
if (task == null) { if (task == null) {
return SchedulingResult( return SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
outcomeCode: SchedulingOutcomeCode.notFound,
notices: [ notices: [
SchedulingNotice( SchedulingNotice(
'Task was not found.', 'Task was not found.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: taskId, taskId: taskId,
issueCode: SchedulingIssueCode.taskNotFound,
), ),
], ],
); );
@ -344,11 +347,14 @@ class FlexibleTaskActionService {
if (!task.isFlexible || task.status != TaskStatus.planned) { if (!task.isFlexible || task.status != TaskStatus.planned) {
return SchedulingResult( return SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
outcomeCode: SchedulingOutcomeCode.invalidState,
notices: [ notices: [
SchedulingNotice( SchedulingNotice(
'Only planned flexible tasks can be moved to backlog.', 'Only planned flexible tasks can be moved to backlog.',
type: SchedulingNoticeType.noFit, type: SchedulingNoticeType.noFit,
taskId: task.id, taskId: task.id,
issueCode: SchedulingIssueCode.invalidTaskState,
), ),
], ],
); );
@ -369,11 +375,20 @@ class FlexibleTaskActionService {
return current; return current;
}), }),
), ),
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
outcomeCode: SchedulingOutcomeCode.success,
notices: [ notices: [
SchedulingNotice( SchedulingNotice(
'Flexible task moved to backlog.', 'Flexible task moved to backlog.',
type: SchedulingNoticeType.moved, type: SchedulingNoticeType.moved,
taskId: task.id, taskId: task.id,
movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog,
parameters: {
'previousStart': task.scheduledStart,
'previousEnd': task.scheduledEnd,
'nextStart': null,
'nextEnd': null,
},
), ),
], ],
changes: [ changes: [
@ -517,8 +532,14 @@ class SurpriseTaskLogService {
surpriseTask: existingTask, surpriseTask: existingTask,
schedulingResult: SchedulingResult( schedulingResult: SchedulingResult(
tasks: input.tasks, tasks: input.tasks,
notices: const [ operationCode: SchedulingOperationCode.logSurpriseTask,
SchedulingNotice('Surprise task already logged.'), outcomeCode: SchedulingOutcomeCode.noOp,
notices: [
SchedulingNotice(
'Surprise task already logged.',
issueCode: SchedulingIssueCode.duplicateSurpriseLog,
taskId: existingTask.id,
),
], ],
), ),
); );
@ -533,7 +554,9 @@ class SurpriseTaskLogService {
surpriseTask: surpriseTask, surpriseTask: surpriseTask,
schedulingResult: SchedulingResult( schedulingResult: SchedulingResult(
tasks: List<Task>.unmodifiable(currentTasks), tasks: List<Task>.unmodifiable(currentTasks),
notices: const [ operationCode: SchedulingOperationCode.logSurpriseTask,
outcomeCode: SchedulingOutcomeCode.success,
notices: [
SchedulingNotice('Surprise task logged.'), SchedulingNotice('Surprise task logged.'),
], ],
), ),
@ -591,6 +614,12 @@ class SurpriseTaskLogService {
'Surprise task overlaps required visible time.', 'Surprise task overlaps required visible time.',
type: SchedulingNoticeType.overlap, type: SchedulingNoticeType.overlap,
taskId: overlap.taskId, taskId: overlap.taskId,
conflictCode:
SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime,
parameters: {
'blockedStart': overlap.blockedInterval.start,
'blockedEnd': overlap.blockedInterval.end,
},
), ),
) )
.toList(growable: false); .toList(growable: false);
@ -599,8 +628,12 @@ class SurpriseTaskLogService {
surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
schedulingResult: SchedulingResult( schedulingResult: SchedulingResult(
tasks: List<Task>.unmodifiable(currentTasks), tasks: List<Task>.unmodifiable(currentTasks),
operationCode: SchedulingOperationCode.logSurpriseTask,
outcomeCode: requiredOverlaps.isEmpty
? SchedulingOutcomeCode.success
: SchedulingOutcomeCode.conflict,
notices: List<SchedulingNotice>.unmodifiable([ notices: List<SchedulingNotice>.unmodifiable([
const SchedulingNotice('Surprise task logged.'), SchedulingNotice('Surprise task logged.'),
...movementNotices, ...movementNotices,
...overlapNotices, ...overlapNotices,
]), ]),

View file

@ -101,7 +101,7 @@ void main() {
window: window, window: window,
tasks: [scheduledTask], tasks: [scheduledTask],
lockedIntervals: [lockedInterval], lockedIntervals: [lockedInterval],
notices: const [ notices: [
SchedulingNotice('Saved schedule'), SchedulingNotice('Saved schedule'),
], ],
operationName: 'load-day', operationName: 'load-day',

View file

@ -1141,7 +1141,7 @@ void main() {
); );
final result = SchedulingResult( final result = SchedulingResult(
tasks: [moved], tasks: [moved],
notices: const [ notices: [
SchedulingNotice( SchedulingNotice(
'Flexible task moved.', 'Flexible task moved.',
type: SchedulingNoticeType.moved, type: SchedulingNoticeType.moved,
@ -1567,7 +1567,10 @@ void main() {
'task-1', 'task-1',
'tomorrow-flexible-1', 'tomorrow-flexible-1',
]); ]);
expect(result.notices.first.message, 'Flexible task moved to tomorrow.'); expect(
result.notices.first.movementCode,
SchedulingMovementCode.flexibleTaskMovedToTomorrow,
);
}); });
test('push flexible task to tomorrow respects required and locked time', test('push flexible task to tomorrow respects required and locked time',
@ -1667,9 +1670,10 @@ void main() {
'tomorrow-flexible-1', 'tomorrow-flexible-1',
]); ]);
expect( expect(
result.notices.first.message, result.notices.first.movementCode,
'2 unfinished flexible tasks were moved to tomorrow.', SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
); );
expect(result.notices.first.parameters['rolledCount'], 2);
}); });
test('rollover excludes locked required completed and cancelled tasks', () { test('rollover excludes locked required completed and cancelled tasks', () {
@ -1733,9 +1737,10 @@ void main() {
DateTime(2026, 6, 19, 18)); DateTime(2026, 6, 19, 18));
expect(result.changes.map((change) => change.taskId), ['flexible-1']); expect(result.changes.map((change) => change.taskId), ['flexible-1']);
expect( expect(
result.notices.first.message, result.notices.first.movementCode,
'1 unfinished flexible tasks were moved to tomorrow.', SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
); );
expect(result.notices.first.parameters['rolledCount'], 1);
}); });
test('moveToBacklog clears active schedule placement', () { test('moveToBacklog clears active schedule placement', () {

View file

@ -0,0 +1,537 @@
import 'dart:math';
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Structured scheduling outcomes', () {
final now = DateTime(2026, 6, 19, 8);
const engine = SchedulingEngine();
test('not-found invalid-state no-slot overflow and no-op paths use codes',
() {
final window = dayWindow();
final backlog = backlogTask(
id: 'backlog',
createdAt: now,
durationMinutes: 60,
);
final planned = flexibleTask(
id: 'planned',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final notFound = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(tasks: [backlog], window: window),
taskId: 'missing',
updatedAt: now,
);
final invalid = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(tasks: [planned], window: window),
taskId: 'planned',
updatedAt: now,
);
final noSlot = engine.insertBacklogTaskIntoNextAvailableSlot(
input: SchedulingInput(
tasks: [backlog],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 9, 30),
),
),
taskId: 'backlog',
updatedAt: now,
);
final overflow = engine.rollOverUnfinishedFlexibleTasks(
input: SchedulingInput(
tasks: [
flexibleTask(
id: 'source',
createdAt: now,
start: DateTime(2026, 6, 18, 9),
durationMinutes: 60,
),
],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 9, 30),
),
),
sourceWindow: SchedulingWindow(
start: DateTime(2026, 6, 18),
end: DateTime(2026, 6, 19),
),
updatedAt: now,
);
final noOp = engine.rollOverUnfinishedFlexibleTasks(
input: SchedulingInput(tasks: const [], window: window),
updatedAt: now,
);
expect(notFound.outcomeCode, SchedulingOutcomeCode.notFound);
expect(
notFound.notices.single.issueCode, SchedulingIssueCode.taskNotFound);
expect(invalid.outcomeCode, SchedulingOutcomeCode.invalidState);
expect(invalid.notices.single.issueCode,
SchedulingIssueCode.invalidTaskState);
expect(noSlot.outcomeCode, SchedulingOutcomeCode.noSlot);
expect(
noSlot.notices.single.issueCode, SchedulingIssueCode.noAvailableSlot);
expect(overflow.outcomeCode, SchedulingOutcomeCode.overflow);
expect(
overflow.notices.single.issueCode,
SchedulingIssueCode.unfinishedTasksCouldNotFit,
);
expect(noOp.outcomeCode, SchedulingOutcomeCode.noOp);
expect(
noOp.notices.single.issueCode,
SchedulingIssueCode.noUnfinishedFlexibleTasks,
);
});
test('conflict paths use conflict codes', () {
final flexible = flexibleTask(
id: 'flexible',
createdAt: now,
start: DateTime(2026, 6, 19, 9),
durationMinutes: 30,
);
final analysis = engine.analyzeSchedule(
SchedulingInput(
tasks: [flexible],
window: dayWindow(),
lockedIntervals: [
TimeInterval(
start: DateTime(2026, 6, 19, 9, 15),
end: DateTime(2026, 6, 19, 9, 45),
),
],
),
);
expect(analysis.operationCode, SchedulingOperationCode.analyzeSchedule);
expect(analysis.outcomeCode, SchedulingOutcomeCode.conflict);
expect(
analysis.notices.single.conflictCode,
SchedulingConflictCode.flexibleTaskOverlapsBlockedTime,
);
});
});
group('Scheduling invariants', () {
final now = DateTime(2026, 6, 19, 8);
const engine = SchedulingEngine();
for (final seed in [1301, 1302, 1303, 1304, 1305, 1306]) {
test('fixed-seed insertion invariants seed $seed', () {
final scenario = randomInsertionScenario(seed: seed, createdAt: now);
try {
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: scenario.input,
taskId: scenario.backlogTaskId,
updatedAt: now,
);
assertTaskIdsUnique(result.tasks);
assertIntervalsValid(result.tasks);
if (result.outcomeCode == SchedulingOutcomeCode.success) {
assertNoMovableTaskOverlapsBlockers(
resultTasks: result.tasks,
originalInput: scenario.input,
);
assertImmovablePlacementsUnchanged(
before: scenario.input,
afterTasks: result.tasks,
);
assertFlexibleRelativeOrderPreserved(
beforeTasks: scenario.input.tasks,
afterTasks: result.tasks,
);
} else {
expect(result.outcomeCode, SchedulingOutcomeCode.noSlot);
expect(result.changes, isEmpty);
assertTaskSchedulesUnchanged(
beforeTasks: scenario.input.tasks,
afterTasks: result.tasks,
);
}
} catch (error, stackTrace) {
fail(
'seed=$seed scenario=${scenario.describe()}\n$error\n$stackTrace',
);
}
});
}
test('idempotent surprise replay does not add changes', () {
const service = SurpriseTaskLogService();
final first = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9),
timeUsedMinutes: 20,
),
input: SchedulingInput(tasks: const [], window: dayWindow()),
updatedAt: now,
);
final replay = service.log(
request: SurpriseTaskLogRequest(
id: 'surprise',
title: 'Unplanned call',
createdAt: now,
startedAt: DateTime(2026, 6, 19, 9),
timeUsedMinutes: 20,
),
input: SchedulingInput(
tasks: first.schedulingResult.tasks,
window: dayWindow(),
),
updatedAt: now,
);
expect(replay.schedulingResult.outcomeCode, SchedulingOutcomeCode.noOp);
expect(replay.schedulingResult.changes, isEmpty);
expect(
replay.schedulingResult.notices.single.issueCode,
SchedulingIssueCode.duplicateSurpriseLog,
);
});
test('large single-day insertion stays inside practical performance guard',
() {
final scenario = largeInsertionScenario(createdAt: now);
final stopwatch = Stopwatch()..start();
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
input: scenario.input,
taskId: scenario.backlogTaskId,
updatedAt: now,
);
stopwatch.stop();
expect(result.outcomeCode, SchedulingOutcomeCode.success);
assertTaskIdsUnique(result.tasks);
assertNoMovableTaskOverlapsBlockers(
resultTasks: result.tasks,
originalInput: scenario.input,
);
// This is a regression guard for a large but realistic single-day queue,
// not a microbenchmark promise.
expect(
stopwatch.elapsedMilliseconds,
lessThan(2000),
reason: 'elapsed=${stopwatch.elapsedMilliseconds}ms',
);
});
});
}
SchedulingWindow dayWindow() {
return SchedulingWindow(
start: DateTime(2026, 6, 19, 9),
end: DateTime(2026, 6, 19, 17),
);
}
InsertionScenario randomInsertionScenario({
required int seed,
required DateTime createdAt,
}) {
final random = Random(seed);
final window = dayWindow();
final tasks = <Task>[];
final lockedIntervals = <TimeInterval>[];
final backlog = backlogTask(
id: 'backlog-$seed',
createdAt: createdAt,
durationMinutes: 10 + random.nextInt(16),
);
tasks.add(backlog);
for (var index = 0; index < 3; index += 1) {
final startMinute = random.nextInt(420);
final duration = 15 + random.nextInt(30);
final start = window.start.add(Duration(minutes: startMinute));
final end = start.add(Duration(minutes: duration));
if (end.isBefore(window.end) || end.isAtSameMomentAs(window.end)) {
lockedIntervals.add(TimeInterval(start: start, end: end));
}
}
var cursor = window.start.add(Duration(minutes: random.nextInt(20)));
for (var index = 0; index < 8; index += 1) {
cursor = cursor.add(Duration(minutes: random.nextInt(20)));
final duration = 10 + random.nextInt(20);
final end = cursor.add(Duration(minutes: duration));
if (end.isAfter(window.end)) {
break;
}
tasks.add(
flexibleTask(
id: 'flex-$seed-$index',
createdAt: createdAt,
start: cursor,
durationMinutes: duration,
),
);
cursor = end;
}
tasks.add(
fixedTask(
id: 'required-$seed',
type: random.nextBool() ? TaskType.critical : TaskType.inflexible,
createdAt: createdAt,
start: DateTime(2026, 6, 19, 15),
durationMinutes: 30,
),
);
return InsertionScenario(
seed: seed,
backlogTaskId: backlog.id,
input: SchedulingInput(
tasks: tasks,
window: window,
lockedIntervals: lockedIntervals,
),
);
}
InsertionScenario largeInsertionScenario({required DateTime createdAt}) {
final window = SchedulingWindow(
start: DateTime(2026, 6, 19, 6),
end: DateTime(2026, 6, 20, 6),
);
final backlog = backlogTask(
id: 'large-backlog',
createdAt: createdAt,
durationMinutes: 1,
);
final tasks = <Task>[backlog];
var cursor = window.start.add(const Duration(minutes: 30));
for (var index = 0; index < 350; index += 1) {
tasks.add(
flexibleTask(
id: 'large-flex-$index',
createdAt: createdAt,
start: cursor,
durationMinutes: 2,
),
);
cursor = cursor.add(const Duration(minutes: 3));
}
final lockedIntervals = <TimeInterval>[
for (var hour = 8; hour < 20; hour += 3)
TimeInterval(
start: DateTime(2026, 6, 19, hour),
end: DateTime(2026, 6, 19, hour, 10),
),
];
return InsertionScenario(
seed: 0,
backlogTaskId: backlog.id,
input: SchedulingInput(
tasks: tasks,
window: window,
lockedIntervals: lockedIntervals,
),
);
}
void assertTaskIdsUnique(List<Task> tasks) {
expect(tasks.map((task) => task.id).toSet().length, tasks.length);
}
void assertIntervalsValid(List<Task> tasks) {
for (final task in tasks) {
final scheduledStart = task.scheduledStart;
final scheduledEnd = task.scheduledEnd;
expect(scheduledStart == null, scheduledEnd == null, reason: task.id);
if (scheduledStart != null && scheduledEnd != null) {
expect(scheduledStart.isBefore(scheduledEnd), isTrue, reason: task.id);
}
final actualStart = task.actualStart;
final actualEnd = task.actualEnd;
expect(actualStart == null, actualEnd == null, reason: task.id);
if (actualStart != null && actualEnd != null) {
expect(actualStart.isBefore(actualEnd), isTrue, reason: task.id);
}
}
}
void assertNoMovableTaskOverlapsBlockers({
required List<Task> resultTasks,
required SchedulingInput originalInput,
}) {
final resultInput = SchedulingInput(
tasks: resultTasks,
window: originalInput.window,
lockedIntervals: originalInput.lockedIntervals,
requiredVisibleIntervals: originalInput.requiredVisibleIntervals,
);
for (final task in resultInput.movablePlannedFlexibleTasks) {
final interval = scheduledInterval(task);
if (interval == null || !resultInput.window.contains(interval)) {
continue;
}
for (final blocker in resultInput.blockedIntervals) {
expect(interval.overlaps(blocker), isFalse, reason: task.id);
}
}
}
void assertImmovablePlacementsUnchanged({
required SchedulingInput before,
required List<Task> afterTasks,
}) {
final afterById = {for (final task in afterTasks) task.id: task};
for (final entry in before.occupancyEntries.where((entry) {
return entry.task != null && entry.isImmovable;
})) {
final beforeTask = entry.task!;
final afterTask = afterById[beforeTask.id];
expect(afterTask, isNotNull, reason: beforeTask.id);
expect(afterTask!.scheduledStart, beforeTask.scheduledStart,
reason: beforeTask.id);
expect(afterTask.scheduledEnd, beforeTask.scheduledEnd,
reason: beforeTask.id);
expect(afterTask.actualStart, beforeTask.actualStart,
reason: beforeTask.id);
expect(afterTask.actualEnd, beforeTask.actualEnd, reason: beforeTask.id);
}
}
void assertFlexibleRelativeOrderPreserved({
required List<Task> beforeTasks,
required List<Task> afterTasks,
}) {
final beforeOrder = plannedFlexibleIdsByStart(beforeTasks);
final afterOrder = plannedFlexibleIdsByStart(afterTasks)
.where(beforeOrder.contains)
.toList(growable: false);
expect(afterOrder, beforeOrder);
}
List<String> plannedFlexibleIdsByStart(List<Task> tasks) {
final planned = tasks.where((task) {
return task.type == TaskType.flexible &&
task.status == TaskStatus.planned &&
task.scheduledStart != null;
}).toList(growable: false)
..sort(
(left, right) => left.scheduledStart!.compareTo(right.scheduledStart!),
);
return planned.map((task) => task.id).toList(growable: false);
}
void assertTaskSchedulesUnchanged({
required List<Task> beforeTasks,
required List<Task> afterTasks,
}) {
expect(afterTasks.map((task) => task.id), beforeTasks.map((task) => task.id));
final afterById = {for (final task in afterTasks) task.id: task};
for (final before in beforeTasks) {
final after = afterById[before.id];
expect(after, isNotNull, reason: before.id);
expect(after!.status, before.status, reason: before.id);
expect(after.scheduledStart, before.scheduledStart, reason: before.id);
expect(after.scheduledEnd, before.scheduledEnd, reason: before.id);
expect(after.actualStart, before.actualStart, reason: before.id);
expect(after.actualEnd, before.actualEnd, reason: before.id);
}
}
TimeInterval? scheduledInterval(Task task) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return null;
}
return TimeInterval(start: start, end: end, label: task.id);
}
Task backlogTask({
required String id,
required DateTime createdAt,
required int durationMinutes,
}) {
return Task.quickCapture(
id: id,
title: id,
createdAt: createdAt,
).copyWith(durationMinutes: durationMinutes);
}
Task flexibleTask({
required String id,
required DateTime createdAt,
required DateTime start,
required int durationMinutes,
}) {
return Task.quickCapture(
id: id,
title: id,
createdAt: createdAt,
).copyWith(
status: TaskStatus.planned,
durationMinutes: durationMinutes,
scheduledStart: start,
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
);
}
Task fixedTask({
required String id,
required TaskType type,
required DateTime createdAt,
required DateTime start,
required int durationMinutes,
}) {
return Task(
id: id,
title: id,
projectId: 'fixed',
type: type,
status: TaskStatus.planned,
priority: PriorityLevel.high,
durationMinutes: durationMinutes,
scheduledStart: start,
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
createdAt: createdAt,
updatedAt: createdAt,
);
}
class InsertionScenario {
const InsertionScenario({
required this.seed,
required this.backlogTaskId,
required this.input,
});
final int seed;
final String backlogTaskId;
final SchedulingInput input;
String describe() {
return 'seed=$seed tasks=${input.tasks.length} '
'locked=${input.lockedIntervals.length} backlog=$backlogTaskId';
}
}

View file

@ -312,8 +312,10 @@ void main() {
taskById(retry.schedulingResult.tasks, 'flexible').scheduledStart, taskById(retry.schedulingResult.tasks, 'flexible').scheduledStart,
taskById(first.schedulingResult.tasks, 'flexible').scheduledStart, taskById(first.schedulingResult.tasks, 'flexible').scheduledStart,
); );
expect(retry.schedulingResult.notices.single.message, expect(
'Surprise task already logged.'); retry.schedulingResult.notices.single.issueCode,
SchedulingIssueCode.duplicateSurpriseLog,
);
}); });
test('later insertion avoids persisted surprise actual occupancy', () { test('later insertion avoids persisted surprise actual occupancy', () {