feat(scheduling): protect free slot scheduling

This commit is contained in:
Ashley Venn 2026-06-25 09:01:15 -07:00
parent b0ec910471
commit 545e882f02
6 changed files with 678 additions and 2 deletions

View file

@ -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.

View file

@ -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:

View file

@ -42,7 +42,7 @@ Status values:
| MVP-AC-05 | Render Today as a timeline with project border, task-type background, task name, reward icon, and difficulty icon. | `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart` | incomplete | Backend read-model tokens exist; actual Flutter rendering is Block 18. | | MVP-AC-05 | Render Today as a timeline with project border, task-type background, task name, reward icon, and difficulty icon. | `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart` | incomplete | Backend read-model tokens exist; actual Flutter rendering is Block 18. |
| MVP-AC-06 | Use compact Today mode manually. | `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart` | complete | Complete at backend/read-model level; Flutter UI remains Block 18. | | MVP-AC-06 | Use compact Today mode manually. | `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart` | complete | Complete at backend/read-model level; Flutter UI remains Block 18. |
| MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Exactly-once activity/stat records remain in Block 13. | | MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Exactly-once activity/stat records remain in Block 13. |
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `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. |

View file

@ -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
View 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
View 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);
}