forked from eva/focus-flow
feat(scheduling): protect free slot scheduling
This commit is contained in:
parent
b0ec910471
commit
545e882f02
6 changed files with 678 additions and 2 deletions
|
|
@ -62,6 +62,8 @@ and surprise-occupancy behavior.
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Complete on 2026-06-25.
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- Add validated creation/update helpers for scheduled Free Slot records.
|
- Add validated creation/update helpers for scheduled Free Slot records.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ following source files:
|
||||||
- `src/backlog.dart`
|
- `src/backlog.dart`
|
||||||
- `src/child_tasks.dart`
|
- `src/child_tasks.dart`
|
||||||
- `src/document_mapping.dart`
|
- `src/document_mapping.dart`
|
||||||
|
- `src/free_slots.dart`
|
||||||
- `src/locked_time.dart`
|
- `src/locked_time.dart`
|
||||||
- `src/occupancy_policy.dart`
|
- `src/occupancy_policy.dart`
|
||||||
- `src/persistence_contract.dart`
|
- `src/persistence_contract.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/free_slots.dart` | `RequiredCommitmentScheduleStatus` |
|
||||||
| `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` |
|
| `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` |
|
||||||
| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` |
|
| `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` |
|
||||||
| `src/quick_capture.dart` | `QuickCaptureStatus` |
|
| `src/quick_capture.dart` | `QuickCaptureStatus` |
|
||||||
|
|
@ -53,6 +55,7 @@ changes from accidental API drift.
|
||||||
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
|
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
|
||||||
| `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/free_slots.dart` | `ProtectedFreeSlotConflict`, `RequiredCommitmentScheduleResult`, `FreeSlotService` |
|
||||||
| `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/occupancy_policy.dart` | `OccupancyEntry`, `OccupancyPolicy` |
|
||||||
| `src/persistence_contract.dart` | `PersistenceEnumName` |
|
| `src/persistence_contract.dart` | `PersistenceEnumName` |
|
||||||
|
|
@ -147,6 +150,14 @@ Chunk 12.1 intentionally changed the public API:
|
||||||
- Changed `SchedulingInput.blockedIntervals` to derive from the central
|
- Changed `SchedulingInput.blockedIntervals` to derive from the central
|
||||||
occupancy policy rather than from duplicated task-type filters.
|
occupancy policy rather than from duplicated task-type filters.
|
||||||
|
|
||||||
|
Chunk 12.2 intentionally changed the public API:
|
||||||
|
|
||||||
|
- Added `src/free_slots.dart` and exported it from `scheduler_core.dart`.
|
||||||
|
- Added `RequiredCommitmentScheduleStatus`, `ProtectedFreeSlotConflict`,
|
||||||
|
`RequiredCommitmentScheduleResult`, and `FreeSlotService`.
|
||||||
|
- Added validated Free Slot create/update helpers and a typed explicit required
|
||||||
|
commitment scheduling result for protected-rest conflicts.
|
||||||
|
|
||||||
## 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`, `test/occupancy_policy_test.dart` | complete | Chunk 12.1 centralized occupancy; broader free-slot edge cases remain in Chunk 12.2. |
|
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart` | complete | Chunk 12.1 centralized occupancy; Chunk 12.2 covers protected Free Slot blockers. |
|
||||||
| MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Domain movement exists; durable next-open rollover notice/read model remains in Block 14. |
|
| MVP-AC-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. |
|
||||||
|
|
@ -58,7 +58,7 @@ Status values:
|
||||||
| 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`, `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-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart` | incomplete | Chunk 12.2 covers scheduling protection, helpers, and explicit required conflicts; reminder directives remain in Block 13. |
|
||||||
| MVP-SUP-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. |
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export 'src/models.dart';
|
||||||
export 'src/backlog.dart';
|
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/free_slots.dart';
|
||||||
export 'src/locked_time.dart';
|
export 'src/locked_time.dart';
|
||||||
export 'src/occupancy_policy.dart';
|
export 'src/occupancy_policy.dart';
|
||||||
export 'src/persistence_contract.dart';
|
export 'src/persistence_contract.dart';
|
||||||
|
|
|
||||||
304
lib/src/free_slots.dart
Normal file
304
lib/src/free_slots.dart
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
// Protected Free Slot helpers.
|
||||||
|
//
|
||||||
|
// Free slots are intentional rest, not empty capacity. They are represented as
|
||||||
|
// scheduled Task records so they can appear in read models, but scheduling
|
||||||
|
// treats them as protected occupancy.
|
||||||
|
|
||||||
|
import 'models.dart';
|
||||||
|
import 'occupancy_policy.dart';
|
||||||
|
import 'scheduling_engine.dart';
|
||||||
|
|
||||||
|
/// Typed outcome for explicitly placing a required visible commitment.
|
||||||
|
enum RequiredCommitmentScheduleStatus {
|
||||||
|
/// Required task was scheduled without protected rest conflicts.
|
||||||
|
scheduled,
|
||||||
|
|
||||||
|
/// Required task was scheduled and overlaps protected rest.
|
||||||
|
scheduledWithProtectedFreeSlotConflict,
|
||||||
|
|
||||||
|
/// No task with the requested id was present.
|
||||||
|
taskNotFound,
|
||||||
|
|
||||||
|
/// The task is not critical or inflexible.
|
||||||
|
invalidTaskType,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed overlap between an explicitly placed required commitment and rest.
|
||||||
|
class ProtectedFreeSlotConflict {
|
||||||
|
const ProtectedFreeSlotConflict({
|
||||||
|
required this.requiredTaskId,
|
||||||
|
required this.freeSlotTaskId,
|
||||||
|
required this.requiredInterval,
|
||||||
|
required this.freeSlotInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Critical or inflexible task that was explicitly placed.
|
||||||
|
final String requiredTaskId;
|
||||||
|
|
||||||
|
/// Protected Free Slot task that was interrupted.
|
||||||
|
final String freeSlotTaskId;
|
||||||
|
|
||||||
|
/// Required commitment interval.
|
||||||
|
final TimeInterval requiredInterval;
|
||||||
|
|
||||||
|
/// Protected rest interval.
|
||||||
|
final TimeInterval freeSlotInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result from explicitly scheduling a required visible commitment.
|
||||||
|
class RequiredCommitmentScheduleResult {
|
||||||
|
RequiredCommitmentScheduleResult({
|
||||||
|
required this.status,
|
||||||
|
required this.schedulingResult,
|
||||||
|
required List<ProtectedFreeSlotConflict> protectedFreeSlotConflicts,
|
||||||
|
}) : protectedFreeSlotConflicts =
|
||||||
|
List<ProtectedFreeSlotConflict>.unmodifiable(
|
||||||
|
protectedFreeSlotConflicts,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// High-level typed outcome.
|
||||||
|
final RequiredCommitmentScheduleStatus status;
|
||||||
|
|
||||||
|
/// Replacement task list, notices, changes, and overlap details.
|
||||||
|
final SchedulingResult schedulingResult;
|
||||||
|
|
||||||
|
/// Protected rest interruptions caused by the explicit placement.
|
||||||
|
final List<ProtectedFreeSlotConflict> protectedFreeSlotConflicts;
|
||||||
|
|
||||||
|
/// Whether this placement interrupted protected rest.
|
||||||
|
bool get hasProtectedFreeSlotConflict {
|
||||||
|
return protectedFreeSlotConflicts.isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates and updates protected Free Slot task records.
|
||||||
|
class FreeSlotService {
|
||||||
|
const FreeSlotService();
|
||||||
|
|
||||||
|
/// Create a scheduled Free Slot record.
|
||||||
|
Task create({
|
||||||
|
required String id,
|
||||||
|
required String title,
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
required DateTime createdAt,
|
||||||
|
String projectId = 'inbox',
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
final interval = TimeInterval(start: start, end: end, label: id);
|
||||||
|
|
||||||
|
return Task(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
projectId: projectId,
|
||||||
|
type: TaskType.freeSlot,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
priority: PriorityLevel.medium,
|
||||||
|
durationMinutes: interval.duration.inMinutes,
|
||||||
|
scheduledStart: interval.start,
|
||||||
|
scheduledEnd: interval.end,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt ?? createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing Free Slot record's schedule and optional metadata.
|
||||||
|
Task update({
|
||||||
|
required Task freeSlot,
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
String? title,
|
||||||
|
String? projectId,
|
||||||
|
}) {
|
||||||
|
if (freeSlot.type != TaskType.freeSlot) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
freeSlot.type,
|
||||||
|
'freeSlot.type',
|
||||||
|
'Task must be a Free Slot.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final interval = TimeInterval(start: start, end: end, label: freeSlot.id);
|
||||||
|
|
||||||
|
return freeSlot.copyWith(
|
||||||
|
title: title,
|
||||||
|
projectId: projectId,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
durationMinutes: interval.duration.inMinutes,
|
||||||
|
scheduledStart: interval.start,
|
||||||
|
scheduledEnd: interval.end,
|
||||||
|
updatedAt: updatedAt ?? freeSlot.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly place a critical or inflexible task.
|
||||||
|
///
|
||||||
|
/// This operation is allowed to overlap protected Free Slots because required
|
||||||
|
/// commitments can interrupt rest. It does not move the Free Slot. Instead it
|
||||||
|
/// returns typed conflict data for the application layer to surface calmly.
|
||||||
|
RequiredCommitmentScheduleResult scheduleRequiredCommitment({
|
||||||
|
required SchedulingInput input,
|
||||||
|
required String taskId,
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
final task = _taskById(input.tasks, taskId);
|
||||||
|
if (task == null) {
|
||||||
|
return _unchangedRequiredResult(
|
||||||
|
input: input,
|
||||||
|
status: RequiredCommitmentScheduleStatus.taskNotFound,
|
||||||
|
notice: SchedulingNotice(
|
||||||
|
'Task was not found.',
|
||||||
|
type: SchedulingNoticeType.noFit,
|
||||||
|
taskId: taskId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.isRequiredVisible) {
|
||||||
|
return _unchangedRequiredResult(
|
||||||
|
input: input,
|
||||||
|
status: RequiredCommitmentScheduleStatus.invalidTaskType,
|
||||||
|
notice: SchedulingNotice(
|
||||||
|
'Only critical or inflexible tasks can be explicitly scheduled here.',
|
||||||
|
type: SchedulingNoticeType.noFit,
|
||||||
|
taskId: task.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final requiredInterval =
|
||||||
|
TimeInterval(start: start, end: end, label: taskId);
|
||||||
|
final conflicts = _protectedFreeSlotConflicts(
|
||||||
|
input: input,
|
||||||
|
requiredTaskId: task.id,
|
||||||
|
requiredInterval: requiredInterval,
|
||||||
|
);
|
||||||
|
final updatedTask = task.copyWith(
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
durationMinutes: requiredInterval.duration.inMinutes,
|
||||||
|
scheduledStart: requiredInterval.start,
|
||||||
|
scheduledEnd: requiredInterval.end,
|
||||||
|
updatedAt: updatedAt ?? task.updatedAt,
|
||||||
|
);
|
||||||
|
final scheduleChanged = !_sameDateTime(task.scheduledStart, start) ||
|
||||||
|
!_sameDateTime(task.scheduledEnd, end) ||
|
||||||
|
task.status != TaskStatus.planned;
|
||||||
|
final updatedTasks = input.tasks.map((current) {
|
||||||
|
if (current.id == task.id) {
|
||||||
|
return updatedTask;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}).toList(growable: false);
|
||||||
|
final overlapDetails = conflicts.map((conflict) {
|
||||||
|
return SchedulingOverlap(
|
||||||
|
taskId: conflict.requiredTaskId,
|
||||||
|
taskInterval: conflict.requiredInterval,
|
||||||
|
blockedInterval: conflict.freeSlotInterval,
|
||||||
|
);
|
||||||
|
}).toList(growable: false);
|
||||||
|
final notices = <SchedulingNotice>[
|
||||||
|
SchedulingNotice(
|
||||||
|
conflicts.isEmpty
|
||||||
|
? 'Required commitment scheduled.'
|
||||||
|
: 'Required commitment overlaps protected rest.',
|
||||||
|
type: conflicts.isEmpty
|
||||||
|
? SchedulingNoticeType.moved
|
||||||
|
: SchedulingNoticeType.overlap,
|
||||||
|
taskId: task.id,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final changes = <SchedulingChange>[
|
||||||
|
if (scheduleChanged)
|
||||||
|
SchedulingChange(
|
||||||
|
taskId: task.id,
|
||||||
|
previousStart: task.scheduledStart,
|
||||||
|
previousEnd: task.scheduledEnd,
|
||||||
|
nextStart: requiredInterval.start,
|
||||||
|
nextEnd: requiredInterval.end,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return RequiredCommitmentScheduleResult(
|
||||||
|
status: conflicts.isEmpty
|
||||||
|
? RequiredCommitmentScheduleStatus.scheduled
|
||||||
|
: RequiredCommitmentScheduleStatus
|
||||||
|
.scheduledWithProtectedFreeSlotConflict,
|
||||||
|
protectedFreeSlotConflicts: conflicts,
|
||||||
|
schedulingResult: SchedulingResult(
|
||||||
|
tasks: updatedTasks,
|
||||||
|
notices: notices,
|
||||||
|
changes: changes,
|
||||||
|
overlaps: overlapDetails,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RequiredCommitmentScheduleResult _unchangedRequiredResult({
|
||||||
|
required SchedulingInput input,
|
||||||
|
required RequiredCommitmentScheduleStatus status,
|
||||||
|
required SchedulingNotice notice,
|
||||||
|
}) {
|
||||||
|
return RequiredCommitmentScheduleResult(
|
||||||
|
status: status,
|
||||||
|
protectedFreeSlotConflicts: const [],
|
||||||
|
schedulingResult: SchedulingResult(
|
||||||
|
tasks: input.tasks,
|
||||||
|
notices: [notice],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ProtectedFreeSlotConflict> _protectedFreeSlotConflicts({
|
||||||
|
required SchedulingInput input,
|
||||||
|
required String requiredTaskId,
|
||||||
|
required TimeInterval requiredInterval,
|
||||||
|
}) {
|
||||||
|
final conflicts = <ProtectedFreeSlotConflict>[];
|
||||||
|
|
||||||
|
for (final entry in input.occupancyEntries) {
|
||||||
|
if (entry.category != OccupancyCategory.protectedFreeSlot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final freeSlot = entry.task;
|
||||||
|
final freeSlotInterval = entry.interval;
|
||||||
|
if (freeSlot == null ||
|
||||||
|
freeSlotInterval == null ||
|
||||||
|
!requiredInterval.overlaps(freeSlotInterval)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts.add(
|
||||||
|
ProtectedFreeSlotConflict(
|
||||||
|
requiredTaskId: requiredTaskId,
|
||||||
|
freeSlotTaskId: freeSlot.id,
|
||||||
|
requiredInterval: requiredInterval,
|
||||||
|
freeSlotInterval: freeSlotInterval,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return List<ProtectedFreeSlotConflict>.unmodifiable(conflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task? _taskById(List<Task> tasks, String taskId) {
|
||||||
|
for (final task in tasks) {
|
||||||
|
if (task.id == taskId) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _sameDateTime(DateTime? first, DateTime? second) {
|
||||||
|
if (first == null || second == null) {
|
||||||
|
return first == null && second == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return first.isAtSameMomentAs(second);
|
||||||
|
}
|
||||||
358
test/free_slots_test.dart
Normal file
358
test/free_slots_test.dart
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
import 'package:adhd_scheduler_core/scheduler_core.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('FreeSlotService', () {
|
||||||
|
final now = DateTime(2026, 6, 19, 8);
|
||||||
|
const service = FreeSlotService();
|
||||||
|
|
||||||
|
test('creates and updates scheduled Free Slot records', () {
|
||||||
|
final freeSlot = service.create(
|
||||||
|
id: 'rest',
|
||||||
|
title: 'Rest',
|
||||||
|
projectId: 'health',
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 9, 30),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
final updated = service.update(
|
||||||
|
freeSlot: freeSlot,
|
||||||
|
title: 'Recovery time',
|
||||||
|
start: DateTime(2026, 6, 19, 10),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
updatedAt: now.add(const Duration(minutes: 1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(freeSlot.type, TaskType.freeSlot);
|
||||||
|
expect(freeSlot.status, TaskStatus.planned);
|
||||||
|
expect(freeSlot.durationMinutes, 30);
|
||||||
|
expect(freeSlot.scheduledStart, DateTime(2026, 6, 19, 9));
|
||||||
|
expect(freeSlot.scheduledEnd, DateTime(2026, 6, 19, 9, 30));
|
||||||
|
|
||||||
|
expect(updated.type, TaskType.freeSlot);
|
||||||
|
expect(updated.title, 'Recovery time');
|
||||||
|
expect(updated.durationMinutes, 60);
|
||||||
|
expect(updated.scheduledStart, DateTime(2026, 6, 19, 10));
|
||||||
|
expect(updated.scheduledEnd, DateTime(2026, 6, 19, 11));
|
||||||
|
expect(updated.updatedAt, now.add(const Duration(minutes: 1)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid Free Slot updates', () {
|
||||||
|
final flexible = flexibleTask(
|
||||||
|
id: 'flexible',
|
||||||
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
durationMinutes: 30,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => service.update(
|
||||||
|
freeSlot: flexible,
|
||||||
|
start: DateTime(2026, 6, 19, 10),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
throwsArgumentError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => service.create(
|
||||||
|
id: 'bad',
|
||||||
|
title: 'Bad rest',
|
||||||
|
start: DateTime(2026, 6, 19, 10),
|
||||||
|
end: DateTime(2026, 6, 19, 10),
|
||||||
|
createdAt: now,
|
||||||
|
),
|
||||||
|
throwsA(isA<DomainValidationException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit required overlap returns typed conflict without moving rest',
|
||||||
|
() {
|
||||||
|
final freeSlot = service.create(
|
||||||
|
id: 'rest',
|
||||||
|
title: 'Rest',
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 10),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
final required = requiredTask(
|
||||||
|
id: 'appointment',
|
||||||
|
type: TaskType.inflexible,
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.scheduleRequiredCommitment(
|
||||||
|
input: inputFor([freeSlot, required]),
|
||||||
|
taskId: 'appointment',
|
||||||
|
start: DateTime(2026, 6, 19, 9, 30),
|
||||||
|
end: DateTime(2026, 6, 19, 10, 30),
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
final scheduled = taskById(result.schedulingResult.tasks, 'appointment');
|
||||||
|
final rest = taskById(result.schedulingResult.tasks, 'rest');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.status,
|
||||||
|
RequiredCommitmentScheduleStatus.scheduledWithProtectedFreeSlotConflict,
|
||||||
|
);
|
||||||
|
expect(result.hasProtectedFreeSlotConflict, isTrue);
|
||||||
|
expect(result.protectedFreeSlotConflicts.single.freeSlotTaskId, 'rest');
|
||||||
|
expect(scheduled.scheduledStart, DateTime(2026, 6, 19, 9, 30));
|
||||||
|
expect(scheduled.scheduledEnd, DateTime(2026, 6, 19, 10, 30));
|
||||||
|
expect(rest.scheduledStart, freeSlot.scheduledStart);
|
||||||
|
expect(rest.scheduledEnd, freeSlot.scheduledEnd);
|
||||||
|
expect(result.schedulingResult.overlaps.single.taskId, 'appointment');
|
||||||
|
expect(result.schedulingResult.changes.single.taskId, 'appointment');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('required boundary touch does not conflict with protected rest', () {
|
||||||
|
final freeSlot = service.create(
|
||||||
|
id: 'rest',
|
||||||
|
title: 'Rest',
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 10),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
final required = requiredTask(
|
||||||
|
id: 'meds',
|
||||||
|
type: TaskType.critical,
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.scheduleRequiredCommitment(
|
||||||
|
input: inputFor([freeSlot, required]),
|
||||||
|
taskId: 'meds',
|
||||||
|
start: DateTime(2026, 6, 19, 10),
|
||||||
|
end: DateTime(2026, 6, 19, 10, 15),
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status, RequiredCommitmentScheduleStatus.scheduled);
|
||||||
|
expect(result.protectedFreeSlotConflicts, isEmpty);
|
||||||
|
expect(result.schedulingResult.overlaps, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Free Slot automatic scheduling protection', () {
|
||||||
|
final now = DateTime(2026, 6, 19, 8);
|
||||||
|
const service = FreeSlotService();
|
||||||
|
const engine = SchedulingEngine();
|
||||||
|
|
||||||
|
test('insertion skips multiple adjacent Free Slots', () {
|
||||||
|
final backlog = Task.quickCapture(
|
||||||
|
id: 'backlog',
|
||||||
|
title: 'Make call',
|
||||||
|
createdAt: now,
|
||||||
|
).copyWith(durationMinutes: 15);
|
||||||
|
final firstRest = service.create(
|
||||||
|
id: 'rest-1',
|
||||||
|
title: 'Rest 1',
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 9, 30),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
final secondRest = service.create(
|
||||||
|
id: 'rest-2',
|
||||||
|
title: 'Rest 2',
|
||||||
|
start: DateTime(2026, 6, 19, 9, 30),
|
||||||
|
end: DateTime(2026, 6, 19, 10),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = engine.insertBacklogTaskIntoNextAvailableSlot(
|
||||||
|
input: inputFor([backlog, firstRest, secondRest]),
|
||||||
|
taskId: 'backlog',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskById(result.tasks, 'backlog').scheduledStart,
|
||||||
|
DateTime(2026, 6, 19, 10));
|
||||||
|
expect(taskById(result.tasks, 'rest-1').scheduledStart,
|
||||||
|
firstRest.scheduledStart);
|
||||||
|
expect(taskById(result.tasks, 'rest-2').scheduledStart,
|
||||||
|
secondRest.scheduledStart);
|
||||||
|
expect(result.changes.map((change) => change.taskId), ['backlog']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('push skips a Free Slot at the push boundary', () {
|
||||||
|
final flexible = flexibleTask(
|
||||||
|
id: 'flexible',
|
||||||
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
durationMinutes: 30,
|
||||||
|
);
|
||||||
|
final rest = service.create(
|
||||||
|
id: 'rest',
|
||||||
|
title: 'Rest',
|
||||||
|
start: DateTime(2026, 6, 19, 9, 30),
|
||||||
|
end: DateTime(2026, 6, 19, 10),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = engine.pushFlexibleTaskToNextAvailableSlot(
|
||||||
|
input: inputFor([flexible, rest]),
|
||||||
|
taskId: 'flexible',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskById(result.tasks, 'flexible').scheduledStart,
|
||||||
|
DateTime(2026, 6, 19, 10));
|
||||||
|
expect(taskById(result.tasks, 'flexible').scheduledEnd,
|
||||||
|
DateTime(2026, 6, 19, 10, 30));
|
||||||
|
expect(
|
||||||
|
taskById(result.tasks, 'rest').scheduledStart, rest.scheduledStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tomorrow placement preserves Free Slots at window edges', () {
|
||||||
|
final task = flexibleTask(
|
||||||
|
id: 'today',
|
||||||
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
durationMinutes: 30,
|
||||||
|
);
|
||||||
|
final startRest = service.create(
|
||||||
|
id: 'start-rest',
|
||||||
|
title: 'Start rest',
|
||||||
|
start: DateTime(2026, 6, 20, 9),
|
||||||
|
end: DateTime(2026, 6, 20, 9, 30),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
final endRest = service.create(
|
||||||
|
id: 'end-rest',
|
||||||
|
title: 'End rest',
|
||||||
|
start: DateTime(2026, 6, 20, 10, 30),
|
||||||
|
end: DateTime(2026, 6, 20, 11),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = engine.pushFlexibleTaskToTomorrowTopOfQueue(
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [task, startRest, endRest],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 20, 9),
|
||||||
|
end: DateTime(2026, 6, 20, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
taskId: 'today',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskById(result.tasks, 'today').scheduledStart,
|
||||||
|
DateTime(2026, 6, 20, 9, 30));
|
||||||
|
expect(taskById(result.tasks, 'start-rest').scheduledStart,
|
||||||
|
startRest.scheduledStart);
|
||||||
|
expect(taskById(result.tasks, 'end-rest').scheduledStart,
|
||||||
|
endRest.scheduledStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whole-window Free Slot leaves no fit and does not move tasks', () {
|
||||||
|
final flexible = flexibleTask(
|
||||||
|
id: 'flexible',
|
||||||
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
durationMinutes: 30,
|
||||||
|
);
|
||||||
|
final rest = service.create(
|
||||||
|
id: 'whole-rest',
|
||||||
|
title: 'Whole morning rest',
|
||||||
|
start: DateTime(2026, 6, 20, 9),
|
||||||
|
end: DateTime(2026, 6, 20, 10),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = engine.rollOverUnfinishedFlexibleTasks(
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [flexible, rest],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 20, 9),
|
||||||
|
end: DateTime(2026, 6, 20, 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
sourceWindow: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 0),
|
||||||
|
end: DateTime(2026, 6, 20, 0),
|
||||||
|
),
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskById(result.tasks, 'flexible'), same(flexible));
|
||||||
|
expect(taskById(result.tasks, 'whole-rest'), same(rest));
|
||||||
|
expect(result.changes, isEmpty);
|
||||||
|
expect(result.notices.single.type, SchedulingNoticeType.overflow);
|
||||||
|
expect(taskById(result.tasks, 'whole-rest').scheduledStart,
|
||||||
|
rest.scheduledStart);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Free Slot timeline metadata', () {
|
||||||
|
final now = DateTime(2026, 6, 19, 8);
|
||||||
|
const service = FreeSlotService();
|
||||||
|
|
||||||
|
test('Free Slot read model is visible without flexible quick actions', () {
|
||||||
|
final freeSlot = service.create(
|
||||||
|
id: 'rest',
|
||||||
|
title: 'Rest',
|
||||||
|
projectId: 'health',
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 10),
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
final item = TimelineItemMapper().fromTask(freeSlot);
|
||||||
|
|
||||||
|
expect(item.taskType, TaskType.freeSlot);
|
||||||
|
expect(item.backgroundToken, TimelineBackgroundToken.freeSlot);
|
||||||
|
expect(item.category, TimelineItemCategory.taskCard);
|
||||||
|
expect(item.quickActions, isEmpty);
|
||||||
|
expect(item.start, freeSlot.scheduledStart);
|
||||||
|
expect(item.end, freeSlot.scheduledEnd);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
SchedulingInput inputFor(List<Task> tasks) {
|
||||||
|
return SchedulingInput(
|
||||||
|
tasks: tasks,
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task flexibleTask({
|
||||||
|
required String id,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime start,
|
||||||
|
required int durationMinutes,
|
||||||
|
}) {
|
||||||
|
return Task.quickCapture(
|
||||||
|
id: id,
|
||||||
|
title: id,
|
||||||
|
createdAt: createdAt,
|
||||||
|
).copyWith(
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
durationMinutes: durationMinutes,
|
||||||
|
scheduledStart: start,
|
||||||
|
scheduledEnd: start.add(Duration(minutes: durationMinutes)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task requiredTask({
|
||||||
|
required String id,
|
||||||
|
required TaskType type,
|
||||||
|
required DateTime createdAt,
|
||||||
|
}) {
|
||||||
|
return Task(
|
||||||
|
id: id,
|
||||||
|
title: id,
|
||||||
|
projectId: 'required',
|
||||||
|
type: type,
|
||||||
|
status: TaskStatus.planned,
|
||||||
|
priority: PriorityLevel.high,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task taskById(List<Task> tasks, String id) {
|
||||||
|
return tasks.singleWhere((task) => task.id == id);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue