Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue