feat(scheduling): preserve surprise actual occupancy
This commit is contained in:
parent
545e882f02
commit
6219706ab3
5 changed files with 354 additions and 5 deletions
|
|
@ -102,6 +102,8 @@ Acceptance criteria:
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Complete on 2026-06-25.
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
- Persist and classify a surprise task’s actual interval as occupied time after
|
- Persist and classify a surprise task’s actual interval as occupied time after
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,15 @@ Chunk 12.2 intentionally changed the public API:
|
||||||
- Added validated Free Slot create/update helpers and a typed explicit required
|
- Added validated Free Slot create/update helpers and a typed explicit required
|
||||||
commitment scheduling result for protected-rest conflicts.
|
commitment scheduling result for protected-rest conflicts.
|
||||||
|
|
||||||
|
Chunk 12.3 intentionally changed the public API:
|
||||||
|
|
||||||
|
- Added optional `revealHiddenLockedDetails` parameters to
|
||||||
|
`SurpriseTaskLogService.log` and `SurpriseTaskLogService.logNew`.
|
||||||
|
- Defined `SurpriseTaskLogRequest.id` as the idempotency key for retrying the
|
||||||
|
same surprise log operation.
|
||||||
|
- Surprise tasks with known time now populate `actualStart` and `actualEnd` in
|
||||||
|
addition to the existing scheduled interval compatibility fields.
|
||||||
|
|
||||||
## 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:
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ Status values:
|
||||||
| 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`, `test/free_slots_test.dart` | complete | Chunk 12.1 centralized occupancy; Chunk 12.2 covers protected Free Slot blockers. |
|
| 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 | Chunk 12.3 covers actual occupancy, same-day blockers, privacy-safe locked overlap reporting, and idempotent retry; statistics/application use cases remain in Blocks 13 and 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. |
|
| 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-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers | `test/child_tasks_test.dart` | complete | Activity/stat updates remain in Block 13. |
|
| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers | `test/child_tasks_test.dart` | complete | Activity/stat updates remain in Block 13. |
|
||||||
| MVP-AC-13 | Display backlog staleness icons without per-task stale prompts. | `BacklogStalenessMarker`, `BacklogStalenessSettings`, `BacklogView` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Backend marker exists; settings persistence and UI display remain in Blocks 14, 15, and 18. |
|
| MVP-AC-13 | Display backlog staleness icons without per-task stale prompts. | `BacklogStalenessMarker`, `BacklogStalenessSettings`, `BacklogView` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Backend marker exists; settings persistence and UI display remain in Blocks 14, 15, and 18. |
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,8 @@ class SurpriseTaskLogRequest {
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Caller-generated id for the new surprise task.
|
/// Caller-generated id for the new surprise task.
|
||||||
|
///
|
||||||
|
/// This id is also the idempotency key for retrying the same log operation.
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
/// User-entered title.
|
/// User-entered title.
|
||||||
|
|
@ -498,10 +500,32 @@ class SurpriseTaskLogService {
|
||||||
required SurpriseTaskLogRequest request,
|
required SurpriseTaskLogRequest request,
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
bool revealHiddenLockedDetails = false,
|
||||||
}) {
|
}) {
|
||||||
final now = updatedAt ?? clock.now();
|
final now = updatedAt ?? clock.now();
|
||||||
|
final existingTask = _taskById(input.tasks, request.id);
|
||||||
|
if (existingTask != null) {
|
||||||
|
if (existingTask.type != TaskType.surprise) {
|
||||||
|
throw ArgumentError.value(
|
||||||
|
request.id,
|
||||||
|
'request.id',
|
||||||
|
'Surprise task id is already used by another task.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SurpriseTaskLogResult(
|
||||||
|
surpriseTask: existingTask,
|
||||||
|
schedulingResult: SchedulingResult(
|
||||||
|
tasks: input.tasks,
|
||||||
|
notices: const [
|
||||||
|
SchedulingNotice('Surprise task already logged.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final surpriseTask = _createSurpriseTask(request, updatedAt: now);
|
final surpriseTask = _createSurpriseTask(request, updatedAt: now);
|
||||||
final surpriseInterval = _scheduledIntervalForTask(surpriseTask);
|
final surpriseInterval = _occupyingIntervalForTask(surpriseTask);
|
||||||
var currentTasks = <Task>[surpriseTask, ...input.tasks];
|
var currentTasks = <Task>[surpriseTask, ...input.tasks];
|
||||||
|
|
||||||
if (surpriseInterval == null) {
|
if (surpriseInterval == null) {
|
||||||
|
|
@ -523,6 +547,7 @@ class SurpriseTaskLogService {
|
||||||
final lockedOverlaps = _lockedOverlaps(
|
final lockedOverlaps = _lockedOverlaps(
|
||||||
input: input,
|
input: input,
|
||||||
surpriseInterval: surpriseInterval,
|
surpriseInterval: surpriseInterval,
|
||||||
|
revealHiddenLockedDetails: revealHiddenLockedDetails,
|
||||||
);
|
);
|
||||||
final flexibleTaskIds = _overlappingFlexibleTaskIds(
|
final flexibleTaskIds = _overlappingFlexibleTaskIds(
|
||||||
input.movablePlannedFlexibleTasks,
|
input.movablePlannedFlexibleTasks,
|
||||||
|
|
@ -600,6 +625,7 @@ class SurpriseTaskLogService {
|
||||||
DifficultyLevel difficulty = DifficultyLevel.notSet,
|
DifficultyLevel difficulty = DifficultyLevel.notSet,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
bool revealHiddenLockedDetails = false,
|
||||||
}) {
|
}) {
|
||||||
final generator = idGenerator;
|
final generator = idGenerator;
|
||||||
if (generator == null) {
|
if (generator == null) {
|
||||||
|
|
@ -622,6 +648,7 @@ class SurpriseTaskLogService {
|
||||||
),
|
),
|
||||||
input: input,
|
input: input,
|
||||||
updatedAt: updatedAt ?? now,
|
updatedAt: updatedAt ?? now,
|
||||||
|
revealHiddenLockedDetails: revealHiddenLockedDetails,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -651,6 +678,9 @@ class SurpriseTaskLogService {
|
||||||
scheduledStart: hasScheduledTime ? start : null,
|
scheduledStart: hasScheduledTime ? start : null,
|
||||||
scheduledEnd:
|
scheduledEnd:
|
||||||
hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
|
hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
|
||||||
|
actualStart: hasScheduledTime ? start : null,
|
||||||
|
actualEnd:
|
||||||
|
hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
|
||||||
createdAt: request.createdAt,
|
createdAt: request.createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
);
|
);
|
||||||
|
|
@ -680,6 +710,20 @@ TimeInterval? _scheduledIntervalForTask(Task task) {
|
||||||
return TimeInterval(start: start, end: end, label: task.id);
|
return TimeInterval(start: start, end: end, label: task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build an actual-first occupancy interval for completed/active task facts.
|
||||||
|
TimeInterval? _occupyingIntervalForTask(Task task) {
|
||||||
|
final actualStart = task.actualStart;
|
||||||
|
final actualEnd = task.actualEnd;
|
||||||
|
|
||||||
|
if (actualStart != null &&
|
||||||
|
actualEnd != null &&
|
||||||
|
actualStart.isBefore(actualEnd)) {
|
||||||
|
return TimeInterval(start: actualStart, end: actualEnd, label: task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _scheduledIntervalForTask(task);
|
||||||
|
}
|
||||||
|
|
||||||
/// Required visible overlaps with a surprise interval.
|
/// Required visible overlaps with a surprise interval.
|
||||||
List<SchedulingOverlap> _requiredOverlaps({
|
List<SchedulingOverlap> _requiredOverlaps({
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
|
|
@ -719,6 +763,7 @@ List<SchedulingOverlap> _requiredOverlaps({
|
||||||
List<TimeInterval> _lockedOverlaps({
|
List<TimeInterval> _lockedOverlaps({
|
||||||
required SchedulingInput input,
|
required SchedulingInput input,
|
||||||
required TimeInterval surpriseInterval,
|
required TimeInterval surpriseInterval,
|
||||||
|
required bool revealHiddenLockedDetails,
|
||||||
}) {
|
}) {
|
||||||
return input.occupancyEntries
|
return input.occupancyEntries
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -728,7 +773,12 @@ List<TimeInterval> _lockedOverlaps({
|
||||||
.map((occupancy) => occupancy.interval)
|
.map((occupancy) => occupancy.interval)
|
||||||
.whereType<TimeInterval>()
|
.whereType<TimeInterval>()
|
||||||
.where((interval) => interval.overlaps(surpriseInterval))
|
.where((interval) => interval.overlaps(surpriseInterval))
|
||||||
.toList(growable: false);
|
.map((interval) {
|
||||||
|
if (revealHiddenLockedDetails) {
|
||||||
|
return interval;
|
||||||
|
}
|
||||||
|
return TimeInterval(start: interval.start, end: interval.end);
|
||||||
|
}).toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Planned flexible task ids overlapping a surprise interval in timeline order.
|
/// Planned flexible task ids overlapping a surprise interval in timeline order.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ void main() {
|
||||||
expect(result.surpriseTask.durationMinutes, 20);
|
expect(result.surpriseTask.durationMinutes, 20);
|
||||||
expect(result.surpriseTask.scheduledStart, DateTime(2026, 6, 19, 9));
|
expect(result.surpriseTask.scheduledStart, DateTime(2026, 6, 19, 9));
|
||||||
expect(result.surpriseTask.scheduledEnd, DateTime(2026, 6, 19, 9, 20));
|
expect(result.surpriseTask.scheduledEnd, DateTime(2026, 6, 19, 9, 20));
|
||||||
|
expect(result.surpriseTask.actualStart, DateTime(2026, 6, 19, 9));
|
||||||
|
expect(result.surpriseTask.actualEnd, DateTime(2026, 6, 19, 9, 20));
|
||||||
expect(
|
expect(
|
||||||
result.schedulingResult.tasks.map((task) => task.id),
|
result.schedulingResult.tasks.map((task) => task.id),
|
||||||
['surprise-1'],
|
['surprise-1'],
|
||||||
|
|
@ -121,7 +123,7 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tracks locked overlap without rendering locked time as a task', () {
|
test('tracks locked overlap without leaking hidden details by default', () {
|
||||||
final result = service.log(
|
final result = service.log(
|
||||||
request: SurpriseTaskLogRequest(
|
request: SurpriseTaskLogRequest(
|
||||||
id: 'surprise-1',
|
id: 'surprise-1',
|
||||||
|
|
@ -147,13 +149,46 @@ void main() {
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.lockedOverlaps.single.label, 'hidden-work');
|
expect(result.lockedOverlaps.single.label, isNull);
|
||||||
|
expect(result.lockedOverlaps.single.start, DateTime(2026, 6, 19, 9));
|
||||||
|
expect(result.lockedOverlaps.single.end, DateTime(2026, 6, 19, 10));
|
||||||
expect(result.schedulingResult.tasks.map((task) => task.type), [
|
expect(result.schedulingResult.tasks.map((task) => task.type), [
|
||||||
TaskType.surprise,
|
TaskType.surprise,
|
||||||
]);
|
]);
|
||||||
expect(result.schedulingResult.overlaps, isEmpty);
|
expect(result.schedulingResult.overlaps, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('can reveal hidden locked overlap details when explicitly requested',
|
||||||
|
() {
|
||||||
|
final result = service.log(
|
||||||
|
request: SurpriseTaskLogRequest(
|
||||||
|
id: 'surprise-1',
|
||||||
|
title: 'Unplanned call',
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: DateTime(2026, 6, 19, 9, 10),
|
||||||
|
timeUsedMinutes: 20,
|
||||||
|
),
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: const [],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
lockedIntervals: [
|
||||||
|
TimeInterval(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 10),
|
||||||
|
label: 'hidden-work',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
updatedAt: now,
|
||||||
|
revealHiddenLockedDetails: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.lockedOverlaps.single.label, 'hidden-work');
|
||||||
|
});
|
||||||
|
|
||||||
test('optional fields can be omitted without blocking save', () {
|
test('optional fields can be omitted without blocking save', () {
|
||||||
final result = service.log(
|
final result = service.log(
|
||||||
request: SurpriseTaskLogRequest(
|
request: SurpriseTaskLogRequest(
|
||||||
|
|
@ -172,6 +207,8 @@ void main() {
|
||||||
expect(result.surpriseTask.durationMinutes, isNull);
|
expect(result.surpriseTask.durationMinutes, isNull);
|
||||||
expect(result.surpriseTask.scheduledStart, isNull);
|
expect(result.surpriseTask.scheduledStart, isNull);
|
||||||
expect(result.surpriseTask.scheduledEnd, isNull);
|
expect(result.surpriseTask.scheduledEnd, isNull);
|
||||||
|
expect(result.surpriseTask.actualStart, isNull);
|
||||||
|
expect(result.surpriseTask.actualEnd, isNull);
|
||||||
expect(result.schedulingResult.tasks.single.id, 'surprise-1');
|
expect(result.schedulingResult.tasks.single.id, 'surprise-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -219,6 +256,257 @@ void main() {
|
||||||
['first', 'second'],
|
['first', 'second'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('retrying the same surprise id is a no-op', () {
|
||||||
|
final flexible = plannedFlexibleTask(
|
||||||
|
id: 'flexible',
|
||||||
|
title: 'Write notes',
|
||||||
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
durationMinutes: 30,
|
||||||
|
);
|
||||||
|
final first = service.log(
|
||||||
|
request: SurpriseTaskLogRequest(
|
||||||
|
id: 'surprise-1',
|
||||||
|
title: 'Unplanned call',
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: DateTime(2026, 6, 19, 9, 10),
|
||||||
|
timeUsedMinutes: 20,
|
||||||
|
),
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [flexible],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final retry = service.log(
|
||||||
|
request: SurpriseTaskLogRequest(
|
||||||
|
id: 'surprise-1',
|
||||||
|
title: 'Unplanned call',
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: DateTime(2026, 6, 19, 9, 10),
|
||||||
|
timeUsedMinutes: 20,
|
||||||
|
),
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: first.schedulingResult.tasks,
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
retry.schedulingResult.tasks
|
||||||
|
.where((task) => task.id == 'surprise-1')
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(retry.schedulingResult.changes, isEmpty);
|
||||||
|
expect(
|
||||||
|
taskById(retry.schedulingResult.tasks, 'flexible').scheduledStart,
|
||||||
|
taskById(first.schedulingResult.tasks, 'flexible').scheduledStart,
|
||||||
|
);
|
||||||
|
expect(retry.schedulingResult.notices.single.message,
|
||||||
|
'Surprise task already logged.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('later insertion avoids persisted surprise actual occupancy', () {
|
||||||
|
final surpriseResult = service.log(
|
||||||
|
request: SurpriseTaskLogRequest(
|
||||||
|
id: 'surprise-1',
|
||||||
|
title: 'Unplanned call',
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: DateTime(2026, 6, 19, 9),
|
||||||
|
timeUsedMinutes: 30,
|
||||||
|
),
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: const [],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
final backlog = Task.quickCapture(
|
||||||
|
id: 'backlog',
|
||||||
|
title: 'Make call',
|
||||||
|
createdAt: now,
|
||||||
|
).copyWith(durationMinutes: 15);
|
||||||
|
|
||||||
|
final later =
|
||||||
|
const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [backlog, ...surpriseResult.schedulingResult.tasks],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
taskId: 'backlog',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskById(later.tasks, 'backlog').scheduledStart,
|
||||||
|
DateTime(2026, 6, 19, 9, 30));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active and completed actual intervals block later insertion', () {
|
||||||
|
final active = plannedFlexibleTask(
|
||||||
|
id: 'active',
|
||||||
|
title: 'Active',
|
||||||
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 8),
|
||||||
|
durationMinutes: 30,
|
||||||
|
).copyWith(
|
||||||
|
status: TaskStatus.active,
|
||||||
|
actualStart: DateTime(2026, 6, 19, 9),
|
||||||
|
actualEnd: DateTime(2026, 6, 19, 9, 20),
|
||||||
|
);
|
||||||
|
final completed = plannedFlexibleTask(
|
||||||
|
id: 'completed',
|
||||||
|
title: 'Completed',
|
||||||
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 8, 30),
|
||||||
|
durationMinutes: 30,
|
||||||
|
).copyWith(
|
||||||
|
status: TaskStatus.completed,
|
||||||
|
actualStart: DateTime(2026, 6, 19, 9, 20),
|
||||||
|
actualEnd: DateTime(2026, 6, 19, 9, 40),
|
||||||
|
);
|
||||||
|
final backlog = Task.quickCapture(
|
||||||
|
id: 'backlog',
|
||||||
|
title: 'Make call',
|
||||||
|
createdAt: now,
|
||||||
|
).copyWith(durationMinutes: 15);
|
||||||
|
|
||||||
|
final result =
|
||||||
|
const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [backlog, active, completed],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
taskId: 'backlog',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskById(result.tasks, 'backlog').scheduledStart,
|
||||||
|
DateTime(2026, 6, 19, 9, 40));
|
||||||
|
expect(taskById(result.tasks, 'active').scheduledStart,
|
||||||
|
active.scheduledStart);
|
||||||
|
expect(taskById(result.tasks, 'completed').actualStart,
|
||||||
|
completed.actualStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact surprise boundaries do not move adjacent flexible tasks', () {
|
||||||
|
final before = plannedFlexibleTask(
|
||||||
|
id: 'before',
|
||||||
|
title: 'Before',
|
||||||
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
durationMinutes: 30,
|
||||||
|
);
|
||||||
|
final after = plannedFlexibleTask(
|
||||||
|
id: 'after',
|
||||||
|
title: 'After',
|
||||||
|
createdAt: now,
|
||||||
|
start: DateTime(2026, 6, 19, 10),
|
||||||
|
durationMinutes: 30,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = service.log(
|
||||||
|
request: SurpriseTaskLogRequest(
|
||||||
|
id: 'surprise-1',
|
||||||
|
title: 'Unplanned call',
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: DateTime(2026, 6, 19, 9, 30),
|
||||||
|
timeUsedMinutes: 30,
|
||||||
|
),
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [before, after],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.schedulingResult.changes, isEmpty);
|
||||||
|
expect(taskById(result.schedulingResult.tasks, 'before').scheduledStart,
|
||||||
|
before.scheduledStart);
|
||||||
|
expect(taskById(result.schedulingResult.tasks, 'after').scheduledStart,
|
||||||
|
after.scheduledStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple surprise entries remain occupied for later scheduling', () {
|
||||||
|
final first = service.log(
|
||||||
|
request: SurpriseTaskLogRequest(
|
||||||
|
id: 'surprise-1',
|
||||||
|
title: 'First',
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: DateTime(2026, 6, 19, 9),
|
||||||
|
timeUsedMinutes: 15,
|
||||||
|
),
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: const [],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
final second = service.log(
|
||||||
|
request: SurpriseTaskLogRequest(
|
||||||
|
id: 'surprise-2',
|
||||||
|
title: 'Second',
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: DateTime(2026, 6, 19, 9, 15),
|
||||||
|
timeUsedMinutes: 15,
|
||||||
|
),
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: first.schedulingResult.tasks,
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
final backlog = Task.quickCapture(
|
||||||
|
id: 'backlog',
|
||||||
|
title: 'Make call',
|
||||||
|
createdAt: now,
|
||||||
|
).copyWith(durationMinutes: 15);
|
||||||
|
|
||||||
|
final later =
|
||||||
|
const SchedulingEngine().insertBacklogTaskIntoNextAvailableSlot(
|
||||||
|
input: SchedulingInput(
|
||||||
|
tasks: [backlog, ...second.schedulingResult.tasks],
|
||||||
|
window: SchedulingWindow(
|
||||||
|
start: DateTime(2026, 6, 19, 9),
|
||||||
|
end: DateTime(2026, 6, 19, 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
taskId: 'backlog',
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(later.tasks.where((task) => task.type == TaskType.surprise).length,
|
||||||
|
2);
|
||||||
|
expect(taskById(later.tasks, 'backlog').scheduledStart,
|
||||||
|
DateTime(2026, 6, 19, 9, 30));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue