Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
17 changed files with 763 additions and 62 deletions
|
|
@ -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.
|
||||
Future<void> breakUpBacklogTask({
|
||||
required String parentTaskId,
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
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<SuggestedSlotReadModel> 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,
|
||||
|
|
|
|||
|
|
@ -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<SuggestedSlotReadModel> 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<SuggestedSlotReadModel> 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<SuggestedSlotReadModel> 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,17 +886,26 @@ 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(
|
||||
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(
|
||||
color: FocusFlowTokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
@ -937,6 +965,8 @@ class _SuggestedSlotRow extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
'schedule action explains missing duration without command write',
|
||||
(tester) async {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Future<ApplicationResult<ApplicationCommandResult>>
|
||||
pushFlexibleToNextAvailableSlot({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ const _schedulingIssueCodeByCode = <String, SchedulingIssueCode>{
|
|||
'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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = <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.
|
||||
///
|
||||
/// 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 = <SchedulingChange>[];
|
||||
final notices = <SchedulingNotice>[];
|
||||
|
|
@ -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<Task>.unmodifiable(updatedTasks),
|
||||
operationCode:
|
||||
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
||||
operationCode: operationCode,
|
||||
outcomeCode: SchedulingOutcomeCode.success,
|
||||
notices: List<SchedulingNotice>.unmodifiable(notices),
|
||||
changes: List<SchedulingChange>.unmodifiable(changes),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue