forked from eva/focus-flow
838 lines
26 KiB
Dart
838 lines
26 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 '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 {
|
|
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.
|
|
///
|
|
/// 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.clock = const SystemClock(),
|
|
});
|
|
|
|
/// Scheduling dependency used for actions that need timeline changes.
|
|
final SchedulingEngine schedulingEngine;
|
|
|
|
/// 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,
|
|
}) {
|
|
if (!task.isFlexible) {
|
|
throw ArgumentError.value(
|
|
task.type, 'task.type', 'Task must be flexible.');
|
|
}
|
|
|
|
switch (action) {
|
|
case FlexibleTaskQuickAction.done:
|
|
final now = updatedAt ?? clock.now();
|
|
return FlexibleTaskActionResult(
|
|
action: action,
|
|
task: task.copyWith(
|
|
status: TaskStatus.completed,
|
|
updatedAt: now,
|
|
),
|
|
);
|
|
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();
|
|
return FlexibleTaskActionResult(
|
|
action: action,
|
|
task: schedulingEngine.moveToBacklog(task, updatedAt: now),
|
|
);
|
|
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 now = updatedAt ?? clock.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,
|
|
);
|
|
}
|
|
|
|
/// 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.clock = const SystemClock(),
|
|
});
|
|
|
|
/// Scheduling dependency used for required missed behavior.
|
|
final SchedulingEngine schedulingEngine;
|
|
|
|
/// 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,
|
|
}) {
|
|
if (!task.isRequiredVisible) {
|
|
throw ArgumentError.value(
|
|
task.type,
|
|
'task.type',
|
|
'Task must be critical or inflexible.',
|
|
);
|
|
}
|
|
|
|
final now = updatedAt ?? clock.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(),
|
|
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,
|
|
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);
|
|
}
|
|
|
|
/// 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);
|
|
}
|