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
|
||||
|
||||
Status: Complete on 2026-06-25.
|
||||
|
||||
Tasks:
|
||||
|
||||
- Add validated creation/update helpers for scheduled Free Slot records.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
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