forked from eva/focus-flow
1760 lines
56 KiB
Dart
1760 lines
56 KiB
Dart
// Scheduling engine for the ADHD scheduling starter project.
|
|
//
|
|
// This file is the core timeline manipulation layer. It takes task data plus a
|
|
// planning window and returns a new task list, notices, changes, and analysis
|
|
// findings. The implementation is deliberately side-effect free so a first-time
|
|
// reader can trace each operation from input validation, to placement planning,
|
|
// to application of the plan.
|
|
//
|
|
// Human reading map:
|
|
// 1. Data wrappers: `SchedulingWindow`, `SchedulingInput`, result classes.
|
|
// 2. Public engine methods: the operations UI/actions can call.
|
|
// 3. Private planning helpers: calculate intervals without changing tasks.
|
|
// 4. Private apply helpers: convert plans into updated tasks and notices.
|
|
|
|
import 'models.dart';
|
|
import 'occupancy_policy.dart';
|
|
import 'task_lifecycle.dart';
|
|
import 'time_contracts.dart';
|
|
|
|
const OccupancyPolicy _occupancyPolicy = OccupancyPolicy();
|
|
const TaskTransitionService _transitionService = TaskTransitionService();
|
|
const TaskActivityAccountingService _activityAccountingService =
|
|
TaskActivityAccountingService();
|
|
|
|
/// Category for scheduler notices.
|
|
///
|
|
/// Notices are human-readable summaries attached to a [SchedulingResult]. They
|
|
/// are not exceptions. The scheduler returns them alongside the task list so UI
|
|
/// can explain what happened without losing the successfully computed output.
|
|
enum SchedulingNoticeType {
|
|
/// General informational notice.
|
|
info,
|
|
|
|
/// A task was moved by a scheduling operation.
|
|
moved,
|
|
|
|
/// A scheduled task overlaps blocked time.
|
|
overlap,
|
|
|
|
/// A task could not fit in the requested window.
|
|
noFit,
|
|
|
|
/// A task would need to move outside the requested window.
|
|
overflow,
|
|
}
|
|
|
|
/// Stable scheduler operation identifiers.
|
|
enum SchedulingOperationCode {
|
|
/// Operation was built outside the core scheduler or is not specified.
|
|
unspecified,
|
|
|
|
/// Insert a backlog task into the next available slot.
|
|
insertBacklogTaskIntoNextAvailableSlot,
|
|
|
|
/// Push a flexible task later in the current window.
|
|
pushFlexibleTaskToNextAvailableSlot,
|
|
|
|
/// Move a flexible task to the top of a future/tomorrow queue.
|
|
pushFlexibleTaskToTomorrowTopOfQueue,
|
|
|
|
/// Roll unfinished flexible tasks into a target window.
|
|
rollOverUnfinishedFlexibleTasks,
|
|
|
|
/// Analyze overlaps without moving tasks.
|
|
analyzeSchedule,
|
|
|
|
/// Move a flexible task to backlog through the action service.
|
|
moveFlexibleTaskToBacklog,
|
|
|
|
/// Log completed surprise work.
|
|
logSurpriseTask,
|
|
|
|
/// Explicitly schedule a critical or inflexible commitment.
|
|
scheduleRequiredCommitment,
|
|
}
|
|
|
|
/// Stable high-level outcome categories for scheduling operations.
|
|
enum SchedulingOutcomeCode {
|
|
/// Operation completed without conflicts.
|
|
success,
|
|
|
|
/// Operation intentionally made no changes.
|
|
noOp,
|
|
|
|
/// Requested record was not found.
|
|
notFound,
|
|
|
|
/// Requested operation does not apply to the current state.
|
|
invalidState,
|
|
|
|
/// No placement slot exists for the requested item.
|
|
noSlot,
|
|
|
|
/// The requested queue would extend beyond the planning window.
|
|
overflow,
|
|
|
|
/// Operation completed or analyzed with a conflict.
|
|
conflict,
|
|
}
|
|
|
|
/// Stable issue identifiers for validation, no-slot, and no-op notices.
|
|
enum SchedulingIssueCode {
|
|
taskNotFound,
|
|
invalidTaskState,
|
|
missingDuration,
|
|
missingScheduledSlot,
|
|
nonPositiveDuration,
|
|
noAvailableSlot,
|
|
unfinishedTasksCouldNotFit,
|
|
noUnfinishedFlexibleTasks,
|
|
duplicateSurpriseLog,
|
|
}
|
|
|
|
/// Stable movement identifiers for task placement changes.
|
|
enum SchedulingMovementCode {
|
|
backlogTaskInserted,
|
|
flexibleTaskMovedToMakeRoom,
|
|
flexibleTaskPushedToNextAvailableSlot,
|
|
flexibleTaskMovedToTomorrow,
|
|
unfinishedFlexibleTasksRolledOver,
|
|
flexibleTaskMovedToBacklog,
|
|
requiredCommitmentScheduled,
|
|
}
|
|
|
|
/// Stable conflict identifiers for overlap/interrupt notices.
|
|
enum SchedulingConflictCode {
|
|
flexibleTaskOverlapsBlockedTime,
|
|
surpriseTaskOverlapsRequiredVisibleTime,
|
|
requiredCommitmentOverlapsProtectedFreeSlot,
|
|
}
|
|
|
|
/// Window of time available to a scheduling operation.
|
|
///
|
|
/// Most engine methods operate on one planning window: "today", "tomorrow",
|
|
/// or any other bounded range supplied by the caller. The window constrains where
|
|
/// flexible tasks can be placed. Anything outside this range is treated as out of
|
|
/// scope for the operation.
|
|
class SchedulingWindow {
|
|
SchedulingWindow({
|
|
required this.start,
|
|
required this.end,
|
|
}) {
|
|
if (!start.isBefore(end)) {
|
|
throw DomainValidationException(
|
|
code: DomainValidationCode.invalidSchedulingWindow,
|
|
invalidValue: {'start': start, 'end': end},
|
|
name: 'SchedulingWindow',
|
|
message: 'Scheduling window end must be after start.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Inclusive beginning of the scheduling range.
|
|
final DateTime start;
|
|
|
|
/// Exclusive ending of the scheduling range.
|
|
final DateTime end;
|
|
|
|
/// The window as a [TimeInterval], useful for overlap checks.
|
|
TimeInterval get interval => TimeInterval(start: start, end: end);
|
|
|
|
/// Whether [interval] is completely inside this window.
|
|
///
|
|
/// A task that starts before the window or ends after the window is considered
|
|
/// outside the operation. The engine may treat such tasks as fixed blocks
|
|
/// instead of moving them.
|
|
bool contains(TimeInterval interval) {
|
|
final startsInWindow =
|
|
interval.start.isAfter(start) || interval.start.isAtSameMomentAs(start);
|
|
final endsInWindow =
|
|
interval.end.isBefore(end) || interval.end.isAtSameMomentAs(end);
|
|
|
|
return startsInWindow && endsInWindow;
|
|
}
|
|
}
|
|
|
|
/// In-memory input for scheduling operations.
|
|
///
|
|
/// This is the complete snapshot the pure scheduling engine needs. It contains
|
|
/// tasks plus the fixed intervals the scheduler must avoid. It deliberately does
|
|
/// not know where the data came from: UI state, a database, tests, or generated
|
|
/// examples can all build this same object.
|
|
class SchedulingInput {
|
|
SchedulingInput({
|
|
required List<Task> tasks,
|
|
required this.window,
|
|
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
|
|
List<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
|
|
}) : tasks = List<Task>.unmodifiable(tasks),
|
|
lockedIntervals = List<TimeInterval>.unmodifiable(lockedIntervals),
|
|
requiredVisibleIntervals =
|
|
List<TimeInterval>.unmodifiable(requiredVisibleIntervals);
|
|
|
|
/// All tasks available to this operation. The scheduler returns a replacement
|
|
/// list rather than mutating this one.
|
|
final List<Task> tasks;
|
|
|
|
/// Date/time range that the operation is allowed to plan inside.
|
|
final SchedulingWindow window;
|
|
|
|
/// External locked time intervals, usually produced by `locked_time.dart`.
|
|
final List<TimeInterval> lockedIntervals;
|
|
|
|
/// Extra fixed visible intervals supplied by the caller. This lets UI/backend
|
|
/// code reserve required time even when that time is not represented as a
|
|
/// [Task] in the current list.
|
|
final List<TimeInterval> requiredVisibleIntervals;
|
|
|
|
/// Tasks that the flexible movement algorithms are allowed to consider.
|
|
List<Task> get flexibleTasks {
|
|
return tasks.where((task) => task.isFlexible).toList(growable: false);
|
|
}
|
|
|
|
/// Central occupancy classification for this scheduling snapshot.
|
|
List<OccupancyEntry> get occupancyEntries {
|
|
return _occupancyPolicy.classify(
|
|
tasks: tasks,
|
|
lockedIntervals: lockedIntervals,
|
|
requiredVisibleIntervals: requiredVisibleIntervals,
|
|
);
|
|
}
|
|
|
|
/// Planned flexible task records that automatic scheduling may move.
|
|
List<Task> get movablePlannedFlexibleTasks {
|
|
return occupancyEntries
|
|
.where((entry) => entry.isMovablePlannedFlexible)
|
|
.map((entry) => entry.task)
|
|
.whereType<Task>()
|
|
.toList(growable: false);
|
|
}
|
|
|
|
/// Locked task records in [tasks], if the caller represents locked time as
|
|
/// tasks instead of only passing [lockedIntervals].
|
|
List<Task> get lockedTasks {
|
|
return tasks.where((task) => task.isLocked).toList(growable: false);
|
|
}
|
|
|
|
/// Critical and inflexible task records that should block flexible placement.
|
|
List<Task> get requiredVisibleTasks {
|
|
return tasks
|
|
.where((task) => task.isRequiredVisible)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
/// Scheduled intervals for flexible tasks only. Useful for analysis/debugging.
|
|
List<TimeInterval> get flexibleIntervals {
|
|
return _scheduledIntervalsFor(flexibleTasks);
|
|
}
|
|
|
|
/// All intervals that flexible scheduling must avoid.
|
|
///
|
|
/// This is derived from the central occupancy policy rather than from UI card
|
|
/// categories. The result is sorted to make interval scanning deterministic.
|
|
List<TimeInterval> get blockedIntervals {
|
|
final intervals = occupancyEntries
|
|
.where((entry) => entry.blocksAutomaticFlexiblePlacement)
|
|
.map((entry) => entry.interval)
|
|
.whereType<TimeInterval>()
|
|
.toList(growable: false)
|
|
..sort((a, b) => a.start.compareTo(b.start));
|
|
|
|
return List<TimeInterval>.unmodifiable(intervals);
|
|
}
|
|
|
|
/// Occupancy entries that should be surfaced as conflicts when overlapped.
|
|
List<OccupancyEntry> get conflictReportingOccupancies {
|
|
return occupancyEntries
|
|
.where((entry) => entry.reportsConflict)
|
|
.toList(growable: false);
|
|
}
|
|
}
|
|
|
|
/// Exact placement change made by a scheduling operation.
|
|
///
|
|
/// Changes are machine-readable before/after records. UI can use notices for
|
|
/// display text, but persistence, undo, analytics, or tests should inspect these
|
|
/// fields to know exactly which task moved and where.
|
|
class SchedulingChange {
|
|
const SchedulingChange({
|
|
required this.taskId,
|
|
required this.previousStart,
|
|
required this.previousEnd,
|
|
required this.nextStart,
|
|
required this.nextEnd,
|
|
});
|
|
|
|
/// Task that moved or had its schedule cleared.
|
|
final String taskId;
|
|
|
|
/// Previous scheduled start, or null if the task was previously unplaced.
|
|
final DateTime? previousStart;
|
|
|
|
/// Previous scheduled end, or null if the task was previously unplaced.
|
|
final DateTime? previousEnd;
|
|
|
|
/// New scheduled start, or null if the task was moved out of the timeline.
|
|
final DateTime? nextStart;
|
|
|
|
/// New scheduled end, or null if the task was moved out of the timeline.
|
|
final DateTime? nextEnd;
|
|
}
|
|
|
|
/// Overlap between a scheduled task and blocked time.
|
|
///
|
|
/// Analysis uses this to report problems without moving anything. This is useful
|
|
/// when loading persisted data, debugging imports, or validating a day before the
|
|
/// UI presents it as clean.
|
|
class SchedulingOverlap {
|
|
const SchedulingOverlap({
|
|
required this.taskId,
|
|
required this.taskInterval,
|
|
required this.blockedInterval,
|
|
});
|
|
|
|
/// Flexible task that overlaps blocked time.
|
|
final String taskId;
|
|
|
|
/// The task's scheduled interval.
|
|
final TimeInterval taskInterval;
|
|
|
|
/// The blocked interval it overlaps.
|
|
final TimeInterval blockedInterval;
|
|
}
|
|
|
|
/// Starter notice type returned by scheduling operations.
|
|
///
|
|
/// A notice is presentation-friendly context about an operation. It intentionally
|
|
/// carries both text and a structured [type] so the UI can decide whether to show
|
|
/// it as neutral info, movement, overlap, or failure.
|
|
class SchedulingNotice {
|
|
SchedulingNotice(
|
|
this.message, {
|
|
this.type = SchedulingNoticeType.info,
|
|
this.taskId,
|
|
this.issueCode,
|
|
this.movementCode,
|
|
this.conflictCode,
|
|
Map<String, Object?> parameters = const <String, Object?>{},
|
|
}) : parameters = Map<String, Object?>.unmodifiable(parameters);
|
|
|
|
/// Human-readable message safe to surface in UI or logs.
|
|
final String message;
|
|
|
|
/// Structured category for UI styling and tests.
|
|
final SchedulingNoticeType type;
|
|
|
|
/// Optional task related to this notice. Null means the notice applies to the
|
|
/// whole operation.
|
|
final String? taskId;
|
|
|
|
/// Stable issue code for validation/no-slot/no-op outcomes.
|
|
final SchedulingIssueCode? issueCode;
|
|
|
|
/// Stable movement code for placement changes.
|
|
final SchedulingMovementCode? movementCode;
|
|
|
|
/// Stable conflict code for overlap outcomes.
|
|
final SchedulingConflictCode? conflictCode;
|
|
|
|
/// Structured notice parameters for callers that need more context.
|
|
final Map<String, Object?> parameters;
|
|
}
|
|
|
|
/// Starter result wrapper for scheduling operations.
|
|
///
|
|
/// Every engine operation returns a [SchedulingResult], even when nothing moved.
|
|
/// This keeps the call pattern predictable: always inspect `tasks`, then surface
|
|
/// any `notices`, `changes`, or `overlaps` relevant to the UI.
|
|
class SchedulingResult {
|
|
SchedulingResult({
|
|
required List<Task> tasks,
|
|
this.operationCode = SchedulingOperationCode.unspecified,
|
|
this.outcomeCode = SchedulingOutcomeCode.success,
|
|
List<SchedulingNotice> notices = const <SchedulingNotice>[],
|
|
List<SchedulingChange> changes = const <SchedulingChange>[],
|
|
List<SchedulingOverlap> overlaps = const <SchedulingOverlap>[],
|
|
}) : tasks = List<Task>.unmodifiable(tasks),
|
|
assert(
|
|
_noticeCodesAreSinglePurpose(notices),
|
|
'Each scheduling notice may have only one issue/movement/conflict code.',
|
|
),
|
|
notices = List<SchedulingNotice>.unmodifiable(notices),
|
|
changes = List<SchedulingChange>.unmodifiable(changes),
|
|
overlaps = List<SchedulingOverlap>.unmodifiable(overlaps);
|
|
|
|
/// Replacement task list after the operation.
|
|
final List<Task> tasks;
|
|
|
|
/// Stable operation identifier.
|
|
final SchedulingOperationCode operationCode;
|
|
|
|
/// Stable high-level outcome category.
|
|
final SchedulingOutcomeCode outcomeCode;
|
|
|
|
/// Human-readable operation messages.
|
|
final List<SchedulingNotice> notices;
|
|
|
|
/// Machine-readable movements or schedule clears.
|
|
final List<SchedulingChange> changes;
|
|
|
|
/// Analysis-only overlap findings.
|
|
final List<SchedulingOverlap> overlaps;
|
|
}
|
|
|
|
bool _noticeCodesAreSinglePurpose(Iterable<SchedulingNotice> notices) {
|
|
for (final notice in notices) {
|
|
final codeCount = [
|
|
notice.issueCode,
|
|
notice.movementCode,
|
|
notice.conflictCode,
|
|
].where((code) => code != null).length;
|
|
if (codeCount > 1) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// Starter scheduling engine.
|
|
///
|
|
/// The engine is a pure domain service: it receives immutable-ish input values
|
|
/// and returns new values. It does not persist data, render UI, send reminders,
|
|
/// or read the clock except where an optional [updatedAt] timestamp is omitted.
|
|
///
|
|
/// Current V1 responsibilities:
|
|
/// - insert backlog tasks into the earliest available flexible slot;
|
|
/// - push flexible tasks later today;
|
|
/// - move flexible tasks to tomorrow's queue;
|
|
/// - roll unfinished flexible tasks into a new planning window;
|
|
/// - analyze overlaps against locked/required time;
|
|
/// - perform small state transitions such as missed/backlog handling.
|
|
///
|
|
/// Important rule vocabulary:
|
|
/// - `fixedBlocks` are intervals the engine will not move.
|
|
/// - `queue` is the ordered set of flexible tasks that may be placed or shifted.
|
|
/// - `placement` is a map from task id to the interval chosen by the planner.
|
|
class SchedulingEngine {
|
|
const SchedulingEngine({
|
|
this.clock = const SystemClock(),
|
|
});
|
|
|
|
/// Clock boundary used when an operation timestamp is not supplied.
|
|
final Clock clock;
|
|
|
|
/// Insert a backlog task into the earliest available slot today.
|
|
///
|
|
/// Locked, inflexible, and critical time is treated as fixed. Planned
|
|
/// flexible tasks at or after the insertion point may shift later, preserving
|
|
/// their relative order.
|
|
///
|
|
/// The selected task must already exist in [input.tasks], be flexible, be in
|
|
/// backlog status, and have a positive duration. If any precondition fails, the
|
|
/// original task list is returned with a no-fit/overflow notice.
|
|
SchedulingResult insertBacklogTaskIntoNextAvailableSlot({
|
|
required SchedulingInput input,
|
|
required String taskId,
|
|
DateTime? updatedAt,
|
|
}) {
|
|
// Step 1: resolve and validate the task. The engine does not create tasks;
|
|
// quick capture or persistence code is responsible for adding it to the list.
|
|
final task = _taskById(input.tasks, taskId);
|
|
if (task == null) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Task was not found.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: taskId,
|
|
issueCode: SchedulingIssueCode.taskNotFound,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.notFound,
|
|
);
|
|
}
|
|
|
|
if (!task.isFlexible || !task.isBacklog) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Only backlog flexible tasks can be inserted.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.invalidTaskState,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.invalidState,
|
|
);
|
|
}
|
|
|
|
final taskDuration = _durationFromMinutes(task.durationMinutes);
|
|
if (taskDuration == null) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Task needs a positive duration before scheduling.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.missingDuration,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.invalidState,
|
|
);
|
|
}
|
|
|
|
// Step 2: compute placements without mutating any task. Planning returns null
|
|
// if the inserted task and shifted queue cannot fit inside the window.
|
|
final placement = _planBacklogInsertion(
|
|
input: input,
|
|
task: task,
|
|
taskDuration: taskDuration,
|
|
);
|
|
|
|
if (placement == null) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'No available flexible slot today.',
|
|
type: SchedulingNoticeType.overflow,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.noAvailableSlot,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.noSlot,
|
|
);
|
|
}
|
|
|
|
return _applyPlacement(
|
|
input: input,
|
|
insertedTask: task,
|
|
placement: placement,
|
|
updatedAt: updatedAt ?? clock.now(),
|
|
);
|
|
}
|
|
|
|
/// Push a planned flexible task to the next available slot today.
|
|
///
|
|
/// The selected task moves after its current slot. Planned flexible tasks
|
|
/// after it may shift later, preserving their relative order.
|
|
///
|
|
/// This is the "not now, later today" action. Anything before the pushed
|
|
/// task's current end time becomes fixed for this operation, while later
|
|
/// planned flexible tasks may be shifted if necessary.
|
|
SchedulingResult pushFlexibleTaskToNextAvailableSlot({
|
|
required SchedulingInput input,
|
|
required String taskId,
|
|
DateTime? updatedAt,
|
|
bool countAsManualPush = true,
|
|
}) {
|
|
// Resolve the selected task by id so UI code only needs to pass a stable
|
|
// identifier, not object references.
|
|
final task = _taskById(input.tasks, taskId);
|
|
if (task == null) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Task was not found.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: taskId,
|
|
issueCode: SchedulingIssueCode.taskNotFound,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.notFound,
|
|
);
|
|
}
|
|
|
|
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Only planned flexible tasks can be pushed.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.invalidTaskState,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.invalidState,
|
|
);
|
|
}
|
|
|
|
final currentInterval = _scheduledIntervalFor(task);
|
|
if (currentInterval == null || !input.window.contains(currentInterval)) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Task needs a current scheduled slot before pushing.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.missingScheduledSlot,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.invalidState,
|
|
);
|
|
}
|
|
|
|
if (currentInterval.duration.inMicroseconds <= 0) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Task needs a positive scheduled duration before pushing.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.nonPositiveDuration,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.invalidState,
|
|
);
|
|
}
|
|
|
|
final placement = _planFlexiblePush(
|
|
input: input,
|
|
task: task,
|
|
currentInterval: currentInterval,
|
|
);
|
|
|
|
if (placement == null) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'No available flexible slot today.',
|
|
type: SchedulingNoticeType.overflow,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.noAvailableSlot,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.noSlot,
|
|
);
|
|
}
|
|
|
|
return _applyPushPlacement(
|
|
input: input,
|
|
pushedTask: task,
|
|
placement: placement,
|
|
pushedTaskMessage: 'Flexible task pushed to next available slot.',
|
|
pushedTaskMovementCode:
|
|
SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot,
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToNextAvailableSlot,
|
|
updatedAt: updatedAt ?? clock.now(),
|
|
countPushedTaskAsManual: countAsManualPush,
|
|
);
|
|
}
|
|
|
|
/// Move a planned flexible task to the top of tomorrow's flexible queue.
|
|
///
|
|
/// The input window represents tomorrow's scheduling window. Existing planned
|
|
/// flexible tasks in that window may shift later, preserving their order.
|
|
///
|
|
/// The method name says "tomorrow" because that is the product action, but the
|
|
/// engine only trusts [input.window]. Tests can pass any future window.
|
|
SchedulingResult pushFlexibleTaskToTomorrowTopOfQueue({
|
|
required SchedulingInput input,
|
|
required String taskId,
|
|
DateTime? updatedAt,
|
|
}) {
|
|
final task = _taskById(input.tasks, taskId);
|
|
if (task == null) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Task was not found.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: taskId,
|
|
issueCode: SchedulingIssueCode.taskNotFound,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
|
outcomeCode: SchedulingOutcomeCode.notFound,
|
|
);
|
|
}
|
|
|
|
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Only planned flexible tasks can be moved to tomorrow.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.invalidTaskState,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
|
outcomeCode: SchedulingOutcomeCode.invalidState,
|
|
);
|
|
}
|
|
|
|
final taskDuration = _scheduledIntervalFor(task)?.duration ??
|
|
_durationFromMinutes(task.durationMinutes);
|
|
if (taskDuration == null || taskDuration.inMicroseconds <= 0) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Task needs a positive duration before moving to tomorrow.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.missingDuration,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
|
outcomeCode: SchedulingOutcomeCode.invalidState,
|
|
);
|
|
}
|
|
|
|
final placement = _planTomorrowQueueInsertion(
|
|
input: input,
|
|
task: task,
|
|
taskDuration: taskDuration,
|
|
);
|
|
|
|
if (placement == null) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'No available flexible slot tomorrow.',
|
|
type: SchedulingNoticeType.overflow,
|
|
taskId: task.id,
|
|
issueCode: SchedulingIssueCode.noAvailableSlot,
|
|
),
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
|
outcomeCode: SchedulingOutcomeCode.noSlot,
|
|
);
|
|
}
|
|
|
|
return _applyPushPlacement(
|
|
input: input,
|
|
pushedTask: task,
|
|
placement: placement,
|
|
pushedTaskMessage: 'Flexible task moved to tomorrow.',
|
|
pushedTaskMovementCode:
|
|
SchedulingMovementCode.flexibleTaskMovedToTomorrow,
|
|
operationCode:
|
|
SchedulingOperationCode.pushFlexibleTaskToTomorrowTopOfQueue,
|
|
updatedAt: updatedAt ?? clock.now(),
|
|
);
|
|
}
|
|
|
|
/// Move unfinished flexible tasks to the top of tomorrow's flexible queue.
|
|
///
|
|
/// The input window represents tomorrow's scheduling window. Only planned and
|
|
/// active flexible tasks are rolled; required, locked, completed, and
|
|
/// cancelled tasks remain unchanged.
|
|
///
|
|
/// Rollover is bulk push behavior for day-end recovery. It collects unfinished
|
|
/// flexible tasks outside the target window, preserves their relative order,
|
|
/// then places them at the start of the new window while shifting already
|
|
/// planned flexible tasks as needed. When [sourceWindow] is supplied, only
|
|
/// unfinished flexible tasks from that source window are rolled over. This
|
|
/// prevents future planned tasks from being pulled into tomorrow accidentally.
|
|
SchedulingResult rollOverUnfinishedFlexibleTasks({
|
|
required SchedulingInput input,
|
|
SchedulingWindow? sourceWindow,
|
|
DateTime? updatedAt,
|
|
}) {
|
|
// Build the explicit queue of tasks to roll before asking the planner to
|
|
// place anything. This keeps selection separate from placement.
|
|
final rolledItems = <_PlacementItem>[];
|
|
final rolloverTasks = input.flexibleTasks
|
|
.where((task) => _shouldRollOver(
|
|
task: task,
|
|
targetWindow: input.window,
|
|
sourceWindow: sourceWindow,
|
|
))
|
|
.toList()
|
|
..sort((a, b) {
|
|
final aStart =
|
|
a.scheduledStart ?? sourceWindow?.start ?? input.window.start;
|
|
final bStart =
|
|
b.scheduledStart ?? sourceWindow?.start ?? input.window.start;
|
|
|
|
return aStart.compareTo(bStart);
|
|
});
|
|
|
|
for (final task in rolloverTasks) {
|
|
final duration = _scheduledIntervalFor(task)?.duration ??
|
|
_durationFromMinutes(task.durationMinutes);
|
|
if (duration == null || duration.inMicroseconds <= 0) {
|
|
continue;
|
|
}
|
|
|
|
rolledItems.add(
|
|
_PlacementItem(
|
|
task: task,
|
|
duration: duration,
|
|
earliestStart: input.window.start,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (rolledItems.isEmpty) {
|
|
return SchedulingResult(
|
|
tasks: input.tasks,
|
|
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
|
outcomeCode: SchedulingOutcomeCode.noOp,
|
|
notices: [
|
|
SchedulingNotice(
|
|
'No unfinished flexible tasks to roll over.',
|
|
issueCode: SchedulingIssueCode.noUnfinishedFlexibleTasks,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
final placement = _planQueueAtWindowStart(
|
|
input: input,
|
|
queue: rolledItems,
|
|
excludeTaskIds: rolledItems.map((item) => item.task.id).toSet(),
|
|
);
|
|
|
|
if (placement == null) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Unfinished flexible tasks could not fit tomorrow.',
|
|
type: SchedulingNoticeType.overflow,
|
|
issueCode: SchedulingIssueCode.unfinishedTasksCouldNotFit,
|
|
),
|
|
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
|
outcomeCode: SchedulingOutcomeCode.overflow,
|
|
);
|
|
}
|
|
|
|
return _applyRolloverPlacement(
|
|
input: input,
|
|
rolledTaskIds: rolledItems.map((item) => item.task.id).toSet(),
|
|
placement: placement,
|
|
updatedAt: updatedAt ?? clock.now(),
|
|
);
|
|
}
|
|
|
|
/// Analyze the current in-memory schedule without moving tasks.
|
|
///
|
|
/// This is a validation/debugging helper. It scans scheduled flexible tasks and
|
|
/// reports any overlap with blocked intervals. It deliberately returns the
|
|
/// original task list unchanged.
|
|
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
|
final overlaps = <SchedulingOverlap>[];
|
|
final notices = <SchedulingNotice>[];
|
|
final conflictOccupancies = input.conflictReportingOccupancies;
|
|
|
|
for (final task in input.movablePlannedFlexibleTasks) {
|
|
final taskInterval = _scheduledIntervalFor(task);
|
|
if (taskInterval == null || !input.window.contains(taskInterval)) {
|
|
continue;
|
|
}
|
|
|
|
for (final occupancy in conflictOccupancies) {
|
|
final blockedInterval = occupancy.interval;
|
|
if (blockedInterval == null) {
|
|
continue;
|
|
}
|
|
|
|
if (!taskInterval.overlaps(blockedInterval)) {
|
|
continue;
|
|
}
|
|
|
|
overlaps.add(
|
|
SchedulingOverlap(
|
|
taskId: task.id,
|
|
taskInterval: taskInterval,
|
|
blockedInterval: blockedInterval,
|
|
),
|
|
);
|
|
notices.add(
|
|
SchedulingNotice(
|
|
'Flexible task overlaps blocked time.',
|
|
type: SchedulingNoticeType.overlap,
|
|
taskId: task.id,
|
|
conflictCode:
|
|
SchedulingConflictCode.flexibleTaskOverlapsBlockedTime,
|
|
parameters: {
|
|
'blockedStart': blockedInterval.start,
|
|
'blockedEnd': blockedInterval.end,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return SchedulingResult(
|
|
tasks: input.tasks,
|
|
operationCode: SchedulingOperationCode.analyzeSchedule,
|
|
outcomeCode: overlaps.isEmpty
|
|
? SchedulingOutcomeCode.success
|
|
: SchedulingOutcomeCode.conflict,
|
|
notices: List<SchedulingNotice>.unmodifiable(notices),
|
|
overlaps: List<SchedulingOverlap>.unmodifiable(overlaps),
|
|
);
|
|
}
|
|
|
|
/// Move a task to backlog.
|
|
///
|
|
/// Backlog does not preserve original schedule/order placement. The task's
|
|
/// schedule is cleared and its moved-to-backlog counter is incremented so
|
|
/// reports can distinguish this from a task that was never scheduled.
|
|
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
|
|
final now = updatedAt ?? clock.now();
|
|
final result = _transitionService.apply(
|
|
task: task,
|
|
transitionCode: TaskTransitionCode.moveToBacklog,
|
|
operationId: 'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}',
|
|
activityId:
|
|
'legacy-move-to-backlog:${task.id}:${now.toIso8601String()}:activity',
|
|
occurredAt: now,
|
|
);
|
|
return result.applied ? result.task : task;
|
|
}
|
|
|
|
/// Mark a flexible task pushed manually.
|
|
///
|
|
/// This updates statistics only. Use the push methods above when the task's
|
|
/// actual scheduled slot should change.
|
|
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
|
|
final now = updatedAt ?? clock.now();
|
|
final result = _transitionService.apply(
|
|
task: task,
|
|
transitionCode: TaskTransitionCode.manualPush,
|
|
operationId: 'legacy-manual-push:${task.id}:${now.toIso8601String()}',
|
|
activityId:
|
|
'legacy-manual-push:${task.id}:${now.toIso8601String()}:activity',
|
|
occurredAt: now,
|
|
);
|
|
return result.applied ? result.task : task;
|
|
}
|
|
|
|
/// Mark missed according to the current MVP rules.
|
|
///
|
|
/// Critical missed tasks go to backlog so they remain actionable. Inflexible
|
|
/// missed tasks stay in place as missed because they represented a fixed event
|
|
/// or time block that cannot simply be rescheduled automatically.
|
|
Task markMissed(Task task, {DateTime? updatedAt}) {
|
|
final now = updatedAt ?? clock.now();
|
|
final result = _transitionService.apply(
|
|
task: task,
|
|
transitionCode: TaskTransitionCode.miss,
|
|
operationId: 'legacy-miss:${task.id}:${now.toIso8601String()}',
|
|
activityId: 'legacy-miss:${task.id}:${now.toIso8601String()}:activity',
|
|
occurredAt: now,
|
|
);
|
|
return result.applied ? result.task : task;
|
|
}
|
|
|
|
/// Finds the first interval that can fit the requested duration while avoiding
|
|
/// blocked intervals.
|
|
///
|
|
/// This public helper is deliberately simple and does not shift existing
|
|
/// flexible tasks. It is useful for UI previews or tests that only need to know
|
|
/// the first open gap. Full bump/queue behavior lives in the private planning
|
|
/// helpers below.
|
|
TimeInterval? findFirstOpenInterval({
|
|
required DateTime windowStart,
|
|
required DateTime windowEnd,
|
|
required Duration duration,
|
|
required List<TimeInterval> blocked,
|
|
}) {
|
|
final sortedBlocked = [...blocked]
|
|
..sort((a, b) => a.start.compareTo(b.start));
|
|
var cursor = windowStart;
|
|
|
|
for (final interval in sortedBlocked) {
|
|
if (cursor.add(duration).isBefore(interval.start) ||
|
|
cursor.add(duration).isAtSameMomentAs(interval.start)) {
|
|
return TimeInterval(start: cursor, end: cursor.add(duration));
|
|
}
|
|
|
|
if (interval.end.isAfter(cursor)) {
|
|
cursor = interval.end;
|
|
}
|
|
}
|
|
|
|
final candidateEnd = cursor.add(duration);
|
|
if (candidateEnd.isBefore(windowEnd) ||
|
|
candidateEnd.isAtSameMomentAs(windowEnd)) {
|
|
return TimeInterval(start: cursor, end: candidateEnd);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Convert a scheduled task into an interval, or null if it is unplaced.
|
|
///
|
|
/// This helper does not validate positive duration; callers that require a valid
|
|
/// duration check that separately.
|
|
TimeInterval? _scheduledIntervalFor(Task task) {
|
|
final start = task.scheduledStart;
|
|
final end = task.scheduledEnd;
|
|
|
|
if (start == null || end == null) {
|
|
return null;
|
|
}
|
|
|
|
return TimeInterval(start: start, end: end, label: task.id);
|
|
}
|
|
|
|
/// Convert all placed tasks in [tasks] into intervals.
|
|
List<TimeInterval> _scheduledIntervalsFor(Iterable<Task> tasks) {
|
|
final intervals = <TimeInterval>[];
|
|
|
|
for (final task in tasks) {
|
|
final interval = _scheduledIntervalFor(task);
|
|
if (interval != null) {
|
|
intervals.add(interval);
|
|
}
|
|
}
|
|
|
|
return List<TimeInterval>.unmodifiable(intervals);
|
|
}
|
|
|
|
/// Return the original task list with one explanatory notice.
|
|
///
|
|
/// Most validation failures use this so callers can keep rendering the existing
|
|
/// schedule while showing why the requested action did not apply.
|
|
SchedulingResult _unchangedResult(
|
|
SchedulingInput input,
|
|
SchedulingNotice notice, {
|
|
SchedulingOperationCode operationCode = SchedulingOperationCode.unspecified,
|
|
SchedulingOutcomeCode outcomeCode = SchedulingOutcomeCode.invalidState,
|
|
}) {
|
|
return SchedulingResult(
|
|
tasks: input.tasks,
|
|
operationCode: operationCode,
|
|
outcomeCode: outcomeCode,
|
|
notices: [notice],
|
|
);
|
|
}
|
|
|
|
/// Find a task by stable id.
|
|
Task? _taskById(List<Task> tasks, String taskId) {
|
|
for (final task in tasks) {
|
|
if (task.id == taskId) {
|
|
return task;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// Convert a nullable minute estimate into a positive [Duration].
|
|
Duration? _durationFromMinutes(int? minutes) {
|
|
if (minutes == null || minutes <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return Duration(minutes: minutes);
|
|
}
|
|
|
|
/// Plan insertion of a backlog task plus any flexible tasks that must shift.
|
|
///
|
|
/// This function only calculates intervals. It does not update task objects. The
|
|
/// returned plan is later applied by [_applyPlacement], which creates notices,
|
|
/// changes, and updated task copies.
|
|
_BacklogInsertionPlan? _planBacklogInsertion({
|
|
required SchedulingInput input,
|
|
required Task task,
|
|
required Duration taskDuration,
|
|
}) {
|
|
// Start with intervals that the algorithm is not allowed to move.
|
|
final fixedBlocks = <TimeInterval>[
|
|
...input.blockedIntervals,
|
|
];
|
|
final queue = <_PlacementItem>[
|
|
_PlacementItem(
|
|
task: task,
|
|
duration: taskDuration,
|
|
earliestStart: input.window.start,
|
|
),
|
|
];
|
|
|
|
// Existing flexible tasks are inspected in timeline order. Planned tasks inside
|
|
// the movable portion become part of the placement queue; everything else is
|
|
// treated as fixed.
|
|
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);
|
|
|
|
if (startsBeforeWindow || startsAfterWindow) {
|
|
fixedBlocks.add(interval);
|
|
continue;
|
|
}
|
|
|
|
queue.add(
|
|
_PlacementItem(
|
|
task: flexibleTask,
|
|
duration: interval.duration,
|
|
earliestStart: interval.start,
|
|
),
|
|
);
|
|
}
|
|
|
|
fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
|
|
|
|
// The cursor tracks the earliest point after the previously placed queue item.
|
|
// Each queued task is placed no earlier than both the cursor and its own
|
|
// original earliest start.
|
|
var cursor = input.window.start;
|
|
final placements = <String, TimeInterval>{};
|
|
|
|
for (final item in queue) {
|
|
final earliestStart = _laterOf(cursor, item.earliestStart);
|
|
final interval = _firstOpenIntervalFrom(
|
|
earliestStart: earliestStart,
|
|
windowEnd: input.window.end,
|
|
duration: item.duration,
|
|
blocked: fixedBlocks,
|
|
);
|
|
|
|
if (interval == null) {
|
|
return null;
|
|
}
|
|
|
|
placements[item.task.id] = interval;
|
|
cursor = interval.end;
|
|
}
|
|
|
|
return _BacklogInsertionPlan(placements: placements);
|
|
}
|
|
|
|
/// Plan the "push later today" behavior for one flexible task.
|
|
///
|
|
/// Items before the pushed task's current end are fixed. The pushed task starts
|
|
/// the queue at its current end, followed by later planned flexible tasks that
|
|
/// may need to move to preserve order.
|
|
_BacklogInsertionPlan? _planFlexiblePush({
|
|
required SchedulingInput input,
|
|
required Task task,
|
|
required TimeInterval currentInterval,
|
|
}) {
|
|
final fixedBlocks = <TimeInterval>[
|
|
...input.blockedIntervals,
|
|
];
|
|
final queue = <_PlacementItem>[
|
|
_PlacementItem(
|
|
task: task,
|
|
duration: currentInterval.duration,
|
|
earliestStart: currentInterval.end,
|
|
),
|
|
];
|
|
|
|
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 startsBeforePushPoint = interval.start.isBefore(currentInterval.end);
|
|
final startsAfterWindow = interval.start.isAfter(input.window.end) ||
|
|
interval.start.isAtSameMomentAs(input.window.end);
|
|
|
|
if (startsBeforePushPoint || startsAfterWindow) {
|
|
fixedBlocks.add(interval);
|
|
continue;
|
|
}
|
|
|
|
queue.add(
|
|
_PlacementItem(
|
|
task: flexibleTask,
|
|
duration: interval.duration,
|
|
earliestStart: interval.start,
|
|
),
|
|
);
|
|
}
|
|
|
|
fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
|
|
|
|
var cursor = currentInterval.end;
|
|
final placements = <String, TimeInterval>{};
|
|
|
|
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 putting a single task at the start of the supplied future window.
|
|
_BacklogInsertionPlan? _planTomorrowQueueInsertion({
|
|
required SchedulingInput input,
|
|
required Task task,
|
|
required Duration taskDuration,
|
|
}) {
|
|
final queue = <_PlacementItem>[
|
|
_PlacementItem(
|
|
task: task,
|
|
duration: taskDuration,
|
|
earliestStart: input.window.start,
|
|
),
|
|
];
|
|
|
|
return _planQueueAtWindowStart(
|
|
input: input,
|
|
queue: queue,
|
|
excludeTaskIds: {task.id},
|
|
);
|
|
}
|
|
|
|
/// Plan a queue of flexible tasks at the beginning of [input.window].
|
|
///
|
|
/// This is shared by tomorrow push and bulk rollover. [excludeTaskIds] identifies
|
|
/// tasks already represented in the incoming [queue] so they are not also pulled
|
|
/// from existing scheduled flexible tasks.
|
|
_BacklogInsertionPlan? _planQueueAtWindowStart({
|
|
required SchedulingInput input,
|
|
required List<_PlacementItem> queue,
|
|
required Set<String> excludeTaskIds,
|
|
}) {
|
|
final fixedBlocks = <TimeInterval>[
|
|
...input.blockedIntervals,
|
|
];
|
|
final placementQueue = <_PlacementItem>[
|
|
...queue,
|
|
];
|
|
|
|
final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks
|
|
.where((flexibleTask) => !excludeTaskIds.contains(flexibleTask.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 overlapsWindow = interval.overlaps(input.window.interval);
|
|
if (!overlapsWindow) {
|
|
continue;
|
|
}
|
|
|
|
if (!input.window.contains(interval)) {
|
|
fixedBlocks.add(interval);
|
|
continue;
|
|
}
|
|
|
|
placementQueue.add(
|
|
_PlacementItem(
|
|
task: flexibleTask,
|
|
duration: interval.duration,
|
|
earliestStart: interval.start,
|
|
),
|
|
);
|
|
}
|
|
|
|
fixedBlocks.sort((a, b) => a.start.compareTo(b.start));
|
|
|
|
var cursor = input.window.start;
|
|
final placements = <String, TimeInterval>{};
|
|
|
|
for (final item in placementQueue) {
|
|
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);
|
|
}
|
|
|
|
/// Apply a backlog insertion plan to the task list.
|
|
///
|
|
/// The inserted backlog task becomes planned and increments
|
|
/// `restoredFromBacklogCount`; any existing flexible tasks moved to make room
|
|
/// increment `autoPushedCount`.
|
|
SchedulingResult _applyPlacement({
|
|
required SchedulingInput input,
|
|
required Task insertedTask,
|
|
required _BacklogInsertionPlan placement,
|
|
required DateTime updatedAt,
|
|
}) {
|
|
final changes = <SchedulingChange>[];
|
|
final notices = <SchedulingNotice>[];
|
|
final updatedTasks = <Task>[];
|
|
|
|
for (final task in input.tasks) {
|
|
final interval = placement.placements[task.id];
|
|
if (interval == null) {
|
|
updatedTasks.add(task);
|
|
continue;
|
|
}
|
|
|
|
final isInsertedTask = task.id == insertedTask.id;
|
|
final moved = isInsertedTask ||
|
|
!_sameDateTime(task.scheduledStart, interval.start) ||
|
|
!_sameDateTime(task.scheduledEnd, interval.end);
|
|
|
|
if (!moved) {
|
|
updatedTasks.add(task);
|
|
continue;
|
|
}
|
|
|
|
final placedTask = task.copyWith(
|
|
status: isInsertedTask ? TaskStatus.planned : task.status,
|
|
scheduledStart: interval.start,
|
|
scheduledEnd: interval.end,
|
|
updatedAt: updatedAt,
|
|
);
|
|
final updatedTask = _applySchedulingActivity(
|
|
originalTask: task,
|
|
updatedTask: placedTask,
|
|
operationCode:
|
|
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
|
activityCode: isInsertedTask
|
|
? TaskActivityCode.restoredFromBacklog
|
|
: TaskActivityCode.automaticallyPushed,
|
|
occurredAt: updatedAt,
|
|
previousStart: task.scheduledStart,
|
|
previousEnd: task.scheduledEnd,
|
|
nextStart: interval.start,
|
|
nextEnd: interval.end,
|
|
);
|
|
|
|
updatedTasks.add(updatedTask);
|
|
changes.add(
|
|
SchedulingChange(
|
|
taskId: task.id,
|
|
previousStart: task.scheduledStart,
|
|
previousEnd: task.scheduledEnd,
|
|
nextStart: interval.start,
|
|
nextEnd: interval.end,
|
|
),
|
|
);
|
|
notices.add(
|
|
SchedulingNotice(
|
|
isInsertedTask
|
|
? 'Backlog task inserted into schedule.'
|
|
: 'Flexible task moved to make room.',
|
|
type: SchedulingNoticeType.moved,
|
|
taskId: task.id,
|
|
movementCode: isInsertedTask
|
|
? SchedulingMovementCode.backlogTaskInserted
|
|
: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
|
|
parameters: {
|
|
'previousStart': task.scheduledStart,
|
|
'previousEnd': task.scheduledEnd,
|
|
'nextStart': interval.start,
|
|
'nextEnd': interval.end,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
return SchedulingResult(
|
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
|
operationCode:
|
|
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
|
|
outcomeCode: SchedulingOutcomeCode.success,
|
|
notices: List<SchedulingNotice>.unmodifiable(notices),
|
|
changes: List<SchedulingChange>.unmodifiable(changes),
|
|
);
|
|
}
|
|
|
|
/// Apply a push/tomorrow placement plan to the task list.
|
|
///
|
|
/// The explicitly pushed task increments `manuallyPushedCount`; other moved
|
|
/// flexible tasks increment `autoPushedCount` because the scheduler moved them as
|
|
/// a side effect.
|
|
SchedulingResult _applyPushPlacement({
|
|
required SchedulingInput input,
|
|
required Task pushedTask,
|
|
required _BacklogInsertionPlan placement,
|
|
required String pushedTaskMessage,
|
|
required SchedulingMovementCode pushedTaskMovementCode,
|
|
required SchedulingOperationCode operationCode,
|
|
required DateTime updatedAt,
|
|
bool countPushedTaskAsManual = true,
|
|
}) {
|
|
final changes = <SchedulingChange>[];
|
|
final notices = <SchedulingNotice>[];
|
|
final updatedTasks = <Task>[];
|
|
|
|
for (final task in input.tasks) {
|
|
final interval = placement.placements[task.id];
|
|
if (interval == null) {
|
|
updatedTasks.add(task);
|
|
continue;
|
|
}
|
|
|
|
final moved = !_sameDateTime(task.scheduledStart, interval.start) ||
|
|
!_sameDateTime(task.scheduledEnd, interval.end);
|
|
|
|
if (!moved) {
|
|
updatedTasks.add(task);
|
|
continue;
|
|
}
|
|
|
|
final isPushedTask = task.id == pushedTask.id;
|
|
final placedTask = task.copyWith(
|
|
scheduledStart: interval.start,
|
|
scheduledEnd: interval.end,
|
|
updatedAt: updatedAt,
|
|
);
|
|
final updatedTask = _applySchedulingActivity(
|
|
originalTask: task,
|
|
updatedTask: placedTask,
|
|
operationCode: operationCode,
|
|
activityCode: isPushedTask && countPushedTaskAsManual
|
|
? TaskActivityCode.manuallyPushed
|
|
: TaskActivityCode.automaticallyPushed,
|
|
occurredAt: updatedAt,
|
|
previousStart: task.scheduledStart,
|
|
previousEnd: task.scheduledEnd,
|
|
nextStart: interval.start,
|
|
nextEnd: interval.end,
|
|
);
|
|
|
|
updatedTasks.add(updatedTask);
|
|
changes.add(
|
|
SchedulingChange(
|
|
taskId: task.id,
|
|
previousStart: task.scheduledStart,
|
|
previousEnd: task.scheduledEnd,
|
|
nextStart: interval.start,
|
|
nextEnd: interval.end,
|
|
),
|
|
);
|
|
notices.add(
|
|
SchedulingNotice(
|
|
isPushedTask ? pushedTaskMessage : 'Flexible task moved to make room.',
|
|
type: SchedulingNoticeType.moved,
|
|
taskId: task.id,
|
|
movementCode: isPushedTask
|
|
? pushedTaskMovementCode
|
|
: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
|
|
parameters: {
|
|
'previousStart': task.scheduledStart,
|
|
'previousEnd': task.scheduledEnd,
|
|
'nextStart': interval.start,
|
|
'nextEnd': interval.end,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
return SchedulingResult(
|
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
|
operationCode: operationCode,
|
|
outcomeCode: SchedulingOutcomeCode.success,
|
|
notices: List<SchedulingNotice>.unmodifiable(notices),
|
|
changes: List<SchedulingChange>.unmodifiable(changes),
|
|
);
|
|
}
|
|
|
|
/// Apply a bulk rollover placement plan.
|
|
///
|
|
/// Rolled tasks are set back to planned status in the target window. Existing
|
|
/// tasks moved to make room receive normal movement notices.
|
|
SchedulingResult _applyRolloverPlacement({
|
|
required SchedulingInput input,
|
|
required Set<String> rolledTaskIds,
|
|
required _BacklogInsertionPlan placement,
|
|
required DateTime updatedAt,
|
|
}) {
|
|
final changes = <SchedulingChange>[];
|
|
final notices = <SchedulingNotice>[];
|
|
final updatedTasks = <Task>[];
|
|
var rolledCount = 0;
|
|
|
|
for (final task in input.tasks) {
|
|
final interval = placement.placements[task.id];
|
|
if (interval == null) {
|
|
updatedTasks.add(task);
|
|
continue;
|
|
}
|
|
|
|
final moved = !_sameDateTime(task.scheduledStart, interval.start) ||
|
|
!_sameDateTime(task.scheduledEnd, interval.end);
|
|
|
|
if (!moved) {
|
|
updatedTasks.add(task);
|
|
continue;
|
|
}
|
|
|
|
final isRolledTask = rolledTaskIds.contains(task.id);
|
|
final placedTask = task.copyWith(
|
|
status: isRolledTask ? TaskStatus.planned : task.status,
|
|
scheduledStart: interval.start,
|
|
scheduledEnd: interval.end,
|
|
updatedAt: updatedAt,
|
|
);
|
|
final updatedTask = _applySchedulingActivity(
|
|
originalTask: task,
|
|
updatedTask: placedTask,
|
|
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
|
activityCode: TaskActivityCode.automaticallyPushed,
|
|
occurredAt: updatedAt,
|
|
previousStart: task.scheduledStart,
|
|
previousEnd: task.scheduledEnd,
|
|
nextStart: interval.start,
|
|
nextEnd: interval.end,
|
|
);
|
|
|
|
updatedTasks.add(updatedTask);
|
|
changes.add(
|
|
SchedulingChange(
|
|
taskId: task.id,
|
|
previousStart: task.scheduledStart,
|
|
previousEnd: task.scheduledEnd,
|
|
nextStart: interval.start,
|
|
nextEnd: interval.end,
|
|
),
|
|
);
|
|
|
|
if (isRolledTask) {
|
|
rolledCount += 1;
|
|
} else {
|
|
notices.add(
|
|
SchedulingNotice(
|
|
'Flexible task moved to make room.',
|
|
type: SchedulingNoticeType.moved,
|
|
taskId: task.id,
|
|
movementCode: SchedulingMovementCode.flexibleTaskMovedToMakeRoom,
|
|
parameters: {
|
|
'previousStart': task.scheduledStart,
|
|
'previousEnd': task.scheduledEnd,
|
|
'nextStart': interval.start,
|
|
'nextEnd': interval.end,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
notices.insert(
|
|
0,
|
|
SchedulingNotice(
|
|
'$rolledCount unfinished flexible tasks were moved to tomorrow.',
|
|
type: SchedulingNoticeType.moved,
|
|
movementCode: SchedulingMovementCode.unfinishedFlexibleTasksRolledOver,
|
|
parameters: {
|
|
'rolledCount': rolledCount,
|
|
},
|
|
),
|
|
);
|
|
|
|
return SchedulingResult(
|
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
|
operationCode: SchedulingOperationCode.rollOverUnfinishedFlexibleTasks,
|
|
outcomeCode: SchedulingOutcomeCode.success,
|
|
notices: List<SchedulingNotice>.unmodifiable(notices),
|
|
changes: List<SchedulingChange>.unmodifiable(changes),
|
|
);
|
|
}
|
|
|
|
/// Whether [task] belongs in the rollover queue.
|
|
///
|
|
/// Planned/active flexible tasks already inside the target window are not rolled
|
|
/// again; they are handled as existing tasks that may shift to make room.
|
|
bool _shouldRollOver({
|
|
required Task task,
|
|
required SchedulingWindow targetWindow,
|
|
SchedulingWindow? sourceWindow,
|
|
}) {
|
|
final interval = _scheduledIntervalFor(task);
|
|
final isTomorrowTask =
|
|
interval != null && interval.overlaps(targetWindow.interval);
|
|
|
|
final isInSourceWindow = sourceWindow == null ||
|
|
(interval != null && sourceWindow.contains(interval));
|
|
|
|
return task.isFlexible &&
|
|
!isTomorrowTask &&
|
|
isInSourceWindow &&
|
|
(task.status == TaskStatus.planned || task.status == TaskStatus.active);
|
|
}
|
|
|
|
/// Return whichever timestamp is later.
|
|
DateTime _laterOf(DateTime first, DateTime second) {
|
|
if (first.isAfter(second)) {
|
|
return first;
|
|
}
|
|
|
|
return second;
|
|
}
|
|
|
|
/// Null-safe exact timestamp comparison.
|
|
bool _sameDateTime(DateTime? first, DateTime? second) {
|
|
if (first == null || second == null) {
|
|
return first == null && second == null;
|
|
}
|
|
|
|
return first.isAtSameMomentAs(second);
|
|
}
|
|
|
|
Task _applySchedulingActivity({
|
|
required Task originalTask,
|
|
required Task updatedTask,
|
|
required SchedulingOperationCode operationCode,
|
|
required TaskActivityCode activityCode,
|
|
required DateTime occurredAt,
|
|
required DateTime? previousStart,
|
|
required DateTime? previousEnd,
|
|
required DateTime? nextStart,
|
|
required DateTime? nextEnd,
|
|
}) {
|
|
final operationId =
|
|
'${operationCode.name}:${originalTask.id}:${occurredAt.toIso8601String()}';
|
|
final activity = TaskActivity(
|
|
id: '$operationId:${activityCode.name}',
|
|
operationId: operationId,
|
|
code: activityCode,
|
|
taskId: originalTask.id,
|
|
projectId: originalTask.projectId,
|
|
occurredAt: occurredAt,
|
|
metadata: {
|
|
'previousStatus': originalTask.status.name,
|
|
'nextStatus': updatedTask.status.name,
|
|
'previousStart': previousStart,
|
|
'previousEnd': previousEnd,
|
|
'nextStart': nextStart,
|
|
'nextEnd': nextEnd,
|
|
},
|
|
);
|
|
|
|
return _activityAccountingService
|
|
.apply(task: updatedTask, activities: [activity]).task;
|
|
}
|
|
|
|
/// Find the first candidate interval at or after [earliestStart].
|
|
///
|
|
/// The scan assumes [blocked] is sorted by start time. When a candidate overlaps
|
|
/// a blocked interval, the cursor jumps to that blocked interval's end and tries
|
|
/// again. This makes the algorithm easy to follow and adequate for the starter
|
|
/// in-memory engine.
|
|
TimeInterval? _firstOpenIntervalFrom({
|
|
required DateTime earliestStart,
|
|
required DateTime windowEnd,
|
|
required Duration duration,
|
|
required List<TimeInterval> blocked,
|
|
}) {
|
|
var cursor = earliestStart;
|
|
|
|
while (true) {
|
|
final candidateEnd = cursor.add(duration);
|
|
if (candidateEnd.isAfter(windowEnd)) {
|
|
return null;
|
|
}
|
|
|
|
final candidate = TimeInterval(start: cursor, end: candidateEnd);
|
|
TimeInterval? overlappingBlock;
|
|
|
|
for (final block in blocked) {
|
|
if (block.end.isBefore(cursor) || block.end.isAtSameMomentAs(cursor)) {
|
|
continue;
|
|
}
|
|
|
|
if (block.start.isAfter(candidate.end) ||
|
|
block.start.isAtSameMomentAs(candidate.end)) {
|
|
break;
|
|
}
|
|
|
|
if (candidate.overlaps(block)) {
|
|
overlappingBlock = block;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (overlappingBlock == null) {
|
|
return candidate;
|
|
}
|
|
|
|
cursor = overlappingBlock.end;
|
|
}
|
|
}
|
|
|
|
/// One item in a placement queue.
|
|
///
|
|
/// [earliestStart] preserves a task's natural ordering constraint. For existing
|
|
/// scheduled tasks, this is usually their current start; for a pushed task, it is
|
|
/// the earliest time the push operation allows.
|
|
class _PlacementItem {
|
|
const _PlacementItem({
|
|
required this.task,
|
|
required this.duration,
|
|
required this.earliestStart,
|
|
});
|
|
|
|
/// Task represented by this queue entry.
|
|
final Task task;
|
|
|
|
/// Duration the planner must reserve.
|
|
final Duration duration;
|
|
|
|
/// Earliest allowed start time for this item.
|
|
final DateTime earliestStart;
|
|
}
|
|
|
|
/// Planned task intervals keyed by task id.
|
|
///
|
|
/// The name is historical from the first insertion feature; it now also supports
|
|
/// push and rollover placement plans. It remains private so it can be renamed or
|
|
/// expanded later without affecting callers.
|
|
class _BacklogInsertionPlan {
|
|
const _BacklogInsertionPlan({required this.placements});
|
|
|
|
/// Chosen interval for each task that should be scheduled or moved.
|
|
final Map<String, TimeInterval> placements;
|
|
}
|