diff --git a/Codex Documentation/Current Software Plan/README.md b/Codex Documentation/Current Software Plan/README.md index 74a00bb..27b9bca 100644 --- a/Codex Documentation/Current Software Plan/README.md +++ b/Codex Documentation/Current Software Plan/README.md @@ -30,7 +30,7 @@ Do not infer a new level name. 3. `V1_BLOCK_03_Scheduling_Engine.md` 4. `V1_BLOCK_04_Backlog_Quick_Capture.md` 5. `V1_BLOCK_05_Recurring_Locked_Blocks.md` -6. `V1_BLOCK_06_Task_Actions_State_Transitions.md` +6. `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md` 7. `V1_BLOCK_07_Child_Tasks.md` 8. `V1_BLOCK_08_Today_Timeline_State.md` 9. `V1_BLOCK_09_Persistence_Preparation.md` diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions.md b/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions.md index 26e6f4c..e1a9c90 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions.md @@ -1,6 +1,9 @@ # V1 Block 06 — Task Actions and State Transitions -Status: Planned +Status: Superseded by `V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md` + +Use the updated Block 06 plan for remaining work. The updated plan inserts the +Chunk 6.3 edge-case regression suite before required task state transitions. Purpose: Define and implement the safe, low-friction actions available from task cards. diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md b/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md index 6f757f1..8f60499 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_06_Task_Actions_State_Transitions_UPDATED.md @@ -85,7 +85,7 @@ BREAKPOINT: Stop here. Confirm `extra high` mode before implementing Chunk 6.3, Recommended Codex level: extra high -Status: Planned +Status: Completed Purpose: @@ -236,6 +236,18 @@ Commit suggestion: test(tasks): add edge-case regression coverage before state transitions ``` +Execution notes: + +- Added `test/edge_case_regression_test.dart` as a dedicated Block 06.3 regression suite. +- Covered task title trimming/validation, `copyWith(clearSchedule: true)`, ProjectProfile task creation validation, flexible insertion no-fit safety, push order/fixed-item preservation, explicit source-window rollover safety, stable backlog sorting, consistent backlog staleness timestamps, quick capture scheduling validation, locked overlay-domain data, locked-hour counters, action-service unsupported-type handling, duplicate-id safety, change metadata, and input-list immutability. +- Added optional `sourceWindow` to `SchedulingEngine.rollOverUnfinishedFlexibleTasks` so day-end rollover can explicitly limit rolled tasks to the intended source day while preserving existing callers. +- Updated backlog stale filtering to use the same creation-age basis as staleness markers; V1 still documents that a separate backlog-entered timestamp belongs to later persistence work. +- Made `ProjectProfile.createTask` trim and reject blank titles, matching quick-capture validation. +- Made backlog sorting stable for equal sort keys by preserving source-list order. +- No Required Task States implementation was added in this chunk. +- No Surprise Task Logging implementation was added in this chunk. +- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed. + BREAKPOINT: Stop here. Confirm `medium` mode before implementing Chunk 6.4 required task state transitions. ## Chunk 6.4 — Required task states diff --git a/Human Documentation/Starter Architecture Notes.md b/Human Documentation/Starter Architecture Notes.md index 208ad31..a1d8b09 100644 --- a/Human Documentation/Starter Architecture Notes.md +++ b/Human Documentation/Starter Architecture Notes.md @@ -31,6 +31,14 @@ Future sync layer, if explicitly planned The scheduling core must never move locked or inflexible blocks during automatic rescheduling. +Flexible end-of-day rollover should use an explicit source-day window when +available. That prevents planned flexible tasks from future days from being +pulled into tomorrow during recovery. + +Backlog staleness in the current V1 domain model uses task creation age for both +the stale filter and green/blue/purple marker. A later MongoDB-backed +persistence pass may add a separate backlog-entered timestamp, but the current +core keeps the timestamp semantics consistent and database-independent. ## Persistence direction diff --git a/lib/src/backlog.dart b/lib/src/backlog.dart index 101e58e..6dcae62 100644 --- a/lib/src/backlog.dart +++ b/lib/src/backlog.dart @@ -1,4 +1,4 @@ - // Backlog query helpers for the ADHD scheduling core. +// Backlog query helpers for the ADHD scheduling core. // // The backlog is not a separate database table in this starter model. It is a // view over tasks whose `Task.status` is `TaskStatus.backlog`. This file keeps @@ -134,7 +134,11 @@ class BacklogView { /// Clock value supplied by the caller so age/staleness behavior is testable. final DateTime now; - /// Age since [Task.updatedAt] that qualifies for the `stale` filter. + /// Age since [Task.createdAt] that qualifies for the `stale` filter. + /// + /// V1 does not yet store a separate "entered backlog at" timestamp. Until + /// persistence adds that field, both stale filtering and visual staleness use + /// task creation age so they do not disagree after a task edit. final Duration staleAfter; /// Color-bucket threshold configuration for backlog aging indicators. @@ -164,10 +168,28 @@ class BacklogView { /// reordered by a read operation. The final list is unmodifiable to make that /// intent explicit to callers. List sorted(BacklogSortKey sortKey) { - final sortedTasks = [...backlogTasks]; - sortedTasks.sort((a, b) => _compareTasks(a, b, sortKey)); + final sortedTasks = backlogTasks + .asMap() + .entries + .map( + (entry) => _IndexedTask( + task: entry.value, + originalIndex: entry.key, + ), + ) + .toList(growable: false); + sortedTasks.sort((a, b) { + final comparison = _compareTasks(a.task, b.task, sortKey); + if (comparison != 0) { + return comparison; + } - return List.unmodifiable(sortedTasks); + return a.originalIndex.compareTo(b.originalIndex); + }); + + return List.unmodifiable( + sortedTasks.map((indexedTask) => indexedTask.task), + ); } /// Return the green/blue/purple marker for one task. @@ -187,7 +209,7 @@ class BacklogView { BacklogFilter.criticalMissed => task.type == TaskType.critical && task.stats.missedCount > 0, BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist), - BacklogFilter.stale => now.difference(task.updatedAt) >= staleAfter, + BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter, BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet, }; } @@ -261,3 +283,14 @@ int _rewardVsEffortScore(Task task) { int _timesPushed(Task task) { return task.stats.manuallyPushedCount + task.stats.autoPushedCount; } + +/// Backlog task paired with its source-list position for stable sorting. +class _IndexedTask { + const _IndexedTask({ + required this.task, + required this.originalIndex, + }); + + final Task task; + final int originalIndex; +} diff --git a/lib/src/models.dart b/lib/src/models.dart index 2bd7909..bbb5a6d 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -441,9 +441,14 @@ class ProjectProfile { Set backlogTags = const {}, DateTime? updatedAt, }) { + final trimmedTitle = title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value(title, 'title', 'Title is required.'); + } + return Task( id: id, - title: title, + title: trimmedTitle, projectId: this.id, type: type, status: status, diff --git a/lib/src/scheduling_engine.dart b/lib/src/scheduling_engine.dart index ed1c014..f308704 100644 --- a/lib/src/scheduling_engine.dart +++ b/lib/src/scheduling_engine.dart @@ -511,20 +511,29 @@ class SchedulingEngine { /// Rollover is bulk push behavior for day-end recovery. It collects unfinished /// flexible tasks outside the target window, preserves their relative order, /// then places them at the start of the new window while shifting already - /// planned flexible tasks as needed. + /// planned flexible tasks as needed. When [sourceWindow] is supplied, only + /// unfinished flexible tasks from that source window are rolled over. This + /// prevents future planned tasks from being pulled into tomorrow accidentally. SchedulingResult rollOverUnfinishedFlexibleTasks({ required SchedulingInput input, + SchedulingWindow? sourceWindow, DateTime? updatedAt, }) { // Build the explicit queue of tasks to roll before asking the planner to // place anything. This keeps selection separate from placement. final rolledItems = <_PlacementItem>[]; final rolloverTasks = input.flexibleTasks - .where((task) => _shouldRollOver(task, input.window)) + .where((task) => _shouldRollOver( + task: task, + targetWindow: input.window, + sourceWindow: sourceWindow, + )) .toList() ..sort((a, b) { - final aStart = a.scheduledStart ?? input.window.start; - final bStart = b.scheduledStart ?? input.window.start; + final aStart = + a.scheduledStart ?? sourceWindow?.start ?? input.window.start; + final bStart = + b.scheduledStart ?? sourceWindow?.start ?? input.window.start; return aStart.compareTo(bStart); }); @@ -1269,13 +1278,21 @@ SchedulingResult _applyRolloverPlacement({ /// /// Planned/active flexible tasks already inside the target window are not rolled /// again; they are handled as existing tasks that may shift to make room. -bool _shouldRollOver(Task task, SchedulingWindow tomorrowWindow) { +bool _shouldRollOver({ + required Task task, + required SchedulingWindow targetWindow, + SchedulingWindow? sourceWindow, +}) { final interval = _scheduledIntervalFor(task); final isTomorrowTask = - interval != null && interval.overlaps(tomorrowWindow.interval); + interval != null && interval.overlaps(targetWindow.interval); + + final isInSourceWindow = sourceWindow == null || + (interval != null && sourceWindow.contains(interval)); return task.isFlexible && !isTomorrowTask && + isInSourceWindow && (task.status == TaskStatus.planned || task.status == TaskStatus.active); } diff --git a/test/edge_case_regression_test.dart b/test/edge_case_regression_test.dart new file mode 100644 index 0000000..bea21f3 --- /dev/null +++ b/test/edge_case_regression_test.dart @@ -0,0 +1,792 @@ +import 'package:adhd_scheduler_core/scheduler_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Block 06.3 domain model regressions', () { + final now = DateTime(2026, 6, 19, 12); + + test('quick capture trims titles before storing them', () { + final task = Task.quickCapture( + id: 'capture-1', + title: ' Refill medication ', + createdAt: now, + ); + + expect(task.title, 'Refill medication'); + }); + + test('copyWith can clear schedule while preserving omitted values', () { + final scheduled = Task.quickCapture( + id: 'task-1', + title: 'Send invoice', + createdAt: now, + projectId: 'admin', + priority: PriorityLevel.high, + reward: RewardLevel.medium, + ).copyWith( + status: TaskStatus.planned, + durationMinutes: 30, + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 30), + ); + + final cleared = scheduled.copyWith(clearSchedule: true); + + expect(cleared.scheduledStart, isNull); + expect(cleared.scheduledEnd, isNull); + expect(cleared.id, scheduled.id); + expect(cleared.title, scheduled.title); + expect(cleared.projectId, 'admin'); + expect(cleared.priority, PriorityLevel.high); + expect(cleared.reward, RewardLevel.medium); + expect(cleared.durationMinutes, 30); + }); + + test('project profile trims and validates task titles like quick capture', + () { + const project = ProjectProfile( + id: 'health', + name: 'Health', + colorKey: 'green', + ); + + final task = project.createTask( + id: 'task-1', + title: ' Book appointment ', + createdAt: now, + ); + + expect(task.title, 'Book appointment'); + expect( + () => project.createTask( + id: 'task-2', + title: ' ', + createdAt: now, + ), + throwsArgumentError, + ); + }); + }); + + group('Block 06.3 scheduling regressions', () { + final now = DateTime(2026, 6, 19, 12); + const engine = SchedulingEngine(); + + test('next-available insertion uses earliest fitting open slot', () { + final backlog = backlogTask( + id: 'new-task', + title: 'Make call', + createdAt: now, + durationMinutes: 15, + ); + final laterFlexible = plannedFlexibleTask( + id: 'later-flex', + title: 'Fold laundry', + createdAt: now, + start: DateTime(2026, 6, 19, 10), + durationMinutes: 30, + ); + + final result = engine.insertBacklogTaskIntoNextAvailableSlot( + input: SchedulingInput( + tasks: [backlog, laterFlexible], + 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, 9, 20), + label: 'locked', + ), + ], + ), + taskId: 'new-task', + updatedAt: now, + ); + final inserted = taskById(result.tasks, 'new-task'); + + expect(inserted.status, TaskStatus.planned); + expect(inserted.scheduledStart, DateTime(2026, 6, 19, 9, 20)); + expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 9, 35)); + }); + + test('no-fit insertion leaves the task backlogged and unplaced', () { + final backlog = backlogTask( + id: 'new-task', + title: 'Make call', + createdAt: now, + durationMinutes: 45, + ); + + final result = engine.insertBacklogTaskIntoNextAvailableSlot( + input: SchedulingInput( + tasks: [backlog], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 9, 30), + ), + ), + taskId: 'new-task', + updatedAt: now, + ); + final unchanged = taskById(result.tasks, 'new-task'); + + expect(unchanged.status, TaskStatus.backlog); + expect(unchanged.scheduledStart, isNull); + expect(unchanged.scheduledEnd, isNull); + expect(result.notices.single.type, SchedulingNoticeType.overflow); + }); + + test('pushing flexible tasks preserves order without moving fixed items', + () { + final first = plannedFlexibleTask( + id: 'first', + title: 'Email reply', + createdAt: now, + start: DateTime(2026, 6, 19, 9), + durationMinutes: 15, + ); + final second = plannedFlexibleTask( + id: 'second', + title: 'Laundry', + createdAt: now, + start: DateTime(2026, 6, 19, 9, 15), + durationMinutes: 15, + ); + final inflexible = fixedTask( + id: 'appointment', + type: TaskType.inflexible, + createdAt: now, + start: DateTime(2026, 6, 19, 10), + durationMinutes: 15, + ); + final critical = fixedTask( + id: 'meds', + type: TaskType.critical, + createdAt: now, + start: DateTime(2026, 6, 19, 10, 15), + durationMinutes: 15, + ); + final locked = fixedTask( + id: 'locked-task', + type: TaskType.locked, + createdAt: now, + start: DateTime(2026, 6, 19, 9, 30), + durationMinutes: 15, + ); + + final result = engine.pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: [first, second, locked, inflexible, critical], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 11), + ), + ), + taskId: 'first', + updatedAt: now, + ); + + expect( + result.changes.map((change) => change.taskId), + ['first', 'second'], + ); + expect( + taskById(result.tasks, 'first').scheduledStart, + DateTime(2026, 6, 19, 9, 15), + ); + expect( + taskById(result.tasks, 'second').scheduledStart, + DateTime(2026, 6, 19, 9, 45), + ); + expect(taskById(result.tasks, 'appointment').type, TaskType.inflexible); + expect(taskById(result.tasks, 'appointment').scheduledStart, + inflexible.scheduledStart); + expect(taskById(result.tasks, 'meds').type, TaskType.critical); + expect(taskById(result.tasks, 'meds').scheduledStart, + critical.scheduledStart); + expect(taskById(result.tasks, 'locked-task').type, TaskType.locked); + expect(taskById(result.tasks, 'locked-task').scheduledStart, + locked.scheduledStart); + }); + + test('rollover uses an explicit source window and ignores future tasks', + () { + final sourceWindow = SchedulingWindow( + start: DateTime(2026, 6, 19), + end: DateTime(2026, 6, 20), + ); + final targetWindow = SchedulingWindow( + start: DateTime(2026, 6, 20, 9), + end: DateTime(2026, 6, 20, 12), + ); + final sourcePlanned = plannedFlexibleTask( + id: 'source-planned', + title: 'Source planned', + createdAt: now, + start: DateTime(2026, 6, 19, 13), + durationMinutes: 15, + ); + final sourceActive = plannedFlexibleTask( + id: 'source-active', + title: 'Source active', + createdAt: now, + start: DateTime(2026, 6, 19, 14), + durationMinutes: 15, + ).copyWith(status: TaskStatus.active); + final completed = plannedFlexibleTask( + id: 'completed', + title: 'Completed', + createdAt: now, + start: DateTime(2026, 6, 19, 15), + durationMinutes: 15, + ).copyWith(status: TaskStatus.completed); + final cancelled = plannedFlexibleTask( + id: 'cancelled', + title: 'Cancelled', + createdAt: now, + start: DateTime(2026, 6, 19, 16), + durationMinutes: 15, + ).copyWith(status: TaskStatus.cancelled); + final backlogged = backlogTask( + id: 'backlog', + title: 'Already backlog', + createdAt: now, + durationMinutes: 15, + ); + final critical = fixedTask( + id: 'critical', + type: TaskType.critical, + createdAt: now, + start: DateTime(2026, 6, 19, 17), + durationMinutes: 15, + ); + final inflexible = fixedTask( + id: 'inflexible', + type: TaskType.inflexible, + createdAt: now, + start: DateTime(2026, 6, 19, 18), + durationMinutes: 15, + ); + final futureFlexible = plannedFlexibleTask( + id: 'future', + title: 'Future', + createdAt: now, + start: DateTime(2026, 6, 21, 9), + durationMinutes: 15, + ); + final tomorrowFlexible = plannedFlexibleTask( + id: 'tomorrow', + title: 'Tomorrow', + createdAt: now, + start: DateTime(2026, 6, 20, 9), + durationMinutes: 30, + ); + + final result = engine.rollOverUnfinishedFlexibleTasks( + input: SchedulingInput( + tasks: [ + futureFlexible, + sourceActive, + sourcePlanned, + completed, + cancelled, + backlogged, + critical, + inflexible, + tomorrowFlexible, + ], + window: targetWindow, + ), + sourceWindow: sourceWindow, + updatedAt: now, + ); + + expect(taskById(result.tasks, 'source-planned').scheduledStart, + DateTime(2026, 6, 20, 9)); + expect(taskById(result.tasks, 'source-active').scheduledStart, + DateTime(2026, 6, 20, 9, 15)); + expect(taskById(result.tasks, 'tomorrow').scheduledStart, + DateTime(2026, 6, 20, 9, 30)); + expect(taskById(result.tasks, 'future').scheduledStart, + futureFlexible.scheduledStart); + expect(taskById(result.tasks, 'completed').status, TaskStatus.completed); + expect(taskById(result.tasks, 'cancelled').status, TaskStatus.cancelled); + expect(taskById(result.tasks, 'backlog').status, TaskStatus.backlog); + expect(taskById(result.tasks, 'critical').type, TaskType.critical); + expect(taskById(result.tasks, 'inflexible').type, TaskType.inflexible); + expect( + result.changes.map((change) => change.taskId), + ['source-active', 'source-planned', 'tomorrow'], + ); + }); + }); + + group('Block 06.3 backlog regressions', () { + final now = DateTime(2026, 6, 19, 12); + + test('filters only include backlog tasks in the expected category', () { + final inbox = backlogTask( + id: 'inbox', + title: 'Inbox', + createdAt: now, + reward: RewardLevel.low, + ); + final pushed = backlogTask( + id: 'pushed', + title: 'Pushed', + createdAt: now, + projectId: 'home', + reward: RewardLevel.low, + ).copyWith(stats: const TaskStatistics(manuallyPushedCount: 1)); + final criticalMissed = backlogTask( + id: 'critical', + title: 'Critical', + createdAt: now, + type: TaskType.critical, + projectId: 'health', + reward: RewardLevel.low, + ).copyWith(stats: const TaskStatistics(missedCount: 1)); + final wishlist = backlogTask( + id: 'wishlist', + title: 'Wishlist', + createdAt: now, + projectId: 'ideas', + reward: RewardLevel.low, + backlogTags: const {BacklogTag.wishlist}, + ); + final stale = backlogTask( + id: 'stale', + title: 'Stale', + createdAt: now.subtract(const Duration(days: 40)), + projectId: 'archive', + reward: RewardLevel.low, + ); + final noReward = backlogTask( + id: 'no-reward', + title: 'No reward', + createdAt: now, + projectId: 'admin', + ); + final plannedNoReward = noReward.copyWith( + id: 'planned-no-reward', + status: TaskStatus.planned, + ); + final view = BacklogView( + tasks: [ + inbox, + pushed, + criticalMissed, + wishlist, + stale, + noReward, + plannedNoReward, + ], + now: now, + ); + + expect(view.filter(BacklogFilter.inbox).map((task) => task.id), [ + 'inbox', + ]); + expect(view.filter(BacklogFilter.pushed).map((task) => task.id), [ + 'pushed', + ]); + expect(view.filter(BacklogFilter.criticalMissed).map((task) => task.id), [ + 'critical', + ]); + expect(view.filter(BacklogFilter.wishlist).map((task) => task.id), [ + 'wishlist', + ]); + expect(view.filter(BacklogFilter.stale).map((task) => task.id), [ + 'stale', + ]); + expect(view.filter(BacklogFilter.noRewardSet).map((task) => task.id), [ + 'no-reward', + ]); + }); + + test('priority sorting preserves input order for equal priorities', () { + final first = backlogTask(id: 'first', title: 'First', createdAt: now); + final second = backlogTask(id: 'second', title: 'Second', createdAt: now); + final third = backlogTask(id: 'third', title: 'Third', createdAt: now); + final view = BacklogView(tasks: [second, first, third], now: now); + + expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [ + 'second', + 'first', + 'third', + ]); + }); + + test('reward not-set sorts as distinct from very-low reward', () { + final notSet = backlogTask( + id: 'not-set', + title: 'Unknown reward', + createdAt: now, + reward: RewardLevel.notSet, + difficulty: DifficultyLevel.easy, + ); + final veryLow = backlogTask( + id: 'very-low', + title: 'Very low reward', + createdAt: now, + reward: RewardLevel.veryLow, + difficulty: DifficultyLevel.easy, + ); + final view = BacklogView(tasks: [notSet, veryLow], now: now); + + expect( + view.sorted(BacklogSortKey.rewardVsEffort).map((task) => task.id), [ + 'very-low', + 'not-set', + ]); + }); + + test('age sorting preserves input order when createdAt values match', () { + final createdAt = DateTime(2026, 6, 1); + final first = + backlogTask(id: 'first', title: 'First', createdAt: createdAt); + final second = + backlogTask(id: 'second', title: 'Second', createdAt: createdAt); + final view = BacklogView(tasks: [second, first], now: now); + + expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [ + 'second', + 'first', + ]); + }); + + test('stale filter and marker use the same creation-age semantics', () { + final oldButRecentlyUpdated = backlogTask( + id: 'old', + title: 'Old idea', + createdAt: now.subtract(const Duration(days: 40)), + ).copyWith(updatedAt: now); + final newButOldUpdate = backlogTask( + id: 'new', + title: 'New idea', + createdAt: now, + ).copyWith(updatedAt: now.subtract(const Duration(days: 40))); + final view = BacklogView( + tasks: [oldButRecentlyUpdated, newButOldUpdate], + now: now, + ); + + expect(view.filter(BacklogFilter.stale).map((task) => task.id), ['old']); + expect( + view.stalenessMarkerFor(oldButRecentlyUpdated), + BacklogStalenessMarker.purple, + ); + expect( + view.stalenessMarkerFor(newButOldUpdate), + BacklogStalenessMarker.green, + ); + }); + }); + + group('Block 06.3 quick capture regressions', () { + final now = DateTime(2026, 6, 19, 12); + const service = QuickCaptureService(); + + test('immediate scheduling uses normal insertion and push rules', () { + final existing = plannedFlexibleTask( + id: 'existing', + title: 'Existing', + createdAt: now, + start: DateTime(2026, 6, 19, 9), + durationMinutes: 15, + ); + + final result = service.capture( + QuickCaptureRequest( + id: 'capture', + title: 'Captured task', + createdAt: now, + addToNextAvailableSlot: true, + durationMinutes: 15, + ), + schedulingInput: SchedulingInput( + tasks: [existing], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 10), + ), + ), + updatedAt: now, + ); + final scheduledTasks = result.schedulingResult!.tasks; + + expect(result.status, QuickCaptureStatus.scheduled); + expect(taskById(scheduledTasks, 'capture').scheduledStart, + DateTime(2026, 6, 19, 9)); + expect(taskById(scheduledTasks, 'existing').scheduledStart, + DateTime(2026, 6, 19, 9, 15)); + }); + + test('immediate scheduling requires enough scheduling data', () { + final result = service.capture( + QuickCaptureRequest( + id: 'capture', + title: 'Captured task', + createdAt: now, + addToNextAvailableSlot: true, + durationMinutes: 15, + ), + updatedAt: now, + ); + + expect(result.status, QuickCaptureStatus.validationError); + expect(result.messages.single, 'Scheduling input is required.'); + }); + + test('quick capture cannot create blank tasks', () { + expect( + () => service.capture( + QuickCaptureRequest( + id: 'capture', + title: ' ', + createdAt: now, + ), + ), + throwsArgumentError, + ); + }); + }); + + group('Block 06.3 locked-time regressions', () { + final now = DateTime(2026, 6, 19, 12); + + test('locked occurrence exposes overlay data without becoming a task', () { + final block = LockedBlock( + id: 'work', + name: 'Work', + startTime: const ClockTime(hour: 9, minute: 0), + endTime: const ClockTime(hour: 17, minute: 0), + recurrence: const LockedBlockRecurrence.weekly( + weekdays: {LockedWeekday.friday}, + ), + createdAt: now, + updatedAt: now, + ); + + final expansion = expandLockedBlocksForDay( + blocks: [block], + overrides: const [], + date: DateTime(2026, 6, 19), + ); + final occurrence = expansion.occurrences.single; + + expect(occurrence, isA()); + expect(occurrence, isNot(isA())); + expect(occurrence.hiddenByDefault, isTrue); + expect(occurrence.schedulingInterval.label, 'Work'); + expect(expansion.schedulingIntervals.single.label, 'Work'); + }); + + test('completed tasks outside locked hours do not increment counters', () { + final task = plannedFlexibleTask( + id: 'task', + title: 'Task', + createdAt: now, + start: DateTime(2026, 6, 19, 8), + durationMinutes: 30, + ).copyWith(status: TaskStatus.completed); + + final tracked = trackCompletedDuringLockedHours( + task: task, + lockedIntervals: [ + TimeInterval( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 10), + ), + ], + ); + + expect(tracked.stats.completedDuringLockedHoursCount, 0); + expect(tracked.stats.completedDuringLockedHoursMinutes, 0); + }); + }); + + group('Block 06.3 action-service regressions', () { + final now = DateTime(2026, 6, 19, 12); + const service = FlexibleTaskActionService(); + + test('unsupported flexible quick action types fail predictably', () { + final inflexible = fixedTask( + id: 'appointment', + type: TaskType.inflexible, + createdAt: now, + start: DateTime(2026, 6, 19, 9), + durationMinutes: 30, + ); + + expect( + () => service.apply( + task: inflexible, + action: FlexibleTaskQuickAction.done, + updatedAt: now, + ), + throwsArgumentError, + ); + }); + + test('push backlog destination does not create duplicate task ids', () { + final task = plannedFlexibleTask( + id: 'task', + title: 'Task', + createdAt: now, + start: DateTime(2026, 6, 19, 9), + durationMinutes: 30, + ); + + final result = service.applyPushDestination( + destination: PushDestination.backlog, + input: SchedulingInput( + tasks: [task], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 10), + ), + ), + taskId: 'task', + updatedAt: now, + ); + + expect(result.schedulingResult.tasks.map((task) => task.id), ['task']); + expect(result.schedulingResult.tasks.toSet().length, 1); + expect(result.schedulingResult.changes.single.previousStart, + DateTime(2026, 6, 19, 9)); + expect(result.schedulingResult.changes.single.nextStart, isNull); + }); + + test('public scheduling services do not reorder the input task list', () { + final first = plannedFlexibleTask( + id: 'first', + title: 'First', + createdAt: now, + start: DateTime(2026, 6, 19, 9), + durationMinutes: 15, + ); + final second = plannedFlexibleTask( + id: 'second', + title: 'Second', + createdAt: now, + start: DateTime(2026, 6, 19, 9, 15), + durationMinutes: 15, + ); + final tasks = [second, first]; + + const SchedulingEngine().pushFlexibleTaskToNextAvailableSlot( + input: SchedulingInput( + tasks: tasks, + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 10), + ), + ), + taskId: 'first', + updatedAt: now, + ); + + expect(tasks.map((task) => task.id), ['second', 'first']); + }); + + test('invalid push destinations leave fixed task types unchanged', () { + final critical = fixedTask( + id: 'critical', + type: TaskType.critical, + createdAt: now, + start: DateTime(2026, 6, 19, 9), + durationMinutes: 30, + ); + + final result = service.applyPushDestination( + destination: PushDestination.nextAvailableSlot, + input: SchedulingInput( + tasks: [critical], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 9), + end: DateTime(2026, 6, 19, 10), + ), + ), + taskId: 'critical', + updatedAt: now, + ); + + expect(result.schedulingResult.notices.single.type, + SchedulingNoticeType.noFit); + expect(result.schedulingResult.tasks.single.type, TaskType.critical); + expect(result.schedulingResult.tasks.single.status, TaskStatus.planned); + }); + }); +} + +Task backlogTask({ + required String id, + required String title, + required DateTime createdAt, + String projectId = 'inbox', + TaskType type = TaskType.flexible, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + int? durationMinutes, + Set backlogTags = const {}, +}) { + return Task.quickCapture( + id: id, + title: title, + createdAt: createdAt, + projectId: projectId, + type: type, + reward: reward, + difficulty: difficulty, + backlogTags: backlogTags, + ).copyWith(durationMinutes: durationMinutes); +} + +Task plannedFlexibleTask({ + required String id, + required String title, + required DateTime createdAt, + required DateTime start, + required int durationMinutes, +}) { + return backlogTask( + id: id, + title: title, + createdAt: createdAt, + durationMinutes: durationMinutes, + ).copyWith( + status: TaskStatus.planned, + scheduledStart: start, + scheduledEnd: start.add(Duration(minutes: durationMinutes)), + ); +} + +Task fixedTask({ + required String id, + required TaskType type, + required DateTime createdAt, + required DateTime start, + required int durationMinutes, +}) { + return Task( + id: id, + title: id, + projectId: 'fixed', + type: type, + status: TaskStatus.planned, + priority: PriorityLevel.medium, + durationMinutes: durationMinutes, + scheduledStart: start, + scheduledEnd: start.add(Duration(minutes: durationMinutes)), + createdAt: createdAt, + updatedAt: createdAt, + ); +} + +Task taskById(List tasks, String id) { + return tasks.singleWhere((task) => task.id == id); +}