feat(scheduling): add central occupancy policy

This commit is contained in:
Ashley Venn 2026-06-25 08:54:11 -07:00
parent 81f137b8c0
commit b0ec910471
8 changed files with 737 additions and 49 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 12 — Occupancy Policy and Scheduling Correctness # V1 Block 12 — Occupancy Policy and Scheduling Correctness
Status: Planned Status: In progress
Purpose: Make every scheduling operation use one explicit occupancy policy so Purpose: Make every scheduling operation use one explicit occupancy policy so
protected free slots, surprise work, required commitments, and hidden locked protected free slots, surprise work, required commitments, and hidden locked
@ -10,6 +10,8 @@ time behave consistently without breaking flexible-task order.
Recommended Codex level: extra high Recommended Codex level: extra high
Status: Complete on 2026-06-25.
Tasks: Tasks:
- Introduce one UI-independent occupancy classifier/policy used by insertion, - Introduce one UI-independent occupancy classifier/policy used by insertion,

View file

@ -18,6 +18,7 @@ following source files:
- `src/child_tasks.dart` - `src/child_tasks.dart`
- `src/document_mapping.dart` - `src/document_mapping.dart`
- `src/locked_time.dart` - `src/locked_time.dart`
- `src/occupancy_policy.dart`
- `src/persistence_contract.dart` - `src/persistence_contract.dart`
- `src/quick_capture.dart` - `src/quick_capture.dart`
- `src/repositories.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/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` |
| `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` | | `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` |
| `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` | | `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` |
| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` |
| `src/quick_capture.dart` | `QuickCaptureStatus` | | `src/quick_capture.dart` | `QuickCaptureStatus` |
| `src/scheduling_engine.dart` | `SchedulingNoticeType` | | `src/scheduling_engine.dart` | `SchedulingNoticeType` |
| `src/task_actions.dart` | `FlexibleTaskQuickAction`, `RequiredTaskAction`, `PushDestination` | | `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/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
| `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` | | `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` |
| `src/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` | | `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/persistence_contract.dart` | `PersistenceEnumName` |
| `src/quick_capture.dart` | `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureService` | | `src/quick_capture.dart` | `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureService` |
| `src/repositories.dart` | `SchedulingStateSnapshot`, `InMemoryTaskRepository`, `InMemoryProjectRepository`, `InMemoryLockedBlockRepository`, `InMemorySchedulingSnapshotRepository` | | `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 - Added civil-date and wall-time persistence conventions that store strings
without timezone conversion. 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 ## Repository contracts
The current repository surface is pure Dart and in-memory only: The current repository surface is pure Dart and in-memory only:

View file

@ -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-05 | Render Today as a timeline with project border, task-type background, task name, reward icon, and difficulty icon. | `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart` | incomplete | Backend read-model tokens exist; actual Flutter rendering is Block 18. |
| MVP-AC-06 | Use compact Today mode manually. | `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart` | complete | Complete at backend/read-model level; Flutter UI remains Block 18. | | MVP-AC-06 | Use compact Today mode manually. | `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart` | complete | Complete at backend/read-model level; Flutter UI remains Block 18. |
| MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Exactly-once activity/stat records remain in Block 13. | | MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Exactly-once activity/stat records remain in Block 13. |
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | 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-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-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. | | 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 | | 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-02 | Critical missed tasks are marked missed and moved to backlog. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.critical` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Durable completion/missed metadata remains in Block 13. |
| MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. | | MVP-SUP-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. |
| MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction` | `test/required_task_actions_test.dart` | complete | Completion/cancellation timestamps remain in Block 13. | | MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction` | `test/required_task_actions_test.dart` | complete | Completion/cancellation timestamps remain in Block 13. |
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, 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-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart` | incomplete | Task override storage exists; effective resolver, learned suggestions, and reminder directives remain in Block 13. |
| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. | | MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. |
| MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. | | MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. |

View file

@ -21,6 +21,7 @@ export 'src/backlog.dart';
export 'src/child_tasks.dart'; export 'src/child_tasks.dart';
export 'src/document_mapping.dart'; export 'src/document_mapping.dart';
export 'src/locked_time.dart'; export 'src/locked_time.dart';
export 'src/occupancy_policy.dart';
export 'src/persistence_contract.dart'; export 'src/persistence_contract.dart';
export 'src/quick_capture.dart'; export 'src/quick_capture.dart';
export 'src/repositories.dart'; export 'src/repositories.dart';

View file

@ -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<OccupancyEntry> classify({
required Iterable<Task> tasks,
Iterable<TimeInterval> lockedIntervals = const <TimeInterval>[],
Iterable<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
}) {
return List<OccupancyEntry>.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);
}

View file

@ -13,8 +13,11 @@
// 4. Private apply helpers: convert plans into updated tasks and notices. // 4. Private apply helpers: convert plans into updated tasks and notices.
import 'models.dart'; import 'models.dart';
import 'occupancy_policy.dart';
import 'time_contracts.dart'; import 'time_contracts.dart';
const OccupancyPolicy _occupancyPolicy = OccupancyPolicy();
/// Category for scheduler notices. /// Category for scheduler notices.
/// ///
/// Notices are human-readable summaries attached to a [SchedulingResult]. They /// 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); return tasks.where((task) => task.isFlexible).toList(growable: false);
} }
/// Central occupancy classification for this scheduling snapshot.
List<OccupancyEntry> get occupancyEntries {
return _occupancyPolicy.classify(
tasks: tasks,
lockedIntervals: lockedIntervals,
requiredVisibleIntervals: requiredVisibleIntervals,
);
}
/// Planned flexible task records that automatic scheduling may move.
List<Task> get movablePlannedFlexibleTasks {
return occupancyEntries
.where((entry) => entry.isMovablePlannedFlexible)
.map((entry) => entry.task)
.whereType<Task>()
.toList(growable: false);
}
/// Locked task records in [tasks], if the caller represents locked time as /// Locked task records in [tasks], if the caller represents locked time as
/// tasks instead of only passing [lockedIntervals]. /// tasks instead of only passing [lockedIntervals].
List<Task> get lockedTasks { List<Task> get lockedTasks {
@ -139,19 +160,25 @@ class SchedulingInput {
/// All intervals that flexible scheduling must avoid. /// All intervals that flexible scheduling must avoid.
/// ///
/// This combines explicit locked intervals, locked task records, caller-supplied /// This is derived from the central occupancy policy rather than from UI card
/// required-visible intervals, and scheduled critical/inflexible tasks. The /// categories. The result is sorted to make interval scanning deterministic.
/// result is sorted to make interval scanning deterministic.
List<TimeInterval> get blockedIntervals { List<TimeInterval> get blockedIntervals {
final intervals = <TimeInterval>[ final intervals = occupancyEntries
...lockedIntervals, .where((entry) => entry.blocksAutomaticFlexiblePlacement)
..._scheduledIntervalsFor(lockedTasks), .map((entry) => entry.interval)
...requiredVisibleIntervals, .whereType<TimeInterval>()
..._scheduledIntervalsFor(requiredVisibleTasks), .toList(growable: false)
]..sort((a, b) => a.start.compareTo(b.start)); ..sort((a, b) => a.start.compareTo(b.start));
return List<TimeInterval>.unmodifiable(intervals); return List<TimeInterval>.unmodifiable(intervals);
} }
/// Occupancy entries that should be surfaced as conflicts when overlapped.
List<OccupancyEntry> get conflictReportingOccupancies {
return occupancyEntries
.where((entry) => entry.reportsConflict)
.toList(growable: false);
}
} }
/// Exact placement change made by a scheduling operation. /// Exact placement change made by a scheduling operation.
@ -618,15 +645,20 @@ class SchedulingEngine {
SchedulingResult analyzeSchedule(SchedulingInput input) { SchedulingResult analyzeSchedule(SchedulingInput input) {
final overlaps = <SchedulingOverlap>[]; final overlaps = <SchedulingOverlap>[];
final notices = <SchedulingNotice>[]; final notices = <SchedulingNotice>[];
final blockedIntervals = input.blockedIntervals; final conflictOccupancies = input.conflictReportingOccupancies;
for (final task in input.flexibleTasks) { for (final task in input.movablePlannedFlexibleTasks) {
final taskInterval = _scheduledIntervalFor(task); final taskInterval = _scheduledIntervalFor(task);
if (taskInterval == null || !input.window.contains(taskInterval)) { if (taskInterval == null || !input.window.contains(taskInterval)) {
continue; continue;
} }
for (final blockedInterval in blockedIntervals) { for (final occupancy in conflictOccupancies) {
final blockedInterval = occupancy.interval;
if (blockedInterval == null) {
continue;
}
if (!taskInterval.overlaps(blockedInterval)) { if (!taskInterval.overlaps(blockedInterval)) {
continue; continue;
} }
@ -831,7 +863,7 @@ _BacklogInsertionPlan? _planBacklogInsertion({
// Existing flexible tasks are inspected in timeline order. Planned tasks inside // Existing flexible tasks are inspected in timeline order. Planned tasks inside
// the movable portion become part of the placement queue; everything else is // the movable portion become part of the placement queue; everything else is
// treated as fixed. // treated as fixed.
final scheduledFlexibleTasks = input.flexibleTasks final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks
.where((flexibleTask) => flexibleTask.id != task.id) .where((flexibleTask) => flexibleTask.id != task.id)
.toList(growable: false) .toList(growable: false)
..sort((a, b) { ..sort((a, b) {
@ -856,11 +888,6 @@ _BacklogInsertionPlan? _planBacklogInsertion({
continue; continue;
} }
if (flexibleTask.status != TaskStatus.planned) {
fixedBlocks.add(interval);
continue;
}
queue.add( queue.add(
_PlacementItem( _PlacementItem(
task: flexibleTask, task: flexibleTask,
@ -919,7 +946,7 @@ _BacklogInsertionPlan? _planFlexiblePush({
), ),
]; ];
final scheduledFlexibleTasks = input.flexibleTasks final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks
.where((flexibleTask) => flexibleTask.id != task.id) .where((flexibleTask) => flexibleTask.id != task.id)
.toList(growable: false) .toList(growable: false)
..sort((a, b) { ..sort((a, b) {
@ -944,11 +971,6 @@ _BacklogInsertionPlan? _planFlexiblePush({
continue; continue;
} }
if (flexibleTask.status != TaskStatus.planned) {
fixedBlocks.add(interval);
continue;
}
queue.add( queue.add(
_PlacementItem( _PlacementItem(
task: flexibleTask, task: flexibleTask,
@ -1021,7 +1043,7 @@ _BacklogInsertionPlan? _planQueueAtWindowStart({
...queue, ...queue,
]; ];
final scheduledFlexibleTasks = input.flexibleTasks final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks
.where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id)) .where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.id))
.toList(growable: false) .toList(growable: false)
..sort((a, b) { ..sort((a, b) {
@ -1042,8 +1064,7 @@ _BacklogInsertionPlan? _planQueueAtWindowStart({
continue; continue;
} }
if (!input.window.contains(interval) || if (!input.window.contains(interval)) {
flexibleTask.status != TaskStatus.planned) {
fixedBlocks.add(interval); fixedBlocks.add(interval);
continue; continue;
} }

View file

@ -6,6 +6,7 @@
// thin and keeps task-type safety checks in one place. // thin and keeps task-type safety checks in one place.
import 'models.dart'; import 'models.dart';
import 'occupancy_policy.dart';
import 'scheduling_engine.dart'; import 'scheduling_engine.dart';
import 'time_contracts.dart'; import 'time_contracts.dart';
@ -516,7 +517,7 @@ class SurpriseTaskLogService {
} }
final requiredOverlaps = _requiredOverlaps( final requiredOverlaps = _requiredOverlaps(
tasks: input.requiredVisibleTasks, input: input,
surpriseInterval: surpriseInterval, surpriseInterval: surpriseInterval,
); );
final lockedOverlaps = _lockedOverlaps( final lockedOverlaps = _lockedOverlaps(
@ -524,7 +525,7 @@ class SurpriseTaskLogService {
surpriseInterval: surpriseInterval, surpriseInterval: surpriseInterval,
); );
final flexibleTaskIds = _overlappingFlexibleTaskIds( final flexibleTaskIds = _overlappingFlexibleTaskIds(
input.flexibleTasks, input.movablePlannedFlexibleTasks,
surpriseInterval, surpriseInterval,
); );
final changes = <SchedulingChange>[]; final changes = <SchedulingChange>[];
@ -545,10 +546,7 @@ class SurpriseTaskLogService {
input: SchedulingInput( input: SchedulingInput(
tasks: currentTasks, tasks: currentTasks,
window: input.window, window: input.window,
lockedIntervals: [ lockedIntervals: input.lockedIntervals,
...input.lockedIntervals,
surpriseInterval,
],
requiredVisibleIntervals: input.requiredVisibleIntervals, requiredVisibleIntervals: input.requiredVisibleIntervals,
), ),
taskId: taskId, taskId: taskId,
@ -684,14 +682,24 @@ TimeInterval? _scheduledIntervalForTask(Task task) {
/// Required visible overlaps with a surprise interval. /// Required visible overlaps with a surprise interval.
List<SchedulingOverlap> _requiredOverlaps({ List<SchedulingOverlap> _requiredOverlaps({
required List<Task> tasks, required SchedulingInput input,
required TimeInterval surpriseInterval, required TimeInterval surpriseInterval,
}) { }) {
final overlaps = <SchedulingOverlap>[]; final overlaps = <SchedulingOverlap>[];
for (final task in tasks) { for (final occupancy in input.occupancyEntries) {
final interval = _scheduledIntervalForTask(task); final task = occupancy.task;
if (interval == null || !interval.overlaps(surpriseInterval)) { 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; continue;
} }
@ -712,14 +720,13 @@ List<TimeInterval> _lockedOverlaps({
required SchedulingInput input, required SchedulingInput input,
required TimeInterval surpriseInterval, required TimeInterval surpriseInterval,
}) { }) {
final lockedIntervals = <TimeInterval>[ return input.occupancyEntries
...input.lockedIntervals, .where(
...input.lockedTasks (occupancy) =>
.map(_scheduledIntervalForTask) occupancy.category == OccupancyCategory.hiddenLockedConstraint,
.whereType<TimeInterval>(), )
]; .map((occupancy) => occupancy.interval)
.whereType<TimeInterval>()
return lockedIntervals
.where((interval) => interval.overlaps(surpriseInterval)) .where((interval) => interval.overlaps(surpriseInterval))
.toList(growable: false); .toList(growable: false);
} }

View file

@ -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<Task> tasks, String id) {
return tasks.singleWhere((task) => task.id == id);
}