From 60039be1273a66a96a3a308bf6788fc096910d87 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 7 Jul 2026 12:46:45 -0700 Subject: [PATCH] feat(backlog): add board query use cases --- .../UI_PLAN_2_IMPLEMENTATION_NOTES.md | 22 + .../application/application_management.dart | 5 + .../requests/backlog_board_group_mode.dart | 13 + .../requests/get_backlog_board_request.dart | 35 + .../get_backlog_task_detail_request.dart | 23 + .../v1_application_management_use_cases.dart | 826 ++++++++++++++++++ .../test/application_management_test.dart | 389 ++++++++- 7 files changed, 1308 insertions(+), 5 deletions(-) create mode 100644 packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_group_mode.dart create mode 100644 packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_board_request.dart create mode 100644 packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_task_detail_request.dart diff --git a/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md b/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md index 3f59e1f..d0cc9df 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md +++ b/Codex Documentation/Current Software Plan/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md @@ -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. diff --git a/packages/scheduler_core/lib/src/application/application_management.dart b/packages/scheduler_core/lib/src/application/application_management.dart index 3e29293..7f84f01 100644 --- a/packages/scheduler_core/lib/src/application/application_management.dart +++ b/packages/scheduler_core/lib/src/application/application_management.dart @@ -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'; diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_group_mode.dart b/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_group_mode.dart new file mode 100644 index 0000000..5a0dbf0 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_group_mode.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, +} diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_board_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_board_request.dart new file mode 100644 index 0000000..ceebb27 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_board_request.dart @@ -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 filters = const [], + this.sortKey = BacklogSortKey.priority, + this.groupMode = BacklogBoardGroupMode.none, + }) : filters = List.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 filters; + + /// User-facing sort order used inside each board bucket. + final BacklogSortKey sortKey; + + /// Optional grouping mode requested by the UI. + final BacklogBoardGroupMode groupMode; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_task_detail_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_task_detail_request.dart new file mode 100644 index 0000000..9f93bae --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_task_detail_request.dart @@ -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; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart index 1b8ea36..ed0b84a 100644 --- a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart @@ -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> getBacklog( @@ -69,6 +81,491 @@ class V1ApplicationManagementUseCases { ); } + /// Load the Backlog board projection without mutating repositories. + Future> 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 [], + ), + ]; + + 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> 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 [], + ); + 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> _loadActiveProjects( + ApplicationUnitOfWorkRepositories repositories, + String ownerId, + ) async { + final projects = []; + 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.unmodifiable(projects); + } + + /// Load all owner-scoped backlog candidates for the board. + Future> _loadBacklogCandidates( + ApplicationUnitOfWorkRepositories repositories, + String ownerId, + ) async { + final tasks = []; + 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.unmodifiable(tasks); + } + + /// Applies request filters using the existing Backlog view policy. + List _filterBacklogTasks({ + required List 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.unmodifiable(filtered); + } + + /// Applies case-insensitive text search over task title, project, and derived tags. + List _searchBacklogTasks({ + required List tasks, + required String? searchText, + required Map projectById, + }) { + final query = searchText?.trim().toLowerCase(); + if (query == null || query.isEmpty) { + return tasks; + } + return List.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 _sortBoardTasks({ + required List tasks, + required GetBacklogBoardRequest request, + required OwnerSettings settings, + required Map 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.unmodifiable(indexed.map((entry) => entry.value)); + } + + /// Loads direct child task previews for [parentTasks]. + Future>> _loadChildrenByParentId({ + required ApplicationUnitOfWorkRepositories repositories, + required String ownerId, + required List parentTasks, + }) async { + final result = >{}; + for (final parent in parentTasks) { + final children = []; + 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.unmodifiable(children); + } + return Map>.unmodifiable(result); + } + + /// Maps one task into its board card read model. + BacklogBoardItemReadModel _boardItemForTask({ + required Task task, + required Map projectById, + required List 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 _columnsForBoardItems( + List 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 items, + ) { + final totalEstimatedMinutes = items.fold( + 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 _projectOptionsFor( + List projects, + ) { + return List.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> _suggestedSlotsForTask({ + required ApplicationUnitOfWorkRepositories repositories, + required ApplicationOperationContext context, + required CivilDate localDate, + required Task task, + }) async { + final durationMinutes = task.durationMinutes; + if (durationMinutes == null) { + return const []; + } + 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 []; + } + final localDateLabel = _localDateLabelFor(localDate, window, context.now); + final slots = [ + _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.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> getProjectDefaults( GetProjectDefaultsRequest request, @@ -605,6 +1102,335 @@ class V1ApplicationManagementUseCases { } } +/// Canonical board bucket order for the V1 Backlog board. +const _boardBucketOrder = [ + 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 projectById, +) { + return projectById[projectId]?.name ?? _fallbackProjectName(projectId); +} + +/// Resolves the UI project color token for [projectId]. +String _projectColorTokenFor( + String projectId, + Map 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 _readOnlyTagLabelsFor(Task task) { + final labels = [ + for (final tag in task.backlogTags) + switch (tag) { + BacklogTag.wishlist => 'someday', + }, + ]..sort(); + return List.unmodifiable(labels); +} + +/// Builds the priority distribution for the summary panel. +BacklogDistributionReadModel _priorityDistributionFor( + List items, +) { + final priorities = [ + PriorityLevel.veryHigh, + PriorityLevel.high, + PriorityLevel.medium, + PriorityLevel.low, + PriorityLevel.veryLow, + null, + ]; + final entries = []; + 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 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 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( + 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 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 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 _ownerSettingsOrDefault( diff --git a/packages/scheduler_core/test/application_management_test.dart b/packages/scheduler_core/test/application_management_test.dart index b79d414..13cfc23 100644 --- a/packages/scheduler_core/test/application_management_test.dart +++ b/packages/scheduler_core/test/application_management_test.dart @@ -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 backlogTags = const {}, 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