forked from eva/focus-flow
feat(backlog): schedule quick capture into next slot
This commit is contained in:
parent
e44b105777
commit
f84d5661c0
4 changed files with 260 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
149
lib/src/quick_capture.dart
Normal file
149
lib/src/quick_capture.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue