feat(backlog): add board query use cases

This commit is contained in:
Ashley Venn 2026-07-07 12:46:45 -07:00
parent d26645affb
commit 60039be127
7 changed files with 1308 additions and 5 deletions

View file

@ -61,3 +61,25 @@ only a drawer read-model change. It would touch at least:
Per `UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md`, this is the
breakpoint where Ashley needs to choose whether drawer tags should be read-only
placeholders for now or true V1 metadata with a schema migration.
Ashley chose the read-only path for UI Plan 2. Backlog drawer notes remain
`null`, and tags are derived only from existing narrow backlog metadata for now
(`BacklogTag.wishlist` displays as `someday`). True notes and freeform tags are
tracked in `Human Documentation/Things to consider todo/Backlog Notes and
Freeform Tags Metadata.md` for a later migration-backed feature.
## Block 02 Board and Detail Queries
1. `GetBacklogBoardRequest` and `GetBacklogTaskDetailRequest` now expose the
public query inputs needed by Flutter without importing scheduler `src/`.
2. `V1ApplicationManagementUseCases.getBacklogBoard` loads backlog candidates,
project display metadata, direct child previews, search/filter/sort/group
state, bucket policy results, and summary distributions through repository
read boundaries only.
3. `V1ApplicationManagementUseCases.getBacklogTaskDetail` returns the selected
backlog task drawer model, read-only derived tags, project options, and
non-mutating suggested slot previews. Missing duration returns a display
reason instead of attempting scheduling.
4. Focused application-management tests cover stable empty columns, bucket and
summary consistency, search/filter behavior, child previews, not-found detail
responses, read-only slot suggestions, and missing-duration handling.

View file

@ -18,9 +18,14 @@ import '../domain/models.dart';
import '../domain/project_statistics.dart';
import '../domain/time_contracts.dart';
import '../logging/focus_flow_logger.dart';
import '../persistence/repositories.dart';
import '../scheduling/scheduling_engine.dart';
part 'application_management/codes/application_management_command_code.dart';
part 'application_management/codes/owner_settings_warning_code.dart';
part 'application_management/requests/backlog_board_group_mode.dart';
part 'application_management/requests/get_backlog_board_request.dart';
part 'application_management/requests/get_backlog_request.dart';
part 'application_management/requests/get_backlog_task_detail_request.dart';
part 'application_management/read_models/backlog_item_read_model.dart';
part 'application_management/read_models/backlog_query_result.dart';
part 'application_management/read_models/backlog_board/backlog_board_column_read_model.dart';

View file

@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../application_management.dart';
/// Optional grouping mode requested by the Backlog board UI.
enum BacklogBoardGroupMode {
/// No additional grouping beyond the canonical board buckets.
none,
/// Preserve board buckets while allowing project-aware ordering inside them.
project,
}

View file

@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../application_management.dart';
/// Request for the Backlog board projection.
class GetBacklogBoardRequest {
/// Creates a board request with optional search, filters, sorting, and grouping.
GetBacklogBoardRequest({
required this.context,
required this.localDate,
this.searchText,
List<BacklogFilter> filters = const <BacklogFilter>[],
this.sortKey = BacklogSortKey.priority,
this.groupMode = BacklogBoardGroupMode.none,
}) : filters = List<BacklogFilter>.unmodifiable(filters);
/// Read context containing owner scope and read instant.
final ApplicationOperationContext context;
/// Owner-local date used for schedule preview context.
final CivilDate localDate;
/// Optional user-entered search text.
final String? searchText;
/// Optional user-facing backlog filters.
final List<BacklogFilter> filters;
/// User-facing sort order used inside each board bucket.
final BacklogSortKey sortKey;
/// Optional grouping mode requested by the UI.
final BacklogBoardGroupMode groupMode;
}

View file

@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../application_management.dart';
/// Request for one Backlog task drawer projection.
class GetBacklogTaskDetailRequest {
/// Creates a detail request for [taskId].
const GetBacklogTaskDetailRequest({
required this.context,
required this.localDate,
required this.taskId,
});
/// Read context containing owner scope and read instant.
final ApplicationOperationContext context;
/// Owner-local date used for schedule preview context.
final CivilDate localDate;
/// Selected backlog task id.
final String taskId;
}

View file

