forked from eva/focus-flow
835 lines
21 KiB
Dart
835 lines
21 KiB
Dart
import 'models.dart';
|
|
|
|
/// Category for scheduler notices.
|
|
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,
|
|
}
|
|
|
|
/// Window of time available to a scheduling operation.
|
|
class SchedulingWindow {
|
|
const SchedulingWindow({
|
|
required this.start,
|
|
required this.end,
|
|
});
|
|
|
|
final DateTime start;
|
|
final DateTime end;
|
|
|
|
TimeInterval get interval => TimeInterval(start: start, end: end);
|
|
|
|
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.
|
|
class SchedulingInput {
|
|
const SchedulingInput({
|
|
required this.tasks,
|
|
required this.window,
|
|
this.lockedIntervals = const <TimeInterval>[],
|
|
this.requiredVisibleIntervals = const <TimeInterval>[],
|
|
});
|
|
|
|
final List<Task> tasks;
|
|
final SchedulingWindow window;
|
|
final List<TimeInterval> lockedIntervals;
|
|
final List<TimeInterval> requiredVisibleIntervals;
|
|
|
|
List<Task> get flexibleTasks {
|
|
return tasks.where((task) => task.isFlexible).toList(growable: false);
|
|
}
|
|
|
|
List<Task> get lockedTasks {
|
|
return tasks.where((task) => task.isLocked).toList(growable: false);
|
|
}
|
|
|
|
List<Task> get requiredVisibleTasks {
|
|
return tasks
|
|
.where((task) => task.isRequiredVisible)
|
|
.toList(growable: false);
|
|
}
|
|
|
|
List<TimeInterval> get flexibleIntervals {
|
|
return _scheduledIntervalsFor(flexibleTasks);
|
|
}
|
|
|
|
List<TimeInterval> get blockedIntervals {
|
|
final intervals = <TimeInterval>[
|
|
...lockedIntervals,
|
|
..._scheduledIntervalsFor(lockedTasks),
|
|
...requiredVisibleIntervals,
|
|
..._scheduledIntervalsFor(requiredVisibleTasks),
|
|
]..sort((a, b) => a.start.compareTo(b.start));
|
|
|
|
return List<TimeInterval>.unmodifiable(intervals);
|
|
}
|
|
}
|
|
|
|
/// Exact placement change made by a scheduling operation.
|
|
class SchedulingChange {
|
|
const SchedulingChange({
|
|
required this.taskId,
|
|
required this.previousStart,
|
|
required this.previousEnd,
|
|
required this.nextStart,
|
|
required this.nextEnd,
|
|
});
|
|
|
|
final String taskId;
|
|
final DateTime? previousStart;
|
|
final DateTime? previousEnd;
|
|
final DateTime? nextStart;
|
|
final DateTime? nextEnd;
|
|
}
|
|
|
|
/// Overlap between a scheduled task and blocked time.
|
|
class SchedulingOverlap {
|
|
const SchedulingOverlap({
|
|
required this.taskId,
|
|
required this.taskInterval,
|
|
required this.blockedInterval,
|
|
});
|
|
|
|
final String taskId;
|
|
final TimeInterval taskInterval;
|
|
final TimeInterval blockedInterval;
|
|
}
|
|
|
|
/// Starter notice type returned by scheduling operations.
|
|
class SchedulingNotice {
|
|
const SchedulingNotice(
|
|
this.message, {
|
|
this.type = SchedulingNoticeType.info,
|
|
this.taskId,
|
|
});
|
|
|
|
final String message;
|
|
final SchedulingNoticeType type;
|
|
final String? taskId;
|
|
}
|
|
|
|
/// Starter result wrapper for scheduling operations.
|
|
class SchedulingResult {
|
|
const SchedulingResult({
|
|
required this.tasks,
|
|
this.notices = const <SchedulingNotice>[],
|
|
this.changes = const <SchedulingChange>[],
|
|
this.overlaps = const <SchedulingOverlap>[],
|
|
});
|
|
|
|
final List<Task> tasks;
|
|
final List<SchedulingNotice> notices;
|
|
final List<SchedulingChange> changes;
|
|
final List<SchedulingOverlap> overlaps;
|
|
}
|
|
|
|
/// Starter scheduling engine.
|
|
///
|
|
/// This is intentionally small. Codex should expand this according to the V1
|
|
/// plan documents and add tests for every scheduling rule.
|
|
class SchedulingEngine {
|
|
const SchedulingEngine();
|
|
|
|
/// 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.
|
|
SchedulingResult insertBacklogTaskIntoNextAvailableSlot({
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!task.isFlexible || !task.isBacklog) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Only backlog flexible tasks can be inserted.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
return _applyPlacement(
|
|
input: input,
|
|
insertedTask: task,
|
|
placement: placement,
|
|
updatedAt: updatedAt ?? DateTime.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.
|
|
SchedulingResult pushFlexibleTaskToNextAvailableSlot({
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!task.isFlexible || task.status != TaskStatus.planned) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Only planned flexible tasks can be pushed.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (currentInterval.duration.inMicroseconds <= 0) {
|
|
return _unchangedResult(
|
|
input,
|
|
SchedulingNotice(
|
|
'Task needs a positive scheduled duration before pushing.',
|
|
type: SchedulingNoticeType.noFit,
|
|
taskId: task.id,
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
return _applyPushPlacement(
|
|
input: input,
|
|
pushedTask: task,
|
|
placement: placement,
|
|
updatedAt: updatedAt ?? DateTime.now(),
|
|
);
|
|
}
|
|
|
|
/// Analyze the current in-memory schedule without moving tasks.
|
|
SchedulingResult analyzeSchedule(SchedulingInput input) {
|
|
final overlaps = <SchedulingOverlap>[];
|
|
final notices = <SchedulingNotice>[];
|
|
final blockedIntervals = input.blockedIntervals;
|
|
|
|
for (final task in input.flexibleTasks) {
|
|
final taskInterval = _scheduledIntervalFor(task);
|
|
if (taskInterval == null || !input.window.contains(taskInterval)) {
|
|
continue;
|
|
}
|
|
|
|
for (final blockedInterval in blockedIntervals) {
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return SchedulingResult(
|
|
tasks: input.tasks,
|
|
notices: List<SchedulingNotice>.unmodifiable(notices),
|
|
overlaps: List<SchedulingOverlap>.unmodifiable(overlaps),
|
|
);
|
|
}
|
|
|
|
/// Move a task to backlog.
|
|
///
|
|
/// Backlog does not preserve original schedule/order placement.
|
|
Task moveToBacklog(Task task, {DateTime? updatedAt}) {
|
|
return task.copyWith(
|
|
status: TaskStatus.backlog,
|
|
updatedAt: updatedAt ?? DateTime.now(),
|
|
stats: task.stats.incrementMovedToBacklog(),
|
|
clearSchedule: true,
|
|
);
|
|
}
|
|
|
|
/// Mark a flexible task pushed manually.
|
|
Task markManuallyPushed(Task task, {DateTime? updatedAt}) {
|
|
return task.copyWith(
|
|
updatedAt: updatedAt ?? DateTime.now(),
|
|
stats: task.stats.incrementManualPush(),
|
|
);
|
|
}
|
|
|
|
/// Mark missed according to the current MVP rules.
|
|
///
|
|
/// Critical missed tasks go to backlog. Inflexible missed tasks stay in place.
|
|
Task markMissed(Task task, {DateTime? updatedAt}) {
|
|
final nextStats = task.stats.incrementMissed();
|
|
final now = updatedAt ?? DateTime.now();
|
|
|
|
if (task.type == TaskType.critical) {
|
|
return task.copyWith(
|
|
status: TaskStatus.backlog,
|
|
updatedAt: now,
|
|
stats: nextStats.incrementMovedToBacklog(),
|
|
clearSchedule: true,
|
|
);
|
|
}
|
|
|
|
return task.copyWith(
|
|
status: TaskStatus.missed,
|
|
updatedAt: now,
|
|
stats: nextStats,
|
|
);
|
|
}
|
|
|
|
/// Finds the first interval that can fit the requested duration while avoiding
|
|
/// blocked intervals.
|
|
///
|
|
/// This helper is deliberately simple. Full flexible bump behavior belongs in
|
|
/// later plan chunks.
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
SchedulingResult _unchangedResult(
|
|
SchedulingInput input,
|
|
SchedulingNotice notice,
|
|
) {
|
|
return SchedulingResult(
|
|
tasks: input.tasks,
|
|
notices: [notice],
|
|
);
|
|
}
|
|
|
|
Task? _taskById(List<Task> tasks, String taskId) {
|
|
for (final task in tasks) {
|
|
if (task.id == taskId) {
|
|
return task;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
Duration? _durationFromMinutes(int? minutes) {
|
|
if (minutes == null || minutes <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return Duration(minutes: minutes);
|
|
}
|
|
|
|
_BacklogInsertionPlan? _planBacklogInsertion({
|
|
required SchedulingInput input,
|
|
required Task task,
|
|
required Duration taskDuration,
|
|
}) {
|
|
final fixedBlocks = <TimeInterval>[
|
|
...input.blockedIntervals,
|
|
];
|
|
final queue = <_PlacementItem>[
|
|
_PlacementItem(
|
|
task: task,
|
|
duration: taskDuration,
|
|
earliestStart: input.window.start,
|
|
),
|
|
];
|
|
|
|
final scheduledFlexibleTasks = input.flexibleTasks
|
|
.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;
|
|
}
|
|
|
|
if (flexibleTask.status != TaskStatus.planned) {
|
|
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 = 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);
|
|
}
|
|
|
|
_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.flexibleTasks
|
|
.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;
|
|
}
|
|
|
|
if (flexibleTask.status != TaskStatus.planned) {
|
|
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);
|
|
}
|
|
|
|
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 updatedTask = task.copyWith(
|
|
status: isInsertedTask ? TaskStatus.planned : task.status,
|
|
scheduledStart: interval.start,
|
|
scheduledEnd: interval.end,
|
|
updatedAt: updatedAt,
|
|
stats: isInsertedTask
|
|
? task.stats.incrementRestoredFromBacklog()
|
|
: task.stats.incrementAutoPush(),
|
|
);
|
|
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
return SchedulingResult(
|
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
|
notices: List<SchedulingNotice>.unmodifiable(notices),
|
|
changes: List<SchedulingChange>.unmodifiable(changes),
|
|
);
|
|
}
|
|
|
|
SchedulingResult _applyPushPlacement({
|
|
required SchedulingInput input,
|
|
required Task pushedTask,
|
|
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 moved = !_sameDateTime(task.scheduledStart, interval.start) ||
|
|
!_sameDateTime(task.scheduledEnd, interval.end);
|
|
|
|
if (!moved) {
|
|
updatedTasks.add(task);
|
|
continue;
|
|
}
|
|
|
|
final isPushedTask = task.id == pushedTask.id;
|
|
final updatedTask = task.copyWith(
|
|
scheduledStart: interval.start,
|
|
scheduledEnd: interval.end,
|
|
updatedAt: updatedAt,
|
|
stats: isPushedTask
|
|
? task.stats.incrementManualPush()
|
|
: task.stats.incrementAutoPush(),
|
|
);
|
|
|
|
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
|
|
? 'Flexible task pushed to next available slot.'
|
|
: 'Flexible task moved to make room.',
|
|
type: SchedulingNoticeType.moved,
|
|
taskId: task.id,
|
|
),
|
|
);
|
|
}
|
|
|
|
return SchedulingResult(
|
|
tasks: List<Task>.unmodifiable(updatedTasks),
|
|
notices: List<SchedulingNotice>.unmodifiable(notices),
|
|
changes: List<SchedulingChange>.unmodifiable(changes),
|
|
);
|
|
}
|
|
|
|
DateTime _laterOf(DateTime first, DateTime second) {
|
|
if (first.isAfter(second)) {
|
|
return first;
|
|
}
|
|
|
|
return second;
|
|
}
|
|
|
|
bool _sameDateTime(DateTime? first, DateTime? second) {
|
|
if (first == null || second == null) {
|
|
return first == null && second == null;
|
|
}
|
|
|
|
return first.isAtSameMomentAs(second);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
class _PlacementItem {
|
|
const _PlacementItem({
|
|
required this.task,
|
|
required this.duration,
|
|
required this.earliestStart,
|
|
});
|
|
|
|
final Task task;
|
|
final Duration duration;
|
|
final DateTime earliestStart;
|
|
}
|
|
|
|
class _BacklogInsertionPlan {
|
|
const _BacklogInsertionPlan({required this.placements});
|
|
|
|
final Map<String, TimeInterval> placements;
|
|
}
|