From f84d5661c021c77b5497369bdb6cb01020741a99 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Fri, 19 Jun 2026 16:10:40 -0700 Subject: [PATCH] feat(backlog): schedule quick capture into next slot --- .../V1_BLOCK_04_Backlog_Quick_Capture.md | 12 ++ lib/scheduler_core.dart | 1 + lib/src/quick_capture.dart | 149 ++++++++++++++++++ test/scheduling_engine_test.dart | 98 ++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 lib/src/quick_capture.dart diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_04_Backlog_Quick_Capture.md b/Codex Documentation/Current Software Plan/V1_BLOCK_04_Backlog_Quick_Capture.md index 66d8fa4..3d0f09e 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_04_Backlog_Quick_Capture.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_04_Backlog_Quick_Capture.md @@ -78,6 +78,8 @@ Execution notes: Recommended Codex level: high +Status: Completed + Tasks: Implement behavior for `Add to next available schedule slot`: @@ -98,6 +100,16 @@ Acceptance criteria: - Test: checked quick capture with duration schedules into next available slot. - Test: checked quick capture without required scheduling fields is handled predictably. +Execution notes: + +- Added `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureStatus`, and `QuickCaptureService`. +- Unchecked quick capture creates a backlog task and does not invoke scheduling. +- Checked quick capture validates duration and flexibility before scheduling. +- Missing duration returns a validation result while preserving the captured backlog task. +- Checked quick capture with duration inserts into the next available flexible slot through `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot`. +- Added tests for unchecked backlog capture, checked scheduling, and checked validation behavior. +- `dart analyze` and `dart test` passed. + BREAKPOINT: Stop here. Confirm `high` mode before implementing scheduling integration. ## Chunk 4.4 — Backlog staleness marker calculation diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index d92d81e..5b8dce0 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -4,5 +4,6 @@ export 'src/models.dart'; export 'src/backlog.dart'; +export 'src/quick_capture.dart'; export 'src/scheduling_engine.dart'; export 'src/task_statistics.dart'; diff --git a/lib/src/quick_capture.dart b/lib/src/quick_capture.dart new file mode 100644 index 0000000..0fbd4e5 --- /dev/null +++ b/lib/src/quick_capture.dart @@ -0,0 +1,149 @@ +import 'models.dart'; +import 'scheduling_engine.dart'; + +/// Outcome of a quick-capture request. +enum QuickCaptureStatus { + addedToBacklog, + scheduled, + validationError, +} + +/// Input for low-friction task capture. +class QuickCaptureRequest { + const QuickCaptureRequest({ + required this.id, + required this.title, + required this.createdAt, + this.addToNextAvailableSlot = false, + this.projectId = 'inbox', + this.priority = PriorityLevel.medium, + this.reward = RewardLevel.notSet, + this.difficulty = DifficultyLevel.notSet, + this.type = TaskType.flexible, + this.durationMinutes, + this.backlogTags = const {}, + }); + + final String id; + final String title; + final DateTime createdAt; + final bool addToNextAvailableSlot; + final String projectId; + final PriorityLevel priority; + final RewardLevel reward; + final DifficultyLevel difficulty; + final TaskType type; + final int? durationMinutes; + final Set backlogTags; +} + +/// Result of a quick-capture request. +class QuickCaptureResult { + const QuickCaptureResult({ + required this.task, + required this.status, + this.schedulingResult, + this.messages = const [], + }); + + final Task task; + final QuickCaptureStatus status; + final SchedulingResult? schedulingResult; + final List messages; + + bool get isValid => status != QuickCaptureStatus.validationError; +} + +/// Coordinates quick capture defaults and optional scheduling. +class QuickCaptureService { + const QuickCaptureService({this.engine = const SchedulingEngine()}); + + final SchedulingEngine engine; + + QuickCaptureResult capture( + QuickCaptureRequest request, { + SchedulingInput? schedulingInput, + DateTime? updatedAt, + }) { + final capturedTask = Task.quickCapture( + id: request.id, + title: request.title, + createdAt: request.createdAt, + projectId: request.projectId, + type: request.type, + priority: request.priority, + reward: request.reward, + difficulty: request.difficulty, + backlogTags: request.backlogTags, + updatedAt: updatedAt, + ).copyWith(durationMinutes: request.durationMinutes); + + if (!request.addToNextAvailableSlot) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.addedToBacklog, + ); + } + + if (request.durationMinutes == null || request.durationMinutes! <= 0) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + messages: const ['Duration is required to schedule quick capture.'], + ); + } + + if (request.type != TaskType.flexible) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + messages: const ['Scheduled quick capture must be flexible.'], + ); + } + + if (schedulingInput == null) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + messages: const ['Scheduling input is required.'], + ); + } + + final result = engine.insertBacklogTaskIntoNextAvailableSlot( + input: SchedulingInput( + tasks: [capturedTask, ...schedulingInput.tasks], + window: schedulingInput.window, + lockedIntervals: schedulingInput.lockedIntervals, + requiredVisibleIntervals: schedulingInput.requiredVisibleIntervals, + ), + taskId: capturedTask.id, + updatedAt: updatedAt, + ); + final scheduledTask = _taskById(result.tasks, capturedTask.id); + + if (scheduledTask == null || scheduledTask.isBacklog) { + return QuickCaptureResult( + task: capturedTask, + status: QuickCaptureStatus.validationError, + schedulingResult: result, + messages: result.notices.map((notice) => notice.message).toList(), + ); + } + + return QuickCaptureResult( + task: scheduledTask, + status: QuickCaptureStatus.scheduled, + schedulingResult: result, + ); + } +} + +Task? _taskById(List tasks, String id) { + for (final task in tasks) { + if (task.id == id) { + return task; + } + } + + return null; +} diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index c189b2f..7660373 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -216,6 +216,104 @@ void main() { ]); }); + test('unchecked quick capture goes to backlog', () { + const service = QuickCaptureService(); + + final result = service.capture( + QuickCaptureRequest( + id: 'capture-1', + title: 'Buy stamps', + createdAt: now, + ), + updatedAt: now, + ); + + expect(result.status, QuickCaptureStatus.addedToBacklog); + expect(result.isValid, isTrue); + expect(result.task.status, TaskStatus.backlog); + expect(result.task.scheduledStart, isNull); + expect(result.task.priority, PriorityLevel.medium); + expect(result.task.reward, RewardLevel.notSet); + expect(result.schedulingResult, isNull); + }); + + test('checked quick capture with duration schedules next available slot', + () { + const service = QuickCaptureService(); + final plannedFlexible = Task.quickCapture( + id: 'flexible-1', + title: 'Existing flexible task', + createdAt: now, + ).copyWith( + status: TaskStatus.planned, + durationMinutes: 30, + scheduledStart: DateTime(2026, 6, 19, 13), + scheduledEnd: DateTime(2026, 6, 19, 13, 30), + ); + + final result = service.capture( + QuickCaptureRequest( + id: 'capture-1', + title: 'Buy stamps', + createdAt: now, + addToNextAvailableSlot: true, + durationMinutes: 15, + projectId: 'errands', + reward: RewardLevel.low, + ), + schedulingInput: SchedulingInput( + tasks: [plannedFlexible], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14), + ), + ), + updatedAt: now, + ); + + expect(result.status, QuickCaptureStatus.scheduled); + expect(result.task.status, TaskStatus.planned); + expect(result.task.projectId, 'errands'); + expect(result.task.reward, RewardLevel.low); + expect(result.task.scheduledStart, DateTime(2026, 6, 19, 13)); + expect(result.task.scheduledEnd, DateTime(2026, 6, 19, 13, 15)); + expect( + result.schedulingResult!.changes.map((change) => change.taskId), + ['capture-1', 'flexible-1'], + ); + }); + + test('checked quick capture without duration returns validation result', + () { + const service = QuickCaptureService(); + + final result = service.capture( + QuickCaptureRequest( + id: 'capture-1', + title: 'Buy stamps', + createdAt: now, + addToNextAvailableSlot: true, + ), + schedulingInput: SchedulingInput( + tasks: const [], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14), + ), + ), + updatedAt: now, + ); + + expect(result.status, QuickCaptureStatus.validationError); + expect(result.isValid, isFalse); + expect(result.task.status, TaskStatus.backlog); + expect(result.task.scheduledStart, isNull); + expect(result.messages, [ + 'Duration is required to schedule quick capture.', + ]); + expect(result.schedulingResult, isNull); + }); + test('project defaults apply while task overrides remain possible', () { const project = ProjectProfile( id: 'home',