feat(tasks): apply push destinations

This commit is contained in:
Ashley Venn 2026-06-19 16:35:09 -07:00
parent f0f8553042
commit f7f239c3f7
3 changed files with 267 additions and 1 deletions

View file

@ -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

View file

@ -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<Task>.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<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}

View file

@ -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', () {