feat(backlog): schedule quick capture into next slot

This commit is contained in:
Ashley Venn 2026-06-19 16:10:40 -07:00
parent e44b105777
commit f84d5661c0
4 changed files with 260 additions and 0 deletions

View file

@ -78,6 +78,8 @@ Execution notes:
Recommended Codex level: high Recommended Codex level: high
Status: Completed
Tasks: Tasks:
Implement behavior for `Add to next available schedule slot`: 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 with duration schedules into next available slot.
- Test: checked quick capture without required scheduling fields is handled predictably. - 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. BREAKPOINT: Stop here. Confirm `high` mode before implementing scheduling integration.
## Chunk 4.4 — Backlog staleness marker calculation ## Chunk 4.4 — Backlog staleness marker calculation

View file

@ -4,5 +4,6 @@
export 'src/models.dart'; export 'src/models.dart';
export 'src/backlog.dart'; export 'src/backlog.dart';
export 'src/quick_capture.dart';
export 'src/scheduling_engine.dart'; export 'src/scheduling_engine.dart';
export 'src/task_statistics.dart'; export 'src/task_statistics.dart';

149
lib/src/quick_capture.dart Normal file
View file

@ -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 <BacklogTag>{},
});
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<BacklogTag> backlogTags;
}
/// Result of a quick-capture request.
class QuickCaptureResult {
const QuickCaptureResult({
required this.task,
required this.status,
this.schedulingResult,
this.messages = const <String>[],
});
final Task task;
final QuickCaptureStatus status;
final SchedulingResult? schedulingResult;
final List<String> 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<Task> tasks, String id) {
for (final task in tasks) {
if (task.id == id) {
return task;
}
}
return null;
}

View file

@ -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', () { test('project defaults apply while task overrides remain possible', () {
const project = ProjectProfile( const project = ProjectProfile(
id: 'home', id: 'home',