From dd25bdfaaefc0606c37bc9e94a9b824b7aa958fe Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Tue, 7 Jul 2026 13:33:55 -0700 Subject: [PATCH] feat(backlog): add board controls --- .../UI_PLAN_2_IMPLEMENTATION_NOTES.md | 18 + .../lib/app/demo_scheduler_composition.dart | 1 + .../app/persistent_scheduler_composition.dart | 1 + .../controllers/backlog_board_controller.dart | 33 +- .../backlog/backlog_board_controls.dart | 954 ++++++++++++++++++ .../widgets/backlog/backlog_board_screen.dart | 198 +--- .../backlog_board_controller_test.dart | 2 + .../widgets/backlog_board_screen_test.dart | 115 ++- .../application/application_management.dart | 1 + .../backlog_board_filter_selection.dart | 93 ++ .../requests/backlog_board_group_mode.dart | 6 + .../requests/get_backlog_board_request.dart | 10 +- .../v1_application_management_use_cases.dart | 206 +++- .../backlog/queries/backlog_sort_key.dart | 6 + .../backlog/views/backlog_view.dart | 21 + .../test/application_management_test.dart | 111 ++ .../test/scheduling_engine_test.dart | 14 +- 17 files changed, 1559 insertions(+), 231 deletions(-) create mode 100644 apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart create mode 100644 packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_filter_selection.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 3bc4fea..2df906e 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 @@ -137,3 +137,21 @@ Freeform Tags Metadata.md` for a later migration-backed feature. tasks rather than parent-child relationships. Child preview rendering is covered by a focused widget test using handcrafted public read models so the seed scope does not expand in this block. + +## Block 05 Search, Filter, Sort, and Group Controls + +1. The board request now has `BacklogBoardFilterSelection` for board-only + filters: priority group, project, duration bucket, board bucket, and missing + duration. Legacy `BacklogFilter.noRewardSet` remains the cleanup filter for + no-reward tasks. +2. `BacklogSortKey.custom` preserves source/read-model order for the board + default, and `BacklogSortKey.duration` supports duration ordering. The simple + backlog list still keeps its existing priority default. +3. `BacklogBoardGroupMode` now supports project, priority, and duration + grouping as stable ordering inside the existing four bucket columns. The UI + does not add section dividers in this block. +4. The Backlog control row lives in + `apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart`. + Search reloads through the board query, filters apply through the public + request object, sort/group menus update controller state, and Compact, + Settings, and New Task remain safe inert shells until later blocks. diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index 47bc0a7..171a822 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -163,6 +163,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition { localDate: request.localDate, searchText: request.searchText, filters: request.filters, + boardFilters: request.boardFilters, sortKey: request.sortKey, groupMode: request.groupMode, ), diff --git a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart index 7c91048..dd59869 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -223,6 +223,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { localDate: request.localDate, searchText: request.searchText, filters: request.filters, + boardFilters: request.boardFilters, sortKey: request.sortKey, groupMode: request.groupMode, ), diff --git a/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart b/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart index be2fe8a..0c84a66 100644 --- a/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart @@ -35,7 +35,8 @@ class BacklogBoardReadRequest { required this.localDate, this.searchText, List filters = const [], - this.sortKey = BacklogSortKey.priority, + this.boardFilters = const BacklogBoardFilterSelection.empty(), + this.sortKey = BacklogSortKey.custom, this.groupMode = BacklogBoardGroupMode.none, }) : filters = List.unmodifiable(filters); @@ -48,6 +49,9 @@ class BacklogBoardReadRequest { /// User-selected backlog filters. final List filters; + /// User-selected board-specific filters. + final BacklogBoardFilterSelection boardFilters; + /// User-selected task ordering. final BacklogSortKey sortKey; @@ -144,9 +148,14 @@ class BacklogBoardController extends ChangeNotifier { /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. List _filters = const []; + /// Private state stored as `_boardFilters` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + BacklogBoardFilterSelection _boardFilters = + const BacklogBoardFilterSelection.empty(); + /// Private state stored as `_sortKey` for this implementation. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. - BacklogSortKey _sortKey = BacklogSortKey.priority; + BacklogSortKey _sortKey = BacklogSortKey.custom; /// Private state stored as `_groupMode` for this implementation. /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. @@ -176,6 +185,9 @@ class BacklogBoardController extends ChangeNotifier { /// Current filters applied to board reads. List get filters => _filters; + /// Current board-specific filters applied to board reads. + BacklogBoardFilterSelection get boardFilters => _boardFilters; + /// Current sort key applied to board reads. BacklogSortKey get sortKey => _sortKey; @@ -198,6 +210,7 @@ class BacklogBoardController extends ChangeNotifier { localDate: date, searchText: _searchText, filters: _filters, + boardFilters: _boardFilters, sortKey: _sortKey, groupMode: _groupMode, ), @@ -333,6 +346,22 @@ class BacklogBoardController extends ChangeNotifier { await load(); } + /// Updates legacy and board-specific filters together and reloads once. + Future updateFilterState({ + required List filters, + required BacklogBoardFilterSelection boardFilters, + }) async { + _filters = List.unmodifiable(filters); + _boardFilters = boardFilters; + await load(); + } + + /// Updates board-specific filters and reloads the board. + Future updateBoardFilters(BacklogBoardFilterSelection value) async { + _boardFilters = value; + await load(); + } + /// Updates sort key and reloads the board. Future updateSortKey(BacklogSortKey value) async { if (value == _sortKey) { diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart new file mode 100644 index 0000000..5e0c8bd --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart @@ -0,0 +1,954 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders Backlog board controls for the FocusFlow Flutter UI. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../controllers/backlog_board_controller.dart'; +import '../../theme/backlog_board_visual_tokens.dart'; +import '../../theme/focus_flow_tokens.dart'; + +/// Header controls for Compact/Board mode and settings. +class BacklogModeAndSettingsControls extends StatelessWidget { + /// Creates mode and settings controls. + const BacklogModeAndSettingsControls({super.key}); + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 42, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Row( + children: const [ + _ModeSegment(label: 'Compact', active: false), + _ModeSegment(label: 'Board', active: true), + ], + ), + ), + const SizedBox(width: 18), + OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.wb_sunny_outlined, size: 18), + label: const Text('Settings'), + style: OutlinedButton.styleFrom( + fixedSize: const Size(122, 42), + foregroundColor: FocusFlowTokens.textPrimary, + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ); + } +} + +/// Search, filter, sort, group, and new-task controls. +class BacklogBoardTopControls extends StatefulWidget { + /// Creates Backlog board top controls. + const BacklogBoardTopControls({required this.controller, super.key}); + + /// Controller updated by shell controls. + final BacklogBoardController controller; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State createState() => + _BacklogBoardTopControlsState(); +} + +/// State for Backlog board top controls. +class _BacklogBoardTopControlsState extends State { + /// Text controller for the search field. + late final TextEditingController _searchController; + + /// Initializes text controller state from the current board controller. + @override + void initState() { + super.initState(); + _searchController = TextEditingController( + text: widget.controller.searchText ?? '', + ); + } + + /// Releases text controller resources. + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final activeFilterCount = + widget.controller.filters.length + + widget.controller.boardFilters.activeFilterCount; + final searchText = widget.controller.searchText ?? ''; + if (searchText.isEmpty && _searchController.text.isNotEmpty) { + _searchController.clear(); + } + return Row( + children: [ + Expanded( + child: SizedBox( + height: 44, + child: TextField( + key: const ValueKey('backlog-search-field'), + controller: _searchController, + onChanged: (value) { + unawaited(widget.controller.updateSearchText(value)); + }, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + decoration: InputDecoration( + prefixIcon: const Icon( + Icons.search, + color: FocusFlowTokens.textMuted, + ), + suffixIcon: searchText.isEmpty + ? null + : IconButton( + key: const ValueKey('backlog-search-clear'), + tooltip: 'Clear search', + onPressed: () { + _searchController.clear(); + unawaited(widget.controller.updateSearchText(null)); + }, + icon: const Icon( + Icons.close, + color: FocusFlowTokens.textMuted, + ), + ), + hintText: 'Search backlog...', + hintStyle: const TextStyle(color: FocusFlowTokens.textFaint), + filled: true, + fillColor: FocusFlowTokens.panelBackground, + contentPadding: EdgeInsets.zero, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: FocusFlowTokens.subtleBorder, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: FocusFlowTokens.accentMagenta, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 16), + _BacklogControlButton( + key: const ValueKey('backlog-filter-button'), + icon: Icons.filter_alt_outlined, + label: activeFilterCount == 0 + ? 'Filters' + : 'Filters $activeFilterCount', + onPressed: () { + unawaited(_showFilterDialog(context, widget.controller)); + }, + ), + const SizedBox(width: 14), + _BacklogMenuControlButton( + key: const ValueKey('backlog-sort-menu-button'), + icon: Icons.tune, + label: 'Sort: ${_sortLabel(widget.controller.sortKey)}', + width: 158, + selectedValue: widget.controller.sortKey, + options: _sortOptions, + onSelected: (value) { + unawaited(widget.controller.updateSortKey(value)); + }, + ), + const SizedBox(width: 14), + _BacklogMenuControlButton( + key: const ValueKey('backlog-group-menu-button'), + icon: Icons.dashboard_customize_outlined, + label: 'Group: ${_groupLabel(widget.controller.groupMode)}', + width: 156, + selectedValue: widget.controller.groupMode, + options: _groupOptions, + onSelected: (value) { + unawaited(widget.controller.updateGroupMode(value)); + }, + ), + const SizedBox(width: 18), + FilledButton( + key: const ValueKey('backlog-new-task-button'), + onPressed: () {}, + style: FilledButton.styleFrom( + fixedSize: const Size(146, 44), + padding: EdgeInsets.zero, + backgroundColor: FocusFlowTokens.accentMagenta, + foregroundColor: FocusFlowTokens.textPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add, size: 18), + SizedBox(width: 8), + Text('New Task'), + SizedBox(width: 4), + Icon(Icons.keyboard_arrow_down, size: 18), + ], + ), + ), + ), + ], + ); + } +} + +/// One segment in the Backlog mode toggle. +class _ModeSegment extends StatelessWidget { + /// Creates a mode segment. + const _ModeSegment({required this.label, required this.active}); + + /// Segment label. + final String label; + + /// Whether this segment is selected. + final bool active; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + width: 78, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? FocusFlowTokens.glassPanelStrong : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: active + ? Border.all(color: FocusFlowTokens.navBorder) + : Border.all(color: Colors.transparent), + ), + child: Text( + label, + style: TextStyle( + color: active + ? FocusFlowTokens.accentMagenta + : FocusFlowTokens.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +/// Compact outlined control button used by the Backlog shell. +class _BacklogControlButton extends StatelessWidget { + /// Creates a Backlog control button. + const _BacklogControlButton({ + required this.icon, + required this.label, + required this.onPressed, + super.key, + }); + + /// Leading icon for the control. + final IconData icon; + + /// Control label. + final String label; + + /// Callback invoked when the control is pressed. + final VoidCallback onPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 18), + label: Text(label), + style: OutlinedButton.styleFrom( + fixedSize: const Size(132, 44), + foregroundColor: FocusFlowTokens.textPrimary, + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ); + } +} + +/// Generic popup menu button styled like the Backlog control row. +class _BacklogMenuControlButton extends StatelessWidget { + /// Creates a popup menu control. + const _BacklogMenuControlButton({ + required this.icon, + required this.label, + required this.width, + required this.selectedValue, + required this.options, + required this.onSelected, + super.key, + }); + + /// Leading icon for the control. + final IconData icon; + + /// Button label. + final String label; + + /// Fixed button width. + final double width; + + /// Currently selected value. + final T selectedValue; + + /// Menu options. + final List<_MenuOption> options; + + /// Selection callback. + final ValueChanged onSelected; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return PopupMenuButton( + color: FocusFlowTokens.elevatedPanel, + onSelected: onSelected, + itemBuilder: (context) { + return [ + for (final option in options) + PopupMenuItem( + value: option.value, + child: Row( + children: [ + Icon( + option.value == selectedValue + ? Icons.check + : Icons.circle_outlined, + size: 16, + color: option.value == selectedValue + ? FocusFlowTokens.accentMagenta + : FocusFlowTokens.textFaint, + ), + const SizedBox(width: 10), + Text( + option.label, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + ), + ], + ), + ), + ]; + }, + child: Container( + width: width, + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: FocusFlowTokens.textPrimary), + const SizedBox(width: 9), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + const Icon( + Icons.keyboard_arrow_down, + color: FocusFlowTokens.textMuted, + size: 18, + ), + ], + ), + ), + ); + } +} + +/// Menu option for a Backlog popup control. +class _MenuOption { + /// Creates a menu option. + const _MenuOption({required this.value, required this.label}); + + /// Option value. + final T value; + + /// Display label. + final String label; +} + +/// Result returned from the filter dialog. +class _FilterDialogResult { + /// Creates a filter dialog result. + _FilterDialogResult({ + required List legacyFilters, + required this.boardFilters, + }) : legacyFilters = List.unmodifiable(legacyFilters); + + /// Legacy backlog filters to apply. + final List legacyFilters; + + /// Board-specific filters to apply. + final BacklogBoardFilterSelection boardFilters; +} + +/// Dialog for editing Backlog board filters. +class _BacklogFilterDialog extends StatefulWidget { + /// Creates a Backlog filter dialog. + const _BacklogFilterDialog({ + required this.initialLegacyFilters, + required this.initialBoardFilters, + required this.projectOptions, + }); + + /// Legacy filters active before the dialog opened. + final List initialLegacyFilters; + + /// Board filters active before the dialog opened. + final BacklogBoardFilterSelection initialBoardFilters; + + /// Project options available to the board. + final List projectOptions; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State<_BacklogFilterDialog> createState() => _BacklogFilterDialogState(); +} + +/// State for the Backlog filter dialog. +class _BacklogFilterDialogState extends State<_BacklogFilterDialog> { + /// Selected priority groups. + late Set _priorityGroups; + + /// Selected project ids. + late Set _projectIds; + + /// Selected duration buckets. + late Set _durationBuckets; + + /// Selected board buckets. + late Set _buckets; + + /// Whether missing-duration tasks are selected. + late bool _missingDurationOnly; + + /// Whether no-reward tasks are selected. + late bool _noRewardSetOnly; + + /// Initializes mutable filter selections. + @override + void initState() { + super.initState(); + _priorityGroups = {...widget.initialBoardFilters.priorityGroups}; + _projectIds = {...widget.initialBoardFilters.projectIds}; + _durationBuckets = {...widget.initialBoardFilters.durationBuckets}; + _buckets = {...widget.initialBoardFilters.buckets}; + _missingDurationOnly = widget.initialBoardFilters.missingDurationOnly; + _noRewardSetOnly = widget.initialLegacyFilters.contains( + BacklogFilter.noRewardSet, + ); + } + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: FocusFlowTokens.elevatedPanel, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680), + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 20, 22, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Row( + children: [ + Icon( + Icons.filter_alt_outlined, + color: FocusFlowTokens.accentMagenta, + ), + SizedBox(width: 10), + Expanded( + child: Text( + 'Filters', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _FilterSection( + title: 'Priority', + children: [ + _checkbox( + label: 'High priority', + value: _priorityGroups.contains( + BacklogBoardPriorityFilter.high, + ), + onChanged: (value) { + _toggleSet( + _priorityGroups, + BacklogBoardPriorityFilter.high, + value, + ); + }, + ), + _checkbox( + label: 'Medium priority', + value: _priorityGroups.contains( + BacklogBoardPriorityFilter.medium, + ), + onChanged: (value) { + _toggleSet( + _priorityGroups, + BacklogBoardPriorityFilter.medium, + value, + ); + }, + ), + _checkbox( + label: 'Low priority', + value: _priorityGroups.contains( + BacklogBoardPriorityFilter.low, + ), + onChanged: (value) { + _toggleSet( + _priorityGroups, + BacklogBoardPriorityFilter.low, + value, + ); + }, + ), + ], + ), + _FilterSection( + title: 'Project', + children: widget.projectOptions.isEmpty + ? const [ + Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No projects loaded', + style: TextStyle( + color: FocusFlowTokens.textMuted, + ), + ), + ), + ] + : [ + for (final project in widget.projectOptions) + _checkbox( + label: project.name, + dotColor: + BacklogBoardVisualTokens.projectColor( + project.colorToken, + ), + value: _projectIds.contains( + project.projectId, + ), + onChanged: (value) { + _toggleSet( + _projectIds, + project.projectId, + value, + ); + }, + ), + ], + ), + _FilterSection( + title: 'Duration', + children: [ + _checkbox( + label: '0-30 min', + value: _durationBuckets.contains( + BacklogBoardDurationFilter.zeroToThirty, + ), + onChanged: (value) { + _toggleSet( + _durationBuckets, + BacklogBoardDurationFilter.zeroToThirty, + value, + ); + }, + ), + _checkbox( + label: '31-60 min', + value: _durationBuckets.contains( + BacklogBoardDurationFilter.thirtyToSixty, + ), + onChanged: (value) { + _toggleSet( + _durationBuckets, + BacklogBoardDurationFilter.thirtyToSixty, + value, + ); + }, + ), + _checkbox( + label: '61-120 min', + value: _durationBuckets.contains( + BacklogBoardDurationFilter.sixtyToOneTwenty, + ), + onChanged: (value) { + _toggleSet( + _durationBuckets, + BacklogBoardDurationFilter.sixtyToOneTwenty, + value, + ); + }, + ), + _checkbox( + label: '120+ min', + value: _durationBuckets.contains( + BacklogBoardDurationFilter.overOneTwenty, + ), + onChanged: (value) { + _toggleSet( + _durationBuckets, + BacklogBoardDurationFilter.overOneTwenty, + value, + ); + }, + ), + _checkbox( + label: 'Missing duration', + value: _missingDurationOnly, + onChanged: (value) { + setState(() { + _missingDurationOnly = value; + }); + }, + ), + ], + ), + _FilterSection( + title: 'Column', + children: [ + for (final bucket in BacklogBoardBucket.values) + _checkbox( + label: _bucketLabel(bucket), + dotColor: BacklogBoardVisualTokens.bucketAccent( + bucket, + ), + value: _buckets.contains(bucket), + onChanged: (value) { + _toggleSet(_buckets, bucket, value); + }, + ), + ], + ), + _FilterSection( + title: 'Cleanup', + children: [ + _checkbox( + label: 'No reward set', + value: _noRewardSetOnly, + onChanged: (value) { + setState(() { + _noRewardSetOnly = value; + }); + }, + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () { + Navigator.of(context).pop( + _FilterDialogResult( + legacyFilters: const [], + boardFilters: + const BacklogBoardFilterSelection.empty(), + ), + ); + }, + child: const Text('Clear'), + ), + const Spacer(), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + Navigator.of(context).pop(_result()); + }, + style: FilledButton.styleFrom( + backgroundColor: FocusFlowTokens.accentMagenta, + foregroundColor: FocusFlowTokens.textPrimary, + ), + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// Builds a styled filter checkbox row. + Widget _checkbox({ + required String label, + required bool value, + required ValueChanged onChanged, + Color? dotColor, + }) { + return Material( + type: MaterialType.transparency, + child: CheckboxListTile( + dense: true, + value: value, + onChanged: (next) { + onChanged(next ?? false); + }, + controlAffinity: ListTileControlAffinity.leading, + activeColor: FocusFlowTokens.accentMagenta, + checkColor: FocusFlowTokens.textPrimary, + contentPadding: EdgeInsets.zero, + title: Row( + children: [ + if (dotColor != null) ...[ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + label, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + ), + ), + ], + ), + ), + ); + } + + /// Toggles [value] in [set] and rebuilds the dialog. + void _toggleSet(Set set, T value, bool selected) { + setState(() { + if (selected) { + set.add(value); + } else { + set.remove(value); + } + }); + } + + /// Builds the immutable result for the current dialog selections. + _FilterDialogResult _result() { + return _FilterDialogResult( + legacyFilters: [if (_noRewardSetOnly) BacklogFilter.noRewardSet], + boardFilters: BacklogBoardFilterSelection( + priorityGroups: _priorityGroups, + projectIds: _projectIds, + durationBuckets: _durationBuckets, + buckets: _buckets, + missingDurationOnly: _missingDurationOnly, + ), + ); + } +} + +/// Section wrapper inside the filter dialog. +class _FilterSection extends StatelessWidget { + /// Creates a filter section. + const _FilterSection({required this.title, required this.children}); + + /// Section title. + final String title; + + /// Section children. + final List children; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.72), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + ...children, + ], + ), + ), + ), + ); + } +} + +/// Opens the filter dialog and applies the returned result. +Future _showFilterDialog( + BuildContext context, + BacklogBoardController controller, +) async { + final result = await showDialog<_FilterDialogResult>( + context: context, + builder: (context) { + return _BacklogFilterDialog( + initialLegacyFilters: controller.filters, + initialBoardFilters: controller.boardFilters, + projectOptions: _projectOptionsForState(controller.state), + ); + }, + ); + if (result == null) { + return; + } + await controller.updateFilterState( + filters: result.legacyFilters, + boardFilters: result.boardFilters, + ); +} + +/// Returns project options from the current ready or empty board state. +List _projectOptionsForState( + BacklogBoardScreenState state, +) { + return switch (state) { + BacklogBoardReady(:final data) => data.board.projectOptions, + BacklogBoardEmpty(:final data) => data.board.projectOptions, + BacklogBoardLoading() || + BacklogBoardFailure() => const [], + }; +} + +/// Display label for [sortKey]. +String _sortLabel(BacklogSortKey sortKey) { + return switch (sortKey) { + BacklogSortKey.custom => 'Custom', + BacklogSortKey.priority => 'Priority', + BacklogSortKey.rewardVsEffort => 'Reward', + BacklogSortKey.age => 'Age', + BacklogSortKey.project => 'Project', + BacklogSortKey.timesPushed => 'Pushed', + BacklogSortKey.duration => 'Duration', + }; +} + +/// Display label for [groupMode]. +String _groupLabel(BacklogBoardGroupMode groupMode) { + return switch (groupMode) { + BacklogBoardGroupMode.none => 'None', + BacklogBoardGroupMode.project => 'Project', + BacklogBoardGroupMode.priority => 'Priority', + BacklogBoardGroupMode.duration => 'Duration', + }; +} + +/// Display label for [bucket]. +String _bucketLabel(BacklogBoardBucket bucket) { + return switch (bucket) { + BacklogBoardBucket.doNext => 'Do Next', + BacklogBoardBucket.needTimeBlock => 'Need Time Block', + BacklogBoardBucket.breakUpFirst => 'Break Up First', + BacklogBoardBucket.notNow => 'Not Now', + }; +} + +/// Sort menu options in display order. +const _sortOptions = [ + _MenuOption(value: BacklogSortKey.custom, label: 'Custom'), + _MenuOption(value: BacklogSortKey.priority, label: 'Priority'), + _MenuOption(value: BacklogSortKey.rewardVsEffort, label: 'Reward vs Effort'), + _MenuOption(value: BacklogSortKey.age, label: 'Age'), + _MenuOption(value: BacklogSortKey.project, label: 'Project'), + _MenuOption(value: BacklogSortKey.timesPushed, label: 'Times Pushed'), + _MenuOption(value: BacklogSortKey.duration, label: 'Duration'), +]; + +/// Group menu options in display order. +const _groupOptions = [ + _MenuOption(value: BacklogBoardGroupMode.none, label: 'None'), + _MenuOption(value: BacklogBoardGroupMode.project, label: 'Project'), + _MenuOption(value: BacklogBoardGroupMode.priority, label: 'Priority'), + _MenuOption(value: BacklogBoardGroupMode.duration, label: 'Duration'), +]; diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart index 79c3f32..3b5e520 100644 --- a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart @@ -13,6 +13,7 @@ import '../../controllers/backlog_board_controller.dart'; import '../../models/backlog/backlog_board_screen_data.dart'; import '../../theme/focus_flow_tokens.dart'; import 'backlog_board_column.dart'; +import 'backlog_board_controls.dart'; /// Backlog board screen backed by a public scheduler read controller. class BacklogBoardScreen extends StatelessWidget { @@ -108,7 +109,7 @@ class _BacklogFrame extends StatelessWidget { children: [ _BacklogHeader(taskCountLabel: taskCountLabel), const SizedBox(height: 22), - _BacklogControls(controller: controller), + BacklogBoardTopControls(controller: controller), const SizedBox(height: 18), Expanded(child: body), ], @@ -166,205 +167,12 @@ class _BacklogHeader extends StatelessWidget { ), ), const SizedBox(width: 20), - const _ModeAndSettingsControls(), + const BacklogModeAndSettingsControls(), ], ); } } -/// Header controls for Compact/Board mode and settings. -class _ModeAndSettingsControls extends StatelessWidget { - /// Creates mode and settings controls. - const _ModeAndSettingsControls(); - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 42, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: Row( - children: const [ - _ModeSegment(label: 'Compact', active: false), - _ModeSegment(label: 'Board', active: true), - ], - ), - ), - const SizedBox(width: 18), - OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.wb_sunny_outlined, size: 18), - label: const Text('Settings'), - style: OutlinedButton.styleFrom( - fixedSize: const Size(122, 42), - foregroundColor: FocusFlowTokens.textPrimary, - side: const BorderSide(color: FocusFlowTokens.subtleBorder), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ], - ); - } -} - -/// One segment in the Backlog mode toggle. -class _ModeSegment extends StatelessWidget { - /// Creates a mode segment. - const _ModeSegment({required this.label, required this.active}); - - /// Segment label. - final String label; - - /// Whether this segment is selected. - final bool active; - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - return Container( - width: 78, - alignment: Alignment.center, - decoration: BoxDecoration( - color: active ? FocusFlowTokens.glassPanelStrong : Colors.transparent, - borderRadius: BorderRadius.circular(8), - border: active - ? Border.all(color: FocusFlowTokens.navBorder) - : Border.all(color: Colors.transparent), - ), - child: Text( - label, - style: TextStyle( - color: active - ? FocusFlowTokens.accentMagenta - : FocusFlowTokens.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -/// Search, filter, sort, group, and new-task controls. -class _BacklogControls extends StatelessWidget { - /// Creates Backlog controls. - const _BacklogControls({required this.controller}); - - /// Controller updated by shell controls. - final BacklogBoardController controller; - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: SizedBox( - height: 44, - child: TextField( - key: const ValueKey('backlog-search-field'), - onSubmitted: (value) { - unawaited(controller.updateSearchText(value)); - }, - style: const TextStyle(color: FocusFlowTokens.textPrimary), - decoration: InputDecoration( - prefixIcon: const Icon( - Icons.search, - color: FocusFlowTokens.textMuted, - ), - hintText: 'Search backlog...', - hintStyle: const TextStyle(color: FocusFlowTokens.textFaint), - filled: true, - fillColor: FocusFlowTokens.panelBackground, - contentPadding: EdgeInsets.zero, - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide( - color: FocusFlowTokens.subtleBorder, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide( - color: FocusFlowTokens.accentMagenta, - ), - ), - ), - ), - ), - ), - const SizedBox(width: 16), - const _BacklogControlButton( - icon: Icons.filter_alt_outlined, - label: 'Filters', - ), - const SizedBox(width: 14), - const _BacklogControlButton(icon: Icons.tune, label: 'Sort: Custom'), - const SizedBox(width: 14), - const _BacklogControlButton( - icon: Icons.dashboard_customize_outlined, - label: 'Group: None', - ), - const SizedBox(width: 18), - FilledButton.icon( - key: const ValueKey('backlog-new-task-button'), - onPressed: () {}, - icon: const Icon(Icons.add), - label: const Text('New Task'), - style: FilledButton.styleFrom( - fixedSize: const Size(146, 44), - backgroundColor: FocusFlowTokens.accentMagenta, - foregroundColor: FocusFlowTokens.textPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ], - ); - } -} - -/// Compact outlined control button used by the Backlog shell. -class _BacklogControlButton extends StatelessWidget { - /// Creates a Backlog control button. - const _BacklogControlButton({required this.icon, required this.label}); - - /// Leading icon for the control. - final IconData icon; - - /// Control label. - final String label; - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - return OutlinedButton.icon( - onPressed: () {}, - icon: Icon(icon, size: 18), - label: Text(label), - style: OutlinedButton.styleFrom( - fixedSize: const Size(138, 44), - foregroundColor: FocusFlowTokens.textPrimary, - side: const BorderSide(color: FocusFlowTokens.subtleBorder), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - ); - } -} - /// Body shown while the Backlog board loads. class _BacklogLoadingBody extends StatelessWidget { /// Creates a loading body. diff --git a/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart b/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart index 44ff677..c7939e1 100644 --- a/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart +++ b/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart @@ -31,6 +31,8 @@ void main() { await controller.load(); expect(capturedRequest!.localDate, CivilDate(2026, 7, 7)); + expect(capturedRequest!.sortKey, BacklogSortKey.custom); + expect(capturedRequest!.boardFilters.isEmpty, isTrue); final state = controller.state; expect(state, isA()); expect((state as BacklogBoardReady).data.summary.totalTaskCount, 1); diff --git a/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart b/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart index 3a983c5..31f5fb0 100644 --- a/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart +++ b/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart @@ -87,6 +87,114 @@ void main() { final border = decoration.border! as Border; expect(border.top.color, FocusFlowTokens.accentMagenta); }); + + testWidgets('controls update board read requests', (tester) async { + final requests = []; + final item = _boardItemWithChildren(); + final board = _boardWithItem( + item, + projectOptions: const [ + ProjectOptionReadModel( + projectId: 'work', + name: 'Work', + colorToken: 'project-work', + isArchived: false, + ), + ], + ); + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + readBoard: (request) async { + requests.add(request); + return ApplicationResult.success(board); + }, + readTaskDetail: (taskId, localDate) async { + return ApplicationResult.success(_detailFor(item)); + }, + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + ); + addTearDown(controller.dispose); + addTearDown(selectedDates.dispose); + + await controller.load(); + await _pumpBacklogScreen(tester, controller); + + await tester.enterText( + find.byKey(const ValueKey('backlog-search-field')), + 'course', + ); + await tester.pumpAndSettle(); + + expect(requests.last.searchText, 'course'); + expect(find.byKey(const ValueKey('backlog-search-clear')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-search-clear'))); + await tester.pumpAndSettle(); + + expect(requests.last.searchText, isNull); + + await tester.tap(find.byKey(const ValueKey('backlog-sort-menu-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reward vs Effort').last); + await tester.pumpAndSettle(); + + expect(requests.last.sortKey, BacklogSortKey.rewardVsEffort); + expect(find.text('Sort: Reward'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-group-menu-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Project').last); + await tester.pumpAndSettle(); + + expect(requests.last.groupMode, BacklogBoardGroupMode.project); + expect(find.text('Group: Project'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-filter-button'))); + await tester.pumpAndSettle(); + + Future tapFilterCheckbox(String label) async { + final checkbox = find.widgetWithText(CheckboxListTile, label); + await tester.ensureVisible(checkbox); + await tester.tap(checkbox); + await tester.pump(); + } + + await tapFilterCheckbox('High priority'); + await tapFilterCheckbox('Work'); + await tapFilterCheckbox('0-30 min'); + await tapFilterCheckbox('Need Time Block'); + await tapFilterCheckbox('No reward set'); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(requests.last.filters, [BacklogFilter.noRewardSet]); + expect( + requests.last.boardFilters.priorityGroups, + contains(BacklogBoardPriorityFilter.high), + ); + expect(requests.last.boardFilters.projectIds, contains('work')); + expect( + requests.last.boardFilters.durationBuckets, + contains(BacklogBoardDurationFilter.zeroToThirty), + ); + expect( + requests.last.boardFilters.buckets, + contains(BacklogBoardBucket.needTimeBlock), + ); + expect(find.text('Filters 5'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-filter-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Clear')); + await tester.pumpAndSettle(); + + expect(requests.last.filters, isEmpty); + expect(requests.last.boardFilters.isEmpty, isTrue); + expect(find.text('Filters'), findsOneWidget); + }); } /// Pumps [controller] inside the dark FocusFlow app shell test harness. @@ -141,7 +249,10 @@ BacklogBoardItemReadModel _boardItemWithChildren() { } /// Builds a Backlog board containing [item] and the other stable empty columns. -BacklogBoardQueryResult _boardWithItem(BacklogBoardItemReadModel item) { +BacklogBoardQueryResult _boardWithItem( + BacklogBoardItemReadModel item, { + List projectOptions = const [], +}) { return BacklogBoardQueryResult( ownerId: 'owner-1', localDate: CivilDate(2026, 7, 7), @@ -208,7 +319,7 @@ BacklogBoardQueryResult _boardWithItem(BacklogBoardItemReadModel item) { ), planningTip: 'Pick one task.', ), - projectOptions: const [], + projectOptions: projectOptions, ); } diff --git a/packages/scheduler_core/lib/src/application/application_management.dart b/packages/scheduler_core/lib/src/application/application_management.dart index 7f84f01..34acd59 100644 --- a/packages/scheduler_core/lib/src/application/application_management.dart +++ b/packages/scheduler_core/lib/src/application/application_management.dart @@ -22,6 +22,7 @@ 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_filter_selection.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'; diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_filter_selection.dart b/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_filter_selection.dart new file mode 100644 index 0000000..46632e1 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_filter_selection.dart @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Priority groups exposed by the Backlog board filter UI. +enum BacklogBoardPriorityFilter { + /// High and very high priority tasks. + high, + + /// Medium priority tasks. + medium, + + /// Low and very low priority tasks. + low, +} + +/// Duration buckets exposed by the Backlog board filter UI. +enum BacklogBoardDurationFilter { + /// Tasks estimated at thirty minutes or less. + zeroToThirty, + + /// Tasks estimated above thirty minutes and up to sixty minutes. + thirtyToSixty, + + /// Tasks estimated above sixty minutes and up to one hundred twenty minutes. + sixtyToOneTwenty, + + /// Tasks estimated above one hundred twenty minutes. + overOneTwenty, +} + +/// Board-specific filters that do not belong to the legacy list filter enum. +class BacklogBoardFilterSelection { + /// Creates a board filter selection from user-facing filter groups. + BacklogBoardFilterSelection({ + Iterable priorityGroups = + const [], + Iterable projectIds = const [], + Iterable durationBuckets = + const [], + Iterable buckets = const [], + this.missingDurationOnly = false, + }) : priorityGroups = Set.unmodifiable( + priorityGroups, + ), + projectIds = Set.unmodifiable(projectIds), + durationBuckets = Set.unmodifiable( + durationBuckets, + ), + buckets = Set.unmodifiable(buckets); + + /// Creates an empty board filter selection. + const BacklogBoardFilterSelection.empty() + : priorityGroups = const {}, + projectIds = const {}, + durationBuckets = const {}, + buckets = const {}, + missingDurationOnly = false; + + /// Selected priority groups. + final Set priorityGroups; + + /// Selected project ids. + final Set projectIds; + + /// Selected duration buckets. + final Set durationBuckets; + + /// Selected board buckets. + final Set buckets; + + /// Whether only tasks without a duration estimate should be returned. + final bool missingDurationOnly; + + /// Whether this selection contains no active board filters. + bool get isEmpty { + return priorityGroups.isEmpty && + projectIds.isEmpty && + durationBuckets.isEmpty && + buckets.isEmpty && + !missingDurationOnly; + } + + /// Number of active filter groups. + int get activeFilterCount { + return priorityGroups.length + + projectIds.length + + durationBuckets.length + + buckets.length + + (missingDurationOnly ? 1 : 0); + } +} 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 index 5a0dbf0..e12f9f1 100644 --- 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 @@ -10,4 +10,10 @@ enum BacklogBoardGroupMode { /// Preserve board buckets while allowing project-aware ordering inside them. project, + + /// Preserve board buckets while ordering by priority groups inside them. + priority, + + /// Preserve board buckets while ordering by duration groups inside them. + duration, } 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 index ceebb27..8e5c2f8 100644 --- 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 @@ -11,9 +11,12 @@ class GetBacklogBoardRequest { required this.localDate, this.searchText, List filters = const [], - this.sortKey = BacklogSortKey.priority, + BacklogBoardFilterSelection? boardFilters, + this.sortKey = BacklogSortKey.custom, this.groupMode = BacklogBoardGroupMode.none, - }) : filters = List.unmodifiable(filters); + }) : filters = List.unmodifiable(filters), + boardFilters = + boardFilters ?? const BacklogBoardFilterSelection.empty(); /// Read context containing owner scope and read instant. final ApplicationOperationContext context; @@ -27,6 +30,9 @@ class GetBacklogBoardRequest { /// Optional user-facing backlog filters. final List filters; + /// Board-specific filters applied after bucket and card metadata resolution. + final BacklogBoardFilterSelection boardFilters; + /// User-facing sort order used inside each board bucket. final BacklogSortKey sortKey; 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 ed0b84a..ad567b9 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 @@ -91,7 +91,9 @@ class V1ApplicationManagementUseCases { final ownerId = context.ownerTimeZone.ownerId; logger.debug(() => 'Loading backlog board. ownerId=$ownerId ' 'localDate=${request.localDate.toIsoString()} ' - 'filterCount=${request.filters.length} sortKey=${request.sortKey.name} ' + 'filterCount=${request.filters.length} ' + 'boardFilterCount=${request.boardFilters.activeFilterCount} ' + 'sortKey=${request.sortKey.name} ' 'groupMode=${request.groupMode.name}'); final settings = await _ownerSettingsOrDefault( repositories, @@ -107,13 +109,8 @@ class V1ApplicationManagementUseCases { request: request, settings: settings, ); - final searched = _searchBacklogTasks( - tasks: filtered, - searchText: request.searchText, - projectById: projectById, - ); final sorted = _sortBoardTasks( - tasks: searched, + tasks: filtered, request: request, settings: settings, projectById: projectById, @@ -131,15 +128,24 @@ class V1ApplicationManagementUseCases { childTasks: childrenByParentId[task.id] ?? const [], ), ]; + final searched = _searchBoardItems( + items: items, + searchText: request.searchText, + ); + final boardFiltered = _filterBoardItems( + items: searched, + filters: request.boardFilters, + ); logger.debug(() => 'Backlog board loaded. ownerId=$ownerId ' - 'candidateCount=${candidates.length} itemCount=${items.length}'); + 'candidateCount=${candidates.length} ' + 'itemCount=${boardFiltered.length}'); return ApplicationResult.success( BacklogBoardQueryResult( ownerId: ownerId, localDate: request.localDate, - summary: _summaryForBoardItems(items), - columns: _columnsForBoardItems(items), + summary: _summaryForBoardItems(boardFiltered), + columns: _columnsForBoardItems(boardFiltered), projectOptions: _projectOptionsFor(projects), ), ); @@ -266,30 +272,120 @@ class V1ApplicationManagementUseCases { return List.unmodifiable(filtered); } - /// Applies case-insensitive text search over task title, project, and derived tags. - List _searchBacklogTasks({ - required List tasks, + /// Applies case-insensitive text search over board card text. + List _searchBoardItems({ + required List items, required String? searchText, - required Map projectById, }) { final query = searchText?.trim().toLowerCase(); if (query == null || query.isEmpty) { - return tasks; + return items; } - return List.unmodifiable( - tasks.where((task) { - final project = projectById[task.projectId]; + return List.unmodifiable( + items.where((item) { final haystack = [ - task.title, - task.projectId, - if (project != null) project.name, - ..._readOnlyTagLabelsFor(task), + item.title, + if (item.subtitle != null) item.subtitle!, + item.bucketReason, + item.projectId, + item.projectName, + ...item.tags, ].join(' ').toLowerCase(); return haystack.contains(query); }), ); } + /// Applies board-specific filters after card bucket metadata has been resolved. + List _filterBoardItems({ + required List items, + required BacklogBoardFilterSelection filters, + }) { + if (filters.isEmpty) { + return items; + } + return List.unmodifiable( + items.where((item) { + return _matchesPriorityFilters(item, filters.priorityGroups) && + _matchesProjectFilters(item, filters.projectIds) && + _matchesDurationFilters( + item, + filters.durationBuckets, + filters.missingDurationOnly, + ) && + _matchesBucketFilters(item, filters.buckets); + }), + ); + } + + /// Whether [item] matches selected priority groups. + bool _matchesPriorityFilters( + BacklogBoardItemReadModel item, + Set priorityGroups, + ) { + if (priorityGroups.isEmpty) { + return true; + } + return priorityGroups.any((filter) { + return switch (filter) { + BacklogBoardPriorityFilter.high => + item.priority == PriorityLevel.high || + item.priority == PriorityLevel.veryHigh, + BacklogBoardPriorityFilter.medium => + item.priority == PriorityLevel.medium, + BacklogBoardPriorityFilter.low => item.priority == PriorityLevel.low || + item.priority == PriorityLevel.veryLow, + }; + }); + } + + /// Whether [item] matches selected project ids. + bool _matchesProjectFilters( + BacklogBoardItemReadModel item, + Set projectIds, + ) { + return projectIds.isEmpty || projectIds.contains(item.projectId); + } + + /// Whether [item] matches selected duration buckets and missing-duration state. + bool _matchesDurationFilters( + BacklogBoardItemReadModel item, + Set durationBuckets, + bool missingDurationOnly, + ) { + final duration = item.durationMinutes; + if (missingDurationOnly && duration == null) { + return true; + } + if (missingDurationOnly && duration != null && durationBuckets.isEmpty) { + return false; + } + if (durationBuckets.isEmpty) { + return !missingDurationOnly; + } + if (duration == null) { + return false; + } + return durationBuckets.any((filter) { + return switch (filter) { + BacklogBoardDurationFilter.zeroToThirty => duration <= 30, + BacklogBoardDurationFilter.thirtyToSixty => + duration > 30 && duration <= 60, + BacklogBoardDurationFilter.sixtyToOneTwenty => + duration > 60 && duration <= 120, + BacklogBoardDurationFilter.overOneTwenty => duration > 120, + }; + }); + } + + /// Whether [item] matches selected board buckets. + bool _matchesBucketFilters( + BacklogBoardItemReadModel item, + Set buckets, + ) { + return buckets.isEmpty || buckets.contains(item.bucket); + } + /// Sorts board tasks using the requested sort and optional project grouping. List _sortBoardTasks({ required List tasks, @@ -308,20 +404,72 @@ class V1ApplicationManagementUseCases { 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), + final groupComparison = _boardGroupComparison( + left: a.value, + right: b.value, + groupMode: request.groupMode, + projectById: projectById, ); - if (projectComparison != 0) { - return projectComparison; + if (groupComparison != 0) { + return groupComparison; } return a.key.compareTo(b.key); }); return List.unmodifiable(indexed.map((entry) => entry.value)); } + /// Compares two tasks for board grouping modes that preserve bucket columns. + int _boardGroupComparison({ + required Task left, + required Task right, + required BacklogBoardGroupMode groupMode, + required Map projectById, + }) { + return switch (groupMode) { + BacklogBoardGroupMode.none => 0, + BacklogBoardGroupMode.project => _projectNameFor( + left.projectId, + projectById, + ).compareTo( + _projectNameFor(right.projectId, projectById), + ), + BacklogBoardGroupMode.priority => + _priorityGroupRank(right.priority).compareTo( + _priorityGroupRank(left.priority), + ), + BacklogBoardGroupMode.duration => + _durationGroupRank(left.durationMinutes).compareTo( + _durationGroupRank(right.durationMinutes), + ), + }; + } + + /// Converts nullable priority to the board's coarse priority group rank. + int _priorityGroupRank(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryHigh || PriorityLevel.high => 2, + PriorityLevel.medium || null => 1, + PriorityLevel.low || PriorityLevel.veryLow => 0, + }; + } + + /// Converts nullable duration to the board's coarse duration group rank. + int _durationGroupRank(int? durationMinutes) { + if (durationMinutes == null) { + return 4; + } + if (durationMinutes <= 30) { + return 0; + } + if (durationMinutes <= 60) { + return 1; + } + if (durationMinutes <= 120) { + return 2; + } + return 3; + } + /// Loads direct child task previews for [parentTasks]. Future>> _loadChildrenByParentId({ required ApplicationUnitOfWorkRepositories repositories, diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart index 7fbdd39..c1997e4 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart @@ -9,6 +9,9 @@ part of '../../backlog.dart'; /// example, `rewardVsEffort` maps to a simple derived score instead of a stored /// field. Persistence can later index the underlying fields if needed. enum BacklogSortKey { + /// Preserve the source/read-model order supplied by persistence. + custom, + /// Highest priority first. priority, @@ -24,4 +27,7 @@ enum BacklogSortKey { /// Most frequently pushed tasks first. timesPushed, + + /// Shortest estimated duration first, with missing estimates last. + duration, } diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart index defde6f..868c330 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart @@ -113,6 +113,7 @@ class BacklogView { /// appear earlier, while older age uses the earliest [Task.createdAt] first. int _compareTasks(Task a, Task b, BacklogSortKey sortKey) { return switch (sortKey) { + BacklogSortKey.custom => 0, BacklogSortKey.priority => _priorityRank(b.priority).compareTo(_priorityRank(a.priority)), BacklogSortKey.rewardVsEffort => @@ -121,6 +122,9 @@ class BacklogView { a.backlogFreshnessAnchorAt.compareTo(b.backlogFreshnessAnchorAt), BacklogSortKey.project => a.projectId.compareTo(b.projectId), BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), + BacklogSortKey.duration => _durationRank(a.durationMinutes).compareTo( + _durationRank(b.durationMinutes), + ), }; } } @@ -177,3 +181,20 @@ int _rewardVsEffortScore(Task task) { int _timesPushed(Task task) { return task.stats.manuallyPushedCount + task.stats.autoPushedCount; } + +/// Convert nullable duration to a stable sort rank. +int _durationRank(int? durationMinutes) { + if (durationMinutes == null) { + return 4; + } + if (durationMinutes <= 30) { + return 0; + } + if (durationMinutes <= 60) { + return 1; + } + if (durationMinutes <= 120) { + return 2; + } + return 3; +} diff --git a/packages/scheduler_core/test/application_management_test.dart b/packages/scheduler_core/test/application_management_test.dart index 13cfc23..4b1642c 100644 --- a/packages/scheduler_core/test/application_management_test.dart +++ b/packages/scheduler_core/test/application_management_test.dart @@ -412,6 +412,117 @@ void main() { ); }); + test('applies backlog board filter selection and 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: 'long-work', + title: 'Long work task', + projectId: 'work', + createdAt: now.subtract(const Duration(days: 3)), + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + durationMinutes: 75, + ), + backlogTask( + id: 'high-work', + title: 'High work task', + projectId: 'work', + createdAt: now.subtract(const Duration(days: 2)), + priority: PriorityLevel.high, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + durationMinutes: 25, + ), + backlogTask( + id: 'low-home', + title: 'Low home task', + projectId: 'home', + createdAt: now.subtract(const Duration(days: 1)), + priority: PriorityLevel.low, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + durationMinutes: 20, + ), + backlogTask( + id: 'missing-work', + title: 'Missing duration task', + projectId: 'work', + createdAt: now, + priority: PriorityLevel.medium, + reward: RewardLevel.notSet, + difficulty: DifficultyLevel.notSet, + durationMinutes: null, + ), + ], + ); + final useCases = V1ApplicationManagementUseCases( + applicationStore: store, + ); + + final filtered = (await useCases.getBacklogBoard( + GetBacklogBoardRequest( + context: appContext(operationId: 'board-filters', now: now), + localDate: CivilDate(2026, 6, 25), + boardFilters: BacklogBoardFilterSelection( + priorityGroups: const [BacklogBoardPriorityFilter.high], + projectIds: const ['work'], + durationBuckets: const [ + BacklogBoardDurationFilter.zeroToThirty, + ], + buckets: const [BacklogBoardBucket.needTimeBlock], + ), + ), + )) + .requireValue; + final filteredIds = filtered.columns + .expand((column) => column.items) + .map((item) => item.taskId); + + expect(filtered.summary.totalTaskCount, 1); + expect(filteredIds, ['high-work']); + + final missing = (await useCases.getBacklogBoard( + GetBacklogBoardRequest( + context: appContext(operationId: 'board-missing', now: now), + localDate: CivilDate(2026, 6, 25), + filters: const [BacklogFilter.noRewardSet], + boardFilters: BacklogBoardFilterSelection( + missingDurationOnly: true, + ), + ), + )) + .requireValue; + + expect(missing.summary.totalTaskCount, 1); + expect( + missing.columns.expand((column) => column.items).single.taskId, + 'missing-work', + ); + + final grouped = (await useCases.getBacklogBoard( + GetBacklogBoardRequest( + context: appContext(operationId: 'board-duration-group', now: now), + localDate: CivilDate(2026, 6, 25), + groupMode: BacklogBoardGroupMode.duration, + ), + )) + .requireValue; + final needTimeBlock = grouped.columns.singleWhere( + (column) => column.bucket == BacklogBoardBucket.needTimeBlock, + ); + + expect(needTimeBlock.items.map((item) => item.taskId), [ + 'high-work', + 'long-work', + ]); + }); + test('loads backlog task detail with child previews', () async { final parent = backlogTask( id: 'parent', diff --git a/packages/scheduler_core/test/scheduling_engine_test.dart b/packages/scheduler_core/test/scheduling_engine_test.dart index bd9a532..2ca2ae0 100644 --- a/packages/scheduler_core/test/scheduling_engine_test.dart +++ b/packages/scheduler_core/test/scheduling_engine_test.dart @@ -188,7 +188,7 @@ void main() { reward: RewardLevel.low, difficulty: DifficultyLevel.hard, projectId: 'zeta', - ); + ).copyWith(durationMinutes: 90); final highPriority = Task.quickCapture( id: 'high', title: 'High', @@ -198,6 +198,7 @@ void main() { difficulty: DifficultyLevel.easy, projectId: 'alpha', ).copyWith( + durationMinutes: 15, stats: const TaskStatistics(autoPushedCount: 2), ); final mediumPriority = Task.quickCapture( @@ -209,6 +210,7 @@ void main() { difficulty: DifficultyLevel.medium, projectId: 'beta', ).copyWith( + durationMinutes: 45, stats: const TaskStatistics(manuallyPushedCount: 1), ); final view = BacklogView( @@ -216,6 +218,11 @@ void main() { now: now, ); + expect(view.sorted(BacklogSortKey.custom).map((task) => task.id), [ + 'low', + 'medium', + 'high', + ]); expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [ 'high', 'medium', @@ -235,6 +242,11 @@ void main() { 'medium', 'low', ]); + expect(view.sorted(BacklogSortKey.duration).map((task) => task.id), [ + 'high', + 'medium', + 'low', + ]); }); test('backlog staleness markers cover default thresholds', () {