Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 112 additions & 58 deletions moneyflow/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -996,6 +996,58 @@ def restore_view_state(self, saved_state: dict) -> None:
self.show_transfers = saved_state.get("show_transfers", self.show_transfers)
self.show_hidden = saved_state.get("show_hidden", self.show_hidden)

def _get_drill_down_order(self) -> List[str]:
"""
Determine the order in which dimensions were drilled down.

Returns a list of dimension names in the order they were selected.
Used to display breadcrumbs in the correct hierarchical order.

Returns:
List of dimension names ('time', 'merchant', 'category', 'group', 'account')
in the order they were selected during navigation.
"""
# Check which dimensions are currently selected
current_selections = {
"time": self.selected_time_year is not None,
"merchant": self.selected_merchant is not None,
"category": self.selected_category is not None,
"group": self.selected_group is not None,
"account": self.selected_account is not None,
}

selected_dims = [k for k, v in current_selections.items() if v]

# If only one or zero dimensions, order is simple
if len(selected_dims) <= 1:
return selected_dims

# Multiple dimensions - determine order from navigation_history
order = []

# Iterate through navigation history to build the drill order
for nav_state in self.navigation_history:
# Check which dimensions were selected at this point in history
hist_selections = {
"time": nav_state.selected_time_year is not None,
"merchant": nav_state.selected_merchant is not None,
"category": nav_state.selected_category is not None,
"group": nav_state.selected_group is not None,
"account": nav_state.selected_account is not None,
}

# Find dimensions that are in hist_selections but not yet in order
for dim, selected in hist_selections.items():
if selected and dim not in order and current_selections[dim]:
order.append(dim)

# Add dimensions that are in current state but not in history (most recent drill)
for dim in selected_dims:
if dim not in order:
order.append(dim)

return order

