Skip to content

Commit 4ffbb84

Browse files
wesmclaude
andauthored
Fix behavior of 'g' key to return to aggregate view from top-level detail view, refine navigation docs (#26)
Fixes #21 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98cba2f commit 4ffbb84

File tree

5 files changed

+169
-32
lines changed

5 files changed

+169
-32
lines changed

docs/guide/navigation.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ alt="Categories view">
7474
Press `d` to view all transactions ungrouped in chronological order,
7575
or press `Enter` from any aggregate row to see the transactions for that specific item.
7676

77+
To return to an aggregate view, press `g` or `Escape`.
78+
7779
**Columns displayed:**
7880

7981
- Date
@@ -168,6 +170,11 @@ This powerful feature lets you combine multiple filters to answer very specific
168170

169171
Press `Escape` to navigate backwards through your drill-down path, removing one filter level at a time.
170172

173+
**From top-level detail view:**
174+
175+
- When viewing all transactions (not drilled down), press `g` or `Escape` to return to an aggregate view
176+
- Both keys restore your previous aggregate view (Merchant, Category, Group, or Account)
177+
171178
**Single-level drill-down with sub-grouping:**
172179

173180
- From `Merchants > Amazon (by Category)`, press `Escape` to return to `Merchants > Amazon` (clears sub-grouping)
@@ -304,10 +311,10 @@ Here are some practical examples of using moneyflow's navigation features to ans
304311

305312
| Key | Action |
306313
|-----|--------|
307-
| `g` | Cycle views (Merchant/Category/Group/Account) |
314+
| `g` | Cycle aggregate views, or return to aggregate view from detail view |
308315
| `d` | Detail view (all transactions) |
309316
| `Enter` | Drill down |
310-
| `Escape` | Go back |
317+
| `Escape` | Go back (or return to aggregate view from detail view) |
311318
| `s` | Cycle sort field |
312319
| `v` | Reverse sort |
313320
| `/` | Search |

moneyflow/app_controller.py

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@
2929
from .data_manager import DataManager
3030
from .formatters import ViewPresenter
3131
from .logging_config import get_logger
32-
from .state import AppState, SortDirection, SortMode, TimeFrame, TransactionEdit, ViewMode
32+
from .state import (
33+
AppState,
34+
NavigationState,
35+
SortDirection,
36+
SortMode,
37+
TimeFrame,
38+
TransactionEdit,
39+
ViewMode,
40+
)
3341
from .time_navigator import TimeNavigator
3442
from .view_interface import IViewPresenter
3543

@@ -407,11 +415,7 @@ def _prepare_aggregate_view(self, view_mode: ViewMode):
407415
def switch_to_merchant_view(self):
408416
"""Switch to merchant aggregation view."""
409417
self.state.view_mode = ViewMode.MERCHANT
410-
self.state.selected_merchant = None
411-
self.state.selected_category = None
412-
self.state.selected_group = None
413-
self.state.selected_account = None
414-
self.state.clear_selection() # Clear all multi-select
418+
self.state.clear_drill_down_and_selection()
415419
# Reset sort to valid field for aggregate views (now includes field name)
416420
if self.state.sort_by not in [SortMode.MERCHANT, SortMode.COUNT, SortMode.AMOUNT]:
417421
self.state.sort_by = SortMode.AMOUNT
@@ -420,35 +424,23 @@ def switch_to_merchant_view(self):
420424
def switch_to_category_view(self):
421425
"""Switch to category aggregation view."""
422426
self.state.view_mode = ViewMode.CATEGORY
423-
self.state.selected_merchant = None
424-
self.state.selected_category = None
425-
self.state.selected_group = None
426-
self.state.selected_account = None
427-
self.state.clear_selection() # Clear all multi-select
427+
self.state.clear_drill_down_and_selection()
428428
if self.state.sort_by not in [SortMode.CATEGORY, SortMode.COUNT, SortMode.AMOUNT]:
429429
self.state.sort_by = SortMode.AMOUNT
430430
self.refresh_view()
431431

432432
def switch_to_group_view(self):
433433
"""Switch to group aggregation view."""
434434
self.state.view_mode = ViewMode.GROUP
435-
self.state.selected_merchant = None
436-
self.state.selected_category = None
437-
self.state.selected_group = None
438-
self.state.selected_account = None
439-
self.state.clear_selection() # Clear all multi-select
435+
self.state.clear_drill_down_and_selection()
440436
if self.state.sort_by not in [SortMode.GROUP, SortMode.COUNT, SortMode.AMOUNT]:
441437
self.state.sort_by = SortMode.AMOUNT
442438
self.refresh_view()
443439

444440
def switch_to_account_view(self):
445441
"""Switch to account aggregation view."""
446442
self.state.view_mode = ViewMode.ACCOUNT
447-
self.state.selected_merchant = None
448-
self.state.selected_category = None
449-
self.state.selected_group = None
450-
self.state.selected_account = None
451-
self.state.clear_selection() # Clear all multi-select
443+
self.state.clear_drill_down_and_selection()
452444
if self.state.sort_by not in [SortMode.ACCOUNT, SortMode.COUNT, SortMode.AMOUNT]:
453445
self.state.sort_by = SortMode.AMOUNT
454446
self.refresh_view()
@@ -457,15 +449,37 @@ def switch_to_detail_view(self, set_default_sort: bool = True):
457449
"""
458450
Switch to transaction detail view (ungrouped).
459451
452+
Saves current state to navigation history if switching from an aggregate view,
453+
so that pressing Esc or 'g' can restore the previous view.
454+
460455
Args:
461456
set_default_sort: If True, set default sort (Date descending)
462457
"""
458+
# Save current state to navigation history if we're in an aggregate view
459+
# This allows Esc/'g' to return to the correct aggregate view
460+
if self.state.view_mode in [
461+
ViewMode.MERCHANT,
462+
ViewMode.CATEGORY,
463+
ViewMode.GROUP,
464+
ViewMode.ACCOUNT,
465+
]:
466+
self.state.navigation_history.append(
467+
NavigationState(
468+
view_mode=self.state.view_mode,
469+
cursor_position=0, # Don't preserve cursor when switching with 'd'
470+
scroll_y=0.0,
471+
sort_by=self.state.sort_by,
472+
sort_direction=self.state.sort_direction,
473+
selected_merchant=self.state.selected_merchant,
474+
selected_category=self.state.selected_category,
475+
selected_group=self.state.selected_group,
476+
selected_account=self.state.selected_account,
477+
sub_grouping_mode=self.state.sub_grouping_mode,
478+
)
479+
)
480+
463481
self.state.view_mode = ViewMode.DETAIL
464-
self.state.selected_merchant = None
465-
self.state.selected_category = None
466-
self.state.selected_group = None
467-
self.state.selected_account = None
468-
self.state.clear_selection() # Clear all multi-select
482+
self.state.clear_drill_down_and_selection()
469483
if set_default_sort:
470484
self.state.sort_by = SortMode.DATE
471485
self.state.sort_direction = SortDirection.DESC
@@ -881,7 +895,7 @@ def _get_action_hints(self) -> str:
881895
):
882896
return "Esc/g=Back | m=✏️ Merchant | c=✏️ Category | h=Hide | x=Delete | Space=Select | Ctrl-A=SelectAll"
883897
else:
884-
return "g=Group | m=✏️ Merchant | c=✏️ Category | h=Hide | x=Delete | Space=Select | Ctrl-A=SelectAll"
898+
return "Esc/g=Group | m=✏️ Merchant | c=✏️ Category | h=Hide | x=Delete | Space=Select | Ctrl-A=SelectAll"
885899

