diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index ec293d4..bd4b3bd 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -134,6 +134,38 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Schedules a backlog task into a specific suggested slot. + Future 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. Future breakUpBacklogTask({ required String parentTaskId, diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart index 345c193..09d7b09 100644 --- a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart @@ -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() { final detail = controller.selectedTaskDetail; if (detail == null) { @@ -137,6 +152,7 @@ class BacklogBoardScreen extends StatelessWidget { onCloseDrawer: controller.clearSelection, onAddTask: _addTaskHandler(context, commandController), onSchedule: scheduleSelectedTask, + onSuggestedSlotPressed: scheduleSuggestedSlot, onBreakUp: breakUpSelectedTask, onPushToSomeday: pushSelectedTaskToSomeday, onRemove: removeSelectedTask, @@ -161,6 +177,7 @@ class BacklogBoardScreen extends StatelessWidget { onCloseDrawer: controller.clearSelection, onAddTask: _addTaskHandler(context, commandController), onSchedule: scheduleSelectedTask, + onSuggestedSlotPressed: scheduleSuggestedSlot, onBreakUp: breakUpSelectedTask, onPushToSomeday: pushSelectedTaskToSomeday, onRemove: removeSelectedTask, @@ -764,6 +781,28 @@ Future _scheduleSelectedBacklogTask({ } } +/// Schedules the selected Backlog [detail] at the exact suggested [slot]. +Future _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. class _BacklogFrame extends StatelessWidget { /// Creates the Backlog frame. @@ -1003,6 +1042,7 @@ class _BacklogReadyBody extends StatelessWidget { required this.onCloseDrawer, required this.onAddTask, required this.onSchedule, + required this.onSuggestedSlotPressed, required this.onBreakUp, required this.onPushToSomeday, required this.onRemove, @@ -1039,6 +1079,9 @@ class _BacklogReadyBody extends StatelessWidget { /// Callback invoked when the drawer Schedule action is pressed. final VoidCallback onSchedule; + /// Callback invoked when a drawer suggested slot is pressed. + final ValueChanged onSuggestedSlotPressed; + /// Callback invoked when the drawer Break Up action is pressed. final VoidCallback onBreakUp; @@ -1099,6 +1142,7 @@ class _BacklogReadyBody extends StatelessWidget { actionMessage: detailActionMessage, onClose: onCloseDrawer, onSchedule: onSchedule, + onSuggestedSlotPressed: onSuggestedSlotPressed, onBreakUp: onBreakUp, onPushToSomeday: onPushToSomeday, onRemove: onRemove, diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart index 853aff8..b315c58 100644 --- a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart @@ -26,6 +26,7 @@ class BacklogTaskDrawer extends StatelessWidget { required this.onBreakUp, required this.onPushToSomeday, required this.onRemove, + required this.onSuggestedSlotPressed, required this.onProjectPressed, required this.onAddTagPressed, required this.onViewDayPressed, @@ -62,6 +63,9 @@ class BacklogTaskDrawer extends StatelessWidget { /// Callback for the Remove action. final VoidCallback onRemove; + /// Callback for scheduling one suggested slot. + final ValueChanged onSuggestedSlotPressed; + /// Callback for the project selector placeholder. final VoidCallback onProjectPressed; @@ -105,6 +109,7 @@ class BacklogTaskDrawer extends StatelessWidget { onBreakUp: onBreakUp, onPushToSomeday: onPushToSomeday, onRemove: onRemove, + onSuggestedSlotPressed: onSuggestedSlotPressed, onProjectPressed: onProjectPressed, onAddTagPressed: onAddTagPressed, onViewDayPressed: onViewDayPressed, @@ -194,6 +199,7 @@ class _DrawerDetailContent extends StatelessWidget { required this.onBreakUp, required this.onPushToSomeday, required this.onRemove, + required this.onSuggestedSlotPressed, required this.onProjectPressed, required this.onAddTagPressed, required this.onViewDayPressed, @@ -218,6 +224,9 @@ class _DrawerDetailContent extends StatelessWidget { /// Callback for Remove. final VoidCallback onRemove; + /// Callback for scheduling one suggested slot. + final ValueChanged onSuggestedSlotPressed; + /// Callback for the project selector placeholder. final VoidCallback onProjectPressed; @@ -258,6 +267,7 @@ class _DrawerDetailContent extends StatelessWidget { _SuggestedSlotsSection( slots: detail.suggestedSlots, unavailableReason: detail.suggestedSlotUnavailableReason, + onSuggestedSlotPressed: onSuggestedSlotPressed, onViewDayPressed: onViewDayPressed, ), ], @@ -814,6 +824,7 @@ class _SuggestedSlotsSection extends StatelessWidget { const _SuggestedSlotsSection({ required this.slots, required this.unavailableReason, + required this.onSuggestedSlotPressed, required this.onViewDayPressed, }); @@ -823,6 +834,9 @@ class _SuggestedSlotsSection extends StatelessWidget { /// Optional reason why suggestions are unavailable. final String? unavailableReason; + /// Callback for scheduling a suggested slot. + final ValueChanged onSuggestedSlotPressed; + /// View-day callback. final VoidCallback onViewDayPressed; @@ -855,7 +869,12 @@ class _SuggestedSlotsSection extends StatelessWidget { ) else for (final slot in slots) ...[ - _SuggestedSlotRow(slot: slot), + _SuggestedSlotRow( + slot: slot, + onPressed: () { + onSuggestedSlotPressed(slot); + }, + ), if (slot != slots.last) const SizedBox(height: 8), ], ], @@ -867,74 +886,85 @@ class _SuggestedSlotsSection extends StatelessWidget { /// One suggested schedule slot row. class _SuggestedSlotRow extends StatelessWidget { /// Creates a suggested slot row. - const _SuggestedSlotRow({required this.slot}); + const _SuggestedSlotRow({required this.slot, required this.onPressed}); /// Suggested slot data. final SuggestedSlotReadModel slot; + /// Callback that schedules this exact slot. + final VoidCallback onPressed; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { final severity = _fitSeverityColor(slot.fitSeverityToken); - return DecoratedBox( - decoration: BoxDecoration( - color: FocusFlowTokens.panelBackground, + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey('backlog-drawer-suggested-slot-${slot.id}'), borderRadius: BorderRadius.circular(8), - border: Border.all( - color: slot.isPrimary - ? FocusFlowTokens.accentMagenta - : severity.withValues(alpha: 0.55), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), - child: Row( - children: [ - const Icon( - Icons.calendar_today_outlined, - color: FocusFlowTokens.textMuted, - size: 17, + onTap: onPressed, + child: DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: slot.isPrimary + ? FocusFlowTokens.accentMagenta + : severity.withValues(alpha: 0.55), ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - slot.localDateLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: slot.isPrimary - ? BacklogBoardVisualTokens.doNextAccent - : FocusFlowTokens.textMuted, - fontSize: 12, - fontWeight: FontWeight.w800, - ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + child: Row( + children: [ + const Icon( + Icons.calendar_today_outlined, + color: FocusFlowTokens.textMuted, + size: 17, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + slot.localDateLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: slot.isPrimary + ? BacklogBoardVisualTokens.doNextAccent + : FocusFlowTokens.textMuted, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + '${slot.startText} - ${slot.endText} | ${slot.durationMinutes} min', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 12, + ), + ), + ], ), - const SizedBox(height: 4), - Text( - '${slot.startText} - ${slot.endText} | ${slot.durationMinutes} min', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 12, - ), - ), - ], - ), + ), + const SizedBox(width: 8), + _FitBadge(label: slot.fitLabel, color: severity), + const SizedBox(width: 4), + const Icon( + Icons.chevron_right, + color: FocusFlowTokens.textMuted, + size: 20, + ), + ], ), - const SizedBox(width: 8), - _FitBadge(label: slot.fitLabel, color: severity), - const SizedBox(width: 4), - const Icon( - Icons.chevron_right, - color: FocusFlowTokens.textMuted, - size: 20, - ), - ], + ), ), ), ); diff --git a/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart b/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart index bae5114..c4aa0d1 100644 --- a/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart +++ b/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart @@ -266,6 +266,62 @@ void main() { 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()); + 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( 'schedule action explains missing duration without command write', (tester) async { diff --git a/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart b/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart index 383dd65..520938b 100644 --- a/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart +++ b/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart @@ -23,6 +23,7 @@ void main() { var removeCount = 0; var addTagCount = 0; var viewDayCount = 0; + var suggestedSlotId = ''; await _pumpDrawer( tester, @@ -47,6 +48,9 @@ void main() { onRemove: () { removeCount += 1; }, + onSuggestedSlotPressed: (slot) { + suggestedSlotId = slot.id; + }, onProjectPressed: () {}, onAddTagPressed: () { addTagCount += 1; @@ -85,6 +89,12 @@ void main() { await tester.tap( 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( 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'))); expect(viewDayCount, 1); + expect(suggestedSlotId, 'slot-1'); expect(addTagCount, 1); expect(scheduleCount, 1); expect(breakUpCount, 1); @@ -127,6 +138,7 @@ void main() { onBreakUp: () {}, onPushToSomeday: () {}, onRemove: () {}, + onSuggestedSlotPressed: (_) {}, onProjectPressed: () {}, onAddTagPressed: () {}, onViewDayPressed: () {}, @@ -149,6 +161,7 @@ void main() { onBreakUp: () {}, onPushToSomeday: () {}, onRemove: () {}, + onSuggestedSlotPressed: (_) {}, onProjectPressed: () {}, onAddTagPressed: () {}, onViewDayPressed: () {}, @@ -214,9 +227,11 @@ BacklogTaskDetailReadModel _detail() { isArchived: false, ), ], - suggestedSlots: const [ + suggestedSlots: [ SuggestedSlotReadModel( id: 'slot-1', + startUtc: DateTime.utc(2026, 7, 7, 14, 30), + endUtc: DateTime.utc(2026, 7, 7, 15, 30), localDateLabel: 'Today', startText: '2:30 PM', endText: '3:30 PM', @@ -227,6 +242,8 @@ BacklogTaskDetailReadModel _detail() { ), SuggestedSlotReadModel( id: 'slot-2', + startUtc: DateTime.utc(2026, 7, 7, 18), + endUtc: DateTime.utc(2026, 7, 7, 19), localDateLabel: 'Today', startText: '6:00 PM', endText: '7:00 PM', @@ -237,6 +254,8 @@ BacklogTaskDetailReadModel _detail() { ), SuggestedSlotReadModel( id: 'slot-3', + startUtc: DateTime.utc(2026, 7, 8, 10), + endUtc: DateTime.utc(2026, 7, 8, 11), localDateLabel: 'Tomorrow', startText: '10:00 AM', endText: '11:00 AM', diff --git a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart index eed4c3f..73241ff 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart @@ -17,6 +17,10 @@ enum ApplicationCommandCode { /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. 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. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. pushFlexibleToNextAvailableSlot, diff --git a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart index 44bca0f..7267db7 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart @@ -285,6 +285,88 @@ class V1ApplicationCommandUseCases { ); } + /// Schedule an existing backlog item into a selected suggested slot. + Future> + 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( + 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. Future> pushFlexibleToNextAvailableSlot({ diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/suggested_slot_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/suggested_slot_read_model.dart index 0d02565..0b98997 100644 --- a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/suggested_slot_read_model.dart +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/suggested_slot_read_model.dart @@ -8,6 +8,8 @@ class SuggestedSlotReadModel { /// Creates a suggested slot preview. const SuggestedSlotReadModel({ required this.id, + required this.startUtc, + required this.endUtc, required this.localDateLabel, required this.startText, required this.endText, @@ -20,6 +22,12 @@ class SuggestedSlotReadModel { /// Stable suggestion 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. final String localDateLabel; diff --git a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart index ad567b9..6891ca8 100644 --- a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart @@ -1535,6 +1535,8 @@ SuggestedSlotReadModel _suggestedSlotReadModel({ }) { return SuggestedSlotReadModel( id: id, + startUtc: interval.start.toUtc(), + endUtc: interval.end.toUtc(), localDateLabel: localDateLabel, startText: _clockText(interval.start), endText: _clockText(interval.end), diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart index 12628cc..1e293cd 100644 --- a/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart @@ -249,6 +249,7 @@ abstract final class PersistenceEnumMapping { SchedulingIssueCode.missingDuration => 'missing_duration', SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot', SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration', + SchedulingIssueCode.durationMismatch => 'duration_mismatch', SchedulingIssueCode.noAvailableSlot => 'no_available_slot', SchedulingIssueCode.unfinishedTasksCouldNotFit => 'unfinished_tasks_could_not_fit', diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart index 64794ec..e43e5a6 100644 --- a/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart @@ -147,6 +147,7 @@ const _schedulingIssueCodeByCode = { 'missing_duration': SchedulingIssueCode.missingDuration, 'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot, 'non_positive_duration': SchedulingIssueCode.nonPositiveDuration, + 'duration_mismatch': SchedulingIssueCode.durationMismatch, 'no_available_slot': SchedulingIssueCode.noAvailableSlot, 'unfinished_tasks_could_not_fit': SchedulingIssueCode.unfinishedTasksCouldNotFit, diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart index 021c4d6..aafcff9 100644 --- a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart @@ -25,6 +25,10 @@ enum SchedulingIssueCode { /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. 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. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. noAvailableSlot, diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart index fa3b4e8..ef14bec 100644 --- a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart @@ -11,6 +11,9 @@ enum SchedulingOperationCode { /// Insert a backlog task into the next available slot. insertBacklogTaskIntoNextAvailableSlot, + /// Insert a backlog task into an explicitly requested open slot. + insertBacklogTaskAtRequestedSlot, + /// Push a flexible task later in the current window. pushFlexibleTaskToNextAvailableSlot, diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart index a420756..b643cad 100644 --- a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart @@ -11,6 +11,7 @@ part of '../../scheduling_engine.dart'; /// /// Current V1 responsibilities: /// - insert backlog tasks into the earliest available flexible slot; +/// - insert backlog tasks into an explicitly selected open slot; /// - push flexible tasks later today; /// - move flexible tasks to tomorrow's queue; /// - 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. /// /// The selected task moves after its current slot. Planned flexible tasks @@ -803,6 +961,88 @@ _BacklogInsertionPlan? _planBacklogInsertion({ 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 = [ + ...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 = { + 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. /// /// Items before the pushed task's current end are fixed. The pushed task starts @@ -996,6 +1236,8 @@ SchedulingResult _applyPlacement({ required Task insertedTask, required _BacklogInsertionPlan placement, required DateTime updatedAt, + SchedulingOperationCode operationCode = + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, }) { final changes = []; final notices = []; @@ -1028,8 +1270,7 @@ SchedulingResult _applyPlacement({ final updatedTask = _applySchedulingActivity( originalTask: task, updatedTask: placedTask, - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + operationCode: operationCode, activityCode: isInsertedTask ? TaskActivityCode.restoredFromBacklog : TaskActivityCode.automaticallyPushed, @@ -1075,8 +1316,7 @@ SchedulingResult _applyPlacement({ 'noticeCount=${notices.length}'); return SchedulingResult( tasks: List.unmodifiable(updatedTasks), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + operationCode: operationCode, outcomeCode: SchedulingOutcomeCode.success, notices: List.unmodifiable(notices), changes: List.unmodifiable(changes), diff --git a/packages/scheduler_core/test/application_commands_test.dart b/packages/scheduler_core/test/application_commands_test.dart index fce380b..95cd4e6 100644 --- a/packages/scheduler_core/test/application_commands_test.dart +++ b/packages/scheduler_core/test/application_commands_test.dart @@ -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 { final backlog = Task.quickCapture( id: 'backlog', diff --git a/packages/scheduler_core/test/application_management_test.dart b/packages/scheduler_core/test/application_management_test.dart index 4b1642c..427fa61 100644 --- a/packages/scheduler_core/test/application_management_test.dart +++ b/packages/scheduler_core/test/application_management_test.dart @@ -197,9 +197,11 @@ void main() { addedLabel: 'Added today', tags: const ['finance'], projectOptions: result.projectOptions, - suggestedSlots: const [ + suggestedSlots: [ SuggestedSlotReadModel( id: 'today-1', + startUtc: DateTime.utc(2026, 6, 25, 14), + endUtc: DateTime.utc(2026, 6, 25, 15), localDateLabel: 'Today', startText: '2:00 PM', endText: '3:00 PM', @@ -655,6 +657,14 @@ void main() { expect(detail.suggestedSlots.first.startText, '10:00 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.suggestedSlotUnavailableReason, isNull); expect(store.currentTasks, beforeTasks); diff --git a/packages/scheduler_core/test/scheduling_engine_test.dart b/packages/scheduler_core/test/scheduling_engine_test.dart index 2ca2ae0..99e040f 100644 --- a/packages/scheduler_core/test/scheduling_engine_test.dart +++ b/packages/scheduler_core/test/scheduling_engine_test.dart @@ -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', () { final backlogTask = Task.quickCapture( id: 'backlog-1',