def get_breadcrumb(self, display_labels: Optional[Dict[str, str]] = None) -> str:
"""
Get breadcrumb string showing current navigation path.
Expand Down Expand Up @@ -1051,65 +1103,67 @@ def get_date_range_suffix() -> str:
else:
parts.append(granularity_label)
elif self.view_mode == ViewMode.DETAIL:
# Show all drill-down levels
# Order: Time (if set) → Merchant/Category/Group/Account
has_any_selection = False

# Time period comes first if selected
if self.selected_time_year is not None:
parts.append("Time")
if self.selected_time_month is not None:
# Format as "Mar 2024"
month_names = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
parts.append(
f"{month_names[self.selected_time_month - 1]} {self.selected_time_year}"
)
else:
# Just year
parts.append(str(self.selected_time_year))
has_any_selection = True

# Then other dimensions
if self.selected_merchant:
if not has_any_selection:
parts.append(merchants_label)
else:
# Show all drill-down levels in the order they were drilled
drill_order = self._get_drill_down_order()

# Helper to add dimension to breadcrumb
def add_dimension(dim: str, is_first: bool):
"""Add a dimension to the breadcrumb parts."""
if dim == "time":
# Only add "Time" label if it's the first dimension
if is_first:
parts.append("Time")
if self.selected_time_month is not None:
# Format as "Mar 2024"
month_names = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
parts.append(
f"{month_names[self.selected_time_month - 1]} {self.selected_time_year}"
)
else:
# Just year
assert self.selected_time_year is not None
parts.append(str(self.selected_time_year))
elif dim == "merchant":
# Merchant always adds label (matching old behavior where both if/else added it)
parts.append(merchants_label)
parts.append(self.selected_merchant)
has_any_selection = True

if self.selected_category:
if not has_any_selection:
parts.append("Categories")
parts.append(self.selected_category)
has_any_selection = True

if self.selected_group:
if not has_any_selection:
parts.append("Groups")
parts.append(self.selected_group)
has_any_selection = True

if self.selected_account:
if not has_any_selection:
parts.append(accounts_label)
parts.append(self.selected_account)
has_any_selection = True

if not has_any_selection:
assert self.selected_merchant is not None
parts.append(self.selected_merchant)
elif dim == "category":
# Category/group/account only add label if first dimension
if is_first:
parts.append("Categories")
assert self.selected_category is not None
parts.append(self.selected_category)
elif dim == "group":
if is_first:
parts.append("Groups")
assert self.selected_group is not None
parts.append(self.selected_group)
elif dim == "account":
if is_first:
parts.append(accounts_label)
assert self.selected_account is not None
parts.append(self.selected_account)

# Add dimensions in drill-down order
for i, dim in enumerate(drill_order):
add_dimension(dim, i == 0)

# If no dimensions selected, show default
if not drill_order:
parts.append("All Transactions")

# Add sub-grouping indicator if active
Expand Down
81 changes: 81 additions & 0 deletions tests/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,87 @@ def test_breadcrumb_with_date_filter(self, app_state):
assert "Year" not in breadcrumb
assert breadcrumb == "Merchants"

def test_breadcrumb_merchant_then_time(self, app_state):
"""Test breadcrumb shows merchant before time when drilled in that order."""
# Simulate drilling: Merchants → Amazon → (by Time) → 2024
app_state.view_mode = ViewMode.MERCHANT
app_state.time_granularity = TimeGranularity.YEAR
# First drill into Amazon
app_state.drill_down("Amazon", cursor_position=0, scroll_y=0.0)
# Cycle to sub-grouping by time
app_state.sub_grouping_mode = ViewMode.TIME
# Then drill into time period (this properly saves navigation history)
app_state.drill_down("2024", cursor_position=0, scroll_y=0.0)

breadcrumb = app_state.get_breadcrumb()

# Should show: Merchants > Amazon > 2024
# NOT: Time > 2024 > Merchants > Amazon
assert breadcrumb == "Merchants > Amazon > 2024"

def test_breadcrumb_merchant_then_time_month(self, app_state):
"""Test breadcrumb shows merchant before time month when drilled in that order."""
# Simulate drilling: Merchants → Amazon → (by Time) → Mar 2024
app_state.view_mode = ViewMode.MERCHANT
app_state.time_granularity = TimeGranularity.MONTH
app_state.drill_down("Amazon", cursor_position=0, scroll_y=0.0)
app_state.sub_grouping_mode = ViewMode.TIME
app_state.drill_down("Mar 2024", cursor_position=0, scroll_y=0.0)

breadcrumb = app_state.get_breadcrumb()

# Should show: Merchants > Amazon > Mar 2024
assert breadcrumb == "Merchants > Amazon > Mar 2024"

def test_breadcrumb_category_then_time(self, app_state):
"""Test breadcrumb shows category before time when drilled in that order."""
# Simulate drilling: Categories → Groceries → (by Time) → 2024
app_state.view_mode = ViewMode.CATEGORY
app_state.time_granularity = TimeGranularity.YEAR
app_state.drill_down("Groceries", cursor_position=0, scroll_y=0.0)
app_state.sub_grouping_mode = ViewMode.TIME
app_state.drill_down("2024", cursor_position=0, scroll_y=0.0)

breadcrumb = app_state.get_breadcrumb()

# Should show: Categories > Groceries > 2024
assert breadcrumb == "Categories > Groceries > 2024"

def test_breadcrumb_time_then_merchant(self, app_state):
"""Test breadcrumb shows time before merchant when drilled in that order."""
# Simulate drilling: Time → 2024 → (by Merchant) → Amazon
app_state.view_mode = ViewMode.TIME
app_state.time_granularity = TimeGranularity.YEAR
app_state.drill_down("2024", cursor_position=0, scroll_y=0.0)
# Cycle to sub-grouping by merchant
app_state.sub_grouping_mode = ViewMode.MERCHANT
# Drill into merchant
app_state.drill_down("Amazon", cursor_position=0, scroll_y=0.0)

breadcrumb = app_state.get_breadcrumb()

# Should show: Time > 2024 > Merchants > Amazon
# NOT: Merchants > Amazon > 2024
# The order should be preserved based on navigation_history
parts = breadcrumb.split(" > ")
# Time should come before Merchants in the breadcrumb
time_index = next((i for i, p in enumerate(parts) if "2024" in p), -1)
merchant_index = next((i for i, p in enumerate(parts) if "Amazon" in p), -1)
assert time_index < merchant_index
assert breadcrumb == "Time > 2024 > Merchants > Amazon"

def test_breadcrumb_time_only(self, app_state):
"""Test breadcrumb shows only time when that's the only drill-down."""
# Simulate drilling: Time → 2024
app_state.view_mode = ViewMode.TIME
app_state.time_granularity = TimeGranularity.YEAR
app_state.drill_down("2024", cursor_position=0, scroll_y=0.0)

breadcrumb = app_state.get_breadcrumb()

# Should show: Time > 2024
assert breadcrumb == "Time > 2024"


class TestSubGrouping:
"""Tests for sub-grouping within drilled-down views."""
Expand Down