Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20

Open
pyr0ball wants to merge 26 commits from Circuit-Forge/focus-flow:feat/9-12-13-wire-backlog-nav-and-modal-actions into main
17 changed files with 763 additions and 62 deletions
Showing only changes of commit 9b99c7810e - Show all commits

View file

@ -134,6 +134,38 @@ class SchedulerCommandController extends ChangeNotifier {
); );
} }
/// Schedules a backlog task into a specific suggested slot.
Future<void> scheduleBacklogItemAtSuggestedSlot({
required String taskId,
required DateTime scheduledStartUtc,
required DateTime scheduledEndUtc,
required DateTime expectedUpdatedAt,
}) async {
logger.debug(
() =>
'UI command requested: schedule backlog item at suggested slot. '
'taskId=$taskId scheduledStart=${scheduledStartUtc.toIso8601String()} '
'scheduledEnd=${scheduledEndUtc.toIso8601String()} '
'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}',
);
await _run(
label: 'Scheduling task',
successMessage: 'Task scheduled',
refreshAfterSuccess: true,
action: (operationId) {
final commandAt = now();
return commands.scheduleBacklogItemAtSuggestedSlot(
context: contextFor(operationId, now: commandAt),
localDate: selectedDate(),
taskId: taskId,
scheduledStart: scheduledStartUtc,
scheduledEnd: scheduledEndUtc,
expectedUpdatedAt: expectedUpdatedAt,
);
},
);
}
/// Breaks a Backlog task into direct child tasks. /// Breaks a Backlog task into direct child tasks.
Future<void> breakUpBacklogTask({ Future<void> breakUpBacklogTask({
required String parentTaskId, required String parentTaskId,

View file

@ -57,6 +57,21 @@ class BacklogBoardScreen extends StatelessWidget {
); );
} }
void scheduleSuggestedSlot(SuggestedSlotReadModel slot) {
final detail = controller.selectedTaskDetail;
if (detail == null) {
return;
}
unawaited(
_scheduleSelectedBacklogTaskAtSuggestedSlot(
detail: detail,
slot: slot,
boardController: controller,
commandController: commandController,
),
);
}
void breakUpSelectedTask() { void breakUpSelectedTask() {
final detail = controller.selectedTaskDetail; final detail = controller.selectedTaskDetail;
if (detail == null) { if (detail == null) {
@ -137,6 +152,7 @@ class BacklogBoardScreen extends StatelessWidget {
onCloseDrawer: controller.clearSelection, onCloseDrawer: controller.clearSelection,
onAddTask: _addTaskHandler(context, commandController), onAddTask: _addTaskHandler(context, commandController),
onSchedule: scheduleSelectedTask, onSchedule: scheduleSelectedTask,
onSuggestedSlotPressed: scheduleSuggestedSlot,
onBreakUp: breakUpSelectedTask, onBreakUp: breakUpSelectedTask,
onPushToSomeday: pushSelectedTaskToSomeday, onPushToSomeday: pushSelectedTaskToSomeday,
onRemove: removeSelectedTask, onRemove: removeSelectedTask,
@ -161,6 +177,7 @@ class BacklogBoardScreen extends StatelessWidget {
onCloseDrawer: controller.clearSelection, onCloseDrawer: controller.clearSelection,
onAddTask: _addTaskHandler(context, commandController), onAddTask: _addTaskHandler(context, commandController),
onSchedule: scheduleSelectedTask, onSchedule: scheduleSelectedTask,
onSuggestedSlotPressed: scheduleSuggestedSlot,
onBreakUp: breakUpSelectedTask, onBreakUp: breakUpSelectedTask,
onPushToSomeday: pushSelectedTaskToSomeday, onPushToSomeday: pushSelectedTaskToSomeday,
onRemove: removeSelectedTask, onRemove: removeSelectedTask,
@ -764,6 +781,28 @@ Future<void> _scheduleSelectedBacklogTask({
} }
} }
/// Schedules the selected Backlog [detail] at the exact suggested [slot].
Future<void> _scheduleSelectedBacklogTaskAtSuggestedSlot({
required BacklogTaskDetailReadModel detail,
required SuggestedSlotReadModel slot,
required BacklogBoardController boardController,
required SchedulerCommandController? commandController,
}) async {
final commands = commandController;
if (commands == null) {
return;
}
await commands.scheduleBacklogItemAtSuggestedSlot(
taskId: detail.item.taskId,
scheduledStartUtc: slot.startUtc,
scheduledEndUtc: slot.endUtc,
expectedUpdatedAt: detail.item.updatedAt,
);
if (commands.state is SchedulerCommandSuccess) {
boardController.clearSelection();
}
}
/// Main Backlog frame containing header, controls, body, and overlay slot. /// Main Backlog frame containing header, controls, body, and overlay slot.
class _BacklogFrame extends StatelessWidget { class _BacklogFrame extends StatelessWidget {
/// Creates the Backlog frame. /// Creates the Backlog frame.
@ -1003,6 +1042,7 @@ class _BacklogReadyBody extends StatelessWidget {
required this.onCloseDrawer, required this.onCloseDrawer,
required this.onAddTask, required this.onAddTask,
required this.onSchedule, required this.onSchedule,
required this.onSuggestedSlotPressed,
required this.onBreakUp, required this.onBreakUp,
required this.onPushToSomeday, required this.onPushToSomeday,
required this.onRemove, required this.onRemove,
@ -1039,6 +1079,9 @@ class _BacklogReadyBody extends StatelessWidget {
/// Callback invoked when the drawer Schedule action is pressed. /// Callback invoked when the drawer Schedule action is pressed.
final VoidCallback onSchedule; final VoidCallback onSchedule;
/// Callback invoked when a drawer suggested slot is pressed.
final ValueChanged<SuggestedSlotReadModel> onSuggestedSlotPressed;
/// Callback invoked when the drawer Break Up action is pressed. /// Callback invoked when the drawer Break Up action is pressed.
final VoidCallback onBreakUp; final VoidCallback onBreakUp;
@ -1099,6 +1142,7 @@ class _BacklogReadyBody extends StatelessWidget {
actionMessage: detailActionMessage, actionMessage: detailActionMessage,
onClose: onCloseDrawer, onClose: onCloseDrawer,
onSchedule: onSchedule, onSchedule: onSchedule,
onSuggestedSlotPressed: onSuggestedSlotPressed,
onBreakUp: onBreakUp, onBreakUp: onBreakUp,
onPushToSomeday: onPushToSomeday, onPushToSomeday: onPushToSomeday,
onRemove: onRemove, onRemove: onRemove,

View file

@ -26,6 +26,7 @@ class BacklogTaskDrawer extends StatelessWidget {
required this.onBreakUp, required this.onBreakUp,
required this.onPushToSomeday, required this.onPushToSomeday,
required this.onRemove, required this.onRemove,
required this.onSuggestedSlotPressed,
required this.onProjectPressed, required this.onProjectPressed,
required this.onAddTagPressed, required this.onAddTagPressed,
required this.onViewDayPressed, required this.onViewDayPressed,
@ -62,6 +63,9 @@ class BacklogTaskDrawer extends StatelessWidget {
/// Callback for the Remove action. /// Callback for the Remove action.
final VoidCallback onRemove; final VoidCallback onRemove;
/// Callback for scheduling one suggested slot.
final ValueChanged<SuggestedSlotReadModel> onSuggestedSlotPressed;
/// Callback for the project selector placeholder. /// Callback for the project selector placeholder.
final VoidCallback onProjectPressed; final VoidCallback onProjectPressed;
@ -105,6 +109,7 @@ class BacklogTaskDrawer extends StatelessWidget {
onBreakUp: onBreakUp, onBreakUp: onBreakUp,
onPushToSomeday: onPushToSomeday, onPushToSomeday: onPushToSomeday,
onRemove: onRemove, onRemove: onRemove,
onSuggestedSlotPressed: onSuggestedSlotPressed,
onProjectPressed: onProjectPressed, onProjectPressed: onProjectPressed,
onAddTagPressed: onAddTagPressed, onAddTagPressed: onAddTagPressed,
onViewDayPressed: onViewDayPressed, onViewDayPressed: onViewDayPressed,
@ -194,6 +199,7 @@ class _DrawerDetailContent extends StatelessWidget {
required this.onBreakUp, required this.onBreakUp,
required this.onPushToSomeday, required this.onPushToSomeday,
required this.onRemove, required this.onRemove,
required this.onSuggestedSlotPressed,
required this.onProjectPressed, required this.onProjectPressed,
required this.onAddTagPressed, required this.onAddTagPressed,
required this.onViewDayPressed, required this.onViewDayPressed,
@ -218,6 +224,9 @@ class _DrawerDetailContent extends StatelessWidget {
/// Callback for Remove. /// Callback for Remove.
final VoidCallback onRemove; final VoidCallback onRemove;
/// Callback for scheduling one suggested slot.
final ValueChanged<SuggestedSlotReadModel> onSuggestedSlotPressed;
/// Callback for the project selector placeholder. /// Callback for the project selector placeholder.
final VoidCallback onProjectPressed; final VoidCallback onProjectPressed;
@ -258,6 +267,7 @@ class _DrawerDetailContent extends StatelessWidget {
_SuggestedSlotsSection( _SuggestedSlotsSection(
slots: detail.suggestedSlots, slots: detail.suggestedSlots,
unavailableReason: detail.suggestedSlotUnavailableReason, unavailableReason: detail.suggestedSlotUnavailableReason,
onSuggestedSlotPressed: onSuggestedSlotPressed,
onViewDayPressed: onViewDayPressed, onViewDayPressed: onViewDayPressed,
), ),
], ],
@ -814,6 +824,7 @@ class _SuggestedSlotsSection extends StatelessWidget {
const _SuggestedSlotsSection({ const _SuggestedSlotsSection({
required this.slots, required this.slots,
required this.unavailableReason, required this.unavailableReason,
required this.onSuggestedSlotPressed,
required this.onViewDayPressed, required this.onViewDayPressed,
}); });
@ -823,6 +834,9 @@ class _SuggestedSlotsSection extends StatelessWidget {
/// Optional reason why suggestions are unavailable. /// Optional reason why suggestions are unavailable.
final String? unavailableReason; final String? unavailableReason;
/// Callback for scheduling a suggested slot.
final ValueChanged<SuggestedSlotReadModel> onSuggestedSlotPressed;
/// View-day callback. /// View-day callback.
final VoidCallback onViewDayPressed; final VoidCallback onViewDayPressed;
@ -855,7 +869,12 @@ class _SuggestedSlotsSection extends StatelessWidget {
) )
else else
for (final slot in slots) ...[ for (final slot in slots) ...[
_SuggestedSlotRow(slot: slot), _SuggestedSlotRow(
slot: slot,
onPressed: () {
onSuggestedSlotPressed(slot);
},
),
if (slot != slots.last) const SizedBox(height: 8), if (slot != slots.last) const SizedBox(height: 8),
], ],
], ],
@ -867,17 +886,26 @@ class _SuggestedSlotsSection extends StatelessWidget {
/// One suggested schedule slot row. /// One suggested schedule slot row.
class _SuggestedSlotRow extends StatelessWidget { class _SuggestedSlotRow extends StatelessWidget {
/// Creates a suggested slot row. /// Creates a suggested slot row.
const _SuggestedSlotRow({required this.slot}); const _SuggestedSlotRow({required this.slot, required this.onPressed});
/// Suggested slot data. /// Suggested slot data.
final SuggestedSlotReadModel slot; final SuggestedSlotReadModel slot;
/// Callback that schedules this exact slot.
final VoidCallback onPressed;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final severity = _fitSeverityColor(slot.fitSeverityToken); final severity = _fitSeverityColor(slot.fitSeverityToken);
return DecoratedBox( return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey('backlog-drawer-suggested-slot-${slot.id}'),
borderRadius: BorderRadius.circular(8),
onTap: onPressed,
child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: FocusFlowTokens.panelBackground, color: FocusFlowTokens.panelBackground,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@ -937,6 +965,8 @@ class _SuggestedSlotRow extends StatelessWidget {
], ],
), ),
), ),
),
),
); );
} }
} }

View file

@ -266,6 +266,62 @@ void main() {
expect(find.byKey(const ValueKey('backlog-card-plan-week')), findsNothing); expect(find.byKey(const ValueKey('backlog-card-plan-week')), findsNothing);
}); });
testWidgets('suggested slot action schedules the indicated time', (
tester,
) async {
final existingFlexible = Task(
id: 'existing-flexible',
title: 'Existing flexible',
projectId: 'home',
type: TaskType.flexible,
status: TaskStatus.planned,
priority: PriorityLevel.medium,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.easy,
durationMinutes: 30,
scheduledStart: _now,
scheduledEnd: _now.add(const Duration(minutes: 30)),
createdAt: _now,
updatedAt: _now,
);
final harness = _BacklogCommandHarness.withTasks([
_domainBacklogTask(
id: 'plan-slot',
title: 'Plan slot',
durationMinutes: 30,
),
existingFlexible,
]);
addTearDown(harness.dispose);
await harness.boardController.load();
await _pumpBacklogScreen(
tester,
harness.boardController,
commandController: harness.commandController,
);
await tester.tap(find.byKey(const ValueKey('backlog-card-plan-slot')));
await tester.pumpAndSettle();
final suggestedSlot = find.byKey(
const ValueKey('backlog-drawer-suggested-slot-plan-slot:primary'),
);
await tester.ensureVisible(suggestedSlot);
await tester.tap(suggestedSlot);
await _settleAsyncUi(tester);
expect(harness.commandController.state, isA<SchedulerCommandSuccess>());
expect(find.text('Task scheduled'), findsOneWidget);
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsNothing);
final scheduled = _taskById(harness.store.currentTasks, 'plan-slot');
final pushed = _taskById(harness.store.currentTasks, 'existing-flexible');
expect(scheduled.status, TaskStatus.planned);
expect(scheduled.scheduledStart, _now);
expect(scheduled.scheduledEnd, _now.add(const Duration(minutes: 30)));
expect(pushed.scheduledStart, _now.add(const Duration(minutes: 30)));
expect(pushed.scheduledEnd, _now.add(const Duration(minutes: 60)));
});
testWidgets( testWidgets(
'schedule action explains missing duration without command write', 'schedule action explains missing duration without command write',
(tester) async { (tester) async {

View file

@ -23,6 +23,7 @@ void main() {
var removeCount = 0; var removeCount = 0;
var addTagCount = 0; var addTagCount = 0;
var viewDayCount = 0; var viewDayCount = 0;
var suggestedSlotId = '';
await _pumpDrawer( await _pumpDrawer(
tester, tester,
@ -47,6 +48,9 @@ void main() {
onRemove: () { onRemove: () {
removeCount += 1; removeCount += 1;
}, },
onSuggestedSlotPressed: (slot) {
suggestedSlotId = slot.id;
},
onProjectPressed: () {}, onProjectPressed: () {},
onAddTagPressed: () { onAddTagPressed: () {
addTagCount += 1; addTagCount += 1;
@ -85,6 +89,12 @@ void main() {
await tester.tap( await tester.tap(
find.byKey(const ValueKey('backlog-drawer-view-day-button')), find.byKey(const ValueKey('backlog-drawer-view-day-button')),
); );
final suggestedSlot = find.byKey(
const ValueKey('backlog-drawer-suggested-slot-slot-1'),
);
await tester.ensureVisible(suggestedSlot);
await tester.pump();
await tester.tap(suggestedSlot);
await tester.tap( await tester.tap(
find.byKey(const ValueKey('backlog-drawer-add-tag-button')), find.byKey(const ValueKey('backlog-drawer-add-tag-button')),
); );
@ -103,6 +113,7 @@ void main() {
await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button'))); await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button')));
expect(viewDayCount, 1); expect(viewDayCount, 1);
expect(suggestedSlotId, 'slot-1');
expect(addTagCount, 1); expect(addTagCount, 1);
expect(scheduleCount, 1); expect(scheduleCount, 1);
expect(breakUpCount, 1); expect(breakUpCount, 1);
@ -127,6 +138,7 @@ void main() {
onBreakUp: () {}, onBreakUp: () {},
onPushToSomeday: () {}, onPushToSomeday: () {},
onRemove: () {}, onRemove: () {},
onSuggestedSlotPressed: (_) {},
onProjectPressed: () {}, onProjectPressed: () {},
onAddTagPressed: () {}, onAddTagPressed: () {},
onViewDayPressed: () {}, onViewDayPressed: () {},
@ -149,6 +161,7 @@ void main() {
onBreakUp: () {}, onBreakUp: () {},
onPushToSomeday: () {}, onPushToSomeday: () {},
onRemove: () {}, onRemove: () {},
onSuggestedSlotPressed: (_) {},
onProjectPressed: () {}, onProjectPressed: () {},
onAddTagPressed: () {}, onAddTagPressed: () {},
onViewDayPressed: () {}, onViewDayPressed: () {},
@ -214,9 +227,11 @@ BacklogTaskDetailReadModel _detail() {
isArchived: false, isArchived: false,
), ),
], ],
suggestedSlots: const [ suggestedSlots: [
SuggestedSlotReadModel( SuggestedSlotReadModel(
id: 'slot-1', id: 'slot-1',
startUtc: DateTime.utc(2026, 7, 7, 14, 30),
endUtc: DateTime.utc(2026, 7, 7, 15, 30),
localDateLabel: 'Today', localDateLabel: 'Today',
startText: '2:30 PM', startText: '2:30 PM',
endText: '3:30 PM', endText: '3:30 PM',
@ -227,6 +242,8 @@ BacklogTaskDetailReadModel _detail() {
), ),
SuggestedSlotReadModel( SuggestedSlotReadModel(
id: 'slot-2', id: 'slot-2',
startUtc: DateTime.utc(2026, 7, 7, 18),
endUtc: DateTime.utc(2026, 7, 7, 19),
localDateLabel: 'Today', localDateLabel: 'Today',
startText: '6:00 PM', startText: '6:00 PM',
endText: '7:00 PM', endText: '7:00 PM',
@ -237,6 +254,8 @@ BacklogTaskDetailReadModel _detail() {
), ),
SuggestedSlotReadModel( SuggestedSlotReadModel(
id: 'slot-3', id: 'slot-3',
startUtc: DateTime.utc(2026, 7, 8, 10),
endUtc: DateTime.utc(2026, 7, 8, 11),
localDateLabel: 'Tomorrow', localDateLabel: 'Tomorrow',
startText: '10:00 AM', startText: '10:00 AM',
endText: '11:00 AM', endText: '11:00 AM',

View file

@ -17,6 +17,10 @@ enum ApplicationCommandCode {
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
scheduleBacklogItemToNextAvailableSlot, scheduleBacklogItemToNextAvailableSlot,
/// Selects the `scheduleBacklogItemAtSuggestedSlot` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
scheduleBacklogItemAtSuggestedSlot,
/// Selects the `pushFlexibleToNextAvailableSlot` option from this enum. /// Selects the `pushFlexibleToNextAvailableSlot` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
pushFlexibleToNextAvailableSlot, pushFlexibleToNextAvailableSlot,

View file

@ -285,6 +285,88 @@ class V1ApplicationCommandUseCases {
); );
} }
/// Schedule an existing backlog item into a selected suggested slot.
Future<ApplicationResult<ApplicationCommandResult>>
scheduleBacklogItemAtSuggestedSlot({
required ApplicationOperationContext context,
required CivilDate localDate,
required String taskId,
required DateTime scheduledStart,
required DateTime scheduledEnd,
DateTime? expectedUpdatedAt,
}) {
const commandCode =
ApplicationCommandCode.scheduleBacklogItemAtSuggestedSlot;
return applicationStore.run<ApplicationCommandResult>(
context: context,
operationName: commandCode.name,
action: (repositories) async {
logger.debug(() =>
'Application command executing. command=${commandCode.name} '
'operationId=${context.operationId} localDate=${localDate.toIsoString()} '
'taskId=$taskId scheduledStart=${scheduledStart.toIso8601String()} '
'scheduledEnd=${scheduledEnd.toIso8601String()}');
var state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId);
if (task == null) {
logger.finer(() =>
'Planning-state task lookup missed; reading by id. '
'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}');
task = await repositories.tasks.findById(taskId);
if (task != null) {
state = state.withTask(task);
}
}
if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
}
final staleFailure = _staleFailure(task, expectedUpdatedAt);
if (staleFailure != null) {
return ApplicationResult.failure(staleFailure);
}
final schedulingResult =
const SchedulingEngine().insertBacklogTaskAtRequestedSlot(
input: state.input,
taskId: taskId,
requestedStart: scheduledStart.toUtc(),
requestedEnd: scheduledEnd.toUtc(),
updatedAt: context.now,
);
final schedulingFailure = _failureForSchedulingResult(
schedulingResult,
entityId: taskId,
);
if (schedulingFailure != null) {
return ApplicationResult.failure(schedulingFailure);
}
final activities = _activitiesForSchedulingChanges(
result: schedulingResult,
beforeTasks: state.tasks,
operationId: context.operationId,
occurredAt: context.now,
selectedTaskId: taskId,
selectedActivityCode: TaskActivityCode.restoredFromBacklog,
movedActivityCode: TaskActivityCode.automaticallyPushed,
);
return _persist(
repositories: repositories,
commandCode: commandCode,
context: context,
beforeTasks: state.tasks,
afterTasks: schedulingResult.tasks,
activities: activities,
schedulingResult: schedulingResult,
readHint: ApplicationCommandReadHint(
localDate: localDate,
affectedTaskIds: _taskIdsFromChanges(schedulingResult.changes),
),
);
},
);
}
/// Push a planned flexible task later in the same local day. /// Push a planned flexible task later in the same local day.
Future<ApplicationResult<ApplicationCommandResult>> Future<ApplicationResult<ApplicationCommandResult>>
pushFlexibleToNextAvailableSlot({ pushFlexibleToNextAvailableSlot({

View file

@ -8,6 +8,8 @@ class SuggestedSlotReadModel {
/// Creates a suggested slot preview. /// Creates a suggested slot preview.
const SuggestedSlotReadModel({ const SuggestedSlotReadModel({
required this.id, required this.id,
required this.startUtc,
required this.endUtc,
required this.localDateLabel, required this.localDateLabel,
required this.startText, required this.startText,
required this.endText, required this.endText,
@ -20,6 +22,12 @@ class SuggestedSlotReadModel {
/// Stable suggestion id. /// Stable suggestion id.
final String id; final String id;
/// UTC instant where the suggested slot starts.
final DateTime startUtc;
/// UTC instant where the suggested slot ends.
final DateTime endUtc;
/// Display label for the owner-local date. /// Display label for the owner-local date.
final String localDateLabel; final String localDateLabel;

View file

@ -1535,6 +1535,8 @@ SuggestedSlotReadModel _suggestedSlotReadModel({
}) { }) {
return SuggestedSlotReadModel( return SuggestedSlotReadModel(
id: id, id: id,
startUtc: interval.start.toUtc(),
endUtc: interval.end.toUtc(),
localDateLabel: localDateLabel, localDateLabel: localDateLabel,
startText: _clockText(interval.start), startText: _clockText(interval.start),
endText: _clockText(interval.end), endText: _clockText(interval.end),

View file

@ -249,6 +249,7 @@ abstract final class PersistenceEnumMapping {
SchedulingIssueCode.missingDuration => 'missing_duration', SchedulingIssueCode.missingDuration => 'missing_duration',
SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot', SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot',
SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration', SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration',
SchedulingIssueCode.durationMismatch => 'duration_mismatch',
SchedulingIssueCode.noAvailableSlot => 'no_available_slot', SchedulingIssueCode.noAvailableSlot => 'no_available_slot',
SchedulingIssueCode.unfinishedTasksCouldNotFit => SchedulingIssueCode.unfinishedTasksCouldNotFit =>
'unfinished_tasks_could_not_fit', 'unfinished_tasks_could_not_fit',

View file

@ -147,6 +147,7 @@ const _schedulingIssueCodeByCode = <String, SchedulingIssueCode>{
'missing_duration': SchedulingIssueCode.missingDuration, 'missing_duration': SchedulingIssueCode.missingDuration,
'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot, 'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot,
'non_positive_duration': SchedulingIssueCode.nonPositiveDuration, 'non_positive_duration': SchedulingIssueCode.nonPositiveDuration,
'duration_mismatch': SchedulingIssueCode.durationMismatch,
'no_available_slot': SchedulingIssueCode.noAvailableSlot, 'no_available_slot': SchedulingIssueCode.noAvailableSlot,
'unfinished_tasks_could_not_fit': 'unfinished_tasks_could_not_fit':
SchedulingIssueCode.unfinishedTasksCouldNotFit, SchedulingIssueCode.unfinishedTasksCouldNotFit,

View file

@ -25,6 +25,10 @@ enum SchedulingIssueCode {
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
nonPositiveDuration, nonPositiveDuration,
/// Selects the `durationMismatch` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
durationMismatch,
/// Selects the `noAvailableSlot` option from this enum. /// Selects the `noAvailableSlot` option from this enum.
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
noAvailableSlot, noAvailableSlot,

View file

@ -11,6 +11,9 @@ enum SchedulingOperationCode {
/// Insert a backlog task into the next available slot. /// Insert a backlog task into the next available slot.
insertBacklogTaskIntoNextAvailableSlot, insertBacklogTaskIntoNextAvailableSlot,
/// Insert a backlog task into an explicitly requested open slot.
insertBacklogTaskAtRequestedSlot,
/// Push a flexible task later in the current window. /// Push a flexible task later in the current window.
pushFlexibleTaskToNextAvailableSlot, pushFlexibleTaskToNextAvailableSlot,

View file

@ -11,6 +11,7 @@ part of '../../scheduling_engine.dart';
/// ///
/// Current V1 responsibilities: /// Current V1 responsibilities:
/// - insert backlog tasks into the earliest available flexible slot; /// - insert backlog tasks into the earliest available flexible slot;
/// - insert backlog tasks into an explicitly selected open slot;
/// - push flexible tasks later today; /// - push flexible tasks later today;
/// - move flexible tasks to tomorrow's queue; /// - move flexible tasks to tomorrow's queue;
/// - roll unfinished flexible tasks into a new planning window; /// - roll unfinished flexible tasks into a new planning window;
@ -137,6 +138,163 @@ class SchedulingEngine {
); );
} }
/// Insert a backlog task into a specific open interval.
///
/// The requested interval must be fully inside [input.window], match the
/// backlog task's duration, and avoid fixed blocked time. Existing planned
/// flexible tasks that overlap or follow the requested slot may move later,
/// preserving their relative order.
SchedulingResult insertBacklogTaskAtRequestedSlot({
required SchedulingInput input,
required String taskId,
required DateTime requestedStart,
required DateTime requestedEnd,
DateTime? updatedAt,
}) {
logger.debug(() => 'Scheduling backlog task into requested slot. '
'taskId=$taskId taskCount=${input.tasks.length} '
'requestedStart=${requestedStart.toIso8601String()} '
'requestedEnd=${requestedEnd.toIso8601String()}');
final task = _taskById(input.tasks, taskId);
if (task == null) {
logger.warn(() =>
'Requested-slot backlog scheduling failed: task not found. taskId=$taskId');
return _unchangedResult(
input,
SchedulingNotice(
'Task was not found.',
type: SchedulingNoticeType.noFit,
taskId: taskId,
issueCode: SchedulingIssueCode.taskNotFound,
),
operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot,
outcomeCode: SchedulingOutcomeCode.notFound,
);
}
if (!task.isFlexible || !task.isBacklog) {
logger.warn(() =>
'Requested-slot backlog scheduling failed: invalid task state. '
'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}');
return _unchangedResult(
input,
SchedulingNotice(
'Only backlog flexible tasks can be inserted.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
issueCode: SchedulingIssueCode.invalidTaskState,
),
operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
final taskDuration = _durationFromMinutes(task.durationMinutes);
if (taskDuration == null) {
logger.warn(() =>
'Requested-slot backlog scheduling failed: missing positive duration. '
'taskId=${task.id} durationMinutes=${task.durationMinutes}');
return _unchangedResult(
input,
SchedulingNotice(
'Task needs a positive duration before scheduling.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
issueCode: SchedulingIssueCode.missingDuration,
),
operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
if (!requestedStart.isBefore(requestedEnd)) {
logger.warn(() =>
'Requested-slot backlog scheduling failed: non-positive requested interval. '
'taskId=${task.id}');
return _unchangedResult(
input,
SchedulingNotice(
'Requested slot must have a positive duration.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
issueCode: SchedulingIssueCode.nonPositiveDuration,
),
operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
final requestedInterval = TimeInterval(
start: requestedStart,
end: requestedEnd,
label: task.id,
);
if (requestedInterval.duration != taskDuration) {
logger.warn(() =>
'Requested-slot backlog scheduling failed: duration mismatch. '
'taskId=${task.id} taskDurationMinutes=${taskDuration.inMinutes} '
'requestedDurationMinutes=${requestedInterval.duration.inMinutes}');
return _unchangedResult(
input,
SchedulingNotice(
'Requested slot no longer matches the task estimate.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
issueCode: SchedulingIssueCode.durationMismatch,
),
operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot,
outcomeCode: SchedulingOutcomeCode.invalidState,
);
}
if (!input.window.contains(requestedInterval)) {
logger.warn(() =>
'Requested-slot backlog scheduling failed: slot outside window. '
'taskId=${task.id}');
return _unchangedResult(
input,
SchedulingNotice(
'Requested slot is outside the planning window.',
type: SchedulingNoticeType.overflow,
taskId: task.id,
issueCode: SchedulingIssueCode.noAvailableSlot,
),
operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot,
outcomeCode: SchedulingOutcomeCode.noSlot,
);
}
final placement = _planBacklogInsertionAtRequestedSlot(
input: input,
task: task,
requestedInterval: requestedInterval,
);
if (placement == null) {
logger.warn(() =>
'Requested-slot backlog scheduling failed: slot blocked or queue overflowed. '
'taskId=${task.id}');
return _unchangedResult(
input,
SchedulingNotice(
'Requested slot is no longer open.',
type: SchedulingNoticeType.overflow,
taskId: task.id,
issueCode: SchedulingIssueCode.noAvailableSlot,
),
operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot,
outcomeCode: SchedulingOutcomeCode.noSlot,
);
}
return _applyPlacement(
input: input,
insertedTask: task,
placement: placement,
updatedAt: updatedAt ?? clock.now(),
operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot,
);
}
/// Push a planned flexible task to the next available slot today. /// Push a planned flexible task to the next available slot today.
/// ///
/// The selected task moves after its current slot. Planned flexible tasks /// The selected task moves after its current slot. Planned flexible tasks
@ -803,6 +961,88 @@ _BacklogInsertionPlan? _planBacklogInsertion({
return _BacklogInsertionPlan(placements: placements); return _BacklogInsertionPlan(placements: placements);
} }
/// Plan a backlog insertion that must start at [requestedInterval].
///
/// Flexible tasks ending before the requested interval remain fixed. Flexible
/// tasks that overlap or follow the requested interval join the queue after the
/// inserted task and may move later to preserve an exact selected slot.
_BacklogInsertionPlan? _planBacklogInsertionAtRequestedSlot({
required SchedulingInput input,
required Task task,
required TimeInterval requestedInterval,
}) {
final fixedBlocks = <TimeInterval>[
...input.blockedIntervals,
];
final queue = <_PlacementItem>[];
final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks
.where((flexibleTask) => flexibleTask.id != task.id)
.toList(growable: false)
..sort((a, b) {
final aStart = a.scheduledStart ?? input.window.end;
final bStart = b.scheduledStart ?? input.window.end;
return aStart.compareTo(bStart);
});
for (final flexibleTask in scheduledFlexibleTasks) {
final interval = _scheduledIntervalFor(flexibleTask);
if (interval == null) {
continue;
}
final startsBeforeWindow = interval.start.isBefore(input.window.start);
final startsAfterWindow = interval.start.isAfter(input.window.end) ||
interval.start.isAtSameMomentAs(input.window.end);
final endsBeforeRequested =
interval.end.isBefore(requestedInterval.start) ||
interval.end.isAtSameMomentAs(requestedInterval.start);
if (startsBeforeWindow || startsAfterWindow || endsBeforeRequested) {
fixedBlocks.add(interval);
continue;
}
queue.add(
_PlacementItem(
task: flexibleTask,
duration: interval.duration,
earliestStart: interval.start,
),
);
}
fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
if (fixedBlocks.any((interval) => interval.overlaps(requestedInterval))) {
return null;
}
var cursor = requestedInterval.end;
final placements = <String, TimeInterval>{
task.id: requestedInterval,
};
for (final item in queue) {
final earliestStart = _laterOf(cursor, item.earliestStart);
final interval = _firstOpenIntervalFrom(
earliestStart: earliestStart,
windowEnd: input.window.end,
duration: item.duration,
blocked: fixedBlocks,
);
if (interval == null) {
return null;
}
placements[item.task.id] = interval;
cursor = interval.end;
}
return _BacklogInsertionPlan(placements: placements);
}
/// Plan the "push later today" behavior for one flexible task. /// Plan the "push later today" behavior for one flexible task.
/// ///
/// Items before the pushed task's current end are fixed. The pushed task starts /// Items before the pushed task's current end are fixed. The pushed task starts
@ -996,6 +1236,8 @@ SchedulingResult _applyPlacement({
required Task insertedTask, required Task insertedTask,
required _BacklogInsertionPlan placement, required _BacklogInsertionPlan placement,
required DateTime updatedAt, required DateTime updatedAt,
SchedulingOperationCode operationCode =
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
}) { }) {
final changes = <SchedulingChange>[]; final changes = <SchedulingChange>[];
final notices = <SchedulingNotice>[]; final notices = <SchedulingNotice>[];
@ -1028,8 +1270,7 @@ SchedulingResult _applyPlacement({
final updatedTask = _applySchedulingActivity( final updatedTask = _applySchedulingActivity(
originalTask: task, originalTask: task,
updatedTask: placedTask, updatedTask: placedTask,
operationCode: operationCode: operationCode,
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
activityCode: isInsertedTask activityCode: isInsertedTask
? TaskActivityCode.restoredFromBacklog ? TaskActivityCode.restoredFromBacklog
: TaskActivityCode.automaticallyPushed, : TaskActivityCode.automaticallyPushed,
@ -1075,8 +1316,7 @@ SchedulingResult _applyPlacement({
'noticeCount=${notices.length}'); 'noticeCount=${notices.length}');
return SchedulingResult( return SchedulingResult(
tasks: List<Task>.unmodifiable(updatedTasks), tasks: List<Task>.unmodifiable(updatedTasks),
operationCode: operationCode: operationCode,
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
outcomeCode: SchedulingOutcomeCode.success, outcomeCode: SchedulingOutcomeCode.success,
notices: List<SchedulingNotice>.unmodifiable(notices), notices: List<SchedulingNotice>.unmodifiable(notices),
changes: List<SchedulingChange>.unmodifiable(changes), changes: List<SchedulingChange>.unmodifiable(changes),

View file

@ -113,6 +113,97 @@ void main() {
]); ]);
}); });
test('schedules backlog item at suggested slot and persists movement',
() async {
final backlog = task(
id: 'suggested',
title: 'Suggested',
type: TaskType.flexible,
status: TaskStatus.backlog,
createdAt: createdAt,
durationMinutes: 30,
);
final planned = task(
id: 'planned',
title: 'Planned',
type: TaskType.flexible,
status: TaskStatus.planned,
createdAt: createdAt,
start: DateTime.utc(2026, 6, 25, 9),
end: DateTime.utc(2026, 6, 25, 9, 30),
);
final store = storeWithSettings(tasks: [backlog, planned]);
final result = await commands(store).scheduleBacklogItemAtSuggestedSlot(
context: appContext('schedule-suggested', DateTime.utc(2026, 6, 25, 8)),
localDate: day,
taskId: backlog.id,
scheduledStart: DateTime.utc(2026, 6, 25, 9),
scheduledEnd: DateTime.utc(2026, 6, 25, 9, 30),
expectedUpdatedAt: backlog.updatedAt,
);
expect(result.isSuccess, isTrue);
expect(
result.requireValue.schedulingResult!.operationCode,
SchedulingOperationCode.insertBacklogTaskAtRequestedSlot,
);
expect(
taskById(store.currentTasks, 'suggested').scheduledStart,
DateTime.utc(2026, 6, 25, 9),
);
expect(
taskById(store.currentTasks, 'suggested').scheduledEnd,
DateTime.utc(2026, 6, 25, 9, 30),
);
expect(
taskById(store.currentTasks, 'planned').scheduledStart,
DateTime.utc(2026, 6, 25, 9, 30),
);
expect(store.currentTaskActivities.map((activity) => activity.code), [
TaskActivityCode.restoredFromBacklog,
TaskActivityCode.automaticallyPushed,
]);
});
test('suggested slot command rejects a blocked interval', () async {
final backlog = task(
id: 'blocked-suggested',
title: 'Blocked suggested',
type: TaskType.flexible,
status: TaskStatus.backlog,
createdAt: createdAt,
durationMinutes: 30,
);
final requiredTask = task(
id: 'appointment',
title: 'Appointment',
type: TaskType.inflexible,
status: TaskStatus.planned,
createdAt: createdAt,
start: DateTime.utc(2026, 6, 25, 10),
end: DateTime.utc(2026, 6, 25, 10, 30),
);
final store = storeWithSettings(tasks: [backlog, requiredTask]);
final result = await commands(store).scheduleBacklogItemAtSuggestedSlot(
context: appContext(
'schedule-blocked-suggested',
DateTime.utc(2026, 6, 25, 8),
),
localDate: day,
taskId: backlog.id,
scheduledStart: DateTime.utc(2026, 6, 25, 10),
scheduledEnd: DateTime.utc(2026, 6, 25, 10, 30),
expectedUpdatedAt: backlog.updatedAt,
);
expect(result.failure!.code, ApplicationFailureCode.noSlot);
expect(taskById(store.currentTasks, backlog.id), backlog);
expect(store.currentTaskActivities, isEmpty);
expect(store.currentOperations, isEmpty);
});
test('schedules backlog item after duration is supplied', () async { test('schedules backlog item after duration is supplied', () async {
final backlog = Task.quickCapture( final backlog = Task.quickCapture(
id: 'backlog', id: 'backlog',

View file

@ -197,9 +197,11 @@ void main() {
addedLabel: 'Added today', addedLabel: 'Added today',
tags: const ['finance'], tags: const ['finance'],
projectOptions: result.projectOptions, projectOptions: result.projectOptions,
suggestedSlots: const [ suggestedSlots: [
SuggestedSlotReadModel( SuggestedSlotReadModel(
id: 'today-1', id: 'today-1',
startUtc: DateTime.utc(2026, 6, 25, 14),
endUtc: DateTime.utc(2026, 6, 25, 15),
localDateLabel: 'Today', localDateLabel: 'Today',
startText: '2:00 PM', startText: '2:00 PM',
endText: '3:00 PM', endText: '3:00 PM',
@ -655,6 +657,14 @@ void main() {
expect(detail.suggestedSlots.first.startText, '10:00 AM'); expect(detail.suggestedSlots.first.startText, '10:00 AM');
expect(detail.suggestedSlots.first.endText, '10:30 AM'); expect(detail.suggestedSlots.first.endText, '10:30 AM');
expect(
detail.suggestedSlots.first.startUtc,
DateTime.utc(2026, 6, 25, 10),
);
expect(
detail.suggestedSlots.first.endUtc,
DateTime.utc(2026, 6, 25, 10, 30),
);
expect(detail.suggestedSlots.first.fitLabel, 'Great Fit'); expect(detail.suggestedSlots.first.fitLabel, 'Great Fit');
expect(detail.suggestedSlotUnavailableReason, isNull); expect(detail.suggestedSlotUnavailableReason, isNull);
expect(store.currentTasks, beforeTasks); expect(store.currentTasks, beforeTasks);

View file

@ -1392,6 +1392,80 @@ void main() {
]); ]);
}); });
test('insert backlog task at requested slot preserves selected interval',
() {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final plannedFlexible = flexibleTask().copyWith(
id: 'flexible-1',
durationMinutes: 30,
scheduledStart: DateTime(2026, 6, 19, 13, 15),
scheduledEnd: DateTime(2026, 6, 19, 13, 45),
);
final result = engine.insertBacklogTaskAtRequestedSlot(
input: SchedulingInput(
tasks: [backlogTask, plannedFlexible],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
),
),
taskId: 'backlog-1',
requestedStart: DateTime(2026, 6, 19, 13, 15),
requestedEnd: DateTime(2026, 6, 19, 13, 30),
updatedAt: now,
);
final inserted = taskById(result.tasks, 'backlog-1');
final pushed = taskById(result.tasks, 'flexible-1');
expect(result.operationCode,
SchedulingOperationCode.insertBacklogTaskAtRequestedSlot);
expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 15));
expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 30));
expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30));
expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 14));
expect(result.changes.map((change) => change.taskId), [
'backlog-1',
'flexible-1',
]);
});
test('insert backlog task at requested slot rejects blocked time', () {
final backlogTask = Task.quickCapture(
id: 'backlog-1',
title: 'Start laundry',
createdAt: now,
).copyWith(durationMinutes: 15);
final inflexibleTask = flexibleTask().copyWith(
id: 'inflexible-1',
type: TaskType.inflexible,
scheduledStart: DateTime(2026, 6, 19, 13, 15),
scheduledEnd: DateTime(2026, 6, 19, 13, 30),
);
final result = engine.insertBacklogTaskAtRequestedSlot(
input: SchedulingInput(
tasks: [backlogTask, inflexibleTask],
window: SchedulingWindow(
start: DateTime(2026, 6, 19, 13),
end: DateTime(2026, 6, 19, 14),
),
),
taskId: 'backlog-1',
requestedStart: DateTime(2026, 6, 19, 13, 15),
requestedEnd: DateTime(2026, 6, 19, 13, 30),
updatedAt: now,
);
expect(result.outcomeCode, SchedulingOutcomeCode.noSlot);
expect(result.changes, isEmpty);
expect(
result.notices.single.issueCode, SchedulingIssueCode.noAvailableSlot);
expect(result.tasks, [backlogTask, inflexibleTask]);
});
test('insert backlog task skips locked time', () { test('insert backlog task skips locked time', () {
final backlogTask = Task.quickCapture( final backlogTask = Task.quickCapture(
id: 'backlog-1', id: 'backlog-1',