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