feat(tasks): apply push destinations
This commit is contained in:
parent
f0f8553042
commit
f7f239c3f7
3 changed files with 267 additions and 1 deletions
|
|
@ -46,6 +46,8 @@ Execution notes:
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Implement push destinations:
|
Implement push destinations:
|
||||||
|
|
@ -64,6 +66,17 @@ Acceptance criteria:
|
||||||
- Test: tomorrow sets tomorrow/top-of-queue metadata.
|
- Test: tomorrow sets tomorrow/top-of-queue metadata.
|
||||||
- Test: backlog clears schedule placement.
|
- 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.
|
BREAKPOINT: Stop here. Confirm `high` mode before integrating push behavior.
|
||||||
|
|
||||||
## Chunk 6.3 — Required task states
|
## Chunk 6.3 — Required task states
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,21 @@ class FlexibleTaskActionResult {
|
||||||
bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty;
|
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.
|
/// Applies low-friction quick actions for flexible task cards.
|
||||||
class FlexibleTaskActionService {
|
class FlexibleTaskActionService {
|
||||||
const 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -864,7 +864,11 @@ void main() {
|
||||||
PushDestination.tomorrowTopOfQueue,
|
PushDestination.tomorrowTopOfQueue,
|
||||||
PushDestination.backlog,
|
PushDestination.backlog,
|
||||||
]);
|
]);
|
||||||
expect(result.pushDestinations, isNot(contains('laterToday')));
|
expect(PushDestination.values, const [
|
||||||
|
PushDestination.nextAvailableSlot,
|
||||||
|
PushDestination.tomorrowTopOfQueue,
|
||||||
|
PushDestination.backlog,
|
||||||
|
]);
|
||||||
expect(result.changedTask, isFalse);
|
expect(result.changedTask, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -886,6 +890,132 @@ void main() {
|
||||||
expect(result.startsChildTaskFlow, isTrue);
|
expect(result.startsChildTaskFlow, isTrue);
|
||||||
expect(result.changedTask, isFalse);
|
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', () {
|
group('SchedulingEngine starter behavior', () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue