feat(backlog): add unified filters and capture defaults

This commit is contained in:
Ashley Venn 2026-06-19 16:08:13 -07:00
parent 89267551b7
commit e44b105777
5 changed files with 348 additions and 2 deletions

View file

@ -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

View file

@ -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';

115
lib/src/backlog.dart Normal file
View file

@ -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<Task> tasks;
final DateTime now;
final Duration staleAfter;
List<Task> get backlogTasks {
return tasks.where((task) => task.isBacklog).toList(growable: false);
}
List<Task> filter(BacklogFilter filter) {
return backlogTasks.where((task) => _matchesFilter(task, filter)).toList(
growable: false,
);
}
List<Task> sorted(BacklogSortKey sortKey) {
final sortedTasks = [...backlogTasks];
sortedTasks.sort((a, b) => _compareTasks(a, b, sortKey));
return List<Task>.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;
}

View file

@ -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 <BacklogTag>{},
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<BacklogTag> backlogTags = const <BacklogTag>{},
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<BacklogTag> backlogTags;
final DateTime createdAt;
final DateTime updatedAt;
final TaskStatistics stats;
@ -199,6 +219,7 @@ class Task {
DateTime? scheduledStart,
DateTime? scheduledEnd,
String? parentTaskId,
Set<BacklogTag>? 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<BacklogTag> backlogTags = const <BacklogTag>{},
DateTime? updatedAt,
}) {
return Task(
@ -276,6 +299,7 @@ class ProjectProfile {
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
parentTaskId: parentTaskId,
backlogTags: backlogTags,
createdAt: createdAt,
updatedAt: updatedAt ?? createdAt,
);

View file

@ -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',