focus-flow/lib/src/task_actions.dart

691 lines
21 KiB
Dart

// Flexible task card actions.
//
// The main scheduling engine moves tasks through time. This file models the
// small user actions that appear on a flexible task card, such as done, push,
// backlog, and break-up. Keeping these in a service makes UI button handlers
// thin and keeps task-type safety checks in one place.
import 'models.dart';
import 'scheduling_engine.dart';
/// Quick actions available from a flexible task card.
///
/// These are the low-friction card controls the UI can expose directly on a
/// planned flexible task. The service below translates each button into either a
/// direct task update, a scheduling operation, or a follow-up flow.
enum FlexibleTaskQuickAction {
/// Mark the task completed.
done,
/// Ask the user where the task should be pushed.
push,
/// Move the task out of today's plan and into backlog.
backlog,
/// Start a flow that splits the task into child tasks.
breakUp,
}
/// State transitions available from required visible task cards.
///
/// Required visible tasks are critical or inflexible items. They are not moved by
/// flexible scheduling, but the user still needs simple lifecycle actions for
/// what happened to the commitment.
enum RequiredTaskAction {
/// Mark the required task completed.
done,
/// Mark that the required task did not happen.
missed,
/// Intentionally remove it from the active plan.
cancel,
/// Dismiss it because it no longer applies, distinct from cancellation.
noLongerRelevant,
}
/// Explicit push destinations shown after choosing the push quick action.
///
/// Push starts as a simple quick action, but the actual destination requires one
/// more choice. Keeping destinations as a separate enum prevents the initial card
/// action list from becoming too crowded.
enum PushDestination {
/// Move the task later within the current planning window.
nextAvailableSlot,
/// Move the task to the beginning of the supplied tomorrow/future window.
tomorrowTopOfQueue,
/// Remove the task from the active timeline and store it for later.
backlog,
}
/// Domain result for a flexible task quick action.
///
/// This result deliberately supports three outcomes: the task changed, the user
/// must choose a push destination, or the UI should start the child-task flow.
/// That keeps card code from guessing how to interpret each action.
class FlexibleTaskActionResult {
const FlexibleTaskActionResult({
required this.action,
required this.task,
this.pushDestinations = const <PushDestination>[],
this.startsChildTaskFlow = false,
});
/// Action the user selected.
final FlexibleTaskQuickAction action;
/// Current or updated task, depending on the action.
final Task task;
/// Destination choices to show after `push`; empty for direct actions.
final List<PushDestination> pushDestinations;
/// Whether the UI should open a child-task creation flow.
final bool startsChildTaskFlow;
/// True when the action directly produced an updated [task].
bool get changedTask => !startsChildTaskFlow && pushDestinations.isEmpty;
}
/// Result from applying a selected push destination.
///
/// The selected destination is included next to the [SchedulingResult] so UI and
/// tests can distinguish "moved later today" from "moved to tomorrow" even if
/// the low-level scheduling change shape is similar.
class PushDestinationResult {
const PushDestinationResult({
required this.destination,
required this.schedulingResult,
});
/// Destination that was applied.
final PushDestination destination;
/// Full scheduler output: updated tasks, notices, changes, and overlaps.
final SchedulingResult schedulingResult;
/// Convenience flag for UI copy or persistence behavior that cares about the
/// tomorrow queue specifically.
bool get placesAtTomorrowTopOfQueue {
return destination == PushDestination.tomorrowTopOfQueue;
}
}
/// Result from applying a required task state transition.
class RequiredTaskActionResult {
const RequiredTaskActionResult({
required this.action,
required this.task,
required this.removedFromActivePlan,
});
/// Action the user selected.
final RequiredTaskAction action;
/// Updated task after the transition.
final Task task;
/// Whether the transition removes the task from the active timeline.
///
/// Critical missed tasks return true because they move to backlog. Cancelled
/// and no-longer-relevant tasks also return true because they are no longer
/// active planned work.
final bool removedFromActivePlan;
}
/// Input for logging work the user already did outside the plan.
///
/// The user may know only a title, or may also know when it happened and how
/// long it took. When time data is present, the resulting surprise task occupies
/// that interval and flexible work overlapping it is pushed out of the way.
class SurpriseTaskLogRequest {
const SurpriseTaskLogRequest({
required this.id,
required this.title,
required this.createdAt,
this.startedAt,
this.timeUsedMinutes,
this.projectId = 'inbox',
this.priority,
this.reward = RewardLevel.notSet,
this.difficulty = DifficultyLevel.notSet,
});
/// Caller-generated id for the new surprise task.
final String id;
/// User-entered title.
final String title;
/// Creation/log timestamp supplied by the caller for testability.
final DateTime createdAt;
/// When the surprise work started, if known.
final DateTime? startedAt;
/// Minutes spent on the surprise work, if known.
final int? timeUsedMinutes;
/// Project id to assign; defaults to inbox when omitted.
final String projectId;
/// Optional priority. Null means the user did not set one.
final PriorityLevel? priority;
/// Optional reward estimate.
final RewardLevel reward;
/// Optional difficulty estimate.
final DifficultyLevel difficulty;
}
/// Result from logging a surprise task.
class SurpriseTaskLogResult {
const SurpriseTaskLogResult({
required this.surpriseTask,
required this.schedulingResult,
this.requiredOverlaps = const <SchedulingOverlap>[],
this.lockedOverlaps = const <TimeInterval>[],
this.usedNormalPushRules = false,
});
/// Newly created completed surprise task.
final Task surpriseTask;
/// Updated task list, movement notices, changes, and visible overlaps.
final SchedulingResult schedulingResult;
/// Critical/inflexible overlaps with the surprise interval.
final List<SchedulingOverlap> requiredOverlaps;
/// Locked overlaps tracked separately so they stay hidden by default.
final List<TimeInterval> lockedOverlaps;
/// Whether flexible movement was delegated to normal push behavior.
final bool usedNormalPushRules;
}
/// Applies low-friction quick actions for flexible task cards.
///
/// This service is the adapter between small UI button presses and domain logic.
/// It intentionally only accepts flexible tasks; required/locked/surprise items
/// should have their own action rules so the UI cannot accidentally apply a
/// flexible-only behavior to a fixed commitment.
class FlexibleTaskActionService {
const FlexibleTaskActionService({
this.schedulingEngine = const SchedulingEngine(),
});
/// Scheduling dependency used for actions that need timeline changes.
final SchedulingEngine schedulingEngine;
/// Apply the first-stage quick action.
///
/// Direct actions (`done`, `backlog`) return a changed task. `push` returns the
/// list of destinations the UI should present. `breakUp` signals that the UI
/// should start a child-task flow rather than changing the task immediately.
FlexibleTaskActionResult apply({
required Task task,
required FlexibleTaskQuickAction action,
DateTime? updatedAt,
}) {
if (!task.isFlexible) {
throw ArgumentError.value(
task.type, 'task.type', 'Task must be flexible.');
}
switch (action) {
case FlexibleTaskQuickAction.done:
return FlexibleTaskActionResult(
action: action,
task: task.copyWith(
status: TaskStatus.completed,
updatedAt: updatedAt ?? DateTime.now(),
),
);
case FlexibleTaskQuickAction.push:
return FlexibleTaskActionResult(
action: action,
task: task,
pushDestinations: const [
PushDestination.nextAvailableSlot,
PushDestination.tomorrowTopOfQueue,
PushDestination.backlog,
],
);
case FlexibleTaskQuickAction.backlog:
return FlexibleTaskActionResult(
action: action,
task: schedulingEngine.moveToBacklog(task, updatedAt: updatedAt),
);
case FlexibleTaskQuickAction.breakUp:
return FlexibleTaskActionResult(
action: action,
task: task,
startsChildTaskFlow: true,
);
}
}
/// Apply the second-stage destination selected after the `push` action.
///
/// This needs the full [SchedulingInput] because pushing can shift other
/// flexible tasks and must avoid locked/required intervals.
PushDestinationResult applyPushDestination({
required PushDestination destination,
required SchedulingInput input,
required String taskId,
DateTime? updatedAt,
}) {
final result = switch (destination) {
PushDestination.nextAvailableSlot =>
schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
input: input,
taskId: taskId,
updatedAt: updatedAt,
),
PushDestination.tomorrowTopOfQueue =>
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
input: input,
taskId: taskId,
updatedAt: updatedAt,
),
PushDestination.backlog => _moveTaskToBacklog(
input: input,
taskId: taskId,
updatedAt: updatedAt,
),
};
return PushDestinationResult(
destination: destination,
schedulingResult: result,
);
}
/// Move one planned flexible task to backlog inside a scheduling result.
///
/// This mirrors the shape of other push destination results so callers can
/// handle every destination through the same `SchedulingResult` interface.
SchedulingResult _moveTaskToBacklog({
required SchedulingInput input,
required String taskId,
DateTime? updatedAt,
}) {
final task = _taskById(input.tasks, taskId);
if (task == null) {
return SchedulingResult(
tasks: input.tasks,
notices: [
SchedulingNotice(
'Task was not found.',
type: SchedulingNoticeType.noFit,
taskId: taskId,
),
],
);
}
if (!task.isFlexible || task.status != TaskStatus.planned) {
return SchedulingResult(
tasks: input.tasks,
notices: [
SchedulingNotice(
'Only planned flexible tasks can be moved to backlog.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
),
],
);
}
final movedTask = schedulingEngine.moveToBacklog(
task,
updatedAt: updatedAt,
);
return SchedulingResult(
tasks: List<Task>.unmodifiable(
input.tasks.map((current) {
if (current.id == task.id) {
return movedTask;
}
return current;
}),
),
notices: [
SchedulingNotice(
'Flexible task moved to backlog.',
type: SchedulingNoticeType.moved,
taskId: task.id,
),
],
changes: [
SchedulingChange(
taskId: task.id,
previousStart: task.scheduledStart,
previousEnd: task.scheduledEnd,
nextStart: null,
nextEnd: null,
),
],
);
}
}
/// Applies lifecycle actions for required visible task cards.
///
/// This service only handles critical and inflexible tasks. Locked blocks are
/// scheduling constraints, not normal task cards, and flexible tasks use
/// [FlexibleTaskActionService].
class RequiredTaskActionService {
const RequiredTaskActionService({
this.schedulingEngine = const SchedulingEngine(),
});
/// Scheduling dependency used for required missed behavior.
final SchedulingEngine schedulingEngine;
/// Apply one required-card state transition.
RequiredTaskActionResult apply({
required Task task,
required RequiredTaskAction action,
DateTime? updatedAt,
}) {
if (!task.isRequiredVisible) {
throw ArgumentError.value(
task.type,
'task.type',
'Task must be critical or inflexible.',
);
}
final now = updatedAt ?? DateTime.now();
return switch (action) {
RequiredTaskAction.done => RequiredTaskActionResult(
action: action,
task: task.copyWith(
status: TaskStatus.completed,
updatedAt: now,
),
removedFromActivePlan: false,
),
RequiredTaskAction.missed => _markMissed(
task: task,
action: action,
updatedAt: now,
),
RequiredTaskAction.cancel => RequiredTaskActionResult(
action: action,
task: task.copyWith(
status: TaskStatus.cancelled,
updatedAt: now,
stats: task.stats.incrementCancelled(),
clearSchedule: true,
),
removedFromActivePlan: true,
),
RequiredTaskAction.noLongerRelevant => RequiredTaskActionResult(
action: action,
task: task.copyWith(
status: TaskStatus.noLongerRelevant,
updatedAt: now,
clearSchedule: true,
),
removedFromActivePlan: true,
),
};
}
RequiredTaskActionResult _markMissed({
required Task task,
required RequiredTaskAction action,
required DateTime updatedAt,
}) {
final missed = schedulingEngine.markMissed(task, updatedAt: updatedAt);
return RequiredTaskActionResult(
action: action,
task: missed,
removedFromActivePlan: missed.status == TaskStatus.backlog,
);
}
}
/// Logs surprise completed work and repairs affected flexible tasks.
///
/// Surprise work is already done, so the new task is created as completed. When
/// it has a concrete interval, overlapping planned flexible tasks are moved by
/// the existing push scheduler with the surprise interval treated as blocked
/// time. Required visible and locked time is never moved.
class SurpriseTaskLogService {
const SurpriseTaskLogService({
this.schedulingEngine = const SchedulingEngine(),
});
/// Scheduling dependency used to move overlapping flexible tasks.
final SchedulingEngine schedulingEngine;
/// Log one surprise task against a scheduling snapshot.
SurpriseTaskLogResult log({
required SurpriseTaskLogRequest request,
required SchedulingInput input,
DateTime? updatedAt,
}) {
final now = updatedAt ?? DateTime.now();
final surpriseTask = _createSurpriseTask(request, updatedAt: now);
final surpriseInterval = _scheduledIntervalForTask(surpriseTask);
var currentTasks = <Task>[surpriseTask, ...input.tasks];
if (surpriseInterval == null) {
return SurpriseTaskLogResult(
surpriseTask: surpriseTask,
schedulingResult: SchedulingResult(
tasks: List<Task>.unmodifiable(currentTasks),
notices: const [
SchedulingNotice('Surprise task logged.'),
],
),
);
}
final requiredOverlaps = _requiredOverlaps(
tasks: input.requiredVisibleTasks,
surpriseInterval: surpriseInterval,
);
final lockedOverlaps = _lockedOverlaps(
input: input,
surpriseInterval: surpriseInterval,
);
final flexibleTaskIds = _overlappingFlexibleTaskIds(
input.flexibleTasks,
surpriseInterval,
);
final changes = <SchedulingChange>[];
final movementNotices = <SchedulingNotice>[];
var usedNormalPushRules = false;
for (final taskId in flexibleTaskIds) {
final currentTask = _taskById(currentTasks, taskId);
final currentInterval =
currentTask == null ? null : _scheduledIntervalForTask(currentTask);
if (currentTask == null ||
currentInterval == null ||
!currentInterval.overlaps(surpriseInterval)) {
continue;
}
final pushResult = schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
input: SchedulingInput(
tasks: currentTasks,
window: input.window,
lockedIntervals: [
...input.lockedIntervals,
surpriseInterval,
],
requiredVisibleIntervals: input.requiredVisibleIntervals,
),
taskId: taskId,
updatedAt: now,
countAsManualPush: false,
);
usedNormalPushRules = true;
currentTasks = pushResult.tasks;
changes.addAll(pushResult.changes);
movementNotices.addAll(pushResult.notices);
}
final overlapNotices = requiredOverlaps
.map(
(overlap) => SchedulingNotice(
'Surprise task overlaps required visible time.',
type: SchedulingNoticeType.overlap,
taskId: overlap.taskId,
),
)
.toList(growable: false);
return SurpriseTaskLogResult(
surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
schedulingResult: SchedulingResult(
tasks: List<Task>.unmodifiable(currentTasks),
notices: List<SchedulingNotice>.unmodifiable([
const SchedulingNotice('Surprise task logged.'),
...movementNotices,
...overlapNotices,
]),
changes: List<SchedulingChange>.unmodifiable(changes),
overlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
),
requiredOverlaps: List<SchedulingOverlap>.unmodifiable(requiredOverlaps),
lockedOverlaps: List<TimeInterval>.unmodifiable(lockedOverlaps),
usedNormalPushRules: usedNormalPushRules,
);
}
Task _createSurpriseTask(
SurpriseTaskLogRequest request, {
required DateTime updatedAt,
}) {
final trimmedTitle = request.title.trim();
if (trimmedTitle.isEmpty) {
throw ArgumentError.value(request.title, 'title', 'Title is required.');
}
final duration = request.timeUsedMinutes;
final start = request.startedAt;
final hasScheduledTime = start != null && duration != null && duration > 0;
return Task(
id: request.id,
title: trimmedTitle,
projectId: request.projectId,
type: TaskType.surprise,
status: TaskStatus.completed,
priority: request.priority,
reward: request.reward,
difficulty: request.difficulty,
durationMinutes: duration != null && duration > 0 ? duration : null,
scheduledStart: hasScheduledTime ? start : null,
scheduledEnd:
hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
createdAt: request.createdAt,
updatedAt: updatedAt,
);
}
}
/// Find one task by id in a list.
Task? _taskById(List<Task> tasks, String taskId) {
for (final task in tasks) {
if (task.id == taskId) {
return task;
}
}
return null;
}
/// Build an interval for a task, or null when it has no usable placement.
TimeInterval? _scheduledIntervalForTask(Task task) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null || !start.isBefore(end)) {
return null;
}
return TimeInterval(start: start, end: end, label: task.id);
}
/// Required visible overlaps with a surprise interval.
List<SchedulingOverlap> _requiredOverlaps({
required List<Task> tasks,
required TimeInterval surpriseInterval,
}) {
final overlaps = <SchedulingOverlap>[];
for (final task in tasks) {
final interval = _scheduledIntervalForTask(task);
if (interval == null || !interval.overlaps(surpriseInterval)) {
continue;
}
overlaps.add(
SchedulingOverlap(
taskId: task.id,
taskInterval: interval,
blockedInterval: surpriseInterval,
),
);
}
return overlaps;
}
/// Locked overlaps with a surprise interval, kept hidden from normal notices.
List<TimeInterval> _lockedOverlaps({
required SchedulingInput input,
required TimeInterval surpriseInterval,
}) {
final lockedIntervals = <TimeInterval>[
...input.lockedIntervals,
...input.lockedTasks
.map(_scheduledIntervalForTask)
.whereType<TimeInterval>(),
];
return lockedIntervals
.where((interval) => interval.overlaps(surpriseInterval))
.toList(growable: false);
}
/// Planned flexible task ids overlapping a surprise interval in timeline order.
List<String> _overlappingFlexibleTaskIds(
List<Task> tasks,
TimeInterval surpriseInterval,
) {
final overlappingTasks = tasks.where((task) {
if (task.status != TaskStatus.planned) {
return false;
}
final interval = _scheduledIntervalForTask(task);
return interval != null && interval.overlaps(surpriseInterval);
}).toList(growable: false)
..sort((a, b) {
final aStart = a.scheduledStart ?? surpriseInterval.end;
final bStart = b.scheduledStart ?? surpriseInterval.end;
return aStart.compareTo(bStart);
});
return overlappingTasks.map((task) => task.id).toList(growable: false);
}