diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md b/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md index 4eac271..d97a19f 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md @@ -1,6 +1,6 @@ # V1 Block 12 — Occupancy Policy and Scheduling Correctness -Status: Planned +Status: In progress Purpose: Make every scheduling operation use one explicit occupancy policy so protected free slots, surprise work, required commitments, and hidden locked @@ -10,6 +10,8 @@ time behave consistently without breaking flexible-task order. Recommended Codex level: extra high +Status: Complete on 2026-06-25. + Tasks: - Introduce one UI-independent occupancy classifier/policy used by insertion, 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 49c455b..3cd1899 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -18,6 +18,7 @@ following source files: - `src/child_tasks.dart` - `src/document_mapping.dart` - `src/locked_time.dart` +- `src/occupancy_policy.dart` - `src/persistence_contract.dart` - `src/quick_capture.dart` - `src/repositories.dart` @@ -37,6 +38,7 @@ changes from accidental API drift. | `src/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` | | `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` | | `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` | +| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` | | `src/quick_capture.dart` | `QuickCaptureStatus` | | `src/scheduling_engine.dart` | `SchedulingNoticeType` | | `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` | @@ -52,6 +54,7 @@ changes from accidental API drift. | `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` | | `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` | | `src/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` | +| `src/occupancy_policy.dart` | `OccupancyEntry`, `OccupancyPolicy` | | `src/persistence_contract.dart` | `PersistenceEnumName` | | `src/quick_capture.dart` | `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureService` | | `src/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` | @@ -133,6 +136,17 @@ Chunk 11.4 intentionally changed the public API: - Added civil-date and wall-time persistence conventions that store strings without timezone conversion. +Chunk 12.1 intentionally changed the public API: + +- Added `src/occupancy_policy.dart` and exported it from `scheduler_core.dart`. +- Added `OccupancyCategory`, `OccupancySource`, `OccupancyEntry`, and + `OccupancyPolicy`. +- Added policy-derived `SchedulingInput.occupancyEntries`, + `SchedulingInput.movablePlannedFlexibleTasks`, and + `SchedulingInput.conflictReportingOccupancies`. +- Changed `SchedulingInput.blockedIntervals` to derive from the central + occupancy policy rather than from duplicated task-type filters. + ## 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 14e6329..5396b34 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -42,7 +42,7 @@ Status values: | 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` | complete | Free-slot blocking and actual-occupancy blocking remain in Block 12. | +| 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` | complete | Chunk 12.1 centralized occupancy; broader free-slot edge cases remain in Chunk 12.2. | | 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 | Initial operation works; persisted actual occupancy and idempotent replay remain in Block 12. | | 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. | @@ -54,11 +54,11 @@ 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. | `SchedulingInput.blockedIntervals`, `SchedulingEngine` placement methods | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Current behavior is covered, but one centralized occupancy policy is Block 12. | +| 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-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`, timeline tokens | `test/timeline_state_test.dart` | incomplete | Scheduling protection and reminder directives remain in Blocks 12 and 13. | +| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart` | incomplete | Chunk 12.1 classifies/protects free slots as blockers; creation/update helpers, overlap outcomes, and reminder directives remain in Blocks 12 and 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. | diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 54f0b81..d304f02 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -21,6 +21,7 @@ export 'src/backlog.dart'; export 'src/child_tasks.dart'; export 'src/document_mapping.dart'; export 'src/locked_time.dart'; +export 'src/occupancy_policy.dart'; export 'src/persistence_contract.dart'; export 'src/quick_capture.dart'; export 'src/repositories.dart'; diff --git a/lib/src/occupancy_policy.dart b/lib/src/occupancy_policy.dart new file mode 100644 index 0000000..60d89c0 --- /dev/null +++ b/lib/src/occupancy_policy.dart @@ -0,0 +1,342 @@ +// Central occupancy policy for V1 scheduling. +// +// This file keeps scheduler movement rules separate from UI timeline +// categories. A task can be hidden, visible, movable, historical, or +// non-occupying for scheduling purposes independent of how a view renders it. + +import 'models.dart'; + +/// Scheduling-facing occupancy category for one task or external interval. +enum OccupancyCategory { + /// Hidden locked time that constrains scheduling. + hiddenLockedConstraint, + + /// Visible critical or inflexible commitment. + requiredVisibleCommitment, + + /// Intentional protected rest. + protectedFreeSlot, + + /// Planned flexible work that automatic scheduling may move. + movablePlannedFlexible, + + /// Work currently in progress. + activeWork, + + /// Known actual historical occupancy from completed work. + completedActualOccupancy, + + /// Historical missed placement retained as context. + retainedMissedHistoricalInterval, + + /// Record that should not occupy the scheduling window. + nonOccupyingRecord, +} + +/// Where an occupancy entry came from. +enum OccupancySource { + /// A [Task] record. + task, + + /// A hidden locked interval supplied outside the task list. + lockedInterval, + + /// A visible required interval supplied outside the task list. + requiredVisibleInterval, +} + +/// Policy classification for one task or external interval. +class OccupancyEntry { + const OccupancyEntry({ + required this.source, + required this.category, + required this.interval, + required this.task, + required this.isImmovable, + required this.blocksAutomaticFlexiblePlacement, + required this.mayBeExplicitlyOverlappedByRequiredCommitment, + required this.reportsConflict, + required this.hiddenByDefault, + }); + + /// Origin of the entry. + final OccupancySource source; + + /// Scheduling-facing category. + final OccupancyCategory category; + + /// Interval used by scheduling, or null when the record is unplaced. + final TimeInterval? interval; + + /// Source task when this entry came from a task record. + final Task? task; + + /// Stable task id when present. + String? get taskId => task?.id; + + /// Whether automatic scheduling must leave this entry's placement unchanged. + final bool isImmovable; + + /// Whether flexible placement must avoid [interval]. + final bool blocksAutomaticFlexiblePlacement; + + /// Whether an explicit critical/inflexible commitment may overlap this entry. + final bool mayBeExplicitlyOverlappedByRequiredCommitment; + + /// Whether overlaps with this entry should be returned as conflicts. + final bool reportsConflict; + + /// Whether entry details should be hidden by default in user-facing output. + final bool hiddenByDefault; + + /// Whether the entry is planned flexible work movable by automatic scheduling. + bool get isMovablePlannedFlexible { + return category == OccupancyCategory.movablePlannedFlexible; + } +} + +/// UI-independent V1 occupancy policy. +class OccupancyPolicy { + const OccupancyPolicy(); + + /// Classify all task and external interval inputs for a scheduling operation. + List classify({ + required Iterable tasks, + Iterable lockedIntervals = const [], + Iterable requiredVisibleIntervals = const [], + }) { + return List.unmodifiable([ + ...tasks.map(classifyTask), + ...lockedIntervals.map(classifyLockedInterval), + ...requiredVisibleIntervals.map(classifyRequiredVisibleInterval), + ]); + } + + /// Classify one task record. + OccupancyEntry classifyTask(Task task) { + final scheduledInterval = _scheduledIntervalFor(task); + final actualInterval = _actualIntervalFor(task); + + if (_isNonOccupyingStatus(task.status)) { + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + if (task.status == TaskStatus.completed) { + final completedInterval = actualInterval ?? + (task.type == TaskType.surprise ? scheduledInterval : null); + + if (completedInterval == null) { + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + return _taskEntry( + task: task, + category: OccupancyCategory.completedActualOccupancy, + interval: completedInterval, + ); + } + + if (task.status == TaskStatus.missed) { + if (scheduledInterval == null) { + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + return _taskEntry( + task: task, + category: OccupancyCategory.retainedMissedHistoricalInterval, + interval: scheduledInterval, + ); + } + + if (task.type == TaskType.locked) { + return _taskEntry( + task: task, + category: OccupancyCategory.hiddenLockedConstraint, + interval: scheduledInterval, + ); + } + + if (task.type == TaskType.freeSlot) { + return _taskEntry( + task: task, + category: OccupancyCategory.protectedFreeSlot, + interval: scheduledInterval, + ); + } + + if (task.status == TaskStatus.active) { + return _taskEntry( + task: task, + category: OccupancyCategory.activeWork, + interval: actualInterval ?? scheduledInterval, + ); + } + + if (task.isRequiredVisible) { + return _taskEntry( + task: task, + category: OccupancyCategory.requiredVisibleCommitment, + interval: scheduledInterval, + ); + } + + if (task.isFlexible && task.status == TaskStatus.planned) { + return _taskEntry( + task: task, + category: OccupancyCategory.movablePlannedFlexible, + interval: scheduledInterval, + ); + } + + // Surprise tasks should normally be completed records. If an unsupported + // lifecycle combination appears, keep it out of automatic placement instead + // of inventing movement behavior for it. + return _taskEntry( + task: task, + category: OccupancyCategory.nonOccupyingRecord, + interval: null, + ); + } + + /// Classify one hidden locked interval supplied outside the task list. + OccupancyEntry classifyLockedInterval(TimeInterval interval) { + return _entry( + source: OccupancySource.lockedInterval, + category: OccupancyCategory.hiddenLockedConstraint, + interval: interval, + task: null, + ); + } + + /// Classify one visible required interval supplied outside the task list. + OccupancyEntry classifyRequiredVisibleInterval(TimeInterval interval) { + return _entry( + source: OccupancySource.requiredVisibleInterval, + category: OccupancyCategory.requiredVisibleCommitment, + interval: interval, + task: null, + ); + } + + OccupancyEntry _taskEntry({ + required Task task, + required OccupancyCategory category, + required TimeInterval? interval, + }) { + return _entry( + source: OccupancySource.task, + category: category, + interval: interval, + task: task, + ); + } + + OccupancyEntry _entry({ + required OccupancySource source, + required OccupancyCategory category, + required TimeInterval? interval, + required Task? task, + }) { + final hasInterval = interval != null; + + return switch (category) { + OccupancyCategory.nonOccupyingRecord => OccupancyEntry( + source: source, + category: category, + interval: null, + task: task, + isImmovable: false, + blocksAutomaticFlexiblePlacement: false, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: false, + hiddenByDefault: false, + ), + OccupancyCategory.movablePlannedFlexible => OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: false, + blocksAutomaticFlexiblePlacement: false, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: false, + hiddenByDefault: false, + ), + OccupancyCategory.protectedFreeSlot => OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: true, + blocksAutomaticFlexiblePlacement: hasInterval, + mayBeExplicitlyOverlappedByRequiredCommitment: hasInterval, + reportsConflict: hasInterval, + hiddenByDefault: false, + ), + OccupancyCategory.hiddenLockedConstraint => OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: true, + blocksAutomaticFlexiblePlacement: hasInterval, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: hasInterval, + hiddenByDefault: true, + ), + OccupancyCategory.requiredVisibleCommitment || + OccupancyCategory.activeWork || + OccupancyCategory.completedActualOccupancy || + OccupancyCategory.retainedMissedHistoricalInterval => + OccupancyEntry( + source: source, + category: category, + interval: interval, + task: task, + isImmovable: true, + blocksAutomaticFlexiblePlacement: hasInterval, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: hasInterval, + hiddenByDefault: false, + ), + }; + } +} + +bool _isNonOccupyingStatus(TaskStatus status) { + return status == TaskStatus.backlog || + status == TaskStatus.cancelled || + status == TaskStatus.noLongerRelevant; +} + +TimeInterval? _scheduledIntervalFor(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); +} + +TimeInterval? _actualIntervalFor(Task task) { + final start = task.actualStart; + final end = task.actualEnd; + if (start == null || end == null) { + return null; + } + + return TimeInterval(start: start, end: end, label: task.id); +} diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index 8f89df2..5c71a4d 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -13,8 +13,11 @@ // 4. Private apply helpers: convert plans into updated tasks and notices. import 'models.dart'; +import 'occupancy_policy.dart'; import 'time_contracts.dart'; +const OccupancyPolicy _occupancyPolicy = OccupancyPolicy(); + /// Category for scheduler notices. /// /// Notices are human-readable summaries attached to a [SchedulingResult]. They @@ -119,6 +122,24 @@ class SchedulingInput { return tasks.where((task) => task.isFlexible).toList(growable: false); } + /// Central occupancy classification for this scheduling snapshot. + List get occupancyEntries { + return _occupancyPolicy.classify( + tasks: tasks, + lockedIntervals: lockedIntervals, + requiredVisibleIntervals: requiredVisibleIntervals, + ); + } + + /// Planned flexible task records that automatic scheduling may move. + List get movablePlannedFlexibleTasks { + return occupancyEntries + .where((entry) => entry.isMovablePlannedFlexible) + .map((entry) => entry.task) + .whereType() + .toList(growable: false); + } + /// Locked task records in [tasks], if the caller represents locked time as /// tasks instead of only passing [lockedIntervals]. List get lockedTasks { @@ -139,19 +160,25 @@ class SchedulingInput { /// All intervals that flexible scheduling must avoid. /// - /// This combines explicit locked intervals, locked task records, caller-supplied - /// required-visible intervals, and scheduled critical/inflexible tasks. The - /// result is sorted to make interval scanning deterministic. + /// This is derived from the central occupancy policy rather than from UI card + /// categories. The result is sorted to make interval scanning deterministic. List get blockedIntervals { - final intervals = [ - ...lockedIntervals, - ..._scheduledIntervalsFor(lockedTasks), - ...requiredVisibleIntervals, - ..._scheduledIntervalsFor(requiredVisibleTasks), - ]..sort((a, b) => a.start.compareTo(b.start)); + final intervals = occupancyEntries + .where((entry) => entry.blocksAutomaticFlexiblePlacement) + .map((entry) => entry.interval) + .whereType() + .toList(growable: false) + ..sort((a, b) => a.start.compareTo(b.start)); return List.unmodifiable(intervals); } + + /// Occupancy entries that should be surfaced as conflicts when overlapped. + List get conflictReportingOccupancies { + return occupancyEntries + .where((entry) => entry.reportsConflict) + .toList(growable: false); + } } /// Exact placement change made by a scheduling operation. @@ -618,15 +645,20 @@ class SchedulingEngine { SchedulingResult analyzeSchedule(SchedulingInput input) { final overlaps = []; final notices = []; - final blockedIntervals = input.blockedIntervals; + final conflictOccupancies = input.conflictReportingOccupancies; - for (final task in input.flexibleTasks) { + for (final task in input.movablePlannedFlexibleTasks) { final taskInterval = _scheduledIntervalFor(task); if (taskInterval == null || !input.window.contains(taskInterval)) { continue; } - for (final blockedInterval in blockedIntervals) { + for (final occupancy in conflictOccupancies) { + final blockedInterval = occupancy.interval; + if (blockedInterval == null) { + continue; + } + if (!taskInterval.overlaps(blockedInterval)) { continue; } @@ -831,7 +863,7 @@ _BacklogInsertionPlan? _planBacklogInsertion({ // Existing flexible tasks are inspected in timeline order. Planned tasks inside // the movable portion become part of the placement queue; everything else is // treated as fixed. - final scheduledFlexibleTasks = input.flexibleTasks + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks .where((flexibleTask) => flexibleTask.id != task.id) .toList(growable: false) ..sort((a, b) { @@ -856,11 +888,6 @@ _BacklogInsertionPlan? _planBacklogInsertion({ continue; } - if (flexibleTask.status != TaskStatus.planned) { - fixedBlocks.add(interval); - continue; - } - queue.add( _PlacementItem( task: flexibleTask, @@ -919,7 +946,7 @@ _BacklogInsertionPlan? _planFlexiblePush({ ), ]; - final scheduledFlexibleTasks = input.flexibleTasks + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks .where((flexibleTask) => flexibleTask.id != task.id) .toList(growable: false) ..sort((a, b) { @@ -944,11 +971,6 @@ _BacklogInsertionPlan? _planFlexiblePush({ continue; } - if (flexibleTask.status != TaskStatus.planned) { - fixedBlocks.add(interval); - continue; - } - queue.add( _PlacementItem( task: flexibleTask, @@ -1021,7 +1043,7 @@ _BacklogInsertionPlan? _planQueueAtWindowStart({ ...queue, ]; - final scheduledFlexibleTasks = input.flexibleTasks + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks .where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id)) .toList(growable: false) ..sort((a, b) { @@ -1042,8 +1064,7 @@ _BacklogInsertionPlan? _planQueueAtWindowStart({ continue; } - if (!input.window.contains(interval) || - flexibleTask.status != TaskStatus.planned) { + if (!input.window.contains(interval)) { fixedBlocks.add(interval); continue; } diff --git a/lib/src/task_actions.dart b/lib/src/task_actions.dart index 9c059d4..80777ae 100644 --- a/lib/src/task_actions.dart +++ b/lib/src/task_actions.dart @@ -6,6 +6,7 @@ // thin and keeps task-type safety checks in one place. import 'models.dart'; +import 'occupancy_policy.dart'; import 'scheduling_engine.dart'; import 'time_contracts.dart'; @@ -516,7 +517,7 @@ class SurpriseTaskLogService { } final requiredOverlaps = _requiredOverlaps( - tasks: input.requiredVisibleTasks, + input: input, surpriseInterval: surpriseInterval, ); final lockedOverlaps = _lockedOverlaps( @@ -524,7 +525,7 @@ class SurpriseTaskLogService { surpriseInterval: surpriseInterval, ); final flexibleTaskIds = _overlappingFlexibleTaskIds( - input.flexibleTasks, + input.movablePlannedFlexibleTasks, surpriseInterval, ); final changes = []; @@ -545,10 +546,7 @@ class SurpriseTaskLogService { input: SchedulingInput( tasks: currentTasks, window: input.window, - lockedIntervals: [ - ...input.lockedIntervals, - surpriseInterval, - ], + lockedIntervals: input.lockedIntervals, requiredVisibleIntervals: input.requiredVisibleIntervals, ), taskId: taskId, @@ -684,14 +682,24 @@ TimeInterval? _scheduledIntervalForTask(Task task) { /// Required visible overlaps with a surprise interval. List _requiredOverlaps({ - required List tasks, + required SchedulingInput input, required TimeInterval surpriseInterval, }) { final overlaps = []; - for (final task in tasks) { - final interval = _scheduledIntervalForTask(task); - if (interval == null || !interval.overlaps(surpriseInterval)) { + for (final occupancy in input.occupancyEntries) { + final task = occupancy.task; + final interval = occupancy.interval; + final isRequiredOccupancy = + occupancy.category == OccupancyCategory.requiredVisibleCommitment || + (occupancy.category == OccupancyCategory.activeWork && + task != null && + task.isRequiredVisible); + + if (task == null || + !isRequiredOccupancy || + interval == null || + !interval.overlaps(surpriseInterval)) { continue; } @@ -712,14 +720,13 @@ List _lockedOverlaps({ required SchedulingInput input, required TimeInterval surpriseInterval, }) { - final lockedIntervals = [ - ...input.lockedIntervals, - ...input.lockedTasks - .map(_scheduledIntervalForTask) - .whereType(), - ]; - - return lockedIntervals + return input.occupancyEntries + .where( + (occupancy) => + occupancy.category == OccupancyCategory.hiddenLockedConstraint, + ) + .map((occupancy) => occupancy.interval) + .whereType() .where((interval) => interval.overlaps(surpriseInterval)) .toList(growable: false); } diff --git a/test/occupancy_policy_test.dart b/test/occupancy_policy_test.dart new file mode 100644 index 0000000..b3b49cf --- /dev/null +++ b/test/occupancy_policy_test.dart @@ -0,0 +1,301 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('OccupancyPolicy', () { + const policy = OccupancyPolicy(); + final scheduledStart = DateTime(2026, 6, 19, 9); + final scheduledEnd = DateTime(2026, 6, 19, 9, 30); + final actualStart = DateTime(2026, 6, 19, 9, 5); + final actualEnd = DateTime(2026, 6, 19, 9, 25); + + test('classifies every task type and status combination', () { + for (final type in TaskType.values) { + for (final status in TaskStatus.values) { + final task = matrixTask( + type: type, + status: status, + scheduledStart: scheduledStart, + scheduledEnd: scheduledEnd, + actualStart: status == TaskStatus.completed ? actualStart : null, + actualEnd: status == TaskStatus.completed ? actualEnd : null, + ); + final entry = policy.classifyTask(task); + final expected = expectationFor(type: type, status: status); + final reason = '${type.name}/${status.name}'; + + expect(entry.category, expected.category, reason: reason); + expect(entry.isImmovable, expected.isImmovable, reason: reason); + expect( + entry.blocksAutomaticFlexiblePlacement, + expected.blocksAutomaticFlexiblePlacement, + reason: reason, + ); + expect( + entry.mayBeExplicitlyOverlappedByRequiredCommitment, + expected.mayBeExplicitlyOverlappedByRequiredCommitment, + reason: reason, + ); + expect(entry.reportsConflict, expected.reportsConflict, + reason: reason); + expect(entry.hiddenByDefault, expected.hiddenByDefault, + reason: reason); + + if (expected.category == OccupancyCategory.nonOccupyingRecord) { + expect(entry.interval, isNull, reason: reason); + } else { + expect(entry.interval, isNotNull, reason: reason); + } + + if (expected.category == OccupancyCategory.completedActualOccupancy) { + expect(entry.interval!.start, actualStart, reason: reason); + expect(entry.interval!.end, actualEnd, reason: reason); + } + } + } + }); + + test('classifies external locked and required intervals', () { + final locked = TimeInterval( + start: DateTime(2026, 6, 19, 10), + end: DateTime(2026, 6, 19, 11), + label: 'locked', + ); + final required = TimeInterval( + start: DateTime(2026, 6, 19, 12), + end: DateTime(2026, 6, 19, 13), + label: 'required', + ); + + final entries = policy.classify( + tasks: const [], + lockedIntervals: [locked], + requiredVisibleIntervals: [required], + ); + + expect(entries.map((entry) => entry.category), [ + OccupancyCategory.hiddenLockedConstraint, + OccupancyCategory.requiredVisibleCommitment, + ]); + expect(entries.first.source, OccupancySource.lockedInterval); + expect(entries.first.hiddenByDefault, isTrue); + expect(entries.first.blocksAutomaticFlexiblePlacement, isTrue); + expect(entries.last.source, OccupancySource.requiredVisibleInterval); + expect(entries.last.hiddenByDefault, isFalse); + expect(entries.last.blocksAutomaticFlexiblePlacement, isTrue); + }); + + test('treats baseline completed surprise schedule as actual occupancy', () { + final surprise = matrixTask( + type: TaskType.surprise, + status: TaskStatus.completed, + scheduledStart: scheduledStart, + scheduledEnd: scheduledEnd, + ); + + final entry = policy.classifyTask(surprise); + + expect(entry.category, OccupancyCategory.completedActualOccupancy); + expect(entry.interval!.start, scheduledStart); + expect(entry.interval!.end, scheduledEnd); + expect(entry.blocksAutomaticFlexiblePlacement, isTrue); + }); + }); + + group('Scheduling occupancy integration', () { + final now = DateTime(2026, 6, 19, 8); + const engine = SchedulingEngine(); + + test('automatic insertion uses policy blockers without moving them', () { + final backlog = Task.quickCapture( + id: 'backlog', + title: 'Make call', + createdAt: now, + ).copyWith(durationMinutes: 15); + final completedActual = Task( + id: 'completed-actual', + title: 'Finished appointment', + projectId: 'health', + type: TaskType.inflexible, + status: TaskStatus.completed, + priority: PriorityLevel.high, + durationMinutes: 30, + actualStart: DateTime(2026, 6, 19, 9), + actualEnd: DateTime(2026, 6, 19, 9, 30), + createdAt: now, + updatedAt: now, + ); + final freeSlot = Task( + id: 'rest', + title: 'Rest', + projectId: 'health', + type: TaskType.freeSlot, + status: TaskStatus.planned, + priority: PriorityLevel.medium, + durationMinutes: 30, + scheduledStart: DateTime(2026, 6, 19, 9, 30), + scheduledEnd: DateTime(2026, 6, 19, 10), + createdAt: now, + updatedAt: now, + ); + + final result = engine.insertBacklogTaskIntoNextAvailableSlot( + input: SchedulingInput( + tasks: [backlog, completedActual, freeSlot], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 11), + ), + ), + taskId: 'backlog', + updatedAt: now, + ); + + expect(taskById(result.tasks, 'backlog').scheduledStart, + DateTime(2026, 6, 19, 10)); + expect(taskById(result.tasks, 'backlog').scheduledEnd, + DateTime(2026, 6, 19, 10, 15)); + expect(taskById(result.tasks, 'completed-actual').actualStart, + completedActual.actualStart); + expect(taskById(result.tasks, 'rest').scheduledStart, + freeSlot.scheduledStart); + expect(result.changes.map((change) => change.taskId), ['backlog']); + }); + }); +} + +Task matrixTask({ + required TaskType type, + required TaskStatus status, + DateTime? scheduledStart, + DateTime? scheduledEnd, + DateTime? actualStart, + DateTime? actualEnd, +}) { + return Task( + id: '${type.name}-${status.name}', + title: '${type.name} ${status.name}', + projectId: 'matrix', + type: type, + status: status, + priority: PriorityLevel.medium, + durationMinutes: 30, + scheduledStart: scheduledStart, + scheduledEnd: scheduledEnd, + actualStart: actualStart, + actualEnd: actualEnd, + createdAt: DateTime(2026, 6, 19, 8), + updatedAt: DateTime(2026, 6, 19, 8), + ); +} + +OccupancyExpectation expectationFor({ + required TaskType type, + required TaskStatus status, +}) { + if (status == TaskStatus.backlog || + status == TaskStatus.cancelled || + status == TaskStatus.noLongerRelevant) { + return const OccupancyExpectation.nonOccupying(); + } + + if (status == TaskStatus.completed) { + return const OccupancyExpectation.blocking( + category: OccupancyCategory.completedActualOccupancy, + ); + } + + if (status == TaskStatus.missed) { + return const OccupancyExpectation.blocking( + category: OccupancyCategory.retainedMissedHistoricalInterval, + ); + } + + if (type == TaskType.locked) { + return const OccupancyExpectation.blocking( + category: OccupancyCategory.hiddenLockedConstraint, + hiddenByDefault: true, + ); + } + + if (type == TaskType.freeSlot) { + return const OccupancyExpectation.blocking( + category: OccupancyCategory.protectedFreeSlot, + mayBeExplicitlyOverlappedByRequiredCommitment: true, + ); + } + + if (status == TaskStatus.active) { + return const OccupancyExpectation.blocking( + category: OccupancyCategory.activeWork, + ); + } + + if (type == TaskType.critical || type == TaskType.inflexible) { + return const OccupancyExpectation.blocking( + category: OccupancyCategory.requiredVisibleCommitment, + ); + } + + if (type == TaskType.flexible && status == TaskStatus.planned) { + return const OccupancyExpectation.movableFlexible(); + } + + return const OccupancyExpectation.nonOccupying(); +} + +class OccupancyExpectation { + const OccupancyExpectation({ + required this.category, + required this.isImmovable, + required this.blocksAutomaticFlexiblePlacement, + required this.mayBeExplicitlyOverlappedByRequiredCommitment, + required this.reportsConflict, + required this.hiddenByDefault, + }); + + const OccupancyExpectation.nonOccupying() + : this( + category: OccupancyCategory.nonOccupyingRecord, + isImmovable: false, + blocksAutomaticFlexiblePlacement: false, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: false, + hiddenByDefault: false, + ); + + const OccupancyExpectation.movableFlexible() + : this( + category: OccupancyCategory.movablePlannedFlexible, + isImmovable: false, + blocksAutomaticFlexiblePlacement: false, + mayBeExplicitlyOverlappedByRequiredCommitment: false, + reportsConflict: false, + hiddenByDefault: false, + ); + + const OccupancyExpectation.blocking({ + required OccupancyCategory category, + bool mayBeExplicitlyOverlappedByRequiredCommitment = false, + bool hiddenByDefault = false, + }) : this( + category: category, + isImmovable: true, + blocksAutomaticFlexiblePlacement: true, + mayBeExplicitlyOverlappedByRequiredCommitment: + mayBeExplicitlyOverlappedByRequiredCommitment, + reportsConflict: true, + hiddenByDefault: hiddenByDefault, + ); + + final OccupancyCategory category; + final bool isImmovable; + final bool blocksAutomaticFlexiblePlacement; + final bool mayBeExplicitlyOverlappedByRequiredCommitment; + final bool reportsConflict; + final bool hiddenByDefault; +} + +Task taskById(List tasks, String id) { + return tasks.singleWhere((task) => task.id == id); +}