diff --git a/packages/scheduler_core/lib/src/scheduling/backlog.dart b/packages/scheduler_core/lib/src/scheduling/backlog.dart index 738cf2a..4ed9359 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog.dart @@ -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'; diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket.dart b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket.dart new file mode 100644 index 0000000..ee9b6da --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket.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, +} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_policy.dart b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_policy.dart new file mode 100644 index 0000000..9ddc6e9 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_policy.dart @@ -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; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_resolution.dart b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_resolution.dart new file mode 100644 index 0000000..7a6e6c9 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_resolution.dart @@ -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; +} diff --git a/packages/scheduler_core/test/scheduling_engine_test.dart b/packages/scheduler_core/test/scheduling_engine_test.dart index 6d9e5d0..bd9a532 100644 --- a/packages/scheduler_core/test/scheduling_engine_test.dart +++ b/packages/scheduler_core/test/scheduling_engine_test.dart @@ -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();