forked from eva/focus-flow
feat(backlog): add unified filters and capture defaults
This commit is contained in:
parent
89267551b7
commit
e44b105777
5 changed files with 348 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
115
lib/src/backlog.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue