// 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 tasks, required this.now, this.staleAfter = const Duration(days: 31), this.stalenessSettings = const BacklogStalenessSettings(), }) : tasks = List.unmodifiable(tasks); /// Master task list supplied by the caller. Only `status == backlog` items are /// shown by this view. final List 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 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 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 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.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; }