@ -10,6 +10,9 @@ class V1ApplicationManagementUseCases {
const V1ApplicationManagementUseCases({
required this.applicationStore,
this.projectSuggestionService = const ProjectSuggestionService(),
this.timeZoneResolver = const FixedOffsetTimeZoneResolver.utc(),
this.resolutionOptions = const TimeZoneResolutionOptions(),
this.backlogBoardBucketPolicy = const BacklogBoardBucketPolicy(),
});
/// Atomic application boundary.
@ -18,6 +21,15 @@ class V1ApplicationManagementUseCases {
/// Learned project suggestion policy.
final ProjectSuggestionService projectSuggestionService;
/// Resolver used for read-only planning previews.
final TimeZoneResolver timeZoneResolver;
/// DST/nonexistent/repeated local time policy for read-only previews.
final TimeZoneResolutionOptions resolutionOptions;
/// Canonical board bucket policy used by Backlog board reads.
final BacklogBoardBucketPolicy backlogBoardBucketPolicy;
/// Load backlog candidates through the repository status filter, then apply
/// existing filter/sort/staleness policies in memory.
Future<ApplicationResult<BacklogQueryResult>> getBacklog(
@ -69,6 +81,491 @@ class V1ApplicationManagementUseCases {
);
}
/// Load the Backlog board projection without mutating repositories.
Future<ApplicationResult<BacklogBoardQueryResult>> getBacklogBoard(
GetBacklogBoardRequest request,
) {
return applicationStore.read(
action: (repositories) async {
final context = request.context;
final ownerId = context.ownerTimeZone.ownerId;
logger.debug(() => 'Loading backlog board. ownerId=$ownerId '
'localDate=${request.localDate.toIsoString()} '
'filterCount=${request.filters.length} sortKey=${request.sortKey.name} '
'groupMode=${request.groupMode.name}');
final settings = await _ownerSettingsOrDefault(
repositories,
context.ownerTimeZone,
);
final projects = await _loadActiveProjects(repositories, ownerId);
final projectById = {
for (final project in projects) project.id: project,
};
final candidates = await _loadBacklogCandidates(repositories, ownerId);
final filtered = _filterBacklogTasks(
tasks: candidates,
request: request,
settings: settings,
);
final searched = _searchBacklogTasks(
tasks: filtered,
searchText: request.searchText,
projectById: projectById,
);
final sorted = _sortBoardTasks(
tasks: searched,
request: request,
settings: settings,
projectById: projectById,
);
final childrenByParentId = await _loadChildrenByParentId(
repositories: repositories,
ownerId: ownerId,
parentTasks: sorted,
);
final items = [
for (final task in sorted)
_boardItemForTask(
task: task,
projectById: projectById,
childTasks: childrenByParentId[task.id] ?? const <Task>[],
),
];
logger.debug(() => 'Backlog board loaded. ownerId=$ownerId '
'candidateCount=${candidates.length} itemCount=${items.length}');
return ApplicationResult.success(
BacklogBoardQueryResult(
ownerId: ownerId,
localDate: request.localDate,
summary: _summaryForBoardItems(items),
columns: _columnsForBoardItems(items),
projectOptions: _projectOptionsFor(projects),
),
);
},
);
}
/// Load one Backlog task drawer projection without mutating repositories.
Future<ApplicationResult<BacklogTaskDetailReadModel>> getBacklogTaskDetail(
GetBacklogTaskDetailRequest request,
) {
return applicationStore.read(
action: (repositories) async {
final context = request.context;
final ownerId = context.ownerTimeZone.ownerId;
logger.debug(() => 'Loading backlog task detail. ownerId=$ownerId '
'taskId=${request.taskId} localDate=${request.localDate.toIsoString()}');
final record = await repositories.tasks.findRecordById(request.taskId);
if (record == null ||
record.ownerId != ownerId ||
!record.value.isBacklog) {
logger.warn(() => 'Backlog task detail not found. '
'ownerId=$ownerId taskId=${request.taskId}');
return _failure(
ApplicationFailureCode.notFound,
entityId: request.taskId,
);
}
final task = record.value;
final projects = await _loadActiveProjects(repositories, ownerId);
final projectById = {
for (final project in projects) project.id: project,
};
final childrenByParentId = await _loadChildrenByParentId(
repositories: repositories,
ownerId: ownerId,
parentTasks: [task],
);
final item = _boardItemForTask(
task: task,
projectById: projectById,
childTasks: childrenByParentId[task.id] ?? const <Task>[],
);
final suggestions = await _suggestedSlotsForTask(
repositories: repositories,
context: context,
localDate: request.localDate,
task: task,
);
logger.debug(() => 'Backlog task detail loaded. ownerId=$ownerId '
'taskId=${task.id} suggestedSlotCount=${suggestions.length}');
return ApplicationResult.success(
BacklogTaskDetailReadModel(
item: item,
notes: null,
addedLabel: _addedLabelFor(task, context.now),
tags: _readOnlyTagLabelsFor(task),
projectOptions: _projectOptionsFor(projects),
suggestedSlots: suggestions,
suggestedSlotUnavailableReason:
_suggestedSlotUnavailableReasonFor(task, suggestions),
),
);
},
);
}
/// Load all active owner projects used by Backlog board reads.
Future<List<ProjectProfile>> _loadActiveProjects(
ApplicationUnitOfWorkRepositories repositories,
String ownerId,
) async {
final projects = <ProjectProfile>[];
String? cursor;
do {
final page = await repositories.projects.findByOwner(
ownerId: ownerId,
page: RepositoryPageRequest(cursor: cursor, limit: 200),
);
projects.addAll(page.items);
cursor = page.nextCursor;
} while (cursor != null);
projects.sort((a, b) => a.name.compareTo(b.name));
return List<ProjectProfile>.unmodifiable(projects);
}
/// Load all owner-scoped backlog candidates for the board.
Future<List<Task>> _loadBacklogCandidates(
ApplicationUnitOfWorkRepositories repositories,
String ownerId,
) async {
final tasks = <Task>[];
String? cursor;
do {
final page = await repositories.tasks.findBacklogCandidates(
BacklogCandidateQuery(
ownerId: ownerId,
page: RepositoryPageRequest(cursor: cursor, limit: 200),
),
);
tasks.addAll(page.items);
cursor = page.nextCursor;
} while (cursor != null);
return List<Task>.unmodifiable(tasks);
}
/// Applies request filters using the existing Backlog view policy.
List<Task> _filterBacklogTasks({
required List<Task> tasks,
required GetBacklogBoardRequest request,
required OwnerSettings settings,
}) {
var filtered = tasks;
for (final filter in request.filters) {
filtered = BacklogView(
tasks: filtered,
now: request.context.now,
stalenessSettings: settings.backlogStalenessSettings,
).filter(filter);
}
return List<Task>.unmodifiable(filtered);
}
/// Applies case-insensitive text search over task title, project, and derived tags.
List<Task> _searchBacklogTasks({
required List<Task> tasks,
required String? searchText,
required Map<String, ProjectProfile> projectById,
}) {
final query = searchText?.trim().toLowerCase();
if (query == null || query.isEmpty) {
return tasks;
}
return List<Task>.unmodifiable(
tasks.where((task) {
final project = projectById[task.projectId];
final haystack = [
task.title,
task.projectId,
if (project != null) project.name,
..._readOnlyTagLabelsFor(task),
].join(' ').toLowerCase();
return haystack.contains(query);
}),
);
}
/// Sorts board tasks using the requested sort and optional project grouping.
List<Task> _sortBoardTasks({
required List<Task> tasks,
required GetBacklogBoardRequest request,
required OwnerSettings settings,
required Map<String, ProjectProfile> projectById,
}) {
final sorted = BacklogView(
tasks: tasks,
now: request.context.now,
stalenessSettings: settings.backlogStalenessSettings,
).sorted(request.sortKey);
if (request.groupMode == BacklogBoardGroupMode.none) {
return sorted;
}
final indexed = sorted.asMap().entries.toList(growable: false);
indexed.sort((a, b) {
final projectComparison = _projectNameFor(
a.value.projectId,
projectById,
).compareTo(
_projectNameFor(b.value.projectId, projectById),
);
if (projectComparison != 0) {
return projectComparison;
}
return a.key.compareTo(b.key);
});
return List<Task>.unmodifiable(indexed.map((entry) => entry.value));
}
/// Loads direct child task previews for [parentTasks].
Future<Map<String, List<Task>>> _loadChildrenByParentId({
required ApplicationUnitOfWorkRepositories repositories,
required String ownerId,
required List<Task> parentTasks,
}) async {
final result = <String, List<Task>>{};
for (final parent in parentTasks) {
final children = <Task>[];
String? cursor;
do {
final page = await repositories.tasks.findByParentTaskId(
ownerId: ownerId,
parentTaskId: parent.id,
page: RepositoryPageRequest(cursor: cursor, limit: 50),
);
children.addAll(page.items);
cursor = page.nextCursor;
} while (cursor != null);
children.sort((a, b) => a.createdAt.compareTo(b.createdAt));
result[parent.id] = List<Task>.unmodifiable(children);
}
return Map<String, List<Task>>.unmodifiable(result);
}
/// Maps one task into its board card read model.
BacklogBoardItemReadModel _boardItemForTask({
required Task task,
required Map<String, ProjectProfile> projectById,
required List<Task> childTasks,
}) {
final resolution = backlogBoardBucketPolicy.resolve(
task: task,
hasChildTasks: childTasks.isNotEmpty,
);
return BacklogBoardItemReadModel(
taskId: task.id,
title: task.title,
subtitle: null,
projectId: task.projectId,
projectName: _projectNameFor(task.projectId, projectById),
projectColorToken: _projectColorTokenFor(task.projectId, projectById),
durationMinutes: task.durationMinutes,
priority: task.priority,
reward: task.reward,
difficulty: task.difficulty,
bucket: resolution.bucket,
bucketReason: resolution.reason,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
hasChildren: childTasks.isNotEmpty,
childPreview: [
for (final child in childTasks.take(3))
BacklogChildPreviewReadModel(
taskId: child.id,
title: child.title,
durationMinutes: child.durationMinutes,
isCompleted: child.status == TaskStatus.completed,
statusLabel: _taskStatusLabel(child.status),
),
],
tags: _readOnlyTagLabelsFor(task),
);
}
/// Builds the stable four-column board from [items].
List<BacklogBoardColumnReadModel> _columnsForBoardItems(
List<BacklogBoardItemReadModel> items,
) {
return [
for (final bucket in _boardBucketOrder)
BacklogBoardColumnReadModel(
bucket: bucket,
title: _bucketTitle(bucket),
subtitle: _bucketSubtitle(bucket),
accentToken: _bucketAccentToken(bucket),
items: [
for (final item in items)
if (item.bucket == bucket) item,
],
),
];
}
/// Builds summary counts from the same filtered board [items].
BacklogBoardSummaryReadModel _summaryForBoardItems(
List<BacklogBoardItemReadModel> items,
) {
final totalEstimatedMinutes = items.fold<int>(
0,
(sum, item) => sum + (item.durationMinutes ?? 0),
);
return BacklogBoardSummaryReadModel(
totalTaskCount: items.length,
totalEstimatedMinutes: totalEstimatedMinutes,
priorityDistribution: _priorityDistributionFor(items),
projectDistribution: _projectDistributionFor(items),
durationDistribution: _durationDistributionFor(items),
planningTip: _planningTipFor(items),
);
}
/// Builds project picker options for the board and drawer.
List<ProjectOptionReadModel> _projectOptionsFor(
List<ProjectProfile> projects,
) {
return List<ProjectOptionReadModel>.unmodifiable(
projects.map(
(project) => ProjectOptionReadModel(
projectId: project.id,
name: project.name,
colorToken: project.colorKey,
isArchived: project.isArchived,
),
),
);
}
/// Builds non-mutating suggested slots for [task].
Future<List<SuggestedSlotReadModel>> _suggestedSlotsForTask({
required ApplicationUnitOfWorkRepositories repositories,
required ApplicationOperationContext context,
required CivilDate localDate,
required Task task,
}) async {
final durationMinutes = task.durationMinutes;
if (durationMinutes == null) {
return const <SuggestedSlotReadModel>[];
}
final ownerId = context.ownerTimeZone.ownerId;
final settings = await _ownerSettingsOrDefault(
repositories,
context.ownerTimeZone,
);
final window = _planningWindowFor(
date: localDate,
settings: settings.copyWith(timeZoneId: context.ownerTimeZone.timeZoneId),
);
final lockedExpansion = expandLockedBlocksForDay(
blocks: (await repositories.lockedBlocks.findBlocksByOwner(
ownerId: ownerId,
))
.items,
overrides: (await repositories.lockedBlocks.findOverridesForDate(
ownerId: ownerId,
date: localDate,
))
.items,
date: localDate,
timeZoneId: context.ownerTimeZone.timeZoneId,
timeZoneResolver: timeZoneResolver,
resolutionOptions: resolutionOptions,
);
final tasks = (await repositories.tasks.findForLocalDay(
ownerId: ownerId,
localDate: localDate,
window: window,
))
.items;
final input = SchedulingInput(
tasks: tasks,
window: window,
lockedIntervals: lockedExpansion.schedulingIntervals,
);
final duration = Duration(minutes: durationMinutes);
final windowStart = _previewStartFor(window, context.now);
final primary = const SchedulingEngine().findFirstOpenInterval(
windowStart: windowStart,
windowEnd: window.end,
duration: duration,
blocked: input.blockedIntervals,
);
if (primary == null) {
return const <SuggestedSlotReadModel>[];
}
final localDateLabel = _localDateLabelFor(localDate, window, context.now);
final slots = <SuggestedSlotReadModel>[
_suggestedSlotReadModel(
id: '${task.id}:primary',
interval: primary,
localDateLabel: localDateLabel,
durationMinutes: durationMinutes,
fitLabel: 'Great Fit',
fitSeverityToken: 'fit-great',
isPrimary: true,
),
];
final secondary = const SchedulingEngine().findFirstOpenInterval(
windowStart: primary.end,
windowEnd: window.end,
duration: duration,
blocked: input.blockedIntervals,
);
if (secondary != null && secondary.start != primary.start) {
slots.add(
_suggestedSlotReadModel(
id: '${task.id}:secondary',
interval: secondary,
localDateLabel: localDateLabel,
durationMinutes: durationMinutes,
fitLabel: 'Okay',
fitSeverityToken: 'fit-okay',
isPrimary: false,
),
);
}
return List<SuggestedSlotReadModel>.unmodifiable(slots);
}
/// Resolves the owner-local planning window for [date].
SchedulingWindow _planningWindowFor({
required CivilDate date,
required OwnerSettings settings,
}) {
return SchedulingWindow(
start: _resolve(
date: date,
wallTime: settings.dayStart,
timeZoneId: settings.timeZoneId,
),
end: _resolve(
date: date,
wallTime: settings.dayEnd,
timeZoneId: settings.timeZoneId,
),
);
}
/// Resolves [wallTime] on [date] through the configured preview resolver.
DateTime _resolve({
required CivilDate date,
required WallTime wallTime,
required String timeZoneId,
}) {
return timeZoneResolver
.resolve(
date: date,
wallTime: wallTime,
timeZoneId: timeZoneId,
options: resolutionOptions,
)
.instant;
}
/// Load configured project defaults with learned suggestions kept separate.
Future<ApplicationResult<ProjectDefaultsQueryResult>> getProjectDefaults(
GetProjectDefaultsRequest request,
@ -605,6 +1102,335 @@ class V1ApplicationManagementUseCases {
}
}
/// Canonical board bucket order for the V1 Backlog board.
const _boardBucketOrder = <BacklogBoardBucket>[
BacklogBoardBucket.doNext,
BacklogBoardBucket.needTimeBlock,
BacklogBoardBucket.breakUpFirst,
BacklogBoardBucket.notNow,
];
/// Resolves the display title for [bucket].
String _bucketTitle(BacklogBoardBucket bucket) {
return switch (bucket) {
BacklogBoardBucket.doNext => 'Do Next',
BacklogBoardBucket.needTimeBlock => 'Need Time Block',
BacklogBoardBucket.breakUpFirst => 'Break Up First',
BacklogBoardBucket.notNow => 'Not Now',
};
}
/// Resolves the display subtitle for [bucket].
String _bucketSubtitle(BacklogBoardBucket bucket) {
return switch (bucket) {
BacklogBoardBucket.doNext => 'Ready when you have a small opening.',
BacklogBoardBucket.needTimeBlock => 'Give these a protected block.',
BacklogBoardBucket.breakUpFirst => 'Split these before scheduling.',
BacklogBoardBucket.notNow => 'Keep these out of today pressure.',
};
}
/// Resolves the UI accent token for [bucket].
String _bucketAccentToken(BacklogBoardBucket bucket) {
return switch (bucket) {
BacklogBoardBucket.doNext => 'backlog-do-next',
BacklogBoardBucket.needTimeBlock => 'backlog-time-block',
BacklogBoardBucket.breakUpFirst => 'backlog-break-up',
BacklogBoardBucket.notNow => 'backlog-not-now',
};
}
/// Resolves the display project name for [projectId].
String _projectNameFor(
String projectId,
Map<String, ProjectProfile> projectById,
) {
return projectById[projectId]?.name ?? _fallbackProjectName(projectId);
}
/// Resolves the UI project color token for [projectId].
String _projectColorTokenFor(
String projectId,
Map<String, ProjectProfile> projectById,
) {
return projectById[projectId]?.colorKey ?? 'project-$projectId';
}
/// Builds a readable project fallback from a stable project id.
String _fallbackProjectName(String projectId) {
final words = projectId
.split(RegExp('[-_]'))
.where((word) => word.trim().isNotEmpty)
.toList(growable: false);
if (words.isEmpty) {
return projectId;
}
return words.map(_capitalized).join(' ');
}
/// Capitalizes [value] for fallback display labels.
String _capitalized(String value) {
if (value.isEmpty) {
return value;
}
return value.substring(0, 1).toUpperCase() + value.substring(1);
}
/// Resolves a compact task status label.
String _taskStatusLabel(TaskStatus status) {
return switch (status) {
TaskStatus.backlog => 'Backlog',
TaskStatus.planned => 'Planned',
TaskStatus.active => 'Active',
TaskStatus.completed => 'Completed',
TaskStatus.cancelled => 'Archived',
TaskStatus.missed => 'Missed',
TaskStatus.noLongerRelevant => 'Archived',
};
}
/// Builds read-only tag labels from existing narrow backlog flags.
List<String> _readOnlyTagLabelsFor(Task task) {
final labels = [
for (final tag in task.backlogTags)
switch (tag) {
BacklogTag.wishlist => 'someday',
},
]..sort();
return List<String>.unmodifiable(labels);
}
/// Builds the priority distribution for the summary panel.
BacklogDistributionReadModel _priorityDistributionFor(
List<BacklogBoardItemReadModel> items,
) {
final priorities = <PriorityLevel?>[
PriorityLevel.veryHigh,
PriorityLevel.high,
PriorityLevel.medium,
PriorityLevel.low,
PriorityLevel.veryLow,
null,
];
final entries = <BacklogDistributionEntryReadModel>[];
for (final priority in priorities) {
final count = items.where((item) => item.priority == priority).length;
if (count == 0) {
continue;
}
entries.add(
BacklogDistributionEntryReadModel(
id: priority?.name ?? 'unset',
label: _priorityLabel(priority),
count: count,
percentage: _percentage(count, items.length),
colorToken: priority == null ? null : 'priority-${priority.name}',
),
);
}
return BacklogDistributionReadModel(title: 'Priority', entries: entries);
}
/// Builds the project distribution for the summary panel.
BacklogDistributionReadModel _projectDistributionFor(
List<BacklogBoardItemReadModel> items,
) {
final projectIds = items.map((item) => item.projectId).toSet().toList()
..sort((a, b) {
final left = items.firstWhere((item) => item.projectId == a).projectName;
final right = items.firstWhere((item) => item.projectId == b).projectName;
return left.compareTo(right);
});
final entries = [
for (final projectId in projectIds)
BacklogDistributionEntryReadModel(
id: projectId,
label:
items.firstWhere((item) => item.projectId == projectId).projectName,
count: items.where((item) => item.projectId == projectId).length,
percentage: _percentage(
items.where((item) => item.projectId == projectId).length,
items.length,
),
colorToken: items
.firstWhere((item) => item.projectId == projectId)
.projectColorToken,
),
];
return BacklogDistributionReadModel(title: 'Project', entries: entries);
}
/// Builds the duration distribution for the summary panel.
BacklogDistributionReadModel _durationDistributionFor(
List<BacklogBoardItemReadModel> items,
) {
final short = items.where((item) {
final duration = item.durationMinutes;
return duration != null && duration <= 30;
}).length;
final medium = items.where((item) {
final duration = item.durationMinutes;
return duration != null && duration > 30 && duration <= 60;
}).length;
final long = items.where((item) {
final duration = item.durationMinutes;
return duration != null && duration > 60 && duration <= 120;
}).length;
final veryLong = items.where((item) {
final duration = item.durationMinutes;
return duration != null && duration > 120;
}).length;
final missing = items.where((item) => item.durationMinutes == null).length;
final entries = <BacklogDistributionEntryReadModel>[
BacklogDistributionEntryReadModel(
id: '0-30',
label: '0-30',
count: short,
percentage: _percentage(short, items.length),
),
BacklogDistributionEntryReadModel(
id: '30-60',
label: '30-60',
count: medium,
percentage: _percentage(medium, items.length),
),
BacklogDistributionEntryReadModel(
id: '60-120',
label: '60-120',
count: long,
percentage: _percentage(long, items.length),
),
BacklogDistributionEntryReadModel(
id: '120+',
label: '120+',
count: veryLong,
percentage: _percentage(veryLong, items.length),
),
if (missing > 0)
BacklogDistributionEntryReadModel(
id: 'no-estimate',
label: 'No estimate',
count: missing,
percentage: _percentage(missing, items.length),
),
];
return BacklogDistributionReadModel(title: 'Duration', entries: entries);
}
/// Resolves a display label for [priority].
String _priorityLabel(PriorityLevel? priority) {
return switch (priority) {
PriorityLevel.veryHigh => 'Very High',
PriorityLevel.high => 'High',
PriorityLevel.medium => 'Medium',
PriorityLevel.low => 'Low',
PriorityLevel.veryLow => 'Very Low',
null => 'Unset',
};
}
/// Calculates a distribution percentage.
double _percentage(int count, int total) {
if (total == 0) {
return 0;
}
return count / total;
}
/// Resolves the summary planning tip from the current board shape.
String _planningTipFor(List<BacklogBoardItemReadModel> items) {
if (items.isEmpty) {
return 'Backlog is clear.';
}
if (items.any((item) => item.bucket == BacklogBoardBucket.breakUpFirst)) {
return 'Break up one large task before adding more to today.';
}
if (items.any((item) => item.bucket == BacklogBoardBucket.needTimeBlock)) {
return 'Protect one focused block before choosing smaller tasks.';
}
return 'Pick one Do Next task that fits your current energy.';
}
/// Picks the preview search start within [window].
DateTime _previewStartFor(SchedulingWindow window, DateTime now) {
if (now.isAfter(window.start) && now.isBefore(window.end)) {
return now;
}
if (now.isAfter(window.end) || now.isAtSameMomentAs(window.end)) {
return window.end;
}
return window.start;
}
/// Builds the display label for a suggested slot date.
String _localDateLabelFor(
CivilDate localDate,
SchedulingWindow window,
DateTime now,
) {
if ((now.isAfter(window.start) || now.isAtSameMomentAs(window.start)) &&
now.isBefore(window.end)) {
return 'Today';
}
return localDate.toIsoString();
}
/// Builds one suggested slot read model from [interval].
SuggestedSlotReadModel _suggestedSlotReadModel({
required String id,
required TimeInterval interval,
required String localDateLabel,
required int durationMinutes,
required String fitLabel,
required String fitSeverityToken,
required bool isPrimary,
}) {
return SuggestedSlotReadModel(
id: id,
localDateLabel: localDateLabel,
startText: _clockText(interval.start),
endText: _clockText(interval.end),
durationMinutes: durationMinutes,
fitLabel: fitLabel,
fitSeverityToken: fitSeverityToken,
isPrimary: isPrimary,
);
}
/// Formats [instant] as compact 12-hour clock text.
String _clockText(DateTime instant) {
final hour12 = instant.hour % 12 == 0 ? 12 : instant.hour % 12;
final minute = instant.minute.toString().padLeft(2, '0');
final period = instant.hour < 12 ? 'AM' : 'PM';
return '$hour12:$minute $period';
}
/// Returns a display reason when suggested slots are unavailable.
String? _suggestedSlotUnavailableReasonFor(
Task task,
List<SuggestedSlotReadModel> suggestions,
) {
if (task.durationMinutes == null) {
return 'Add an estimate to see suggested slots.';
}
if (suggestions.isEmpty) {
return 'No open slot fits this estimate today.';
}
return null;
}
/// Builds the drawer added-age label for [task].
String _addedLabelFor(Task task, DateTime now) {
final days = now.difference(task.backlogFreshnessAnchorAt).inDays;
if (days <= 0) {
return 'Added today';
}
if (days == 1) {
return 'Added yesterday';
}
return 'Added $days days ago';
}
/// Top-level helper that performs the `_ownerSettingsOrDefault` operation for this file.
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Future<OwnerSettings> _ownerSettingsOrDefault(

View file

@ -217,6 +217,368 @@ void main() {
expect(() => detail.suggestedSlots.clear(), throwsUnsupportedError);
});
test('returns empty backlog board with stable columns', () async {
final store = InMemoryApplicationUnitOfWork();
final result = await V1ApplicationManagementUseCases(
applicationStore: store,
).getBacklogBoard(
GetBacklogBoardRequest(
context: appContext(operationId: 'empty-board', now: now),
localDate: CivilDate(2026, 6, 25),
),
);
final board = result.requireValue;
expect(board.ownerId, 'owner-1');
expect(board.columns.map((column) => column.bucket), [
BacklogBoardBucket.doNext,
BacklogBoardBucket.needTimeBlock,
BacklogBoardBucket.breakUpFirst,
BacklogBoardBucket.notNow,
]);
expect(board.columns.map((column) => column.count), [0, 0, 0, 0]);
expect(
board.columns.every((column) => column.items.isEmpty),
isTrue,
);
expect(board.summary.totalTaskCount, 0);
expect(board.summary.totalEstimatedMinutes, 0);
expect(board.summary.projectDistribution.entries, isEmpty);
});
test('groups backlog board tasks into buckets and matching summaries',
() async {
final tasks = [
backlogTask(
id: 'quick',
title: 'Reply to 3 emails',
projectId: 'home',
createdAt: now.subtract(const Duration(days: 1)),
durationMinutes: 20,
priority: PriorityLevel.medium,
reward: RewardLevel.high,
difficulty: DifficultyLevel.easy,
),
backlogTask(
id: 'block',
title: 'Review Q3 budget',
projectId: 'work',
createdAt: now.subtract(const Duration(days: 2)),
durationMinutes: 60,
priority: PriorityLevel.high,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
),
backlogTask(
id: 'break',
title: 'Plan next week',
projectId: 'work',
createdAt: now.subtract(const Duration(days: 3)),
durationMinutes: 120,
priority: PriorityLevel.medium,
reward: RewardLevel.medium,
difficulty: DifficultyLevel.medium,
),
backlogTask(
id: 'later',
title: 'Learn watercolor',
projectId: 'personal',
createdAt: now.subtract(const Duration(days: 4)),
durationMinutes: 15,
priority: PriorityLevel.low,
backlogTags: const {BacklogTag.wishlist},
),
backlogTask(
id: 'planned',
title: 'Already planned',
projectId: 'home',
createdAt: now,
status: TaskStatus.planned,
scheduledStart: DateTime.utc(2026, 6, 25, 15),
scheduledEnd: DateTime.utc(2026, 6, 25, 15, 30),
),
];
final store = InMemoryApplicationUnitOfWork(
initialProjects: [
ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'),
ProjectProfile(id: 'personal', name: 'Personal', colorKey: 'pink'),
ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'),
],
initialTasks: tasks,
);
final board = (await V1ApplicationManagementUseCases(
applicationStore: store,
).getBacklogBoard(
GetBacklogBoardRequest(
context: appContext(operationId: 'bucket-board', now: now),
localDate: CivilDate(2026, 6, 25),
sortKey: BacklogSortKey.age,
),
))
.requireValue;
BacklogBoardColumnReadModel column(BacklogBoardBucket bucket) {
return board.columns.singleWhere((column) => column.bucket == bucket);
}
expect(column(BacklogBoardBucket.doNext).items.single.taskId, 'quick');
expect(
column(BacklogBoardBucket.needTimeBlock).items.single.taskId,
'block',
);
expect(
column(BacklogBoardBucket.breakUpFirst).items.single.taskId,
'break',
);
expect(column(BacklogBoardBucket.notNow).items.single.taskId, 'later');
expect(
board.columns
.expand((column) => column.items)
.map((item) => item.taskId),
isNot(contains('planned')),
);
expect(board.summary.totalTaskCount, 4);
expect(board.summary.totalEstimatedMinutes, 215);
expect(
board.summary.projectDistribution.entries.map((entry) => entry.label),
['Home', 'Personal', 'Work'],
);
expect(
board.summary.projectDistribution.entries.map((entry) => entry.count),
[1, 1, 2],
);
});
test('applies backlog board search, filters, and project grouping',
() async {
final store = InMemoryApplicationUnitOfWork(
initialProjects: [
ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'),
ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'),
],
initialTasks: [
backlogTask(
id: 'home-wish',
title: 'Organize hallway closet',
projectId: 'home',
createdAt: now.subtract(const Duration(days: 1)),
backlogTags: const {BacklogTag.wishlist},
),
backlogTask(
id: 'work-wish',
title: 'Read optional design notes',
projectId: 'work',
createdAt: now.subtract(const Duration(days: 2)),
backlogTags: const {BacklogTag.wishlist},
),
backlogTask(
id: 'not-filtered',
title: 'Someday wording but normal backlog',
projectId: 'work',
createdAt: now.subtract(const Duration(days: 3)),
),
],
);
final board = (await V1ApplicationManagementUseCases(
applicationStore: store,
).getBacklogBoard(
GetBacklogBoardRequest(
context: appContext(operationId: 'search-board', now: now),
localDate: CivilDate(2026, 6, 25),
searchText: 'someday',
filters: const [BacklogFilter.wishlist],
groupMode: BacklogBoardGroupMode.project,
),
))
.requireValue;
final notNow = board.columns.singleWhere(
(column) => column.bucket == BacklogBoardBucket.notNow,
);
expect(board.summary.totalTaskCount, 2);
expect(notNow.items.map((item) => item.taskId), [
'home-wish',
'work-wish',
]);
expect(
notNow.items.every((item) => item.tags.contains('someday')), isTrue);
expect(
board.columns
.expand((column) => column.items)
.map((item) => item.taskId),
isNot(contains('not-filtered')),
);
});
test('loads backlog task detail with child previews', () async {
final parent = backlogTask(
id: 'parent',
title: 'Plan next week',
createdAt: now.subtract(const Duration(days: 5)),
durationMinutes: 30,
);
final childOne = backlogTask(
id: 'child-1',
title: 'Define weekly goals',
createdAt: now.subtract(const Duration(days: 4)),
durationMinutes: 15,
parentTaskId: parent.id,
);
final childTwo = backlogTask(
id: 'child-2',
title: 'Time block schedule',
createdAt: now.subtract(const Duration(days: 3)),
durationMinutes: 20,
parentTaskId: parent.id,
status: TaskStatus.completed,
);
final store = InMemoryApplicationUnitOfWork(
initialTasks: [parent, childTwo, childOne],
);
final detail = (await V1ApplicationManagementUseCases(
applicationStore: store,
).getBacklogTaskDetail(
GetBacklogTaskDetailRequest(
context: appContext(operationId: 'detail-children', now: now),
localDate: CivilDate(2026, 6, 25),
taskId: parent.id,
),
))
.requireValue;
expect(detail.item.bucket, BacklogBoardBucket.breakUpFirst);
expect(detail.item.hasChildren, isTrue);
expect(detail.item.childPreview.map((child) => child.title), [
'Define weekly goals',
'Time block schedule',
]);
expect(detail.item.childPreview.map((child) => child.statusLabel), [
'Backlog',
'Completed',
]);
expect(detail.notes, isNull);
});
test('returns not found for missing or non-backlog task details', () async {
final store = InMemoryApplicationUnitOfWork(
initialTasks: [
backlogTask(
id: 'planned',
createdAt: now,
status: TaskStatus.planned,
scheduledStart: DateTime.utc(2026, 6, 25, 14),
scheduledEnd: DateTime.utc(2026, 6, 25, 14, 30),
),
],
);
final useCases = V1ApplicationManagementUseCases(
applicationStore: store,
);
final missing = await useCases.getBacklogTaskDetail(
GetBacklogTaskDetailRequest(
context: appContext(operationId: 'detail-missing', now: now),
localDate: CivilDate(2026, 6, 25),
taskId: 'missing',
),
);
final nonBacklog = await useCases.getBacklogTaskDetail(
GetBacklogTaskDetailRequest(
context: appContext(operationId: 'detail-planned', now: now),
localDate: CivilDate(2026, 6, 25),
taskId: 'planned',
),
);
expect(missing.failure!.code, ApplicationFailureCode.notFound);
expect(nonBacklog.failure!.code, ApplicationFailureCode.notFound);
});
test('previews detail suggested slots without mutating state', () async {
final day = CivilDate(2026, 6, 25);
final candidate = backlogTask(
id: 'candidate',
title: 'Update project status',
createdAt: now,
durationMinutes: 30,
);
final existingBlock = backlogTask(
id: 'existing-block',
title: 'Existing meeting',
createdAt: now,
type: TaskType.inflexible,
status: TaskStatus.planned,
scheduledStart: DateTime.utc(2026, 6, 25, 9),
scheduledEnd: DateTime.utc(2026, 6, 25, 10),
);
final store = InMemoryApplicationUnitOfWork(
initialOwnerSettings: [
OwnerSettings(
ownerId: 'owner-1',
timeZoneId: 'UTC',
dayStart: WallTime(hour: 9, minute: 0),
dayEnd: WallTime(hour: 12, minute: 0),
),
],
initialTasks: [candidate, existingBlock],
);
final beforeTasks = store.currentTasks;
final detail = (await V1ApplicationManagementUseCases(
applicationStore: store,
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
).getBacklogTaskDetail(
GetBacklogTaskDetailRequest(
context: appContext(
operationId: 'detail-suggestions',
now: DateTime.utc(2026, 6, 25, 8),
),
localDate: day,
taskId: candidate.id,
),
))
.requireValue;
expect(detail.suggestedSlots.first.startText, '10:00 AM');
expect(detail.suggestedSlots.first.endText, '10:30 AM');
expect(detail.suggestedSlots.first.fitLabel, 'Great Fit');
expect(detail.suggestedSlotUnavailableReason, isNull);
expect(store.currentTasks, beforeTasks);
expect(store.currentSchedulingSnapshots, isEmpty);
expect(store.currentOperations, isEmpty);
});
test('returns missing-duration suggestion reason without crashing',
() async {
final task = backlogTask(
id: 'missing-duration',
title: 'Clarify estimate',
createdAt: now,
durationMinutes: null,
);
final store = InMemoryApplicationUnitOfWork(initialTasks: [task]);
final detail = (await V1ApplicationManagementUseCases(
applicationStore: store,
).getBacklogTaskDetail(
GetBacklogTaskDetailRequest(
context: appContext(operationId: 'detail-no-duration', now: now),
localDate: CivilDate(2026, 6, 25),
taskId: task.id,
),
))
.requireValue;
expect(detail.suggestedSlots, isEmpty);
expect(
detail.suggestedSlotUnavailableReason,
'Add an estimate to see suggested slots.',
);
});
test('keeps configured project defaults separate from learned suggestions',
() async {
final project = ProjectProfile(
@ -487,17 +849,34 @@ ApplicationOperationContext appContext({
Task backlogTask({
required String id,
required DateTime createdAt,
String? title,
String projectId = 'inbox',
TaskType type = TaskType.flexible,
TaskStatus status = TaskStatus.backlog,
PriorityLevel? priority = PriorityLevel.medium,
RewardLevel reward = RewardLevel.notSet,
DifficultyLevel difficulty = DifficultyLevel.notSet,
int? durationMinutes = 30,
DateTime? scheduledStart,
DateTime? scheduledEnd,
String? parentTaskId,
Set<BacklogTag> backlogTags = const <BacklogTag>{},
bool pushed = false,
}) {
return Task(
id: id,
title: 'Task $id',
title: title ?? 'Task $id',
projectId: projectId,
type: TaskType.flexible,
status: TaskStatus.backlog,
priority: PriorityLevel.medium,
durationMinutes: 30,
type: type,
status: status,
priority: priority,
reward: reward,
difficulty: difficulty,
durationMinutes: durationMinutes,
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
parentTaskId: parentTaskId,
backlogTags: backlogTags,
createdAt: createdAt,
updatedAt: createdAt,
stats: pushed