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.
|
// scheduler so the engine can focus on moving tasks through time.
|
||||||
|
|
||||||
import '../domain/models.dart';
|
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_filter.dart';
|
||||||
part 'backlog/queries/backlog_sort_key.dart';
|
part 'backlog/queries/backlog_sort_key.dart';
|
||||||
part 'backlog/staleness/backlog_staleness_marker.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);
|
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', () {
|
test('unchecked quick capture goes to backlog', () {
|
||||||
const service = QuickCaptureService();
|
const service = QuickCaptureService();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue