feat(backlog): add board bucket policy

This commit is contained in:
Ashley Venn 2026-07-07 12:21:11 -07:00
parent f8bd0e1dd6
commit b71d74290f
5 changed files with 249 additions and 0 deletions

View file

@ -12,6 +12,9 @@ library;
// scheduler so the engine can focus on moving tasks through time.
import '../domain/models.dart';
part 'backlog/board/backlog_board_bucket.dart';
part 'backlog/board/backlog_board_bucket_policy.dart';
part 'backlog/board/backlog_board_bucket_resolution.dart';
part 'backlog/queries/backlog_filter.dart';
part 'backlog/queries/backlog_sort_key.dart';
part 'backlog/staleness/backlog_staleness_marker.dart';

View file

@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../backlog.dart';
/// Stable column identifiers for the V1 Backlog board.
enum BacklogBoardBucket {
/// Schedule-ready tasks that are good candidates for the next open slot.
doNext,
/// Tasks that need a deliberate focused block before they should be scheduled.
needTimeBlock,
/// Tasks whose size or effort suggests planning smaller child tasks first.
breakUpFirst,
/// Low-pressure or intentionally deferred tasks that should stay out of the main queue.
notNow,
}

View file

@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../backlog.dart';
/// Deterministic V1 policy that assigns backlog tasks to board columns.
class BacklogBoardBucketPolicy {
/// Creates the default V1 backlog board bucket policy.
const BacklogBoardBucketPolicy();
/// Resolves the board bucket for [task].
///
/// [hasChildTasks] is supplied by the application read layer after it has
/// looked up child task metadata. The policy stays pure and does not read
/// repositories directly.
BacklogBoardBucketResolution resolve({
required Task task,
bool hasChildTasks = false,
}) {
if (task.backlogTags.contains(BacklogTag.wishlist)) {
return const BacklogBoardBucketResolution(
bucket: BacklogBoardBucket.notNow,
reason: 'Saved for someday.',
);
}
if (_isLowPriority(task.priority) &&
!_isOtherwiseUrgent(task: task, hasChildTasks: hasChildTasks)) {
return const BacklogBoardBucketResolution(
bucket: BacklogBoardBucket.notNow,
reason: 'Low pressure and not urgent.',
);
}
if (task.durationMinutes == null &&
!_isReadyToSchedule(task: task, hasChildTasks: hasChildTasks)) {
return const BacklogBoardBucketResolution(
bucket: BacklogBoardBucket.notNow,
reason: 'Needs an estimate before scheduling.',
);
}
if ((task.durationMinutes ?? 0) >= 90) {
return const BacklogBoardBucketResolution(
bucket: BacklogBoardBucket.breakUpFirst,
reason: 'Large enough to break into smaller steps.',
);
}
if (_isHard(task.difficulty)) {
return const BacklogBoardBucketResolution(
bucket: BacklogBoardBucket.breakUpFirst,
reason: 'High effort; smaller steps may help.',
);
}
if (hasChildTasks) {
return const BacklogBoardBucketResolution(
bucket: BacklogBoardBucket.breakUpFirst,
reason: 'Already has child-task planning.',
);
}
if ((task.durationMinutes ?? 0) >= 45) {
return const BacklogBoardBucketResolution(
bucket: BacklogBoardBucket.needTimeBlock,
reason: 'Needs a focused time block.',
);
}
if (_isHighPriority(task.priority)) {
return const BacklogBoardBucketResolution(
bucket: BacklogBoardBucket.needTimeBlock,
reason: 'High priority work.',
);
}
return const BacklogBoardBucketResolution(
bucket: BacklogBoardBucket.doNext,
reason: 'Ready for the next available slot.',
);
}
/// Whether [priority] should stay out of the main queue when no urgent signal exists.
bool _isLowPriority(PriorityLevel? priority) {
return priority == PriorityLevel.veryLow || priority == PriorityLevel.low;
}
/// Whether [priority] should be treated as important enough to plan deliberately.
bool _isHighPriority(PriorityLevel? priority) {
return priority == PriorityLevel.high || priority == PriorityLevel.veryHigh;
}
/// Whether [difficulty] indicates the task should be broken down first.
bool _isHard(DifficultyLevel difficulty) {
return difficulty == DifficultyLevel.hard ||
difficulty == DifficultyLevel.veryHard;
}
/// Whether [task] has any signal that should override low-pressure handling.
bool _isOtherwiseUrgent({
required Task task,
required bool hasChildTasks,
}) {
return _isHighPriority(task.priority) ||
(task.durationMinutes ?? 0) >= 45 ||
_isHard(task.difficulty) ||
hasChildTasks;
}
/// Whether [task] has enough scheduling information for board planning.
bool _isReadyToSchedule({
required Task task,
required bool hasChildTasks,
}) {
return task.durationMinutes != null ||
_isHighPriority(task.priority) ||
_isHard(task.difficulty) ||
hasChildTasks;
}
}

View file

@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../backlog.dart';
/// Result returned by [BacklogBoardBucketPolicy] for one backlog task.
class BacklogBoardBucketResolution {
/// Creates a bucket resolution with the selected [bucket] and user-facing [reason].
const BacklogBoardBucketResolution({
required this.bucket,
required this.reason,
});
/// Board bucket selected for the task.
final BacklogBoardBucket bucket;
/// Short user-facing explanation for why the task belongs in [bucket].
final String reason;
}

View file

@ -316,6 +316,100 @@ void main() {
expect(view.stalenessMarkerFor(task), BacklogStalenessMarker.purple);
});
test('backlog board bucket policy covers all default columns', () {
const policy = BacklogBoardBucketPolicy();
final doNext = Task.quickCapture(
id: 'do-next',
title: 'Reply to emails',
createdAt: now,
priority: PriorityLevel.medium,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.easy,
).copyWith(durationMinutes: 20);
final needTimeBlock = Task.quickCapture(
id: 'need-time-block',
title: 'Review budget',
createdAt: now,
priority: PriorityLevel.high,
).copyWith(durationMinutes: 30);
final breakUpFirst = Task.quickCapture(
id: 'break-up-first',
title: 'Build dashboard',
createdAt: now,
).copyWith(durationMinutes: 120);
final notNow = Task.quickCapture(
id: 'not-now',
title: 'Visit Japan',
createdAt: now,
backlogTags: const {BacklogTag.wishlist},
).copyWith(durationMinutes: 20);
expect(
policy.resolve(task: doNext).bucket,
BacklogBoardBucket.doNext,
);
expect(
policy.resolve(task: needTimeBlock).bucket,
BacklogBoardBucket.needTimeBlock,
);
expect(
policy.resolve(task: breakUpFirst).bucket,
BacklogBoardBucket.breakUpFirst,
);
expect(
policy.resolve(task: notNow).bucket,
BacklogBoardBucket.notNow,
);
});
test('backlog board bucket policy uses deterministic precedence', () {
const policy = BacklogBoardBucketPolicy();
final somedayLarge = Task.quickCapture(
id: 'someday-large',
title: 'Start someday company',
createdAt: now,
backlogTags: const {BacklogTag.wishlist},
).copyWith(durationMinutes: 120);
final hardFocused = Task.quickCapture(
id: 'hard-focused',
title: 'Write strategy',
createdAt: now,
difficulty: DifficultyLevel.hard,
).copyWith(durationMinutes: 60);
final lowButFocused = Task.quickCapture(
id: 'low-focused',
title: 'Organize closet',
createdAt: now,
priority: PriorityLevel.low,
).copyWith(durationMinutes: 60);
final missingEstimate = Task.quickCapture(
id: 'missing-estimate',
title: 'Clean up files',
createdAt: now,
);
expect(
policy.resolve(task: somedayLarge).bucket,
BacklogBoardBucket.notNow,
);
expect(
policy.resolve(task: hardFocused).bucket,
BacklogBoardBucket.breakUpFirst,
);
expect(
policy.resolve(task: lowButFocused).bucket,
BacklogBoardBucket.needTimeBlock,
);
expect(
policy.resolve(task: missingEstimate).bucket,
BacklogBoardBucket.notNow,
);
expect(
policy.resolve(task: missingEstimate, hasChildTasks: true).bucket,
BacklogBoardBucket.breakUpFirst,
);
});
test('unchecked quick capture goes to backlog', () {
const service = QuickCaptureService();