feat(scheduling): preserve surprise actual occupancy

This commit is contained in:
Ashley Venn 2026-06-25 09:04:48 -07:00
parent 545e882f02
commit 6219706ab3
5 changed files with 354 additions and 5 deletions

View file

@ -102,6 +102,8 @@ Acceptance criteria:
Recommended Codex level: high
Status: Complete on 2026-06-25.
Tasks:
- Persist and classify a surprise tasks actual interval as occupied time after

View file

@ -158,6 +158,15 @@ Chunk 12.2 intentionally changed the public API:
- Added validated Free Slot create/update helpers and a typed explicit required
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
The current repository surface is pure Dart and in-memory only:

View file

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

View file

@ -158,6 +158,8 @@ class SurpriseTaskLogRequest {
});
/// Caller-generated id for the new surprise task.
///
/// This id is also the idempotency key for retrying the same log operation.
final String id;
/// User-entered title.
@ -498,10 +500,32 @@ class SurpriseTaskLogService {
required SurpriseTaskLogRequest request,
required SchedulingInput input,
DateTime? updatedAt,
bool revealHiddenLockedDetails = false,
}) {
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 surpriseInterval = _scheduledIntervalForTask(surpriseTask);
final surpriseInterval = _occupyingIntervalForTask(surpriseTask);
var currentTasks = <Task>[surpriseTask, ...input.tasks];
if (surpriseInterval == null) {
@ -523,6 +547,7 @@ class SurpriseTaskLogService {
final lockedOverlaps = _lockedOverlaps(
input: input,
surpriseInterval: surpriseInterval,
revealHiddenLockedDetails: revealHiddenLockedDetails,
);
final flexibleTaskIds = _overlappingFlexibleTaskIds(
input.movablePlannedFlexibleTasks,
@ -600,6 +625,7 @@ class SurpriseTaskLogService {
DifficultyLevel difficulty = DifficultyLevel.notSet,
DateTime? createdAt,
DateTime? updatedAt,
bool revealHiddenLockedDetails = false,
}) {
final generator = idGenerator;
if (generator == null) {
@ -622,6 +648,7 @@ class SurpriseTaskLogService {
),
input: input,
updatedAt: updatedAt ?? now,
revealHiddenLockedDetails: revealHiddenLockedDetails,
);
}
@ -651,6 +678,9 @@ class SurpriseTaskLogService {
scheduledStart: hasScheduledTime ? start : null,
scheduledEnd:
hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
actualStart: hasScheduledTime ? start : null,
actualEnd:
hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
createdAt: request.createdAt,
updatedAt: updatedAt,
);
@ -680,6 +710,20 @@ TimeInterval? _scheduledIntervalForTask(Task task) {
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.
List<SchedulingOverlap> _requiredOverlaps({
required SchedulingInput input,
@ -719,6 +763,7 @@ List<SchedulingOverlap> _requiredOverlaps({
List<TimeInterval> _lockedOverlaps({
required SchedulingInput input,
required TimeInterval surpriseInterval,
required bool revealHiddenLockedDetails,
}) {
return input.occupancyEntries
.where(
@ -728,7 +773,12 @@ List<TimeInterval> _lockedOverlaps({
.map((occupancy) => occupancy.interval)
.whereType<TimeInterval>()
.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.

View file

@ -31,6 +31,8 @@ void main() {
expect(result.surpriseTask.durationMinutes, 20);
expect(result.surpriseTask.scheduledStart, DateTime(2026, 6, 19, 9));
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(
result.schedulingResult.tasks.map((task) => task.id),
['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(
request: SurpriseTaskLogRequest(
id: 'surprise-1',
@ -147,13 +149,46 @@ void main() {
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), [
TaskType.surprise,
]);
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', () {
final result = service.log(
request: SurpriseTaskLogRequest(
@ -172,6 +207,8 @@ void main() {
expect(result.surpriseTask.durationMinutes, isNull);
expect(result.surpriseTask.scheduledStart, isNull);
expect(result.surpriseTask.scheduledEnd, isNull);
expect(result.surpriseTask.actualStart, isNull);
expect(result.surpriseTask.actualEnd, isNull);
expect(result.schedulingResult.tasks.single.id, 'surprise-1');
});
@ -219,6 +256,257 @@ void main() {
['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));
});
});
}