From e44b1057779b45d611e8e6213005af656b1ff935 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Fri, 19 Jun 2026 16:08:13 -0700 Subject: [PATCH] feat(backlog): add unified filters and capture defaults --- .../V1_BLOCK_04_Backlog_Quick_Capture.md | 24 +++ lib/scheduler_core.dart | 1 + lib/src/backlog.dart | 115 +++++++++++ lib/src/models.dart | 26 ++- test/scheduling_engine_test.dart | 184 +++++++++++++++++- 5 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 lib/src/backlog.dart diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_04_Backlog_Quick_Capture.md b/Codex Documentation/Current Software Plan/V1_BLOCK_04_Backlog_Quick_Capture.md index 5a777fb..66d8fa4 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_04_Backlog_Quick_Capture.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_04_Backlog_Quick_Capture.md @@ -8,6 +8,8 @@ Purpose: Support low-friction task capture and a unified backlog/wishlist model. Recommended Codex level: medium +Status: Completed + Tasks: Implement backlog-friendly task metadata: @@ -28,10 +30,21 @@ Acceptance criteria: - Moving to backlog clears active schedule placement. - Tests verify original schedule order is not used to restore backlog items. +Execution notes: + +- Added `BacklogView` as a read-only projection over the unified task list. +- Added derived backlog filters for inbox, pushed, critical missed, wishlist, stale, and no reward set. +- Added backlog sort keys for priority, reward vs effort, age, project, and times pushed. +- Added `BacklogTag.wishlist` metadata without creating separate hard backlog sections. +- Verified moving to backlog clears active schedule placement and backlog age sorting does not use original schedule order. +- `dart analyze` and `dart test` passed. + ## Chunk 4.2 — Quick capture defaults Recommended Codex level: medium +Status: Completed + Tasks: Implement quick capture creation rules: @@ -50,6 +63,17 @@ Acceptance criteria: - Test: neutral defaults are applied. - Test: reward notSet is preserved distinctly. +Execution notes: + +- Updated `Task.quickCapture` to require a non-empty title. +- Default quick capture destination remains backlog. +- Priority now defaults to `PriorityLevel.medium`. +- Reward defaults to `RewardLevel.notSet` and remains distinct from low reward. +- Difficulty defaults to `DifficultyLevel.notSet`. +- Project defaults to `inbox`, and time remains unset. +- Added tests for title validation and neutral defaults. +- `dart analyze` and `dart test` passed. + ## Chunk 4.3 — Quick capture schedule checkbox behavior Recommended Codex level: high diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index 301550e..d92d81e 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -3,5 +3,6 @@ // Keep this file small. Implementation details belong in `lib/src/`. export 'src/models.dart'; +export 'src/backlog.dart'; export 'src/scheduling_engine.dart'; export 'src/task_statistics.dart'; diff --git a/lib/src/backlog.dart b/lib/src/backlog.dart new file mode 100644 index 0000000..a8b62f4 --- /dev/null +++ b/lib/src/backlog.dart @@ -0,0 +1,115 @@ +import 'models.dart'; + +/// Derived backlog filters for a unified backlog list. +enum BacklogFilter { + inbox, + pushed, + criticalMissed, + wishlist, + stale, + noRewardSet, +} + +/// Sort options for a unified backlog list. +enum BacklogSortKey { + priority, + rewardVsEffort, + age, + project, + timesPushed, +} + +/// Read-only backlog projection over the unified task list. +class BacklogView { + const BacklogView({ + required this.tasks, + required this.now, + this.staleAfter = const Duration(days: 31), + }); + + final List tasks; + final DateTime now; + final Duration staleAfter; + + List get backlogTasks { + return tasks.where((task) => task.isBacklog).toList(growable: false); + } + + List filter(BacklogFilter filter) { + return backlogTasks.where((task) => _matchesFilter(task, filter)).toList( + growable: false, + ); + } + + List sorted(BacklogSortKey sortKey) { + final sortedTasks = [...backlogTasks]; + sortedTasks.sort((a, b) => _compareTasks(a, b, sortKey)); + + return List.unmodifiable(sortedTasks); + } + + 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.updatedAt) >= staleAfter, + BacklogFilter.noRewardSet => task.reward == RewardLevel.notSet, + }; + } + + 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)), + }; + } +} + +int _priorityRank(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryLow => 0, + PriorityLevel.low => 1, + PriorityLevel.medium || null => 2, + PriorityLevel.high => 3, + PriorityLevel.veryHigh => 4, + }; +} + +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, + }; +} + +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, + }; +} + +int _rewardVsEffortScore(Task task) { + return _rewardRank(task.reward) - _difficultyRank(task.difficulty); +} + +int _timesPushed(Task task) { + return task.stats.manuallyPushedCount + task.stats.autoPushedCount; +} diff --git a/lib/src/models.dart b/lib/src/models.dart index d168ae9..b3ed26c 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -120,6 +120,12 @@ enum ReminderProfile { strict, } +/// Lightweight backlog-only metadata. +enum BacklogTag { + /// Task is intentionally saved as a someday/wishlist item. + wishlist, +} + /// Starter task model for the scheduling core. /// /// This is a public placeholder API for early V1 chunks. Keep behavior changes @@ -140,6 +146,7 @@ class Task { this.scheduledStart, this.scheduledEnd, this.parentTaskId, + this.backlogTags = const {}, this.stats = const TaskStatistics(), }); @@ -151,16 +158,28 @@ class Task { String projectId = 'inbox', TaskType type = TaskType.flexible, TaskStatus status = TaskStatus.backlog, + PriorityLevel priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + Set backlogTags = const {}, DateTime? updatedAt, }) { + if (title.trim().isEmpty) { + throw ArgumentError.value(title, 'title', 'Title is required.'); + } + return Task( id: id, - title: title, + title: title.trim(), projectId: projectId, type: type, status: status, + priority: priority, + reward: reward, + difficulty: difficulty, createdAt: createdAt, updatedAt: updatedAt ?? createdAt, + backlogTags: backlogTags, ); } @@ -176,6 +195,7 @@ class Task { final DateTime? scheduledStart; final DateTime? scheduledEnd; final String? parentTaskId; + final Set backlogTags; final DateTime createdAt; final DateTime updatedAt; final TaskStatistics stats; @@ -199,6 +219,7 @@ class Task { DateTime? scheduledStart, DateTime? scheduledEnd, String? parentTaskId, + Set? backlogTags, DateTime? createdAt, DateTime? updatedAt, TaskStatistics? stats, @@ -218,6 +239,7 @@ class Task { clearSchedule ? null : (scheduledStart ?? this.scheduledStart), scheduledEnd: clearSchedule ? null : (scheduledEnd ?? this.scheduledEnd), parentTaskId: parentTaskId ?? this.parentTaskId, + backlogTags: backlogTags ?? this.backlogTags, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, stats: stats ?? this.stats, @@ -261,6 +283,7 @@ class ProjectProfile { DateTime? scheduledStart, DateTime? scheduledEnd, String? parentTaskId, + Set backlogTags = const {}, DateTime? updatedAt, }) { return Task( @@ -276,6 +299,7 @@ class ProjectProfile { scheduledStart: scheduledStart, scheduledEnd: scheduledEnd, parentTaskId: parentTaskId, + backlogTags: backlogTags, createdAt: createdAt, updatedAt: updatedAt ?? createdAt, ); diff --git a/test/scheduling_engine_test.dart b/test/scheduling_engine_test.dart index 1e43b72..c189b2f 100644 --- a/test/scheduling_engine_test.dart +++ b/test/scheduling_engine_test.dart @@ -24,7 +24,7 @@ void main() { expect(task.projectId, 'inbox'); expect(task.type, TaskType.flexible); expect(task.status, TaskStatus.backlog); - expect(task.priority, isNull); + expect(task.priority, PriorityLevel.medium); expect(task.reward, RewardLevel.notSet); expect(task.difficulty, DifficultyLevel.notSet); expect(task.durationMinutes, isNull); @@ -34,6 +34,188 @@ void main() { expect(task.updatedAt, now); }); + test('quick capture requires a title', () { + expect( + () => Task.quickCapture( + id: 'capture-1', + title: ' ', + createdAt: now, + ), + throwsArgumentError, + ); + }); + + test('backlog filters use derived categories over a unified list', () { + final inbox = Task.quickCapture( + id: 'inbox-1', + title: 'Buy stamps', + createdAt: now, + ); + final pushed = Task.quickCapture( + id: 'pushed-1', + title: 'Fold laundry', + createdAt: now, + projectId: 'home', + ).copyWith( + stats: const TaskStatistics(manuallyPushedCount: 1), + ); + final criticalMissed = Task.quickCapture( + id: 'critical-1', + title: 'Pay bill', + createdAt: now, + projectId: 'money', + type: TaskType.critical, + ).copyWith( + stats: const TaskStatistics(missedCount: 1), + ); + final wishlist = Task.quickCapture( + id: 'wishlist-1', + title: 'Try new notebook', + createdAt: now, + projectId: 'ideas', + backlogTags: const {BacklogTag.wishlist}, + ); + final stale = Task.quickCapture( + id: 'stale-1', + title: 'Sort cables', + createdAt: now.subtract(const Duration(days: 40)), + projectId: 'home', + ).copyWith(updatedAt: now.subtract(const Duration(days: 40))); + final planned = Task.quickCapture( + id: 'planned-1', + title: 'Scheduled task', + createdAt: now, + ).copyWith(status: TaskStatus.planned); + final view = BacklogView( + tasks: [inbox, pushed, criticalMissed, wishlist, stale, planned], + now: now, + ); + + expect(view.backlogTasks.map((task) => task.id), [ + 'inbox-1', + 'pushed-1', + 'critical-1', + 'wishlist-1', + 'stale-1', + ]); + expect(view.filter(BacklogFilter.inbox).map((task) => task.id), [ + 'inbox-1', + ]); + expect(view.filter(BacklogFilter.pushed).map((task) => task.id), [ + 'pushed-1', + ]); + expect(view.filter(BacklogFilter.criticalMissed).map((task) => task.id), [ + 'critical-1', + ]); + expect(view.filter(BacklogFilter.wishlist).map((task) => task.id), [ + 'wishlist-1', + ]); + expect(view.filter(BacklogFilter.stale).map((task) => task.id), [ + 'stale-1', + ]); + expect(view.filter(BacklogFilter.noRewardSet).map((task) => task.id), [ + 'inbox-1', + 'pushed-1', + 'critical-1', + 'wishlist-1', + 'stale-1', + ]); + }); + + test('backlog sorting uses backlog keys instead of original schedule', () { + final older = Task.quickCapture( + id: 'older', + title: 'Older backlog item', + createdAt: DateTime(2026, 6, 1), + ).copyWith( + scheduledStart: DateTime(2026, 6, 19, 18), + scheduledEnd: DateTime(2026, 6, 19, 18, 15), + ); + final newer = Task.quickCapture( + id: 'newer', + title: 'Newer backlog item', + createdAt: DateTime(2026, 6, 10), + ).copyWith( + scheduledStart: DateTime(2026, 6, 19, 9), + scheduledEnd: DateTime(2026, 6, 19, 9, 15), + ); + final movedOlder = const SchedulingEngine().moveToBacklog( + older, + updatedAt: now, + ); + final movedNewer = const SchedulingEngine().moveToBacklog( + newer, + updatedAt: now, + ); + final view = BacklogView(tasks: [movedNewer, movedOlder], now: now); + + expect(movedOlder.scheduledStart, isNull); + expect(movedNewer.scheduledStart, isNull); + expect(view.sorted(BacklogSortKey.age).map((task) => task.id), [ + 'older', + 'newer', + ]); + }); + + test('backlog sorting supports priority reward effort project and pushes', + () { + final lowPriority = Task.quickCapture( + id: 'low', + title: 'Low', + createdAt: now, + priority: PriorityLevel.low, + reward: RewardLevel.low, + difficulty: DifficultyLevel.hard, + projectId: 'zeta', + ); + final highPriority = Task.quickCapture( + id: 'high', + title: 'High', + createdAt: now, + priority: PriorityLevel.high, + reward: RewardLevel.high, + difficulty: DifficultyLevel.easy, + projectId: 'alpha', + ).copyWith( + stats: const TaskStatistics(autoPushedCount: 2), + ); + final mediumPriority = Task.quickCapture( + id: 'medium', + title: 'Medium', + createdAt: now, + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + projectId: 'beta', + ).copyWith( + stats: const TaskStatistics(manuallyPushedCount: 1), + ); + final view = BacklogView( + tasks: [lowPriority, mediumPriority, highPriority], + now: now, + ); + + expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [ + 'high', + 'medium', + 'low', + ]); + expect( + view.sorted(BacklogSortKey.rewardVsEffort).map((task) => task.id), + ['high', 'medium', 'low'], + ); + expect(view.sorted(BacklogSortKey.project).map((task) => task.id), [ + 'high', + 'medium', + 'low', + ]); + expect(view.sorted(BacklogSortKey.timesPushed).map((task) => task.id), [ + 'high', + 'medium', + 'low', + ]); + }); + test('project defaults apply while task overrides remain possible', () { const project = ProjectProfile( id: 'home',