886900
# Edit Orchestration Methods
887901

moneyflow/state.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,21 @@ def clear_selection(self):
222222
self.selected_ids.clear()
223223
self.selected_group_keys.clear()
224224

225+
def clear_drill_down_and_selection(self):
226+
"""
227+
Clear all drill-down filters and selections.
228+
229+
This is a common operation when switching views or returning to top-level.
230+
Clears:
231+
- All drill-down filters (merchant, category, group, account)
232+
- Multi-select state (transaction IDs and group keys)
233+
"""
234+
self.selected_merchant = None
235+
self.selected_category = None
236+
self.selected_group = None
237+
self.selected_account = None
238+
self.clear_selection()
239+
225240
def set_timeframe(
226241
self,
227242
timeframe: TimeFrame,
@@ -460,7 +475,8 @@ def cycle_grouping(self) -> str:
460475
Cycle through grouping modes.
461476
462477
If drilled down: Cycle sub-groupings within current filter
463-
If not drilled down: Cycle top-level aggregation views
478+
If in top-level detail view: Go back to previous aggregate view (or MERCHANT)
479+
If in aggregate view: Cycle top-level aggregation views
464480
465481
When cycling views, if currently sorting by an aggregate field (MERCHANT,
466482
CATEGORY, GROUP, or ACCOUNT), the sort field is updated to match the new
@@ -476,9 +492,32 @@ def cycle_grouping(self) -> str:
476492
if self.is_drilled_down():
477493
return self.cycle_sub_grouping()
478494

479-
# Only cycle if in an aggregation view (not DETAIL)
495+
# If in top-level detail view, go back to aggregate view (like Escape)
480496
if self.view_mode == ViewMode.DETAIL:
481-
return ""
497+
# Try to restore from navigation history first
498+
if self.navigation_history:
499+
nav_state = self.navigation_history.pop()
500+
self.view_mode = nav_state.view_mode
501+
self.sort_by = nav_state.sort_by
502+
self.sort_direction = nav_state.sort_direction
503+
# Restore any drill-down context from history
504+
self.selected_merchant = nav_state.selected_merchant
505+
self.selected_category = nav_state.selected_category
506+
self.selected_group = nav_state.selected_group
507+
self.selected_account = nav_state.selected_account
508+
self.sub_grouping_mode = nav_state.sub_grouping_mode
509+
# Return friendly name for the restored view
510+
view_names = {
511+
ViewMode.MERCHANT: "Merchants",
512+
ViewMode.CATEGORY: "Categories",
513+
ViewMode.GROUP: "Groups",
514+
ViewMode.ACCOUNT: "Accounts",
515+
}
516+
return view_names.get(nav_state.view_mode, "")
517+
else:
518+
# No history - default to merchant view
519+
self.view_mode = ViewMode.MERCHANT
520+
return "Merchants"
482521

483522
# Clear any drill-down selections when switching views
484523
self.selected_merchant = None

tests/test_app_controller.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,39 @@ async def test_switch_to_detail_view_preserve_sort(self, controller, mock_view):
941941
assert controller.state.sort_by == SortMode.AMOUNT
942942
assert controller.state.sort_direction == SortDirection.ASC
943943

944+
async def test_switch_to_detail_view_saves_navigation_history(self, controller, mock_view):
945+
"""Test that switching from aggregate to detail view saves navigation history."""
946+
# Start in category view
947+
controller.state.view_mode = ViewMode.CATEGORY
948+
controller.state.sort_by = SortMode.CATEGORY
949+
controller.state.sort_direction = SortDirection.ASC
950+
951+
# Switch to detail view
952+
controller.switch_to_detail_view(set_default_sort=True)
953+
954+
# Should have saved the category view to navigation history
955+
assert len(controller.state.navigation_history) == 1
956+
nav_state = controller.state.navigation_history[0]
957+
assert nav_state.view_mode == ViewMode.CATEGORY
958+
assert nav_state.sort_by == SortMode.CATEGORY
959+
assert nav_state.sort_direction == SortDirection.ASC
960+
961+
# Now in detail view
962+
assert controller.state.view_mode == ViewMode.DETAIL
963+
964+
async def test_switch_to_detail_view_from_detail_no_duplicate_history(
965+
self, controller, mock_view
966+
):
967+
"""Test that switching from detail to detail doesn't add duplicate history."""
968+
# Already in detail view
969+
controller.state.view_mode = ViewMode.DETAIL
970+
971+
# Switch to detail view again
972+
controller.switch_to_detail_view(set_default_sort=True)
973+
974+
# Should not have added to navigation history
975+
assert len(controller.state.navigation_history) == 0
976+
944977
async def test_view_switch_clears_selections(self, controller, mock_view):
945978
"""Test that switching views clears all drill-down selections."""
946979
# Set up some selections

tests/test_state.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,50 @@ def test_cycle_grouping_works_normally_when_not_drilled_down(self):
11431143
assert state.view_mode == ViewMode.CATEGORY
11441144
assert result == "Categories"
11451145

1146+
def test_cycle_grouping_from_detail_view_with_history_restores_previous_view(self):
1147+
"""Pressing 'g' from top-level DETAIL view with navigation history should restore previous view."""
1148+
state = AppState()
1149+
state.view_mode = ViewMode.DETAIL
1150+
state.sort_by = SortMode.DATE
1151+
state.sort_direction = SortDirection.ASC
1152+
1153+
# Simulate having navigation history from a previous CATEGORY view
1154+
nav_state = NavigationState(
1155+
view_mode=ViewMode.CATEGORY,
1156+
sort_by=SortMode.CATEGORY,
1157+
sort_direction=SortDirection.DESC,
1158+
cursor_position=5,
1159+
scroll_y=100.0,
1160+
)
1161+
state.navigation_history.append(nav_state)
1162+
1163+
result = state.cycle_grouping()
1164+
1165+
# Should restore to CATEGORY view with previous sort settings
1166+
assert state.view_mode == ViewMode.CATEGORY
1167+
assert state.sort_by == SortMode.CATEGORY
1168+
assert state.sort_direction == SortDirection.DESC
1169+
assert result == "Categories"
1170+
# Navigation history should be consumed
1171+
assert len(state.navigation_history) == 0
1172+
1173+
def test_cycle_grouping_from_detail_view_without_history_defaults_to_merchant(self):
1174+
"""Pressing 'g' from top-level DETAIL view without history should default to MERCHANT view."""
1175+
state = AppState()
1176+
state.view_mode = ViewMode.DETAIL
1177+
state.sort_by = SortMode.DATE
1178+
state.sort_direction = SortDirection.ASC
1179+
# No navigation history
1180+
1181+
result = state.cycle_grouping()
1182+
1183+
# Should default to MERCHANT view
1184+
assert state.view_mode == ViewMode.MERCHANT
1185+
# Sort settings should be preserved from current state
1186+
assert state.sort_by == SortMode.DATE
1187+
assert state.sort_direction == SortDirection.ASC
1188+
assert result == "Merchants"
1189+
11461190
def test_cycle_sub_grouping_resets_date_sort_to_amount(self):
11471191
"""When cycling from detail to aggregated sub-grouping, should reset DATE sort to AMOUNT."""
11481192
state = AppState()

0 commit comments

Comments
 (0)