forked from eva/focus-flow
feat(backlog): schedule suggested slots
This commit is contained in:
parent
ce997dcf2e
commit
9b99c7810e
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.
|
/// Breaks a Backlog task into direct child tasks.
|
||||||
Future<void> breakUpBacklogTask({
|
Future<void> breakUpBacklogTask({
|
||||||
required String parentTaskId,
|
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() {
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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,74 +886,85 @@ 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(
|
||||||
decoration: BoxDecoration(
|
color: Colors.transparent,
|
||||||
color: FocusFlowTokens.panelBackground,
|
child: InkWell(
|
||||||
|
key: ValueKey('backlog-drawer-suggested-slot-${slot.id}'),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
onTap: onPressed,
|
||||||
color: slot.isPrimary
|
child: DecoratedBox(
|
||||||
? FocusFlowTokens.accentMagenta
|
decoration: BoxDecoration(
|
||||||
: severity.withValues(alpha: 0.55),
|
color: FocusFlowTokens.panelBackground,
|
||||||
),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
border: Border.all(
|
||||||
child: Padding(
|
color: slot.isPrimary
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
? FocusFlowTokens.accentMagenta
|
||||||
child: Row(
|
: severity.withValues(alpha: 0.55),
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.calendar_today_outlined,
|
|
||||||
color: FocusFlowTokens.textMuted,
|
|
||||||
size: 17,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
),
|
||||||
Expanded(
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const Icon(
|
||||||
slot.localDateLabel,
|
Icons.calendar_today_outlined,
|
||||||
maxLines: 1,
|
color: FocusFlowTokens.textMuted,
|
||||||
overflow: TextOverflow.ellipsis,
|
size: 17,
|
||||||
style: TextStyle(
|
),
|
||||||
color: slot.isPrimary
|
const SizedBox(width: 10),
|
||||||
? BacklogBoardVisualTokens.doNextAccent
|
Expanded(
|
||||||
: FocusFlowTokens.textMuted,
|
child: Column(
|
||||||
fontSize: 12,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontWeight: FontWeight.w800,
|
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(
|
const SizedBox(width: 8),
|
||||||
'${slot.startText} - ${slot.endText} | ${slot.durationMinutes} min',
|
_FitBadge(label: slot.fitLabel, color: severity),
|
||||||
maxLines: 1,
|
const SizedBox(width: 4),
|
||||||
overflow: TextOverflow.ellipsis,
|
const Icon(
|
||||||
style: const TextStyle(
|
Icons.chevron_right,
|
||||||
color: FocusFlowTokens.textPrimary,
|
color: FocusFlowTokens.textMuted,
|
||||||
fontSize: 12,
|
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue