296 lines
10 KiB
Dart
296 lines
10 KiB
Dart
// Backlog query helpers for the ADHD scheduling core.
|
|
//
|
|
// The backlog is not a separate database table in this starter model. It is a
|
|
// view over tasks whose `Task.status` is `TaskStatus.backlog`. This file keeps
|
|
// display-oriented backlog filtering, sorting, and age markers out of the main
|
|
// scheduler so the engine can focus on moving tasks through time.
|
|
|
|
import 'models.dart';
|
|
|
|
/// Derived backlog filters for a unified backlog list.
|
|
///
|
|
/// These filters do not store separate task collections. They are projections
|
|
/// over the same master task list. That is important because a task can move
|
|
/// between today's timeline and backlog by changing [Task.status], without
|
|
/// needing to copy it between separate stores.
|
|
enum BacklogFilter {
|
|
/// Uncategorized captured tasks in the default inbox project.
|
|
inbox,
|
|
|
|
/// Tasks that have been manually or automatically pushed at least once.
|
|
pushed,
|
|
|
|
/// Critical tasks that have missed at least once and need recovery attention.
|
|
criticalMissed,
|
|
|
|
/// Someday/maybe tasks that are intentionally kept out of normal pressure.
|
|
wishlist,
|
|
|
|
/// Tasks whose [Task.updatedAt] age exceeds the configured stale threshold.
|
|
stale,
|
|
|
|
/// Tasks still missing a reward estimate. Useful during cleanup/review.
|
|
noRewardSet,
|
|
}
|
|
|
|
/// Sort options for a unified backlog list.
|
|
///
|
|
/// Sort keys are intentionally product-facing rather than database-facing. For
|
|
/// example, `rewardVsEffort` maps to a simple derived score instead of a stored
|
|
/// field. Persistence can later index the underlying fields if needed.
|
|
enum BacklogSortKey {
|
|
/// Highest priority first.
|
|
priority,
|
|
|
|
/// Best simple reward-minus-difficulty score first.
|
|
rewardVsEffort,
|
|
|
|
/// Oldest created task first.
|
|
age,
|
|
|
|
/// Lexicographic project id grouping. Future UI can replace this with project
|
|
/// display order while keeping the same public key.
|
|
project,
|
|
|
|
/// Most frequently pushed tasks first.
|
|
timesPushed,
|
|
}
|
|
|
|
/// Visual age bucket for backlog display.
|
|
///
|
|
/// This supports the design rule that old backlog items should visually age
|
|
/// from green to blue to purple. The enum names describe semantic buckets; UI
|
|
/// code should translate them into actual theme colors.
|
|
enum BacklogStalenessMarker {
|
|
/// Fresh backlog item. Default: created within seven days.
|
|
green,
|
|
|
|
/// Aging backlog item. Default: created within thirty days.
|
|
blue,
|
|
|
|
/// Old/stale backlog item. Default: created more than thirty days ago.
|
|
purple,
|
|
}
|
|
|
|
/// Configurable thresholds for backlog age markers.
|
|
///
|
|
/// The defaults match the current design spec: less than a week is fresh, less
|
|
/// than a month is aging, and anything older is stale. Keeping the thresholds in
|
|
/// a value object makes future settings/preferences easy to inject in tests or
|
|
/// user configuration.
|
|
class BacklogStalenessSettings {
|
|
const BacklogStalenessSettings({
|
|
this.greenMaxAge = const Duration(days: 7),
|
|
this.blueMaxAge = const Duration(days: 30),
|
|
});
|
|
|
|
/// Maximum age that still counts as fresh/green.
|
|
final Duration greenMaxAge;
|
|
|
|
/// Maximum age that still counts as aging/blue. Anything older is purple.
|
|
final Duration blueMaxAge;
|
|
|
|
/// Return the visual age marker for [task] relative to [now].
|
|
///
|
|
/// This uses [Task.createdAt], not [Task.updatedAt], because the marker is
|
|
/// meant to show how long the idea has existed in the system. A task edited
|
|
/// yesterday but created two months ago should still feel old in the backlog.
|
|
BacklogStalenessMarker markerFor({
|
|
required Task task,
|
|
required DateTime now,
|
|
}) {
|
|
final age = now.difference(task.createdAt);
|
|
|
|
if (age <= greenMaxAge) {
|
|
return BacklogStalenessMarker.green;
|
|
}
|
|
|
|
if (age <= blueMaxAge) {
|
|
return BacklogStalenessMarker.blue;
|
|
}
|
|
|
|
return BacklogStalenessMarker.purple;
|
|
}
|
|
}
|
|
|
|
/// Read-only backlog projection over the unified task list.
|
|
///
|
|
/// [BacklogView] is a query/helper object. It does not mutate tasks or own data;
|
|
/// it receives the current task list and exposes common backlog slices for UI.
|
|
/// That keeps backlog display logic out of widgets and avoids duplicating the
|
|
/// same filtering rules in multiple screens.
|
|
class BacklogView {
|
|
BacklogView({
|
|
required List<Task> tasks,
|
|
required this.now,
|
|
this.staleAfter = const Duration(days: 31),
|
|
this.stalenessSettings = const BacklogStalenessSettings(),
|
|
}) : tasks = List<Task>.unmodifiable(tasks);
|
|
|
|
/// Master task list supplied by the caller. Only `status == backlog` items are
|
|
/// shown by this view.
|
|
final List<Task> tasks;
|
|
|
|
/// Clock value supplied by the caller so age/staleness behavior is testable.
|
|
final DateTime now;
|
|
|
|
/// Age since [Task.createdAt] that qualifies for the `stale` filter.
|
|
///
|
|
/// V1 does not yet store a separate "entered backlog at" timestamp. Until
|
|
/// persistence adds that field, both stale filtering and visual staleness use
|
|
/// task creation age so they do not disagree after a task edit.
|
|
final Duration staleAfter;
|
|
|
|
/// Color-bucket threshold configuration for backlog aging indicators.
|
|
final BacklogStalenessSettings stalenessSettings;
|
|
|
|
/// All tasks currently in backlog status.
|
|
///
|
|
/// The returned list is a snapshot. It is not intended to be modified and then
|
|
/// written back; state changes should go through scheduling/action services.
|
|
List<Task> get backlogTasks {
|
|
return tasks.where((task) => task.isBacklog).toList(growable: false);
|
|
}
|
|
|
|
/// Return backlog tasks matching a single user-facing filter.
|
|
///
|
|
/// Filtering always starts from [backlogTasks], so a completed or planned task
|
|
/// will never appear here even if it has matching statistics.
|
|
List<Task> filter(BacklogFilter filter) {
|
|
return backlogTasks.where((task) => _matchesFilter(task, filter)).toList(
|
|
growable: false,
|
|
);
|
|
}
|
|
|
|
/// Return all backlog tasks sorted by a user-facing ordering.
|
|
///
|
|
/// A new list is created before sorting so the original [tasks] list is never
|
|
/// reordered by a read operation. The final list is unmodifiable to make that
|
|
/// intent explicit to callers.
|
|
List<Task> sorted(BacklogSortKey sortKey) {
|
|
final sortedTasks = backlogTasks
|
|
.asMap()
|
|
.entries
|
|
.map(
|
|
(entry) => _IndexedTask(
|
|
task: entry.value,
|
|
originalIndex: entry.key,
|
|
),
|
|
)
|
|
.toList(growable: false);
|
|
sortedTasks.sort((a, b) {
|
|
final comparison = _compareTasks(a.task, b.task, sortKey);
|
|
if (comparison != 0) {
|
|
return comparison;
|
|
}
|
|
|
|
return a.originalIndex.compareTo(b.originalIndex);
|
|
});
|
|
|
|
return List<Task>.unmodifiable(
|
|
sortedTasks.map((indexedTask) => indexedTask.task),
|
|
);
|
|
}
|
|
|
|
/// Return the green/blue/purple marker for one task.
|
|
BacklogStalenessMarker stalenessMarkerFor(Task task) {
|
|
return stalenessSettings.markerFor(task: task, now: now);
|
|
}
|
|
|
|
/// Private predicate implementing every [BacklogFilter] option.
|
|
///
|
|
/// Keeping this as a switch expression makes new filters obvious: add the enum
|
|
/// value and the compiler forces this method to handle it.
|
|
bool _matchesFilter(Task task, BacklogFilter filter) {
|
|
return switch (filter) {
|
|
BacklogFilter.inbox => task.projectId == 'inbox',
|
|
BacklogFilter.pushed =>
|
|
task.stats.manuallyPushedCount > 0 || task.stats.autoPushedCount > 0,
|
|
BacklogFilter.criticalMissed =>
|
|
task.type == TaskType.critical && task.stats.missedCount > 0,
|
|
BacklogFilter.wishlist => task.backlogTags.contains(BacklogTag.wishlist),
|
|
BacklogFilter.stale => now.difference(task.createdAt) >= staleAfter,
|
|
BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet,
|
|
};
|
|
}
|
|
|
|
/// Comparison callback used by [sorted].
|
|
///
|
|
/// Sort directions are encoded here. Higher priority/reward/push counts should
|
|
/// appear earlier, while older age uses the earliest [Task.createdAt] first.
|
|
int _compareTasks(Task a, Task b, BacklogSortKey sortKey) {
|
|
return switch (sortKey) {
|
|
BacklogSortKey.priority =>
|
|
_priorityRank(b.priority).compareTo(_priorityRank(a.priority)),
|
|
BacklogSortKey.rewardVsEffort =>
|
|
_rewardVsEffortScore(b).compareTo(_rewardVsEffortScore(a)),
|
|
BacklogSortKey.age => a.createdAt.compareTo(b.createdAt),
|
|
BacklogSortKey.project => a.projectId.compareTo(b.projectId),
|
|
BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Convert nullable priority into a stable numeric rank for sorting.
|
|
///
|
|
/// Null priority is treated like medium so partially imported data behaves like
|
|
/// normal starter tasks instead of sinking to the bottom.
|
|
int _priorityRank(PriorityLevel? priority) {
|
|
return switch (priority) {
|
|
PriorityLevel.veryLow => 0,
|
|
PriorityLevel.low => 1,
|
|
PriorityLevel.medium || null => 2,
|
|
PriorityLevel.high => 3,
|
|
PriorityLevel.veryHigh => 4,
|
|
};
|
|
}
|
|
|
|
/// Convert reward enum values to numeric ranks for derived scoring.
|
|
int _rewardRank(RewardLevel reward) {
|
|
return switch (reward) {
|
|
RewardLevel.notSet => 0,
|
|
RewardLevel.veryLow => 1,
|
|
RewardLevel.low => 2,
|
|
RewardLevel.medium => 3,
|
|
RewardLevel.high => 4,
|
|
RewardLevel.veryHigh => 5,
|
|
};
|
|
}
|
|
|
|
/// Convert difficulty enum values to numeric ranks for derived scoring.
|
|
int _difficultyRank(DifficultyLevel difficulty) {
|
|
return switch (difficulty) {
|
|
DifficultyLevel.notSet => 0,
|
|
DifficultyLevel.veryEasy => 1,
|
|
DifficultyLevel.easy => 2,
|
|
DifficultyLevel.medium => 3,
|
|
DifficultyLevel.hard => 4,
|
|
DifficultyLevel.veryHard => 5,
|
|
};
|
|
}
|
|
|
|
/// Simple motivation score: reward minus difficulty.
|
|
///
|
|
/// Positive scores suggest high payoff for lower activation cost. Negative scores
|
|
/// suggest high effort for lower payoff. This is deliberately simple for V1 and
|
|
/// can be replaced by richer heuristics later without changing the public sort
|
|
/// key.
|
|
int _rewardVsEffortScore(Task task) {
|
|
return _rewardRank(task.reward) - _difficultyRank(task.difficulty);
|
|
}
|
|
|
|
/// Total manual and automatic pushes recorded on the task.
|
|
int _timesPushed(Task task) {
|
|
return task.stats.manuallyPushedCount + task.stats.autoPushedCount;
|
|
}
|
|
|
|
/// Backlog task paired with its source-list position for stable sorting.
|
|
class _IndexedTask {
|
|
const _IndexedTask({
|
|
required this.task,
|
|
required this.originalIndex,
|
|
});
|
|
|
|
final Task task;
|
|
final int originalIndex;
|
|
}
|