diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md b/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md index f860c36..273b0e9 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_12_Occupancy_Scheduling_Correctness.md @@ -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 diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index eddd5a6..11ce693 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -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: diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index 359b742..9842f53 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -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. | diff --git a/lib/src/task_actions.dart b/lib/src/task_actions.dart index 80777ae..bcd6a2b 100644 --- a/lib/src/task_actions.dart +++ b/lib/src/task_actions.dart @@ -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 = [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 _requiredOverlaps({ required SchedulingInput input, @@ -719,6 +763,7 @@ List _requiredOverlaps({ List _lockedOverlaps({ required SchedulingInput input, required TimeInterval surpriseInterval, + required bool revealHiddenLockedDetails, }) { return input.occupancyEntries .where( @@ -728,7 +773,12 @@ List _lockedOverlaps({ .map((occupancy) => occupancy.interval) .whereType() .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. diff --git a/test/surprise_task_logging_test.dart b/test/surprise_task_logging_test.dart index 49e04d2..824ce2d 100644 --- a/test/surprise_task_logging_test.dart +++ b/test/surprise_task_logging_test.dart @@ -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)); + }); }); }