Skip to content

Commit aebe2c4

Browse files
wesmclaude
andcommitted
feat: Add bulk recategorization from Category and Group aggregate views
Implemented bulk recategorization feature for aggregate views, matching the existing bulk merchant edit functionality. **New Feature:** - Press 'r' in Category view → recategorize all transactions in that category - Press 'r' in Group view → recategorize all transactions in that group - Shows category picker modal - Queues edits for all transactions in the selected category/group **Use Cases:** 1. **Category View**: Recategorize all "Miscellaneous" to "Groceries" 2. **Group View**: Move all "Uncategorized" group transactions to specific categories **Workflow Example:** ``` 1. View Categories (press 'c' or cycle with 'g') 2. Navigate to "Miscellaneous" category 3. Press 'r' to bulk recategorize 4. Type "groc" to filter, select "Groceries" 5. All Miscellaneous transactions queued for recategorization 6. Press 'w' to review and commit ``` **Implementation:** - Added _bulk_recategorize_from_aggregate() method - Handles both CATEGORY and GROUP views - Similar pattern to _bulk_edit_merchant_from_aggregate() - Shows clear notification with count and old→new category names **UI Updates:** - Action hints now show "r=Recategorize (bulk)" in Category/Group views - Consistent with merchant bulk edit ("m=Edit merchant (bulk)") **Benefits:** - Quickly fix miscategorized transactions - Power user workflow for bulk cleanup - Same consistent pattern as bulk merchant edits All 465 tests passing, pyright clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 12c77c8 commit aebe2c4

File tree

1 file changed

+105
-4
lines changed

1 file changed

+105
-4
lines changed

moneyflow/app.py

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -833,7 +833,10 @@ def update_action_hints(self) -> None:
833833
hints = (
834834
f"Enter=Drill down | m=Edit merchant (bulk) | s=Sort({sort_name}) | g=Change grouping | ←/→=Change period"
835835
)
836-
elif self.state.view_mode in [ViewMode.CATEGORY, ViewMode.GROUP, ViewMode.ACCOUNT]:
836+
elif self.state.view_mode in [ViewMode.CATEGORY, ViewMode.GROUP]:
837+
sort_name = self.state.sort_by.value.capitalize()
838+
hints = f"Enter=Drill down | r=Recategorize (bulk) | s=Sort({sort_name}) | g=Change grouping | ←/→=Change period"
839+
elif self.state.view_mode == ViewMode.ACCOUNT:
837840
sort_name = self.state.sort_by.value.capitalize()
838841
hints = f"Enter=Drill down | s=Sort({sort_name}) | g=Change grouping | ←/→=Change period"
839842
else: # DETAIL (transactions)
@@ -1319,14 +1322,112 @@ async def _edit_merchant_detail(self) -> None:
13191322
table.move_cursor(row=saved_cursor_row)
13201323

13211324
def action_recategorize(self) -> None:
1322-
"""Change category for current selection."""
1325+
"""Change category for current selection (works in aggregate and detail views)."""
13231326
if self.data_manager is None:
13241327
return
13251328

1326-
self.run_worker(self._recategorize(), exclusive=False)
1329+
# Check if in aggregate view (CATEGORY or GROUP) or detail view
1330+
if self.state.view_mode in [ViewMode.CATEGORY, ViewMode.GROUP]:
1331+
# Aggregate view - recategorize all transactions for this category/group
1332+
self.run_worker(self._bulk_recategorize_from_aggregate(), exclusive=False)
1333+
else:
1334+
# Detail view - recategorize selected transaction(s)
1335+
self.run_worker(self._recategorize(), exclusive=False)
1336+
1337+
async def _bulk_recategorize_from_aggregate(self) -> None:
1338+
"""Recategorize all transactions in selected category/group."""
1339+
from .screens.edit_screens import SelectCategoryScreen
1340+
1341+
if self.state.current_data is None:
1342+
return
1343+
1344+
table = self.query_one("#data-table", DataTable)
1345+
if table.cursor_row < 0:
1346+
return
1347+
1348+
# Get the category/group from current row
1349+
row_data = self.state.current_data.row(table.cursor_row, named=True)
1350+
1351+
if self.state.view_mode == ViewMode.CATEGORY:
1352+
category_name = row_data["category"]
1353+
category_id = row_data["category_id"]
1354+
transaction_count = row_data["count"]
1355+
total_amount = row_data["total"]
1356+
1357+
# Show category selection
1358+
new_category_id = await self.push_screen(
1359+
SelectCategoryScreen(
1360+
self.data_manager.categories,
1361+
category_id,
1362+
None # No transaction details for bulk
1363+
),
1364+
wait_for_dismiss=True,
1365+
)
1366+
1367+
if new_category_id and new_category_id != category_id:
1368+
# Get all transactions for this category
1369+
filtered_df = self.state.get_filtered_df()
1370+
category_txns = self.data_manager.filter_by_category(filtered_df, category_name)
1371+
1372+
# Add edits for all transactions
1373+
for txn in category_txns.iter_rows(named=True):
1374+
self.data_manager.pending_edits.append(
1375+
TransactionEdit(
1376+
transaction_id=txn["id"],
1377+
field="category",
1378+
old_value=category_id,
1379+
new_value=new_category_id,
1380+
timestamp=datetime.now(),
1381+
)
1382+
)
1383+
1384+
new_cat_name = self.data_manager.categories.get(new_category_id, {}).get("name", "Unknown")
1385+
self.notify(
1386+
f"Queued {len(category_txns)} transactions to recategorize: {category_name}{new_cat_name}. Press w to commit.",
1387+
timeout=3
1388+
)
1389+
self.refresh_view()
1390+
elif self.state.view_mode == ViewMode.GROUP:
1391+
group_name = row_data["group"]
1392+
transaction_count = row_data["count"]
1393+
1394+
# For group view, show category picker
1395+
# (groups don't have IDs, so we pick a category from that group)
1396+
new_category_id = await self.push_screen(
1397+
SelectCategoryScreen(
1398+
self.data_manager.categories,
1399+
None, # No current category
1400+
None # No transaction details
1401+
),
1402+
wait_for_dismiss=True,
1403+
)
1404+
1405+
if new_category_id:
1406+
# Get all transactions for this group
1407+
filtered_df = self.state.get_filtered_df()
1408+
group_txns = self.data_manager.filter_by_group(filtered_df, group_name)
1409+
1410+
# Add edits for all transactions
1411+
for txn in group_txns.iter_rows(named=True):
1412+
self.data_manager.pending_edits.append(
1413+
TransactionEdit(
1414+
transaction_id=txn["id"],
1415+
field="category",
1416+
old_value=txn["category_id"],
1417+
new_value=new_category_id,
1418+
timestamp=datetime.now(),
1419+
)
1420+
)
1421+
1422+
new_cat_name = self.data_manager.categories.get(new_category_id, {}).get("name", "Unknown")
1423+
self.notify(
1424+
f"Queued {len(group_txns)} transactions from {group_name} to recategorize to {new_cat_name}. Press w to commit.",
1425+
timeout=3
1426+
)
1427+
self.refresh_view()
13271428

13281429
async def _recategorize(self) -> None:
1329-
"""Show category selection and apply."""
1430+
"""Show category selection and apply (for detail view)."""
13301431
from .screens.edit_screens import SelectCategoryScreen
13311432
from .state import TransactionEdit
13321433

0 commit comments

Comments
 (0)