focus-flow/lib/src/task_actions.dart

996 lines
32 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 'occupancy_policy.dart';
import 'scheduling_engine.dart';
import 'task_lifecycle.dart';
import 'time_contracts.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 {
FlexibleTaskActionResult({
required this.action,
required this.task,
this.pushDestinations = const <PushDestination>[],
this.startsChildTaskFlow = false,
List<TaskActivity> activities = const <TaskActivity>[],
this.transitionOutcomeCode,
}) : activities = List<TaskActivity>.unmodifiable(activities);
/// 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;
/// Internal activities produced by direct lifecycle actions.
final List<TaskActivity> activities;
/// Typed transition outcome for direct lifecycle actions.
final TaskTransitionOutcomeCode? transitionOutcomeCode;
/// 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 {
PushDestinationResult({
required this.destination,
required this.schedulingResult,
List<TaskActivity> activities = const <TaskActivity>[],
}) : activities = List<TaskActivity>.unmodifiable(activities);
/// Destination that was applied.
final PushDestination destination;
/// Full scheduler output: updated tasks, notices, changes, and overlaps.
final SchedulingResult schedulingResult;
/// Internal activities produced by applying the selected destination.
final List<TaskActivity> activities;
/// 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 {
RequiredTaskActionResult({
required this.action,
required this.task,
required this.removedFromActivePlan,
List<TaskActivity> activities = const <TaskActivity>[],
this.transitionOutcomeCode,
}) : activities = List<TaskActivity>.unmodifiable(activities);
/// 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;
/// Internal activities produced by the transition.
final List<TaskActivity> activities;
/// Typed transition outcome.
final TaskTransitionOutcomeCode? transitionOutcomeCode;
}
/// 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.
///
/// This id is also the idempotency key for retrying the same log operation.
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(),
this.transitionService = const TaskTransitionService(),
this.clock = const SystemClock(),
});
/// Scheduling dependency used for actions that need timeline changes.
final SchedulingEngine schedulingEngine;
/// Canonical lifecycle transition dependency.
final TaskTransitionService transitionService;
/// Clock boundary used when an action timestamp is not supplied.
final Clock clock;
/// 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,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
}) {
if (!task.isFlexible) {
throw ArgumentError.value(
task.type, 'task.type', 'Task must be flexible.');
}
switch (action) {
case FlexibleTaskQuickAction.done:
final now = updatedAt ?? clock.now();
final opId =
operationId ?? _defaultOperationId(action.name, task.id, now);
final transition = transitionService.apply(
task: task,
transitionCode: TaskTransitionCode.complete,
operationId: opId,
activityId: _activityId(
opId,
task.id,
TaskActivityCode.completed,
),
occurredAt: now,
existingActivities: existingActivities,
);
return FlexibleTaskActionResult(
action: action,
task: transition.task,
activities: transition.activities,
transitionOutcomeCode: transition.outcomeCode,
);
case FlexibleTaskQuickAction.push:
return FlexibleTaskActionResult(
action: action,
task: task,
pushDestinations: const [
PushDestination.nextAvailableSlot,
PushDestination.tomorrowTopOfQueue,
PushDestination.backlog,
],
);
case FlexibleTaskQuickAction.backlog:
final now = updatedAt ?? clock.now();
final opId =
operationId ?? _defaultOperationId(action.name, task.id, now);
final transition = transitionService.apply(
task: task,
transitionCode: TaskTransitionCode.moveToBacklog,
operationId: opId,
activityId: _activityId(
opId,
task.id,
TaskActivityCode.movedToBacklog,
),
occurredAt: now,
existingActivities: existingActivities,
);
return FlexibleTaskActionResult(
action: action,
task: transition.task,
activities: transition.activities,
transitionOutcomeCode: transition.outcomeCode,
);
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,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
}) {
final now = updatedAt ?? clock.now();
final opId = operationId ??
_defaultOperationId('push-${destination.name}', taskId, now);
final result = switch (destination) {
PushDestination.nextAvailableSlot =>
schedulingEngine.pushFlexibleTaskToNextAvailableSlot(
input: input,
taskId: taskId,
updatedAt: now,
),
PushDestination.tomorrowTopOfQueue =>
schedulingEngine.pushFlexibleTaskToTomorrowTopOfQueue(
input: input,
taskId: taskId,
updatedAt: now,
),
PushDestination.backlog => _moveTaskToBacklog(
input: input,
taskId: taskId,
updatedAt: now,
),
};
return PushDestinationResult(
destination: destination,
schedulingResult: result,
activities: _activitiesForPushDestination(
destination: destination,
input: input,
result: result,
taskId: taskId,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
),
);
}
/// 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,
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
outcomeCode: SchedulingOutcomeCode.notFound,
notices: [
SchedulingNotice(
'Task was not found.',
type: SchedulingNoticeType.noFit,
taskId: taskId,
issueCode: SchedulingIssueCode.taskNotFound,
),
],
);
}
if (!task.isFlexible || task.status != TaskStatus.planned) {
return SchedulingResult(
tasks: input.tasks,
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
outcomeCode: SchedulingOutcomeCode.invalidState,
notices: [
SchedulingNotice(
'Only planned flexible tasks can be moved to backlog.',
type: SchedulingNoticeType.noFit,
taskId: task.id,
issueCode: SchedulingIssueCode.invalidTaskState,
),
],
);
}
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;
}),
),
operationCode: SchedulingOperationCode.moveFlexibleTaskToBacklog,
outcomeCode: SchedulingOutcomeCode.success,
notices: [
SchedulingNotice(
'Flexible task moved to backlog.',
type: SchedulingNoticeType.moved,
taskId: task.id,
movementCode: SchedulingMovementCode.flexibleTaskMovedToBacklog,
parameters: {
'previousStart': task.scheduledStart,
'previousEnd': task.scheduledEnd,
'nextStart': null,
'nextEnd': null,
},
),
],
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(),
this.transitionService = const TaskTransitionService(),
this.clock = const SystemClock(),
});
/// Scheduling dependency used for required missed behavior.
final SchedulingEngine schedulingEngine;
/// Canonical lifecycle transition dependency.
final TaskTransitionService transitionService;
/// Clock boundary used when an action timestamp is not supplied.
final Clock clock;
/// Apply one required-card state transition.
RequiredTaskActionResult apply({
required Task task,
required RequiredTaskAction action,
DateTime? updatedAt,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
}) {
if (!task.isRequiredVisible) {
throw ArgumentError.value(
task.type,
'task.type',
'Task must be critical or inflexible.',
);
}
final now = updatedAt ?? clock.now();
final transitionCode = switch (action) {
RequiredTaskAction.done => TaskTransitionCode.complete,
RequiredTaskAction.missed => TaskTransitionCode.miss,
RequiredTaskAction.cancel => TaskTransitionCode.cancel,
RequiredTaskAction.noLongerRelevant =>
TaskTransitionCode.noLongerRelevant,
};
final activityCode = switch (action) {
RequiredTaskAction.done => TaskActivityCode.completed,
RequiredTaskAction.missed => TaskActivityCode.missed,
RequiredTaskAction.cancel => TaskActivityCode.cancelled,
RequiredTaskAction.noLongerRelevant => TaskActivityCode.noLongerRelevant,
};
final opId = operationId ?? _defaultOperationId(action.name, task.id, now);
final transition = transitionService.apply(
task: task,
transitionCode: transitionCode,
operationId: opId,
activityId: _activityId(opId, task.id, activityCode),
occurredAt: now,
existingActivities: existingActivities,
);
return RequiredTaskActionResult(
action: action,
task: transition.task,
removedFromActivePlan: transition.task.status == TaskStatus.backlog ||
transition.task.status == TaskStatus.cancelled ||
transition.task.status == TaskStatus.noLongerRelevant,
activities: transition.activities,
transitionOutcomeCode: transition.outcomeCode,
);
}
}
/// 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(),
this.clock = const SystemClock(),
this.idGenerator,
});
/// Scheduling dependency used to move overlapping flexible tasks.
final SchedulingEngine schedulingEngine;
/// Clock boundary used when a log timestamp is not supplied.
final Clock clock;
/// Optional ID boundary for outer convenience entry points.
final IdGenerator? idGenerator;
/// Log one surprise task against a scheduling snapshot.
SurpriseTaskLogResult log({
required SurpriseTaskLogRequest request,
required SchedulingInput input,
DateTime? updatedAt,
bool revealHiddenLockedDetails = false,
}) {
final now = updatedAt ?? clock.now();
final existingTask = _taskById(input.tasks, request.id);
if (existingTask != null) {
if (existingTask.type != TaskType.surprise) {
throw ArgumentError.value(
request.id,
'request.id',
'Surprise task id is already used by another task.',
);
}
return SurpriseTaskLogResult(
surpriseTask: existingTask,
schedulingResult: SchedulingResult(
tasks: input.tasks,
operationCode: SchedulingOperationCode.logSurpriseTask,
outcomeCode: SchedulingOutcomeCode.noOp,
notices: [
SchedulingNotice(
'Surprise task already logged.',
issueCode: SchedulingIssueCode.duplicateSurpriseLog,
taskId: existingTask.id,
),
],
),
);
}
final surpriseTask = _createSurpriseTask(request, updatedAt: now);
final surpriseInterval = _occupyingIntervalForTask(surpriseTask);
var currentTasks = <Task>[surpriseTask, ...input.tasks];
if (surpriseInterval == null) {
return SurpriseTaskLogResult(
surpriseTask: surpriseTask,
schedulingResult: SchedulingResult(
tasks: List<Task>.unmodifiable(currentTasks),
operationCode: SchedulingOperationCode.logSurpriseTask,
outcomeCode: SchedulingOutcomeCode.success,
notices: [
SchedulingNotice('Surprise task logged.'),
],
),
);
}
final requiredOverlaps = _requiredOverlaps(
input: input,
surpriseInterval: surpriseInterval,
);
final lockedOverlaps = _lockedOverlaps(
input: input,
surpriseInterval: surpriseInterval,
revealHiddenLockedDetails: revealHiddenLockedDetails,
);
final flexibleTaskIds = _overlappingFlexibleTaskIds(
input.movablePlannedFlexibleTasks,
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,
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,
conflictCode:
SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime,
parameters: {
'blockedStart': overlap.blockedInterval.start,
'blockedEnd': overlap.blockedInterval.end,
},
),
)
.toList(growable: false);
return SurpriseTaskLogResult(
surpriseTask: _taskById(currentTasks, surpriseTask.id) ?? surpriseTask,
schedulingResult: SchedulingResult(
tasks: List<Task>.unmodifiable(currentTasks),
operationCode: SchedulingOperationCode.logSurpriseTask,
outcomeCode: requiredOverlaps.isEmpty
? SchedulingOutcomeCode.success
: SchedulingOutcomeCode.conflict,
notices: List<SchedulingNotice>.unmodifiable([
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,
);
}
/// Convenience outer-boundary wrapper that supplies id and creation time.
SurpriseTaskLogResult logNew({
required String title,
required SchedulingInput input,
DateTime? startedAt,
int? timeUsedMinutes,
String projectId = 'inbox',
PriorityLevel? priority,
RewardLevel reward = RewardLevel.notSet,
DifficultyLevel difficulty = DifficultyLevel.notSet,
DateTime? createdAt,
DateTime? updatedAt,
bool revealHiddenLockedDetails = false,
}) {
final generator = idGenerator;
if (generator == null) {
throw StateError('Surprise task logging needs an id generator.');
}
final now = createdAt ?? updatedAt ?? clock.now();
return log(
request: SurpriseTaskLogRequest(
id: generator.nextId(),
title: title,
createdAt: now,
startedAt: startedAt,
timeUsedMinutes: timeUsedMinutes,
projectId: projectId,
priority: priority,
reward: reward,
difficulty: difficulty,
),
input: input,
updatedAt: updatedAt ?? now,
revealHiddenLockedDetails: revealHiddenLockedDetails,
);
}
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,
actualStart: hasScheduledTime ? start : null,
actualEnd:
hasScheduledTime ? start.add(Duration(minutes: duration)) : null,
completedAt: updatedAt,
createdAt: request.createdAt,
updatedAt: updatedAt,
);
}
}
List<TaskActivity> _activitiesForPushDestination({
required PushDestination destination,
required SchedulingInput input,
required SchedulingResult result,
required String taskId,
required String operationId,
required DateTime occurredAt,
required Iterable<TaskActivity> existingActivities,
}) {
if (result.outcomeCode != SchedulingOutcomeCode.success) {
return const <TaskActivity>[];
}
final activities = <TaskActivity>[];
for (final change in result.changes) {
if (_hasDuplicateActivity(
existingActivities: existingActivities,
operationId: operationId,
taskId: change.taskId,
)) {
continue;
}
final task = _taskById(input.tasks, change.taskId);
if (task == null) {
continue;
}
final code = _activityCodeForPushChange(
destination: destination,
isSelectedTask: change.taskId == taskId,
);
activities.add(
TaskActivity(
id: _activityId(operationId, change.taskId, code),
operationId: operationId,
code: code,
taskId: change.taskId,
projectId: task.projectId,
occurredAt: occurredAt,
metadata: {
'previousStatus': task.status.name,
'nextStatus': code == TaskActivityCode.movedToBacklog
? TaskStatus.backlog.name
: task.status.name,
'previousStart': change.previousStart,
'previousEnd': change.previousEnd,
'nextStart': change.nextStart,
'nextEnd': change.nextEnd,
'destination': destination.name,
},
),
);
}
return List<TaskActivity>.unmodifiable(activities);
}
TaskActivityCode _activityCodeForPushChange({
required PushDestination destination,
required bool isSelectedTask,
}) {
if (destination == PushDestination.backlog) {
return TaskActivityCode.movedToBacklog;
}
return isSelectedTask
? TaskActivityCode.manuallyPushed
: TaskActivityCode.automaticallyPushed;
}
bool _hasDuplicateActivity({
required Iterable<TaskActivity> existingActivities,
required String operationId,
required String taskId,
}) {
for (final activity in existingActivities) {
if (activity.operationId == operationId && activity.taskId == taskId) {
return true;
}
}
return false;
}
String _defaultOperationId(
String actionName, String taskId, DateTime occurredAt) {
return '$actionName:$taskId:${occurredAt.toIso8601String()}';
}
String _activityId(
String operationId,
String taskId,
TaskActivityCode activityCode,
) {
return '$operationId:$taskId:${activityCode.name}';
}
/// 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);
}
/// Build an actual-first occupancy interval for completed/active task facts.
TimeInterval? _occupyingIntervalForTask(Task task) {
final actualStart = task.actualStart;
final actualEnd = task.actualEnd;
if (actualStart != null &&
actualEnd != null &&
actualStart.isBefore(actualEnd)) {
return TimeInterval(start: actualStart, end: actualEnd, label: task.id);
}
return _scheduledIntervalForTask(task);
}
/// Required visible overlaps with a surprise interval.
List<SchedulingOverlap> _requiredOverlaps({
required SchedulingInput input,
required TimeInterval surpriseInterval,
}) {
final overlaps = <SchedulingOverlap>[];
for (final occupancy in input.occupancyEntries) {
final task = occupancy.task;
final interval = occupancy.interval;
final isRequiredOccupancy =
occupancy.category == OccupancyCategory.requiredVisibleCommitment ||
(occupancy.category == OccupancyCategory.activeWork &&
task != null &&
task.isRequiredVisible);
if (task == null ||
!isRequiredOccupancy ||
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,
required bool revealHiddenLockedDetails,
}) {
return input.occupancyEntries
.where(
(occupancy) =>
occupancy.category == OccupancyCategory.hiddenLockedConstraint,
)
.map((occupancy) => occupancy.interval)
.whereType<TimeInterval>()
.where((interval) => interval.overlaps(surpriseInterval))
.map((interval) {
if (revealHiddenLockedDetails) {
return interval;
}
return TimeInterval(start: interval.start, end: interval.end);
}).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);
}