feat(scheduling): add central occupancy policy
This commit is contained in:
parent
81f137b8c0
commit
b0ec910471
8 changed files with 737 additions and 49 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
342
lib/src/occupancy_policy.dart
Normal file
342
lib/src/occupancy_policy.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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<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
|
||||
/// tasks instead of only passing [lockedIntervals].
|
||||
List<Task> 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<TimeInterval> get blockedIntervals {
|
||||
final intervals = <TimeInterval>[
|
||||
...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<TimeInterval>()
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => a.start.compareTo(b.start));
|
||||
|
||||
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.
|
||||
|
|
@ -618,15 +645,20 @@ class SchedulingEngine {
|
|||
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
||||
final overlaps = <SchedulingOverlap>[];
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <SchedulingChange>[];
|
||||
|
|
@ -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<SchedulingOverlap> _requiredOverlaps({
|
||||
required List<Task> tasks,
|
||||
required SchedulingInput input,
|
||||
required TimeInterval surpriseInterval,
|
||||
}) {
|
||||
final overlaps = <SchedulingOverlap>[];
|
||||
|
||||
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<TimeInterval> _lockedOverlaps({
|
|||
required SchedulingInput input,
|
||||
required TimeInterval surpriseInterval,
|
||||
}) {
|
||||
final lockedIntervals = <TimeInterval>[
|
||||
...input.lockedIntervals,
|
||||
...input.lockedTasks
|
||||
.map(_scheduledIntervalForTask)
|
||||
.whereType<TimeInterval>(),
|
||||
];
|
||||
|
||||
return lockedIntervals
|
||||
return input.occupancyEntries
|
||||
.where(
|
||||
(occupancy) =>
|
||||
occupancy.category == OccupancyCategory.hiddenLockedConstraint,
|
||||
)
|
||||
.map((occupancy) => occupancy.interval)
|
||||
.whereType<TimeInterval>()
|
||||
.where((interval) => interval.overlaps(surpriseInterval))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
|
|
|||
301
test/occupancy_policy_test.dart
Normal file
301
test/occupancy_policy_test.dart
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue