forked from eva/focus-flow
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
|
||||
|
||||
Status: Complete on 2026-06-25.
|
||||
|
||||
Tasks:
|
||||
|
||||
- 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
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue