Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
5 changed files with 249 additions and 0 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue