feat(backlog): add staleness markers

This commit is contained in:
Ashley Venn 2026-06-19 16:13:04 -07:00
parent f84d5661c0
commit de05eafe21
3 changed files with 94 additions and 1 deletions

View file

@ -1,6 +1,6 @@
# V1 Block 04 — Backlog and Quick Capture
Status: Planned
Status: Completed
Purpose: Support low-friction task capture and a unified backlog/wishlist model.
@ -116,6 +116,8 @@ BREAKPOINT: Stop here. Confirm `high` mode before implementing scheduling integr
Recommended Codex level: low
Status: Completed
Tasks:
Implement age/staleness calculation for backlog display:
@ -130,6 +132,15 @@ Acceptance criteria:
- Tests cover fresh, aging, stale, and configurable thresholds.
Execution notes:
- Added `BacklogStalenessMarker` values for green, blue, and purple display buckets.
- Added `BacklogStalenessSettings` with configurable green and blue thresholds.
- Added `BacklogView.stalenessMarkerFor` to calculate display marker state without side effects or prompts.
- Default thresholds are green through 7 days, blue through 30 days, and purple from 31 days onward.
- Added tests for fresh, aging, stale, and custom threshold behavior.
- `dart analyze` and `dart test` passed.
Commit suggestion:
```text

View file

@ -19,17 +19,54 @@ enum BacklogSortKey {
timesPushed,
}
/// Visual age bucket for backlog display.
enum BacklogStalenessMarker {
green,
blue,
purple,
}
/// Configurable thresholds for backlog age markers.
class BacklogStalenessSettings {
const BacklogStalenessSettings({
this.greenMaxAge = const Duration(days: 7),
this.blueMaxAge = const Duration(days: 30),
});
final Duration greenMaxAge;
final Duration blueMaxAge;
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.
class BacklogView {
const BacklogView({
required this.tasks,
required this.now,
this.staleAfter = const Duration(days: 31),
this.stalenessSettings = const BacklogStalenessSettings(),
});
final List<Task> tasks;
final DateTime now;
final Duration staleAfter;
final BacklogStalenessSettings stalenessSettings;
List<Task> get backlogTasks {
return tasks.where((task) => task.isBacklog).toList(growable: false);
@ -48,6 +85,10 @@ class BacklogView {
return List<Task>.unmodifiable(sortedTasks);
}
BacklogStalenessMarker stalenessMarkerFor(Task task) {
return stalenessSettings.markerFor(task: task, now: now);
}
bool _matchesFilter(Task task, BacklogFilter filter) {
return switch (filter) {
BacklogFilter.inbox => task.projectId == 'inbox',

View file

@ -216,6 +216,47 @@ void main() {
]);
});
test('backlog staleness markers cover default thresholds', () {
final fresh = Task.quickCapture(
id: 'fresh',
title: 'Fresh',
createdAt: now.subtract(const Duration(days: 7)),
);
final aging = Task.quickCapture(
id: 'aging',
title: 'Aging',
createdAt: now.subtract(const Duration(days: 8)),
);
final stale = Task.quickCapture(
id: 'stale',
title: 'Stale',
createdAt: now.subtract(const Duration(days: 31)),
);
final view = BacklogView(tasks: [fresh, aging, stale], now: now);
expect(view.stalenessMarkerFor(fresh), BacklogStalenessMarker.green);
expect(view.stalenessMarkerFor(aging), BacklogStalenessMarker.blue);
expect(view.stalenessMarkerFor(stale), BacklogStalenessMarker.purple);
});
test('backlog staleness markers support configurable thresholds', () {
final task = Task.quickCapture(
id: 'custom',
title: 'Custom',
createdAt: now.subtract(const Duration(days: 5)),
);
final view = BacklogView(
tasks: [task],
now: now,
stalenessSettings: const BacklogStalenessSettings(
greenMaxAge: Duration(days: 2),
blueMaxAge: Duration(days: 4),
),
);
expect(view.stalenessMarkerFor(task), BacklogStalenessMarker.purple);
});
test('unchecked quick capture goes to backlog', () {
const service = QuickCaptureService();