forked from eva/focus-flow
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
|
# 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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
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.
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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