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 Recommended Codex level: high
Status: Complete on 2026-06-25.
Tasks: Tasks:
- Persist and classify a surprise tasks actual interval as occupied time after - 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 - 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:

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

View file

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

View file

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