feat(backlog): add board query use cases
This commit is contained in:
parent
d26645affb
commit
60039be127
7 changed files with 1308 additions and 5 deletions
|
|
@ -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
|
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
|
breakpoint where Ashley needs to choose whether drawer tags should be read-only
|
||||||
placeholders for now or true V1 metadata with a schema migration.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,14 @@ import '../domain/models.dart';
|
||||||
import '../domain/project_statistics.dart';
|
import '../domain/project_statistics.dart';
|
||||||
import '../domain/time_contracts.dart';
|
import '../domain/time_contracts.dart';
|
||||||
import '../logging/focus_flow_logger.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/application_management_command_code.dart';
|
||||||
part 'application_management/codes/owner_settings_warning_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_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_item_read_model.dart';
|
||||||
part 'application_management/read_models/backlog_query_result.dart';
|
part 'application_management/read_models/backlog_query_result.dart';
|
||||||
part 'application_management/read_models/backlog_board/backlog_board_column_read_model.dart';
|
part 'application_management/read_models/backlog_board/backlog_board_column_read_model.dart';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,9 @@ class V1ApplicationManagementUseCases {
|
||||||
const V1ApplicationManagementUseCases({
|
const V1ApplicationManagementUseCases({
|
||||||
required this.applicationStore,
|
required this.applicationStore,
|
||||||
this.projectSuggestionService = const ProjectSuggestionService(),
|
this.projectSuggestionService = const ProjectSuggestionService(),
|
||||||
|
this.timeZoneResolver = const FixedOffsetTimeZoneResolver.utc(),
|
||||||
|
this.resolutionOptions = const TimeZoneResolutionOptions(),
|
||||||
|
this.backlogBoardBucketPolicy = const BacklogBoardBucketPolicy(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Atomic application boundary.
|
/// Atomic application boundary.
|
||||||
|
|
@ -18,6 +21,15 @@ class V1ApplicationManagementUseCases {
|
||||||
/// Learned project suggestion policy.
|
/// Learned project suggestion policy.
|
||||||
final ProjectSuggestionService projectSuggestionService;
|
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
|
/// Load backlog candidates through the repository status filter, then apply
|
||||||
/// existing filter/sort/staleness policies in memory.
|
/// existing filter/sort/staleness policies in memory.
|
||||||
Future<ApplicationResult<BacklogQueryResult>> getBacklog(
|
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.
|
/// Load configured project defaults with learned suggestions kept separate.
|
||||||
Future<ApplicationResult<ProjectDefaultsQueryResult>> getProjectDefaults(
|
Future<ApplicationResult<ProjectDefaultsQueryResult>> getProjectDefaults(
|
||||||
GetProjectDefaultsRequest request,
|
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.
|
/// 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.
|
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||||
Future<OwnerSettings> _ownerSettingsOrDefault(
|
Future<OwnerSettings> _ownerSettingsOrDefault(
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,368 @@ void main() {
|
||||||
expect(() => detail.suggestedSlots.clear(), throwsUnsupportedError);
|
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',
|
test('keeps configured project defaults separate from learned suggestions',
|
||||||
() async {
|
() async {
|
||||||
final project = ProjectProfile(
|
final project = ProjectProfile(
|
||||||
|
|
@ -487,17 +849,34 @@ ApplicationOperationContext appContext({
|
||||||
Task backlogTask({
|
Task backlogTask({
|
||||||
required String id,
|
required String id,
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
|
String? title,
|
||||||
String projectId = 'inbox',
|
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,
|
bool pushed = false,
|
||||||
}) {
|
}) {
|
||||||
return Task(
|
return Task(
|
||||||
id: id,
|
id: id,
|
||||||
title: 'Task $id',
|
title: title ?? 'Task $id',
|
||||||
projectId: projectId,
|
projectId: projectId,
|
||||||
type: TaskType.flexible,
|
type: type,
|
||||||
status: TaskStatus.backlog,
|
status: status,
|
||||||
priority: PriorityLevel.medium,
|
priority: priority,
|
||||||
durationMinutes: 30,
|
reward: reward,
|
||||||
|
difficulty: difficulty,
|
||||||
|
durationMinutes: durationMinutes,
|
||||||
|
scheduledStart: scheduledStart,
|
||||||
|
scheduledEnd: scheduledEnd,
|
||||||
|
parentTaskId: parentTaskId,
|
||||||
|
backlogTags: backlogTags,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: createdAt,
|
updatedAt: createdAt,
|
||||||
stats: pushed
|
stats: pushed
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue