diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md b/Codex Documentation/Archived plans/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md similarity index 96% rename from Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md rename to Codex Documentation/Archived plans/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md index 273b0e9..2da2645 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md +++ b/Codex Documentation/Archived plans/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md @@ -1,6 +1,6 @@ # 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 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 +Status: Complete on 2026-06-25. + Tasks: - 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. - 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. Commit suggestion: diff --git a/Codex Documentation/Current Software Plan/README.md b/Codex Documentation/Current Software Plan/README.md index b5d237a..b3eb053 100644 --- a/Codex Documentation/Current Software Plan/README.md +++ b/Codex Documentation/Current Software Plan/README.md @@ -1,7 +1,7 @@ # Current Software Plan -Blocks 01–10 are complete and remain historical records in -`Codex Documentation/Archived plans/`. The active path resumes at Block 11. +Blocks 01–12 are complete and remain historical records in +`Codex Documentation/Archived plans/`. The active path resumes at Block 13. The backend sequence is Blocks 11–17. Block 18 is a deliberately limited UI 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 | |---|---|---|---| | 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 | | 14 | `V1_BLOCK_14_Application_Use_Cases_Read_Models.md` | Planned | Backend | | 15 | `V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md` | Planned | Backend | diff --git a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md index 7c68814..d67cebb 100644 --- a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md @@ -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 | | Status semantics | Planned/active/completed/missed/cancelled/no-longer-relevant/backlog | Reconcile the human specification’s `pushed` and `skipped` labels with event/stat semantics without importing V2 burnout behavior | 11, 13 | | Time model | DateTime-based schedule intervals and recurring wall-clock helpers | Civil-date/wall-time/time-zone semantics, DST policy, deterministic clocks, and safe date-only persistence | 11 | -| Scheduling engine | Flexible insertion, next-slot push, tomorrow push, backlog push, rollover, overlap analysis | 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 | | 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 | diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index 11ce693..ead3ce4 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -42,7 +42,7 @@ changes from accidental API drift. | `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` | | `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` | | `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/time_contracts.dart` | `LocalTimeResolution`, `NonexistentLocalTimePolicy`, `RepeatedLocalTimePolicy` | | `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 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 The current repository surface is pure Dart and in-memory only: diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index 9842f53..e42bdb0 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -21,6 +21,13 @@ Verification result on 2026-06-24: - `dart test`: passed, 143 tests - `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: - `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-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-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-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-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-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-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-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. | @@ -54,15 +61,15 @@ Status values: | 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-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-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-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`, 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. | ## Intentionally deferred human-spec items diff --git a/lib/src/free_slots.dart b/lib/src/free_slots.dart index cb84499..8c925f8 100644 --- a/lib/src/free_slots.dart +++ b/lib/src/free_slots.dart @@ -153,7 +153,9 @@ class FreeSlotService { 'Task was not found.', type: SchedulingNoticeType.noFit, taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, ), + outcomeCode: SchedulingOutcomeCode.notFound, ); } @@ -165,7 +167,9 @@ class FreeSlotService { 'Only critical or inflexible tasks can be explicitly scheduled here.', type: SchedulingNoticeType.noFit, taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, ), + outcomeCode: SchedulingOutcomeCode.invalidState, ); } @@ -208,6 +212,18 @@ class FreeSlotService { ? SchedulingNoticeType.moved : SchedulingNoticeType.overlap, 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 = [ @@ -229,6 +245,10 @@ class FreeSlotService { protectedFreeSlotConflicts: conflicts, schedulingResult: SchedulingResult( tasks: updatedTasks, + operationCode: SchedulingOperationCode.scheduleRequiredCommitment, + outcomeCode: conflicts.isEmpty + ? SchedulingOutcomeCode.success + : SchedulingOutcomeCode.conflict, notices: notices, changes: changes, overlaps: overlapDetails, @@ -241,12 +261,15 @@ RequiredCommitmentScheduleResult _unchangedRequiredResult({ required SchedulingInput input, required RequiredCommitmentScheduleStatus status, required SchedulingNotice notice, + required SchedulingOutcomeCode outcomeCode, }) { return RequiredCommitmentScheduleResult( status: status, protectedFreeSlotConflicts: const [], schedulingResult: SchedulingResult( tasks: input.tasks, + operationCode: SchedulingOperationCode.scheduleRequiredCommitment, + outcomeCode: outcomeCode, notices: [notice], ), ); diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index 5c71a4d..31f814c 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -40,6 +40,91 @@ enum SchedulingNoticeType { 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. /// /// 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 /// it as neutral info, movement, overlap, or failure. class SchedulingNotice { - const SchedulingNotice( + SchedulingNotice( this.message, { this.type = SchedulingNoticeType.info, this.taskId, - }); + this.issueCode, + this.movementCode, + this.conflictCode, + Map parameters = const {}, + }) : parameters = Map.unmodifiable(parameters); /// Human-readable message safe to surface in UI or logs. final String message; @@ -254,6 +343,18 @@ class SchedulingNotice { /// Optional task related to this notice. Null means the notice applies to the /// whole operation. 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 parameters; } /// Starter result wrapper for scheduling operations. @@ -264,10 +365,16 @@ class SchedulingNotice { class SchedulingResult { SchedulingResult({ required List tasks, + this.operationCode = SchedulingOperationCode.unspecified, + this.outcomeCode = SchedulingOutcomeCode.success, List notices = const [], List changes = const [], List overlaps = const [], }) : tasks = List.unmodifiable(tasks), + assert( + _noticeCodesAreSinglePurpose(notices), + 'Each scheduling notice may have only one issue/movement/conflict code.', + ), notices = List.unmodifiable(notices), changes = List.unmodifiable(changes), overlaps = List.unmodifiable(overlaps); @@ -275,6 +382,12 @@ class SchedulingResult { /// Replacement task list after the operation. final List tasks; + /// Stable operation identifier. + final SchedulingOperationCode operationCode; + + /// Stable high-level outcome category. + final SchedulingOutcomeCode outcomeCode; + /// Human-readable operation messages. final List notices; @@ -285,6 +398,21 @@ class SchedulingResult { final List overlaps; } +bool _noticeCodesAreSinglePurpose(Iterable 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. /// /// The engine is a pure domain service: it receives immutable-ish input values @@ -335,7 +463,11 @@ class SchedulingEngine { 'Task was not found.', type: SchedulingNoticeType.noFit, taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.notFound, ); } @@ -346,7 +478,11 @@ class SchedulingEngine { 'Only backlog flexible tasks can be inserted.', type: SchedulingNoticeType.noFit, 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.', type: SchedulingNoticeType.noFit, taskId: task.id, + issueCode: SchedulingIssueCode.missingDuration, ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, ); } @@ -377,7 +517,11 @@ class SchedulingEngine { 'No available flexible slot today.', type: SchedulingNoticeType.overflow, taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, ), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.noSlot, ); } @@ -413,7 +557,11 @@ class SchedulingEngine { 'Task was not found.', type: SchedulingNoticeType.noFit, taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.notFound, ); } @@ -424,7 +572,11 @@ class SchedulingEngine { 'Only planned flexible tasks can be pushed.', type: SchedulingNoticeType.noFit, 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.', type: SchedulingNoticeType.noFit, 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.', type: SchedulingNoticeType.noFit, taskId: task.id, + issueCode: SchedulingIssueCode.nonPositiveDuration, ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, ); } @@ -464,7 +624,11 @@ class SchedulingEngine { 'No available flexible slot today.', type: SchedulingNoticeType.overflow, taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.noSlot, ); } @@ -473,6 +637,10 @@ class SchedulingEngine { pushedTask: task, placement: placement, pushedTaskMessage: 'Flexible task pushed to next available slot.', + pushedTaskMovementCode: + SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, + operationCode: + SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot, updatedAt: updatedAt ?? clock.now(), countPushedTaskAsManual: countAsManualPush, ); @@ -498,7 +666,11 @@ class SchedulingEngine { 'Task was not found.', type: SchedulingNoticeType.noFit, 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.', type: SchedulingNoticeType.noFit, 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.', type: SchedulingNoticeType.noFit, taskId: task.id, + issueCode: SchedulingIssueCode.missingDuration, ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.invalidState, ); } @@ -539,7 +719,11 @@ class SchedulingEngine { 'No available flexible slot tomorrow.', type: SchedulingNoticeType.overflow, taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, ), + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, + outcomeCode: SchedulingOutcomeCode.noSlot, ); } @@ -548,6 +732,10 @@ class SchedulingEngine { pushedTask: task, placement: placement, pushedTaskMessage: 'Flexible task moved to tomorrow.', + pushedTaskMovementCode: + SchedulingMovementCode.flexibleTaskMovedToTomorrow, + operationCode: + SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue, updatedAt: updatedAt ?? clock.now(), ); } @@ -607,8 +795,13 @@ class SchedulingEngine { if (rolledItems.isEmpty) { return SchedulingResult( tasks: input.tasks, - notices: const [ - SchedulingNotice('No unfinished flexible tasks to roll over.'), + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + outcomeCode: SchedulingOutcomeCode.noOp, + notices: [ + SchedulingNotice( + 'No unfinished flexible tasks to roll over.', + issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks, + ), ], ); } @@ -622,10 +815,13 @@ class SchedulingEngine { if (placement == null) { return _unchangedResult( input, - const SchedulingNotice( + SchedulingNotice( 'Unfinished flexible tasks could not fit tomorrow.', type: SchedulingNoticeType.overflow, + issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit, ), + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + outcomeCode: SchedulingOutcomeCode.overflow, ); } @@ -675,6 +871,12 @@ class SchedulingEngine { 'Flexible task overlaps blocked time.', type: SchedulingNoticeType.overlap, taskId: task.id, + conflictCode: + SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, + parameters: { + 'blockedStart': blockedInterval.start, + 'blockedEnd': blockedInterval.end, + }, ), ); } @@ -682,6 +884,10 @@ class SchedulingEngine { return SchedulingResult( tasks: input.tasks, + operationCode: SchedulingOperationCode.analyzeSchedule, + outcomeCode: overlaps.isEmpty + ? SchedulingOutcomeCode.success + : SchedulingOutcomeCode.conflict, notices: List.unmodifiable(notices), overlaps: List.unmodifiable(overlaps), ); @@ -810,10 +1016,14 @@ List _scheduledIntervalsFor(Iterable tasks) { /// schedule while showing why the requested action did not apply. SchedulingResult _unchangedResult( SchedulingInput input, - SchedulingNotice notice, -) { + SchedulingNotice notice, { + SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified, + SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState, +}) { return SchedulingResult( tasks: input.tasks, + operationCode: operationCode, + outcomeCode: outcomeCode, notices: [notice], ); } @@ -1162,12 +1372,24 @@ SchedulingResult _applyPlacement({ : 'Flexible task moved to make room.', type: SchedulingNoticeType.moved, taskId: task.id, + movementCode: isInsertedTask + ? SchedulingMovementCode.backlogTaskInserted + : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': interval.start, + 'nextEnd': interval.end, + }, ), ); } return SchedulingResult( tasks: List.unmodifiable(updatedTasks), + operationCode: + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + outcomeCode: SchedulingOutcomeCode.success, notices: List.unmodifiable(notices), changes: List.unmodifiable(changes), ); @@ -1183,6 +1405,8 @@ SchedulingResult _applyPushPlacement({ required Task pushedTask, required _BacklogInsertionPlan placement, required String pushedTaskMessage, + required SchedulingMovementCode pushedTaskMovementCode, + required SchedulingOperationCode operationCode, required DateTime updatedAt, bool countPushedTaskAsManual = true, }) { @@ -1230,12 +1454,23 @@ SchedulingResult _applyPushPlacement({ isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.', type: SchedulingNoticeType.moved, taskId: task.id, + movementCode: isPushedTask + ? pushedTaskMovementCode + : SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': interval.start, + 'nextEnd': interval.end, + }, ), ); } return SchedulingResult( tasks: List.unmodifiable(updatedTasks), + operationCode: operationCode, + outcomeCode: SchedulingOutcomeCode.success, notices: List.unmodifiable(notices), changes: List.unmodifiable(changes), ); @@ -1299,6 +1534,13 @@ SchedulingResult _applyRolloverPlacement({ 'Flexible task moved to make room.', type: SchedulingNoticeType.moved, 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( '$rolledCount unfinished flexible tasks were moved to tomorrow.', type: SchedulingNoticeType.moved, + movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + parameters: { + 'rolledCount': rolledCount, + }, ), ); return SchedulingResult( tasks: List.unmodifiable(updatedTasks), + operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks, + outcomeCode: SchedulingOutcomeCode.success, notices: List.unmodifiable(notices), changes: List.unmodifiable(changes), ); diff --git a/lib/src/task_actions.dart b/lib/src/task_actions.dart index bcd6a2b..9c50006 100644 --- a/lib/src/task_actions.dart +++ b/lib/src/task_actions.dart @@ -331,11 +331,14 @@ class FlexibleTaskActionService { if (task == null) { return SchedulingResult( tasks: input.tasks, + operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, + outcomeCode: SchedulingOutcomeCode.notFound, notices: [ SchedulingNotice( 'Task was not found.', type: SchedulingNoticeType.noFit, taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, ), ], ); @@ -344,11 +347,14 @@ class FlexibleTaskActionService { if (!task.isFlexible || task.status != TaskStatus.planned) { return SchedulingResult( tasks: input.tasks, + operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, + outcomeCode: SchedulingOutcomeCode.invalidState, notices: [ SchedulingNotice( 'Only planned flexible tasks can be moved to backlog.', type: SchedulingNoticeType.noFit, taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, ), ], ); @@ -369,11 +375,20 @@ class FlexibleTaskActionService { return current; }), ), + operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog, + outcomeCode: SchedulingOutcomeCode.success, notices: [ SchedulingNotice( 'Flexible task moved to backlog.', type: SchedulingNoticeType.moved, taskId: task.id, + movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog, + parameters: { + 'previousStart': task.scheduledStart, + 'previousEnd': task.scheduledEnd, + 'nextStart': null, + 'nextEnd': null, + }, ), ], changes: [ @@ -517,8 +532,14 @@ class SurpriseTaskLogService { surpriseTask: existingTask, schedulingResult: SchedulingResult( tasks: input.tasks, - notices: const [ - SchedulingNotice('Surprise task already logged.'), + operationCode: SchedulingOperationCode.logSurpriseTask, + outcomeCode: SchedulingOutcomeCode.noOp, + notices: [ + SchedulingNotice( + 'Surprise task already logged.', + issueCode: SchedulingIssueCode.duplicateSurpriseLog, + taskId: existingTask.id, + ), ], ), ); @@ -533,7 +554,9 @@ class SurpriseTaskLogService { surpriseTask: surpriseTask, schedulingResult: SchedulingResult( tasks: List.unmodifiable(currentTasks), - notices: const [ + operationCode: SchedulingOperationCode.logSurpriseTask, + outcomeCode: SchedulingOutcomeCode.success, + notices: [ SchedulingNotice('Surprise task logged.'), ], ), @@ -591,6 +614,12 @@ class SurpriseTaskLogService { 'Surprise task overlaps required visible time.', type: SchedulingNoticeType.overlap, taskId: overlap.taskId, + conflictCode: + SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, + parameters: { + 'blockedStart': overlap.blockedInterval.start, + 'blockedEnd': overlap.blockedInterval.end, + }, ), ) .toList(growable: false); @@ -599,8 +628,12 @@ class SurpriseTaskLogService { surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask, schedulingResult: SchedulingResult( tasks: List.unmodifiable(currentTasks), + operationCode: SchedulingOperationCode.logSurpriseTask, + outcomeCode: requiredOverlaps.isEmpty + ? SchedulingOutcomeCode.success + : SchedulingOutcomeCode.conflict, notices: List.unmodifiable([ - const SchedulingNotice('Surprise task logged.'), + SchedulingNotice('Surprise task logged.'), ...movementNotices, ...overlapNotices, ]), diff --git a/test/repositories_test.dart b/test/repositories_test.dart index 6df6437..e97659e 100644 --- a/test/repositories_test.dart +++ b/test/repositories_test.dart @@ -101,7 +101,7 @@ void main() { window: window, tasks: [scheduledTask], lockedIntervals: [lockedInterval], - notices: const [ + notices: [ SchedulingNotice('Saved schedule'), ], operationName: 'load-day', diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index f065be3..02f2c03 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -1141,7 +1141,7 @@ void main() { ); final result = SchedulingResult( tasks: [moved], - notices: const [ + notices: [ SchedulingNotice( 'Flexible task moved.', type: SchedulingNoticeType.moved, @@ -1567,7 +1567,10 @@ void main() { 'task-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', @@ -1667,9 +1670,10 @@ void main() { 'tomorrow-flexible-1', ]); expect( - result.notices.first.message, - '2 unfinished flexible tasks were moved to tomorrow.', + result.notices.first.movementCode, + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, ); + expect(result.notices.first.parameters['rolledCount'], 2); }); test('rollover excludes locked required completed and cancelled tasks', () { @@ -1733,9 +1737,10 @@ void main() { DateTime(2026, 6, 19, 18)); expect(result.changes.map((change) => change.taskId), ['flexible-1']); expect( - result.notices.first.message, - '1 unfinished flexible tasks were moved to tomorrow.', + result.notices.first.movementCode, + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, ); + expect(result.notices.first.parameters['rolledCount'], 1); }); test('moveToBacklog clears active schedule placement', () { diff --git a/test/scheduling_invariants_test.dart b/test/scheduling_invariants_test.dart new file mode 100644 index 0000000..58511ad --- /dev/null +++ b/test/scheduling_invariants_test.dart @@ -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 = []; + final lockedIntervals = []; + 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 = [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 = [ + 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 tasks) { + expect(tasks.map((task) => task.id).toSet().length, tasks.length); +} + +void assertIntervalsValid(List 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 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 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 beforeTasks, + required List afterTasks, +}) { + final beforeOrder = plannedFlexibleIdsByStart(beforeTasks); + final afterOrder = plannedFlexibleIdsByStart(afterTasks) + .where(beforeOrder.contains) + .toList(growable: false); + + expect(afterOrder, beforeOrder); +} + +List plannedFlexibleIdsByStart(List 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 beforeTasks, + required List 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'; + } +} diff --git a/test/surprise_task_logging_test.dart b/test/surprise_task_logging_test.dart index 824ce2d..981951b 100644 --- a/test/surprise_task_logging_test.dart +++ b/test/surprise_task_logging_test.dart @@ -312,8 +312,10 @@ void main() { taskById(retry.schedulingResult.tasks, 'flexible').scheduledStart, taskById(first.schedulingResult.tasks, 'flexible').scheduledStart, ); - expect(retry.schedulingResult.notices.single.message, - 'Surprise task already logged.'); + expect( + retry.schedulingResult.notices.single.issueCode, + SchedulingIssueCode.duplicateSurpriseLog, + ); }); test('later insertion avoids persisted surprise actual occupancy', () {