diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md b/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md index d97a19f..f860c36 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md @@ -62,6 +62,8 @@ and surprise-occupancy behavior. Recommended Codex level: high +Status: Complete on 2026-06-25. + Tasks: - Add validated creation/update helpers for scheduled Free Slot records. diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index 3cd1899..eddd5a6 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -17,6 +17,7 @@ following source files: - `src/backlog.dart` - `src/child_tasks.dart` - `src/document_mapping.dart` +- `src/free_slots.dart` - `src/locked_time.dart` - `src/occupancy_policy.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/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` | +| `src/free_slots.dart` | `RequiredCommitmentScheduleStatus` | | `src/locked_time.dart` | `LockedWeekday`, `LockedBlockOverrideType` | | `src/occupancy_policy.dart` | `OccupancyCategory`, `OccupancySource` | | `src/quick_capture.dart` | `QuickCaptureStatus` | @@ -53,6 +55,7 @@ changes from accidental API drift. | `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` | | `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` | | `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/occupancy_policy.dart` | `OccupancyEntry`, `OccupancyPolicy` | | `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 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 The current repository surface is pure Dart and in-memory only: diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index 5396b34..359b742 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -42,7 +42,7 @@ Status values: | MVP-AC-05 | Render Today as a timeline with project border, task-type background, task name, reward icon, and difficulty icon. | `TimelineItemMapper`, `TimelineItem`, timeline token enums | `test/timeline_state_test.dart` | incomplete | Backend read-model tokens exist; actual Flutter rendering is Block 18. | | MVP-AC-06 | Use compact Today mode manually. | `TimelineItemMapper.compactStateForTasks`, `CompactTimelineState` | `test/timeline_state_test.dart` | complete | Complete at backend/read-model level; Flutter UI remains Block 18. | | MVP-AC-07 | Push flexible tasks to next available slot, tomorrow, or backlog. | `FlexibleTaskQuickAction.push`, `PushDestination`, `FlexibleTaskActionService.applyPushDestination`, `SchedulingEngine` push methods | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Exactly-once activity/stat records remain in Block 13. | -| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `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-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. | @@ -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-03 | Inflexible missed tasks are marked missed and left in place/history. | `RequiredTaskActionService`, `SchedulingEngine.markMissed`, `TaskType.inflexible` | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart` | complete | Actual/historical interval semantics are formalized in Chunk 11.2 and Block 12. | | MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction` | `test/required_task_actions_test.dart` | complete | Completion/cancellation timestamps remain in Block 13. | -| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `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-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. | diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index d304f02..6705cd4 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -20,6 +20,7 @@ export 'src/models.dart'; export 'src/backlog.dart'; export 'src/child_tasks.dart'; export 'src/document_mapping.dart'; +export 'src/free_slots.dart'; export 'src/locked_time.dart'; export 'src/occupancy_policy.dart'; export 'src/persistence_contract.dart'; diff --git a/lib/src/free_slots.dart b/lib/src/free_slots.dart new file mode 100644 index 0000000..cb84499 --- /dev/null +++ b/lib/src/free_slots.dart @@ -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 protectedFreeSlotConflicts, + }) : protectedFreeSlotConflicts = + List.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 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( + conflicts.isEmpty + ? 'Required commitment scheduled.' + : 'Required commitment overlaps protected rest.', + type: conflicts.isEmpty + ? SchedulingNoticeType.moved + : SchedulingNoticeType.overlap, + taskId: task.id, + ), + ]; + final changes = [ + 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 _protectedFreeSlotConflicts({ + required SchedulingInput input, + required String requiredTaskId, + required TimeInterval requiredInterval, +}) { + final conflicts = []; + + 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.unmodifiable(conflicts); +} + +Task? _taskById(List 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); +} diff --git a/test/free_slots_test.dart b/test/free_slots_test.dart new file mode 100644 index 0000000..bc35b0c --- /dev/null +++ b/test/free_slots_test.dart @@ -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()), + ); + }); + + 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 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 tasks, String id) { + return tasks.singleWhere((task) => task.id == id); +}