From f7f239c3f708487c4132c78ae1d83e1bdb88b27c Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Fri, 19 Jun 2026 16:35:09 -0700 Subject: [PATCH] feat(tasks): apply push destinations --- ...BLOCK_06_Task_Actions_State_Transitions.md | 13 ++ lib/src/task_actions.dart | 123 ++++++++++++++++ test/scheduling_engine_test.dart | 132 +++++++++++++++++- 3 files changed, 267 insertions(+), 1 deletion(-) 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 c4a0e70..26e6f4c 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 @@ -46,6 +46,8 @@ Execution notes: Recommended Codex level: high +Status: Completed + Tasks: Implement push destinations: @@ -64,6 +66,17 @@ Acceptance criteria: - Test: tomorrow sets tomorrow/top-of-queue metadata. - Test: backlog clears schedule placement. +Execution notes: + +- Added `PushDestinationResult` to carry selected destination metadata with the scheduling result. +- Added `FlexibleTaskActionService.applyPushDestination`. +- Next available delegates to `SchedulingEngine.pushFlexibleTaskToNextAvailableSlot`. +- Tomorrow/top-of-queue delegates to `SchedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue` and reports `placesAtTomorrowTopOfQueue`. +- Backlog destination updates the task list with `SchedulingEngine.moveToBacklog`, clears placement, and records a scheduling change. +- `PushDestination` remains limited to next available slot, tomorrow/top of queue, and backlog; later today is not represented. +- Added tests for next available, tomorrow/top-of-queue metadata, backlog clearing schedule placement, and the excluded later-today destination. +- `dart format lib test`, `dart analyze`, `dart test`, and `git diff --check` passed. + BREAKPOINT: Stop here. Confirm `high` mode before integrating push behavior. ## Chunk 6.3 — Required task states diff --git a/lib/src/task_actions.dart b/lib/src/task_actions.dart index 0da49fd..0f219b5 100644 --- a/lib/src/task_actions.dart +++ b/lib/src/task_actions.dart @@ -33,6 +33,21 @@ class FlexibleTaskActionResult { bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty; } +/// Result from applying a selected push destination. +class PushDestinationResult { + const PushDestinationResult({ + required this.destination, + required this.schedulingResult, + }); + + final PushDestination destination; + final SchedulingResult schedulingResult; + + bool get placesAtTomorrowTopOfQueue { + return destination == PushDestination.tomorrowTopOfQueue; + } +} + /// Applies low-friction quick actions for flexible task cards. class FlexibleTaskActionService { const FlexibleTaskActionService({ @@ -83,4 +98,112 @@ class FlexibleTaskActionService { ); } } + + PushDestinationResult applyPushDestination({ + required PushDestination destination, + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + }) { + final result = switch (destination) { + PushDestination.nextAvailableSlot => + schedulingEngine.pushFlexibleTaskToNextAvailableSlot( + input: input, + taskId: taskId, + updatedAt: updatedAt, + ), + PushDestination.tomorrowTopOfQueue => + schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue( + input: input, + taskId: taskId, + updatedAt: updatedAt, + ), + PushDestination.backlog => _moveTaskToBacklog( + input: input, + taskId: taskId, + updatedAt: updatedAt, + ), + }; + + return PushDestinationResult( + destination: destination, + schedulingResult: result, + ); + } + + SchedulingResult _moveTaskToBacklog({ + required SchedulingInput input, + required String taskId, + DateTime? updatedAt, + }) { + final task = _taskById(input.tasks, taskId); + if (task == null) { + return SchedulingResult( + tasks: input.tasks, + notices: [ + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + ), + ], + ); + } + + if (!task.isFlexible || task.status != TaskStatus.planned) { + return SchedulingResult( + tasks: input.tasks, + notices: [ + SchedulingNotice( + 'Only planned flexible tasks can be moved to backlog.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + ), + ], + ); + } + + final movedTask = schedulingEngine.moveToBacklog( + task, + updatedAt: updatedAt, + ); + + return SchedulingResult( + tasks: List.unmodifiable( + input.tasks.map((current) { + if (current.id == task.id) { + return movedTask; + } + + return current; + }), + ), + notices: [ + SchedulingNotice( + 'Flexible task moved to backlog.', + type: SchedulingNoticeType.moved, + taskId: task.id, + ), + ], + changes: [ + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: null, + nextEnd: null, + ), + ], + ); + } +} + +Task? _taskById(List tasks, String taskId) { + for (final task in tasks) { + if (task.id == taskId) { + return task; + } + } + + return null; } diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index 3469ca6..88e047f 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -864,7 +864,11 @@ void main() { PushDestination.tomorrowTopOfQueue, PushDestination.backlog, ]); - expect(result.pushDestinations, isNot(contains('laterToday'))); + expect(PushDestination.values, const [ + PushDestination.nextAvailableSlot, + PushDestination.tomorrowTopOfQueue, + PushDestination.backlog, + ]); expect(result.changedTask, isFalse); }); @@ -886,6 +890,132 @@ void main() { expect(result.startsChildTaskFlow, isTrue); expect(result.changedTask, isFalse); }); + + test('push destination next available uses scheduling engine', () { + const service = FlexibleTaskActionService(); + final task = Task.quickCapture( + id: 'task-1', + title: 'Handle paperwork', + createdAt: now, + ).copyWith( + status: TaskStatus.planned, + durationMinutes: 15, + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 15), + ); + + final result = service.applyPushDestination( + destination: PushDestination.nextAvailableSlot, + input: SchedulingInput( + tasks: [task], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14), + ), + lockedIntervals: [ + TimeInterval( + start: DateTime(2026, 6, 19, 13, 15), + end: DateTime(2026, 6, 19, 13, 30), + label: 'locked-block', + ), + ], + ), + taskId: 'task-1', + updatedAt: now, + ); + final pushed = result.schedulingResult.tasks.single; + + expect(result.destination, PushDestination.nextAvailableSlot); + expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30)); + expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 13, 45)); + expect(pushed.stats.manuallyPushedCount, 1); + expect(result.schedulingResult.notices.single.type, + SchedulingNoticeType.moved); + }); + + test('push destination tomorrow sets tomorrow top-of-queue metadata', () { + const service = FlexibleTaskActionService(); + final task = Task.quickCapture( + id: 'task-1', + title: 'Handle paperwork', + createdAt: now, + ).copyWith( + status: TaskStatus.planned, + durationMinutes: 15, + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 15), + ); + final tomorrowTask = Task.quickCapture( + id: 'tomorrow-1', + title: 'Fold laundry', + createdAt: now, + ).copyWith( + status: TaskStatus.planned, + durationMinutes: 30, + scheduledStart: DateTime(2026, 6, 20, 9), + scheduledEnd: DateTime(2026, 6, 20, 9, 30), + ); + + final result = service.applyPushDestination( + destination: PushDestination.tomorrowTopOfQueue, + input: SchedulingInput( + tasks: [task, tomorrowTask], + window: SchedulingWindow( + start: DateTime(2026, 6, 20, 9), + end: DateTime(2026, 6, 20, 10), + ), + ), + taskId: 'task-1', + updatedAt: now, + ); + final pushed = result.schedulingResult.tasks.singleWhere( + (current) => current.id == 'task-1', + ); + final shiftedTomorrow = result.schedulingResult.tasks.singleWhere( + (current) => current.id == 'tomorrow-1', + ); + + expect(result.destination, PushDestination.tomorrowTopOfQueue); + expect(result.placesAtTomorrowTopOfQueue, isTrue); + expect(pushed.scheduledStart, DateTime(2026, 6, 20, 9)); + expect(pushed.scheduledEnd, DateTime(2026, 6, 20, 9, 15)); + expect(shiftedTomorrow.scheduledStart, DateTime(2026, 6, 20, 9, 15)); + expect(shiftedTomorrow.scheduledEnd, DateTime(2026, 6, 20, 9, 45)); + }); + + test('push destination backlog clears schedule placement', () { + const service = FlexibleTaskActionService(); + final task = Task.quickCapture( + id: 'task-1', + title: 'Handle paperwork', + createdAt: now, + ).copyWith( + status: TaskStatus.planned, + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 30), + ); + + final result = service.applyPushDestination( + destination: PushDestination.backlog, + input: SchedulingInput( + tasks: [task], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14), + ), + ), + taskId: 'task-1', + updatedAt: now, + ); + final moved = result.schedulingResult.tasks.single; + + expect(result.destination, PushDestination.backlog); + expect(moved.status, TaskStatus.backlog); + expect(moved.scheduledStart, isNull); + expect(moved.scheduledEnd, isNull); + expect(moved.stats.movedToBacklogCount, 1); + expect(result.schedulingResult.changes.single.nextStart, isNull); + }); }); group('SchedulingEngine starter behavior', () {