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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Full keyboard shortcuts and tutorials: [moneyflow.dev](https://moneyflow.dev)
**Analyze spending:**

1. Press `g` to cycle views (Merchants → Categories → Groups → Accounts → Time)
2. In Time view: Press `t`/`y` to toggle year/month, `Enter` to drill into a period
2. In Time view: Press `t` to cycle granularity (Year → Month → Day), `Enter` to drill into a period
3. In any aggregate view: Press `Enter` to drill down
4. Press `g` to cycle sub-groupings (including by Time)
5. Press `a` to clear time drill-down, `Escape` to go back
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ First run downloads all your transactions:
You're in! Here's what to try:

- Press ++g++ to cycle views: Merchants, Categories, Groups, Accounts, Time
- In Time view: Press ++y++/++t++ to toggle year/month granularity
- In Time view: Press ++t++ to cycle through Year, Month, and Day granularities
- Press ++enter++ on any row to drill down
- Press ++escape++ to go back
- Press ++question++ for help
Expand Down
15 changes: 7 additions & 8 deletions docs/guide/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,18 @@ moneyflow is designed to be used entirely with the keyboard. Here's your complet

| Key | Action | Context |
|-----|--------|---------|
| ++y++ | Toggle to year granularity | TIME view only |
| ++t++ | Toggle to month granularity | TIME view only |
| ++t++ | Toggle time granularity (Year → Month → Day) | TIME view only |
| ++a++ | Clear time drill-down (return to all data) | When drilled into time period |

### Period Navigation

| Key | Action | Context |
|-----|--------|---------|
| ++left++ | Previous period (month or year) | When drilled into time period |
| ++right++ | Next period (month or year) | When drilled into time period |
| ++left++ | Previous period (year, month, or day) | When drilled into time period |
| ++right++ | Next period (year, month, or day) | When drilled into time period |

**Navigation behavior**: Arrow keys navigate between periods when you've drilled into a specific year or month.
The granularity matches your drill-down level (year-to-year or month-to-month).
**Navigation behavior**: Arrow keys navigate between periods when you've drilled into a specific year, month, or day.
The granularity matches your drill-down level (year-to-year, month-to-month, or day-to-day).

---

Expand Down Expand Up @@ -243,7 +242,7 @@ When in a modal dialog (edit merchant, select category, etc.):
- The cursor stays in place after edits - keep pressing ++m++ or ++c++

!!! tip "TIME View Navigation"
- Press ++g++ to cycle to TIME view, then ++y++/++t++ to toggle granularity
- Press ++g++ to cycle to TIME view, then ++t++ to cycle through Year, Month, and Day granularities
- ++left++/++right++ navigate between periods when drilled into a time period
- ++a++ clears time drill-down (shortcut for ++escape++)

Expand All @@ -260,7 +259,7 @@ Print this for reference:

```text
Views: g (cycle: Merchant/Category/Group/Account/Time) d (detail) D (duplicates)
Time: y (year granularity) t (month granularity) a (clear drill-down) ←/→ (navigate periods)
Time: t (toggle granularity: Year→Month→Day) a (clear drill-down) ←/→ (navigate periods)
Edit: m (merchant) c (category) h (hide) x (delete) u (undo)
Select: Space (multi-select) Ctrl+A (select all)
Sort: s (toggle field) v (reverse)
Expand Down
29 changes: 19 additions & 10 deletions docs/guide/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ alt="Time view by years">
alt="Time view by months">
</td>
</tr>
<tr>
<td width="50%">
<strong>TIME View (by Days)</strong><br>
<img src="https://raw.githubusercontent.com/wesm/moneyflow-assets/main/time-view-days.svg" width="100%"
alt="Time view by days">
</td>
<td width="50%">
</td>
</tr>
</table>

| View | What It Shows | Use For |
Expand All @@ -56,7 +65,7 @@ alt="Time view by months">
| **Category** | Spending by category | Identify which categories consume your budget |
| **Group** | Spending by category group | Monthly budget reviews, broad spending patterns |
| **Account** | Spending by payment method | Reconciliation, per-account spending analysis |
| **Time** | Spending by time period (years or months) | Analyze spending trends over time, year-over-year comparisons |
| **Time** | Spending by time period (years, months, or days) | Analyze spending trends over time, year-over-year comparisons |

**Columns displayed:**

Expand Down Expand Up @@ -148,7 +157,7 @@ This allows you to analyze the same transactions from multiple perspectives with
4. **Press `g` again** - View changes to `Merchants > Amazon (by Account)`
- Shows which payment methods you use at Amazon
5. **Press `g` again** - View changes to `Merchants > Amazon (by Year)`
- Shows Amazon spending trends over time (press `t` to toggle to monthly)
- Shows Amazon spending trends over time (press `t` to cycle granularity)
6. **Press `g` again** - Returns to detail view
- Shows all Amazon transactions ungrouped

Expand Down Expand Up @@ -237,22 +246,22 @@ temporal analysis.

### TIME View

Press `g` to cycle to the TIME view, which shows your transactions aggregated by time period. Toggle between year
and month granularity to adjust the time grouping:
Press `g` to cycle to the TIME view, which shows your transactions aggregated by time period. Toggle through three
granularity levels to adjust the time grouping:

- **Press `y`** - Switch to year granularity (view by years: 2023, 2024, 2025)
- **Press `t`** - Switch to month granularity (view by months: Jan 2024, Feb 2024, ...)
- **Press `t`** - Cycle through granularities (Year, then Month, then Day, then back to Year)

**Example workflow:**

1. **Press `g` until you reach TIME view** - Shows all years in your dataset
2. **Press `t`** - Toggle to monthly view - Shows all months with data
3. **Press `Enter` on a specific period** - Drill into that time period
4. **Press `g` to sub-group** - Pivot by Merchant/Category/etc within that period
3. **Press `t` again** - Toggle to daily view - Shows all days with data
4. **Press `Enter` on a specific period** - Drill into that time period
5. **Press `g` to sub-group** - Pivot by Merchant/Category/etc within that period

### Drilling Into Time Periods

From TIME view, press `Enter` on any year or month to drill down and see only transactions from that period.
From TIME view, press `Enter` on any year, month, or day to drill down and see only transactions from that period.

<table>
<tr>
Expand Down Expand Up @@ -419,7 +428,7 @@ Here are some practical examples of using moneyflow's navigation features to ans
| `x` | Delete selected transaction(s) |
| `u` | Undo pending edit |
| `w` | Commit pending edits |
| `y` / `t` | Toggle time granularity (year/month) in TIME view |
| `t` | Cycle time granularity (Year → Month → Day) in TIME view |
| `a` | Clear time period drill-down |
| `←` / `→` | Navigate time periods (when drilled into time) |

Expand Down
26 changes: 4 additions & 22 deletions moneyflow/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
from .screens.review_screen import ReviewChangesScreen
from .screens.search_screen import SearchScreen
from .screens.transaction_detail_screen import TransactionDetailScreen
from .state import AppState, TimeGranularity, ViewMode
from .state import AppState, ViewMode
from .textual_view import TextualViewPresenter
from .widgets.help_screen import HelpScreen

Expand Down Expand Up @@ -119,8 +119,7 @@ class MoneyflowApp(App):
# Note: 'c' removed - conflicts with commit confirmation in review screen
Binding("A", "view_accounts", "Accounts", show=False, key_display="A"),
# Time granularity (only active in TIME view)
Binding("y", "toggle_year_granularity", "Year", show=False),
Binding("t", "toggle_month_granularity", "Month", show=False),
Binding("t", "toggle_time_granularity", "Toggle Time", show=False),
Binding("a", "clear_time_period", "Clear Time", show=False),
# Sorting
Binding("s", "toggle_sort_field", "Sort", show=True),
Expand Down Expand Up @@ -982,31 +981,14 @@ def action_undo_pending_edits(self) -> None:
)

# Time navigation actions
def action_toggle_year_granularity(self) -> None:
"""Toggle to year granularity (in TIME view or when sub-grouping by time)."""
def action_toggle_time_granularity(self) -> None:
"""Cycle through time granularities: Year → Month → Day → Year."""
# Allow in TIME view or when sub-grouping by time
if not (
self.state.view_mode == ViewMode.TIME or self.state.sub_grouping_mode == ViewMode.TIME
):
return # Ignore if not in TIME context

if self.state.time_granularity == TimeGranularity.YEAR:
return # Already in year view

view_name = self.controller.toggle_time_granularity()
self.notify(f"Switched to {view_name}", timeout=1)

def action_toggle_month_granularity(self) -> None:
"""Toggle to month granularity (in TIME view or when sub-grouping by time)."""
# Allow in TIME view or when sub-grouping by time
if not (
self.state.view_mode == ViewMode.TIME or self.state.sub_grouping_mode == ViewMode.TIME
):
return # Ignore if not in TIME context

if self.state.time_granularity == TimeGranularity.MONTH:
return # Already in month view

view_name = self.controller.toggle_time_granularity()
self.notify(f"Switched to {view_name}", timeout=1)

Expand Down
27 changes: 18 additions & 9 deletions moneyflow/app_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,11 +358,13 @@ def refresh_view(self, force_rebuild: bool = True) -> None:
total_expenses = float(expense_df["amount"].sum()) if not expense_df.is_empty() else 0.0
net_savings = total_income + total_expenses

# Use abbreviated labels for compact display
# In: (Income), Out: (Expenses/Outflow), Net: (Savings)
stats_text = (
f"{len(filtered_df):,} txns | "
f"Income: {ViewPresenter.format_amount(total_income)} | "
f"Expenses: {ViewPresenter.format_amount(total_expenses)} | "
f"Savings: {ViewPresenter.format_amount(net_savings)}"
f"In: {ViewPresenter.format_amount(total_income)} | "
f"Out: {ViewPresenter.format_amount(total_expenses)} | "
f"Net: {ViewPresenter.format_amount(net_savings)}"
)
self.view.update_stats(stats_text)
else:
Expand Down Expand Up @@ -522,7 +524,10 @@ def _prepare_time_view_data(self, agg: pl.DataFrame):
for row_dict in agg.to_dicts():
year = row_dict["year"]
month = row_dict.get("month")
period_str = ViewPresenter.format_time_period(year, month, self.state.time_granularity)
day = row_dict.get("day")
period_str = ViewPresenter.format_time_period(
year, month, day, self.state.time_granularity
)
count_str = str(row_dict["count"])
total_str = ViewPresenter.format_amount(row_dict["total"])

Expand Down Expand Up @@ -1011,11 +1016,15 @@ def _get_action_hints(self) -> str:

if self.state.view_mode == ViewMode.TIME:
# TIME view - show granularity toggle
granularity = "year" if self.state.time_granularity == TimeGranularity.YEAR else "month"
toggle_key = "t" if granularity == "year" else "y"
toggle_to = "Month" if granularity == "year" else "Year"

return f"Enter=Drill | {toggle_key}={toggle_to} | s=Sort({sort_name}) | g=Group"
# Determine next granularity in cycle: Year → Month → Day → Year
if self.state.time_granularity == TimeGranularity.YEAR:
toggle_to = "By Month"
elif self.state.time_granularity == TimeGranularity.MONTH:
toggle_to = "By Day"
else: # DAY
toggle_to = "By Year"

return f"Enter=Drill | t={toggle_to} | s=Sort({sort_name}) | g=Group"
elif self.state.view_mode == ViewMode.MERCHANT:
return f"Enter=Drill | Space=Select | m=✏️ Merchant (bulk) | c=✏️ Category (bulk) | s=Sort({sort_name}) | g=Group"
elif self.state.view_mode in [ViewMode.CATEGORY, ViewMode.GROUP]:
Expand Down
72 changes: 64 additions & 8 deletions moneyflow/data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,8 +657,8 @@ def _fill_time_gaps(self, df: pl.DataFrame, granularity: TimeGranularity) -> pl.
filling missing periods with count=0 and total=0.

Args:
df: Aggregated time DataFrame with year, month, count, total columns
granularity: TIME granularity (YEAR or MONTH)
df: Aggregated time DataFrame with year, month, day, count, total columns
granularity: TIME granularity (YEAR, MONTH, or DAY)

Returns:
DataFrame with all periods filled, sorted chronologically
Expand Down Expand Up @@ -686,7 +686,7 @@ def _fill_time_gaps(self, df: pl.DataFrame, granularity: TimeGranularity) -> pl.
}
)
join_cols = ["year", "time_period_display"]
else: # MONTH
elif granularity == TimeGranularity.MONTH:
# Find actual min and max months (not just years)
# Sort by time_period_display to get actual earliest/latest
sorted_df = df.sort("time_period_display")
Expand Down Expand Up @@ -722,6 +722,39 @@ def _fill_time_gaps(self, df: pl.DataFrame, granularity: TimeGranularity) -> pl.

all_periods = pl.DataFrame(periods)
join_cols = ["year", "month", "time_period_display"]
else: # DAY
# Find actual min and max days
from datetime import date, timedelta

sorted_df = df.sort("time_period_display")
first_row = sorted_df.head(1)
last_row = sorted_df.tail(1)

min_year = first_row["year"][0]
min_month = first_row["month"][0]
min_day = first_row["day"][0]
max_year = last_row["year"][0]
max_month = last_row["month"][0]
max_day = last_row["day"][0]

# Generate all days between min and max
periods = []
current_date = date(min_year, min_month, min_day)
end_date = date(max_year, max_month, max_day)

while current_date <= end_date:
periods.append(
{
"year": current_date.year,
"month": current_date.month,
"day": current_date.day,
"time_period_display": f"{current_date.year}-{current_date.month:02d}-{current_date.day:02d}",
}
)
current_date += timedelta(days=1)

all_periods = pl.DataFrame(periods)
join_cols = ["year", "month", "day", "time_period_display"]

# Left join to preserve all periods, filling missing with 0
result = (
Expand All @@ -746,13 +779,14 @@ def aggregate_by_time(self, df: pl.DataFrame, granularity: TimeGranularity) -> p

Args:
df: Transaction DataFrame to aggregate
granularity: TIME granularity (YEAR or MONTH)
granularity: TIME granularity (YEAR, MONTH, or DAY)

Returns:
Aggregated DataFrame with columns:
- time_period_display: "2024" or "2024-03" (for sorting)
- time_period_display: "2024", "2024-03", or "2024-03-15" (for sorting)
- year: int
- month: int (only for MONTH granularity)
- month: int (for MONTH and DAY granularity)
- day: int (only for DAY granularity)
- count: int (number of transactions)
- total: float (sum of amounts, excluding hidden)

Expand All @@ -768,11 +802,12 @@ def aggregate_by_time(self, df: pl.DataFrame, granularity: TimeGranularity) -> p
if df.is_empty():
return pl.DataFrame()

# Add year and month columns extracted from date
# Add year, month, and day columns extracted from date
df = df.with_columns(
[
pl.col("date").dt.year().alias("year"),
pl.col("date").dt.month().alias("month"),
pl.col("date").dt.day().alias("day"),
]
)

Expand All @@ -787,7 +822,7 @@ def aggregate_by_time(self, df: pl.DataFrame, granularity: TimeGranularity) -> p
pl.col("amount").filter(~pl.col("hideFromReports")).sum().alias("total"),
]
)
else: # MONTH
elif granularity == TimeGranularity.MONTH:
# Group by year and month
df = df.with_columns(
[
Expand All @@ -806,6 +841,27 @@ def aggregate_by_time(self, df: pl.DataFrame, granularity: TimeGranularity) -> p
pl.col("amount").filter(~pl.col("hideFromReports")).sum().alias("total"),
]
)
else: # DAY
# Group by year, month, and day
df = df.with_columns(
[
(
pl.col("year").cast(pl.Utf8)
+ "-"
+ pl.col("month").cast(pl.Utf8).str.zfill(2)
+ "-"
+ pl.col("day").cast(pl.Utf8).str.zfill(2)
).alias("time_period_display")
]
)

aggregated = df.group_by(["year", "month", "day", "time_period_display"]).agg(
[
pl.count("id").alias("count"),
# Exclude hidden transactions from totals
pl.col("amount").filter(~pl.col("hideFromReports")).sum().alias("total"),
]
)

# Fill gaps between earliest and latest period
result = self._fill_time_gaps(aggregated, granularity)
Expand Down
Loading