From d6cf8e45b839eb78a4e91bdf8efd8d5cd0988982 Mon Sep 17 00:00:00 2001 From: humber-web Date: Wed, 7 Oct 2026 11:43:48 -0100 Subject: [PATCH 1/7] Delete an account's transactions along with it Deleted accounts left their transactions behind, so they still showed in the transaction list and counted towards the overview totals. Deleting an account now removes its transactions too and unlinks its subscriptions, and startup cleans up accounts that were deleted before this change. Co-Authored-By: Claude Opus 5.5 --- src/commands/mod.rs | 95 ++++++++++++++++++++++++++++++++++++++++++++- src/database/mod.rs | 12 ++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a7a7632..c0d823e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -286,16 +286,42 @@ pub fn update_account( Ok(account) } +/// Deletes the account along with its transactions, so they stop counting +/// towards totals. Subscriptions billed to it are kept but unlinked. pub fn delete_account(state: &DbState, id: String) -> Result<(), String> { - let conn = state.0.lock().map_err(|e| e.to_string())?; - conn.execute( + let mut conn = state.0.lock().map_err(|e| e.to_string())?; + let tx = conn.transaction().map_err(|e| e.to_string())?; + tx.execute( "UPDATE accounts SET deleted_at = datetime('now') WHERE id = ?1", params![id], ) .map_err(|e| e.to_string())?; + tx.execute( + "UPDATE transactions SET deleted_at = datetime('now') WHERE account_id = ?1 AND deleted_at IS NULL", + params![id], + ) + .map_err(|e| e.to_string())?; + tx.execute( + "UPDATE subscriptions SET account_id = NULL, updated_at = datetime('now') WHERE account_id = ?1", + params![id], + ) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; Ok(()) } +/// How many live transactions belong to an account. +pub fn count_account_transactions(state: &DbState, id: &str) -> Result { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.query_row( + "SELECT COUNT(*) FROM transactions WHERE account_id = ?1 AND deleted_at IS NULL", + params![id], + |row| row.get::<_, i64>(0), + ) + .map(|n| n as usize) + .map_err(|e| e.to_string()) +} + fn get_account_by_id(conn: &rusqlite::Connection, id: &str) -> Result { conn.query_row( "SELECT id, name, account_type, currency, balance, color, icon, is_active, sort_order, created_at, updated_at @@ -1690,4 +1716,69 @@ mod tests { assert_eq!(overview.total_balance, accounts_total); assert!(overview.total_income > 0 && overview.total_expenses > 0); } + + /// The seeded account with the most transactions. + fn busiest_account(db: &DbState) -> String { + let txs = get_transactions(db).unwrap(); + get_accounts(db) + .unwrap() + .into_iter() + .max_by_key(|a| txs.iter().filter(|t| t.account_id == a.id).count()) + .unwrap() + .id + } + + #[test] + fn deleting_an_account_hides_its_transactions() { + let db = seeded(); + let id = busiest_account(&db); + let before = get_transactions(&db).unwrap(); + let owned = before.iter().filter(|t| t.account_id == id).count(); + assert!(owned > 0, "seed gives the account some transactions"); + assert_eq!(count_account_transactions(&db, &id).unwrap(), owned); + + delete_account(&db, id.clone()).unwrap(); + + let after = get_transactions(&db).unwrap(); + assert_eq!(after.len(), before.len() - owned); + assert!(after.iter().all(|t| t.account_id != id)); + assert!(get_overview(&db).unwrap().recent_transactions.iter().all(|t| t.account_id != id)); + assert_eq!(count_account_transactions(&db, &id).unwrap(), 0); + } + + #[test] + fn deleting_an_account_keeps_its_subscriptions_unlinked() { + let db = seeded(); + let id = busiest_account(&db); + let sub = create_subscription( + &db, + CreateSubscriptionRequest { + name: "Streaming".into(), + amount: 999, + currency: "USD".into(), + frequency: "monthly".into(), + category_id: None, + account_id: Some(id.clone()), + start_date: None, + }, + ) + .unwrap(); + + delete_account(&db, id).unwrap(); + + let kept = get_subscriptions(&db).unwrap().into_iter().find(|s| s.id == sub.id).expect("subscription kept"); + assert_eq!(kept.account_id, None); + } + + #[test] + fn migrations_clean_up_accounts_deleted_the_old_way() { + let db = seeded(); + let id = busiest_account(&db); + { + let conn = db.0.lock().unwrap(); + conn.execute("UPDATE accounts SET deleted_at = datetime('now') WHERE id = ?1", params![id]).unwrap(); + crate::database::run_migrations(&conn).unwrap(); + } + assert!(get_transactions(&db).unwrap().iter().all(|t| t.account_id != id)); + } } diff --git a/src/database/mod.rs b/src/database/mod.rs index d298ca2..b52b6a8 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -104,6 +104,18 @@ pub fn run_migrations(conn: &Connection) -> Result<()> { ", )?; + // Accounts deleted before deletes cascaded left their transactions behind. + conn.execute_batch( + " + UPDATE transactions SET deleted_at = datetime('now') + WHERE deleted_at IS NULL + AND account_id IN (SELECT id FROM accounts WHERE deleted_at IS NOT NULL); + + UPDATE subscriptions SET account_id = NULL + WHERE account_id IN (SELECT id FROM accounts WHERE deleted_at IS NOT NULL); + ", + )?; + Ok(()) } From 0a8af1c545378db38d78d44f3ac09c522e53a6ac Mon Sep 17 00:00:00 2001 From: humber-web Date: Wed, 7 Oct 2026 11:44:35 -0100 Subject: [PATCH 2/7] Ask before deleting an account The delete button removed the account straight away. It now opens the same confirmation dialog as transactions and subscriptions, and says how many transactions go with the account. Co-Authored-By: Claude Opus 5.5 --- src/ui/accounts.rs | 68 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index 23e4253..a1077b7 100644 --- a/src/ui/accounts.rs +++ b/src/ui/accounts.rs @@ -16,6 +16,7 @@ use std::sync::Arc; enum Dialog { Add(Entity), Transfer(Entity), + Confirm(Entity, String), } pub struct AccountsScreen { @@ -79,10 +80,40 @@ impl AccountsScreen { cx.notify(); } + fn ask_delete(&mut self, id: String, cx: &mut Context) { + let Some(name) = self.accounts().iter().find(|a| a.id == id).map(|a| a.name.clone()) else { + return; + }; + let records = commands::count_account_transactions(&self.db, &id).unwrap_or(0); + let message = if records == 0 { + format!("This will permanently remove {name}. This action cannot be undone.") + } else { + format!( + "This will permanently remove {name} and its {}. This action cannot be undone.", + plural(records, "transaction") + ) + }; + let dialog = cx.new(|cx| ConfirmDialog::new("Delete account?", message, "Delete", cx)); + self._dialog_sub = Some(cx.subscribe(&dialog, |this, _, event: &ConfirmEvent, cx| { + if *event == ConfirmEvent::Confirm { + if let Some(Dialog::Confirm(_, id)) = this.dialog.take() { + this.delete(id, cx); + } + } + this.dialog = None; + this._dialog_sub = None; + cx.notify(); + })); + self.dialog = Some(Dialog::Confirm(dialog, id)); + cx.notify(); + } + fn delete(&mut self, id: String, cx: &mut Context) { + let name = self.accounts().iter().find(|a| a.id == id).map(|a| a.name.clone()).unwrap_or_default(); match commands::delete_account(&self.db, id) { Ok(()) => { self.accounts = commands::get_accounts(&self.db); + cx.emit(ScreenEvent::ok(format!("Deleted account {name}"))); } Err(e) => cx.emit(ScreenEvent::error(format!("Could not delete account: {e}"))), } @@ -127,7 +158,7 @@ impl Render for AccountsScreen { ) .child( button(SharedString::from(format!("delete-account-{}", a.id)), "Delete", ButtonVariant::Danger, cx) - .on_click(cx.listener(move |this, _, _, cx| this.delete(id.clone(), cx))), + .on_click(cx.listener(move |this, _, _, cx| this.ask_delete(id.clone(), cx))), ) }) .collect(); @@ -138,6 +169,7 @@ impl Render for AccountsScreen { let dialog = self.dialog.as_ref().map(|d| match d { Dialog::Add(form) => form.clone().into_any_element(), Dialog::Transfer(form) => form.clone().into_any_element(), + Dialog::Confirm(dialog, _) => dialog.clone().into_any_element(), }); div() @@ -449,15 +481,39 @@ mod tests { assert!(matches!(events.borrow().last(), Some(ScreenEvent::Toast { ok: false, .. }))); } + fn ask_delete(screen: &Entity, id: &str, cx: &mut VisualTestContext) -> Entity { + cx.update(|_, cx| screen.update(cx, |s, cx| s.ask_delete(id.to_string(), cx))); + cx.update(|_, cx| match screen.read(cx).dialog.as_ref() { + Some(Dialog::Confirm(d, _)) => d.clone(), + _ => panic!("confirm dialog should be open"), + }) + } + + #[gpui_kit::test] + fn delete_asks_first_and_removes_on_confirm(cx: &mut TestAppContext) { + let (screen, cx, events) = setup(cx); + let id = cx.update(|_, cx| screen.read(cx).accounts()[0].id.clone()); + let dialog = ask_delete(&screen, &id, cx); + cx.update(|_, cx| assert_eq!(screen.read(cx).accounts().len(), 4, "nothing deleted until confirmed")); + cx.update(|_, cx| dialog.update(cx, |_, cx| cx.emit(ConfirmEvent::Confirm))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(!s.has_dialog()); + assert_eq!(s.accounts().len(), 3); + assert!(s.accounts().iter().all(|a| a.id != id)); + }); + assert!(matches!(events.borrow().last(), Some(ScreenEvent::Toast { ok: true, .. }))); + } + #[gpui_kit::test] - fn delete_removes_the_account(cx: &mut TestAppContext) { + fn cancelling_the_delete_keeps_the_account(cx: &mut TestAppContext) { let (screen, cx, _) = setup(cx); let id = cx.update(|_, cx| screen.read(cx).accounts()[0].id.clone()); - cx.update(|_, cx| screen.update(cx, |s, cx| s.delete(id.clone(), cx))); + let dialog = ask_delete(&screen, &id, cx); + cx.update(|_, cx| dialog.update(cx, |_, cx| cx.emit(ConfirmEvent::Cancel))); cx.update(|_, cx| { - let accounts = screen.read(cx).accounts(); - assert_eq!(accounts.len(), 3); - assert!(accounts.iter().all(|a| a.id != id)); + assert!(!screen.read(cx).has_dialog()); + assert_eq!(screen.read(cx).accounts().len(), 4); }); } From 194468eb4f89fcb1aaf759e83b1b5986ff7cb4f7 Mon Sep 17 00:00:00 2001 From: humber-web Date: Wed, 7 Oct 2026 11:45:16 -0100 Subject: [PATCH 3/7] Ask before deleting a category The trash button removed the category straight away. It now asks first and says how many transactions will be left without a category. Co-Authored-By: Claude Opus 5.5 --- src/ui/categories.rs | 70 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/src/ui/categories.rs b/src/ui/categories.rs index 1435568..532f128 100644 --- a/src/ui/categories.rs +++ b/src/ui/categories.rs @@ -36,6 +36,8 @@ pub struct CategoriesScreen { counts: HashMap, form: Option>, _form_sub: Option, + confirm: Option<(Entity, String)>, + _confirm_sub: Option, } impl EventEmitter for CategoriesScreen {} @@ -48,6 +50,8 @@ impl CategoriesScreen { counts: HashMap::new(), form: None, _form_sub: None, + confirm: None, + _confirm_sub: None, }; screen.load(); screen @@ -104,9 +108,40 @@ impl CategoriesScreen { cx.notify(); } + fn ask_delete(&mut self, id: String, cx: &mut Context) { + let Some(name) = self.categories().iter().find(|c| c.id == id).map(|c| c.name.clone()) else { + return; + }; + let records = self.counts.get(&id).copied().unwrap_or(0); + let message = if records == 0 { + format!("This will permanently remove {name}. This action cannot be undone.") + } else { + format!( + "This will permanently remove {name}. Its {} will be kept without a category. This action cannot be undone.", + plural(records, "transaction") + ) + }; + let dialog = cx.new(|cx| ConfirmDialog::new("Delete category?", message, "Delete", cx)); + self._confirm_sub = Some(cx.subscribe(&dialog, |this, _, event: &ConfirmEvent, cx| { + if let Some((_, id)) = this.confirm.take() { + if *event == ConfirmEvent::Confirm { + this.delete(id, cx); + } + } + this._confirm_sub = None; + cx.notify(); + })); + self.confirm = Some((dialog, id)); + cx.notify(); + } + fn delete(&mut self, id: String, cx: &mut Context) { + let name = self.categories().iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default(); match commands::delete_category(&self.db, id) { - Ok(()) => self.load(), + Ok(()) => { + self.load(); + cx.emit(ScreenEvent::ok(format!("Deleted category {name}"))); + } Err(e) => cx.emit(ScreenEvent::error(format!("Could not delete category: {e}"))), } cx.notify(); @@ -175,7 +210,7 @@ impl CategoriesScreen { ButtonVariant::Danger, cx, ) - .on_click(cx.listener(move |this, _, _, cx| this.delete(id.clone(), cx))), + .on_click(cx.listener(move |this, _, _, cx| this.ask_delete(id.clone(), cx))), ), ); } @@ -217,6 +252,7 @@ impl Render for CategoriesScreen { .child(page_header("Categories", add, cx)) .child(div().flex().gap(rems(1.5)).child(expenses).child(income)) .children(self.form.clone()) + .children(self.confirm.as_ref().map(|(dialog, _)| dialog.clone())) } } @@ -453,11 +489,35 @@ mod tests { }); } + fn ask_delete(screen: &Entity, id: &str, cx: &mut VisualTestContext) -> Entity { + cx.update(|_, cx| screen.update(cx, |s, cx| s.ask_delete(id.to_string(), cx))); + cx.update(|_, cx| screen.read(cx).confirm.as_ref().map(|(d, _)| d.clone()).expect("confirm dialog open")) + } + #[gpui_kit::test] - fn delete_removes_category(cx: &mut TestAppContext) { + fn delete_asks_first_and_removes_on_confirm(cx: &mut TestAppContext) { let (screen, cx) = setup(cx); let id = cx.update(|_, cx| screen.read(cx).categories()[0].id.clone()); - cx.update(|_, cx| screen.update(cx, |s, cx| s.delete(id.clone(), cx))); - cx.update(|_, cx| assert!(screen.read(cx).categories().iter().all(|c| c.id != id))); + let dialog = ask_delete(&screen, &id, cx); + cx.update(|_, cx| assert_eq!(screen.read(cx).categories().len(), 8, "nothing deleted until confirmed")); + cx.update(|_, cx| dialog.update(cx, |_, cx| cx.emit(ConfirmEvent::Confirm))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(s.confirm.is_none()); + assert!(s.categories().iter().all(|c| c.id != id)); + }); + } + + #[gpui_kit::test] + fn cancelling_the_delete_keeps_the_category(cx: &mut TestAppContext) { + let (screen, cx) = setup(cx); + let id = cx.update(|_, cx| screen.read(cx).categories()[0].id.clone()); + let dialog = ask_delete(&screen, &id, cx); + cx.update(|_, cx| dialog.update(cx, |_, cx| cx.emit(ConfirmEvent::Cancel))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(s.confirm.is_none()); + assert_eq!(s.categories().len(), 8); + }); } } From ae9147983b388adf25c53fe23fb09635e09e5c10 Mon Sep 17 00:00:00 2001 From: humber-web Date: Wed, 7 Oct 2026 11:45:21 -0100 Subject: [PATCH 4/7] Check off account deletion protection in the roadmap Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 81f6106..bee608a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ Features that build directly on what exists today. - [ ] **Auto calculate charges for Ecocash** - For payments and tranfers done using Ecocash specify amount spend and left amount then auto calculate charges -- [ ] **Protect account deletion** +- [x] **Protect account deletion** - If a user press delete account prompt them to confirm before deletion - [ ] **Correct account amounts** From 73d4d561ab0b15640aad6fd85a3ebbdb17ad5cf4 Mon Sep 17 00:00:00 2001 From: humber-web Date: Wed, 7 Oct 2026 12:52:31 -0100 Subject: [PATCH 5/7] Add search, filters and sort order for transactions Matching works on the loaded list, so it composes with the date filter and paging. Search looks at the description, merchant, notes, category, account and amount, and every word has to match. Co-Authored-By: Claude Opus 5.5 --- src/lib.rs | 1 + src/transaction_filter.rs | 309 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 src/transaction_filter.rs diff --git a/src/lib.rs b/src/lib.rs index 710329d..915183d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,3 +10,4 @@ pub mod prefs; pub mod projection; pub mod seed; pub mod subscriptions; +pub mod transaction_filter; diff --git a/src/transaction_filter.rs b/src/transaction_filter.rs new file mode 100644 index 0000000..a742f02 --- /dev/null +++ b/src/transaction_filter.rs @@ -0,0 +1,309 @@ +//! Search, filters and sort order for the transactions list. Works on the +//! loaded transactions, so it composes with the date filter and paging. +use crate::commands::{Account, Category, Transaction}; +use std::cmp::Ordering; + +/// Category filter value that matches transactions without a category. +pub const UNCATEGORIZED: &str = "none"; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TransactionFilter { + /// Words that must all appear in the description, merchant, notes, + /// category, account or amount. Case-insensitive. + pub search: String, + /// `income` or `expense`. + pub kind: Option, + pub account_id: Option, + /// A category id, or [`UNCATEGORIZED`]. + pub category_id: Option, +} + +impl TransactionFilter { + pub fn is_empty(&self) -> bool { + self.search.trim().is_empty() && self.kind.is_none() && self.account_id.is_none() && self.category_id.is_none() + } + + pub fn matches(&self, t: &Transaction, accounts: &[Account], categories: &[Category]) -> bool { + if self.kind.as_ref().is_some_and(|k| *k != t.transaction_type) { + return false; + } + if self.account_id.as_ref().is_some_and(|a| *a != t.account_id) { + return false; + } + match self.category_id.as_deref() { + None => {} + Some(UNCATEGORIZED) => { + if category_name(t, categories).is_some() { + return false; + } + } + Some(id) => { + if t.category_id.as_deref() != Some(id) { + return false; + } + } + } + let terms: Vec = self.search.split_whitespace().map(str::to_lowercase).collect(); + if terms.is_empty() { + return true; + } + let haystack = [ + Some(t.description.as_str()), + t.merchant.as_deref(), + t.notes.as_deref(), + category_name(t, categories), + account_name(t, accounts), + ] + .into_iter() + .flatten() + .collect::>() + .join("\n") + .to_lowercase(); + let amount = amount_text(t.amount); + terms.iter().all(|term| { + haystack.contains(term.as_str()) || { + let digits: String = term.trim_start_matches('$').chars().filter(|c| *c != ',').collect(); + !digits.is_empty() && amount.contains(&digits) + } + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortKey { + Date, + Description, + Category, + Account, + Amount, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Sort { + pub key: SortKey, + pub descending: bool, +} + +impl Default for Sort { + /// Newest first, the order transactions are loaded in. + fn default() -> Self { + Self { key: SortKey::Date, descending: true } + } +} + +impl Sort { + /// The order after clicking a column header: the same column flips + /// direction, a new one starts newest/largest first for dates and amounts + /// and A–Z for text. + pub fn toggled(self, key: SortKey) -> Self { + if key == self.key { + Self { key, descending: !self.descending } + } else { + Self { key, descending: matches!(key, SortKey::Date | SortKey::Amount) } + } + } + + /// Arrow for the column header of `key`, empty for the other columns. + pub fn arrow(self, key: SortKey) -> &'static str { + match (key == self.key, self.descending) { + (false, _) => "", + (true, true) => " ↓", + (true, false) => " ↑", + } + } + + fn compare(self, a: &Transaction, b: &Transaction, accounts: &[Account], categories: &[Category]) -> Ordering { + let text = |s: Option<&str>| s.unwrap_or("").to_lowercase(); + let order = match self.key { + SortKey::Date => a.transaction_date.cmp(&b.transaction_date), + SortKey::Description => text(Some(&a.description)).cmp(&text(Some(&b.description))), + SortKey::Category => text(category_name(a, categories)).cmp(&text(category_name(b, categories))), + SortKey::Account => text(account_name(a, accounts)).cmp(&text(account_name(b, accounts))), + SortKey::Amount => signed(a).cmp(&signed(b)), + }; + if self.descending { + order.reverse() + } else { + order + } + } +} + +/// The transactions that pass `filter`, in `sort` order. Ties keep their +/// loaded order (newest first). +pub fn apply<'a>( + transactions: impl IntoIterator, + filter: &TransactionFilter, + sort: Sort, + accounts: &[Account], + categories: &[Category], +) -> Vec<&'a Transaction> { + let mut rows: Vec<&Transaction> = + transactions.into_iter().filter(|t| filter.matches(t, accounts, categories)).collect(); + if sort != Sort::default() { + rows.sort_by(|a, b| sort.compare(a, b, accounts, categories)); + } + rows +} + +fn category_name<'a>(t: &Transaction, categories: &'a [Category]) -> Option<&'a str> { + let id = t.category_id.as_deref()?; + categories.iter().find(|c| c.id == id).map(|c| c.name.as_str()) +} + +fn account_name<'a>(t: &Transaction, accounts: &'a [Account]) -> Option<&'a str> { + accounts.iter().find(|a| a.id == t.account_id).map(|a| a.name.as_str()) +} + +fn signed(t: &Transaction) -> i64 { + if t.transaction_type == "expense" { + -t.amount + } else { + t.amount + } +} + +fn amount_text(cents: i64) -> String { + let abs = cents.unsigned_abs(); + format!("{}.{:02}", abs / 100, abs % 100) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tx(id: &str, date: &str, kind: &str, amount: i64, description: &str, account: &str, category: Option<&str>) -> Transaction { + Transaction { + id: id.into(), + account_id: account.into(), + category_id: category.map(String::from), + transaction_type: kind.into(), + amount, + currency: "USD".into(), + description: description.into(), + merchant: None, + notes: None, + transaction_date: date.into(), + created_at: String::new(), + updated_at: String::new(), + } + } + + fn account(id: &str, name: &str) -> Account { + Account { + id: id.into(), + name: name.into(), + account_type: "bank".into(), + currency: "USD".into(), + balance: 0, + color: None, + icon: None, + is_active: true, + sort_order: 0, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn category(id: &str, name: &str) -> Category { + Category { + id: id.into(), + name: name.into(), + parent_id: None, + category_type: "expense".into(), + icon: None, + color: None, + sort_order: 0, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn fixture() -> (Vec, Vec, Vec) { + let mut coffee = tx("1", "2026-03-03", "expense", 450, "Coffee", "bank", Some("food")); + coffee.merchant = Some("Blue Bottle".into()); + let txs = vec![ + coffee, + tx("2", "2026-03-02", "income", 250000, "Salary", "bank", Some("pay")), + tx("3", "2026-03-01", "expense", 125050, "Rent", "cash", None), + tx("4", "2026-02-28", "expense", 2000, "Lunch", "cash", Some("gone")), + ]; + let accounts = vec![account("bank", "Main Bank"), account("cash", "Wallet")]; + let categories = vec![category("food", "Food"), category("pay", "Paycheck")]; + (txs, accounts, categories) + } + + fn ids(rows: &[&Transaction]) -> Vec { + rows.iter().map(|t| t.id.clone()).collect() + } + + fn run(filter: &TransactionFilter, sort: Sort) -> Vec { + let (txs, accounts, categories) = fixture(); + ids(&apply(&txs, filter, sort, &accounts, &categories)) + } + + fn search(text: &str) -> Vec { + run(&TransactionFilter { search: text.into(), ..Default::default() }, Sort::default()) + } + + #[test] + fn an_empty_filter_keeps_everything_in_order() { + assert!(TransactionFilter::default().is_empty()); + assert_eq!(run(&TransactionFilter::default(), Sort::default()), ["1", "2", "3", "4"]); + } + + #[test] + fn search_looks_at_every_text_field() { + assert_eq!(search("coffee"), ["1"], "description, any case"); + assert_eq!(search("bottle"), ["1"], "merchant"); + assert_eq!(search("paycheck"), ["2"], "category name"); + assert_eq!(search("wallet"), ["3", "4"], "account name"); + assert_eq!(search("wallet lunch"), ["4"], "every word has to match"); + assert_eq!(search("nothing"), Vec::::new()); + } + + #[test] + fn search_matches_amounts() { + assert_eq!(search("4.50"), ["1"]); + assert_eq!(search("$1,250"), ["3"]); + assert_eq!(search("2500"), ["2"]); + } + + #[test] + fn filters_by_type_account_and_category() { + let kind = TransactionFilter { kind: Some("income".into()), ..Default::default() }; + assert_eq!(run(&kind, Sort::default()), ["2"]); + let acct = TransactionFilter { account_id: Some("cash".into()), ..Default::default() }; + assert_eq!(run(&acct, Sort::default()), ["3", "4"]); + let cat = TransactionFilter { category_id: Some("food".into()), ..Default::default() }; + assert_eq!(run(&cat, Sort::default()), ["1"]); + } + + #[test] + fn uncategorized_includes_deleted_categories() { + let filter = TransactionFilter { category_id: Some(UNCATEGORIZED.into()), ..Default::default() }; + assert_eq!(run(&filter, Sort::default()), ["3", "4"]); + } + + #[test] + fn sorts_by_each_column() { + let all = TransactionFilter::default(); + let by = |key, descending| run(&all, Sort { key, descending }); + assert_eq!(by(SortKey::Date, false), ["4", "3", "2", "1"]); + assert_eq!(by(SortKey::Description, false), ["1", "4", "3", "2"]); + assert_eq!(by(SortKey::Amount, true), ["2", "1", "4", "3"], "income first, biggest expense last"); + assert_eq!(by(SortKey::Account, false), ["1", "2", "3", "4"]); + assert_eq!(by(SortKey::Category, false), ["3", "4", "1", "2"], "uncategorized first"); + } + + #[test] + fn clicking_a_header_toggles_the_order() { + let sort = Sort::default(); + assert_eq!(sort.toggled(SortKey::Date), Sort { key: SortKey::Date, descending: false }); + assert_eq!(sort.toggled(SortKey::Description), Sort { key: SortKey::Description, descending: false }); + assert_eq!(sort.toggled(SortKey::Amount), Sort { key: SortKey::Amount, descending: true }); + assert_eq!(sort.arrow(SortKey::Date), " ↓"); + assert_eq!(sort.toggled(SortKey::Date).arrow(SortKey::Date), " ↑"); + assert_eq!(sort.arrow(SortKey::Amount), ""); + } +} From c31e82c7a3d40d486988f46c482c52b04be155c2 Mon Sep 17 00:00:00 2001 From: humber-web Date: Wed, 7 Oct 2026 12:52:31 -0100 Subject: [PATCH 6/7] Search, filter and sort the transactions list A search box plus type, account and category dropdowns sit next to the date filter, with a Clear button while any of them is set. Clicking a column header sorts by it and clicking again reverses the order. Co-Authored-By: Claude Opus 5.5 --- src/ui/mod.rs | 1 + src/ui/transaction_filter_control.rs | 107 +++++++++++++++++ src/ui/transactions.rs | 170 +++++++++++++++++++++++++-- 3 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 src/ui/transaction_filter_control.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 019b484..c727218 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,6 +13,7 @@ mod settings; mod sidebar; mod subscriptions; mod theme_bridge; +mod transaction_filter_control; mod transactions; mod widgets; diff --git a/src/ui/transaction_filter_control.rs b/src/ui/transaction_filter_control.rs new file mode 100644 index 0000000..b44c8d7 --- /dev/null +++ b/src/ui/transaction_filter_control.rs @@ -0,0 +1,107 @@ +//! Search box plus type, account and category dropdowns for the transactions +//! list. Rebuilt whenever the accounts or categories change, keeping the +//! current selection where it still exists. +use super::widgets::{choice, choice_value, text_input_with, text_of}; +use chelete_lib::commands::{Account, Category}; +use chelete_lib::transaction_filter::{TransactionFilter, UNCATEGORIZED}; +use gpui_kit::base::input::{InputEvent, InputState}; +use gpui_kit::{div, prelude::*, rems, App, Context, Div, Entity, Subscription, Window}; +use gpui_omarchy::{input, select, ChoiceState}; + +const ALL: &str = "all"; + +pub struct TransactionFilterControl { + pub search: Entity, + pub kind: Entity, + pub account: Entity, + pub category: Entity, + /// The account and category lists the dropdowns were built from. + options: Vec<(String, String)>, +} + +impl TransactionFilterControl { + /// Fresh controls with `keep` pre-selected where it still applies. + pub fn new( + keep: &TransactionFilter, + accounts: &[Account], + categories: &[Category], + window: &mut Window, + cx: &mut Context, + ) -> Self { + let kinds = vec![ + (ALL.to_string(), "All Types".to_string()), + ("income".to_string(), "Income".to_string()), + ("expense".to_string(), "Expense".to_string()), + ]; + let mut account_items = vec![(ALL.to_string(), "All Accounts".to_string())]; + account_items.extend(accounts.iter().map(|a| (a.id.clone(), a.name.clone()))); + let mut category_items = vec![ + (ALL.to_string(), "All Categories".to_string()), + (UNCATEGORIZED.to_string(), "Uncategorized".to_string()), + ]; + category_items.extend(categories.iter().map(|c| (c.id.clone(), c.name.clone()))); + + let pick = |items: &[(String, String)], value: &Option| -> String { + value.clone().filter(|v| items.iter().any(|(id, _)| id == v)).unwrap_or_else(|| ALL.to_string()) + }; + let kind = pick(&kinds, &keep.kind); + let account = pick(&account_items, &keep.account_id); + let category = pick(&category_items, &keep.category_id); + Self { + search: text_input_with("Search transactions", &keep.search, window, cx), + kind: choice(kinds, Some(&kind), window, cx), + options: options(accounts, categories), + account: choice(account_items, Some(&account), window, cx), + category: choice(category_items, Some(&category), window, cx), + } + } + + /// Whether the dropdowns are out of date with these lists. + pub fn is_stale(&self, accounts: &[Account], categories: &[Category]) -> bool { + self.options != options(accounts, categories) + } + + pub fn filter(&self, cx: &App) -> TransactionFilter { + let picked = |state: &Entity| choice_value(state, cx).filter(|v| v != ALL); + TransactionFilter { + search: text_of(&self.search, cx), + kind: picked(&self.kind), + account_id: picked(&self.account), + category_id: picked(&self.category), + } + } + + /// Re-render `owner` whenever the search text or a dropdown changes. + pub fn observe(&self, cx: &mut Context, on_change: impl Fn(&mut V, &mut Context) + Clone + 'static) -> Vec { + let typed = on_change.clone(); + let mut subs = vec![cx.subscribe(&self.search, move |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + typed(this, cx); + } + })]; + for state in [&self.kind, &self.account, &self.category] { + let changed = on_change.clone(); + subs.push(cx.observe(state, move |this, _, cx| changed(this, cx))); + } + subs + } + + pub fn render(&self, window: &mut Window, cx: &mut App) -> Div { + div() + .flex() + .items_center() + .gap(rems(0.5)) + .child(div().w(rems(16.)).child(input("tx-search", &self.search, window, cx))) + .child(div().w(rems(9.)).child(select("tx-kind", &self.kind, window, cx))) + .child(div().w(rems(11.)).child(select("tx-account", &self.account, window, cx))) + .child(div().w(rems(11.)).child(select("tx-category", &self.category, window, cx))) + } +} + +fn options(accounts: &[Account], categories: &[Category]) -> Vec<(String, String)> { + accounts + .iter() + .map(|a| (a.id.clone(), a.name.clone())) + .chain(categories.iter().map(|c| (c.id.clone(), c.name.clone()))) + .collect() +} diff --git a/src/ui/transactions.rs b/src/ui/transactions.rs index 0cdc486..f01eca1 100644 --- a/src/ui/transactions.rs +++ b/src/ui/transactions.rs @@ -3,6 +3,7 @@ use super::csv_import::{CsvImportDialog, CsvImportEvent}; use super::date_field; use super::date_filter_control::DateFilterControl; use super::icons::category_icon; +use super::transaction_filter_control::TransactionFilterControl; use super::widgets::*; use chelete_lib::commands::{ self, Account, Category, CreateTransactionRequest, ExportData, Transaction, UpdateTransactionRequest, @@ -12,6 +13,7 @@ use chelete_lib::date_filter::is_date_in_range; use chelete_lib::export::ExportFormat; use chelete_lib::format::{format_money, parse_cents}; use chelete_lib::paging::{page_slice, Pager}; +use chelete_lib::transaction_filter::{self, Sort, SortKey, TransactionFilter}; use gpui_kit::base::input::InputState; use gpui_kit::component::date_picker::DatePicker; use gpui_kit::component::date_picker::DatePickerState; @@ -40,6 +42,8 @@ pub struct TransactionsScreen { accounts: Vec, categories: Vec, date_filter: DateFilterControl, + filters: TransactionFilterControl, + sort: Sort, per_page: Entity, applied_per_page: usize, page: usize, @@ -48,6 +52,7 @@ pub struct TransactionsScreen { view_focus: gpui_kit::FocusHandle, mappings_path: PathBuf, _dialog_sub: Option, + _filter_subs: Vec, _subs: Vec, } @@ -69,12 +74,15 @@ impl TransactionsScreen { } cx.notify(); })); + let filters = TransactionFilterControl::new(&TransactionFilter::default(), &[], &[], window, cx); let mut screen = Self { db, transactions: Vec::new(), accounts: Vec::new(), categories: Vec::new(), date_filter, + filters, + sort: Sort::default(), per_page, applied_per_page: 25, page: 1, @@ -83,12 +91,47 @@ impl TransactionsScreen { view_focus: cx.focus_handle(), mappings_path: chelete_lib::import::session::SavedMappings::default_path(), _dialog_sub: None, + _filter_subs: Vec::new(), _subs: subs, }; screen.load(); + screen.rebuild_filters(&TransactionFilter::default(), window, cx); screen } + /// Recreate the search and filter controls, e.g. after the accounts or + /// categories changed or to clear them. + fn rebuild_filters(&mut self, keep: &TransactionFilter, window: &mut Window, cx: &mut Context) { + self.filters = TransactionFilterControl::new(keep, &self.accounts, &self.categories, window, cx); + self._filter_subs = self.filters.observe(cx, |this: &mut Self, cx| { + this.page = 1; + cx.notify(); + }); + } + + fn clear_filters(&mut self, window: &mut Window, cx: &mut Context) { + self.rebuild_filters(&TransactionFilter::default(), window, cx); + self.page = 1; + cx.notify(); + } + + fn sort_by(&mut self, key: SortKey, cx: &mut Context) { + self.sort = self.sort.toggled(key); + self.page = 1; + cx.notify(); + } + + /// A clickable column header showing the sort direction. + fn sort_header(&self, label: &str, key: SortKey, right: bool, cx: &mut Context) -> impl IntoElement { + div() + .id(SharedString::from(format!("sort-{}", label.to_lowercase()))) + .flex() + .when(right, |d| d.justify_end()) + .cursor_pointer() + .child(format!("{label}{}", self.sort.arrow(key))) + .on_click(cx.listener(move |this, _, _, cx| this.sort_by(key, cx))) + } + fn load(&mut self) { self.transactions = commands::get_transactions(&self.db).unwrap_or_default(); self.accounts = commands::get_accounts(&self.db).unwrap_or_default(); @@ -109,10 +152,8 @@ impl TransactionsScreen { fn filtered(&self, cx: &gpui_kit::App) -> Vec<&Transaction> { let (start, end) = self.date_filter.bounds(chrono::Local::now().date_naive(), cx); - self.transactions - .iter() - .filter(|t| is_date_in_range(&t.transaction_date, &start, &end)) - .collect() + let in_range = self.transactions.iter().filter(|t| is_date_in_range(&t.transaction_date, &start, &end)); + transaction_filter::apply(in_range, &self.filters.filter(cx), self.sort, &self.accounts, &self.categories) } fn pager(&self, cx: &gpui_kit::App) -> Pager { @@ -282,8 +323,13 @@ fn short_date(iso: &str) -> String { impl Render for TransactionsScreen { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.filters.is_stale(&self.accounts, &self.categories) { + let keep = self.filters.filter(cx); + self.rebuild_filters(&keep, window, cx); + } let t = cx.omarchy().clone(); let today = chrono::Local::now().date_naive(); + let filtering = !self.filters.filter(cx).is_empty(); let filtered = self.filtered(cx); let pager = self.pager(cx); let rows: Vec = page_slice(&filtered, &pager).iter().map(|t| (*t).clone()).collect(); @@ -333,12 +379,12 @@ impl Render for TransactionsScreen { let screen = cx.entity(); move |_, cx| screen.update(cx, |s, cx| s.toggle_page(cx)) }, cx))) - .child(cell(Some(6.), "Date")) - .child(cell(None, "Description")) + .child(cell(Some(6.), self.sort_header("Date", SortKey::Date, false, cx))) + .child(cell(None, self.sort_header("Description", SortKey::Description, false, cx))) .child(cell(Some(6.), "Type")) - .child(cell(None, "Category")) - .child(cell(None, "Account")) - .child(cell(Some(8.), div().flex().justify_end().child("Amount"))) + .child(cell(None, self.sort_header("Category", SortKey::Category, false, cx))) + .child(cell(None, self.sort_header("Account", SortKey::Account, false, cx))) + .child(cell(Some(8.), self.sort_header("Amount", SortKey::Amount, true, cx))) .child(cell(Some(7.5), "")); let mut list = div().flex().flex_col().border_1().border_color(t.border).child(header); for tx in rows { @@ -416,7 +462,19 @@ impl Render for TransactionsScreen { Dialog::Import(i) => i.clone().into_any_element(), Dialog::View(tx) => self.view_dialog(tx, cx).into_any_element(), }); - let filter_row = self.date_filter.render(today, window, cx); + let filter_row = div() + .flex() + .flex_wrap() + .items_center() + .gap(rems(0.5)) + .child(self.filters.render(window, cx)) + .child(self.date_filter.render(today, window, cx)) + .when(filtering, |row| { + row.child( + button("clear-filters", "Clear", ButtonVariant::Outline, cx) + .on_click(cx.listener(|this, _, window, cx| this.clear_filters(window, cx))), + ) + }); div() .id("transactions") @@ -894,6 +952,98 @@ mod tests { cx.update(|_, cx| assert_eq!(screen.read(cx).page, 1)); } + fn pick(state: Entity, index: usize, cx: &mut VisualTestContext) { + cx.update(|_, cx| state.update(cx, |c, cx| c.set_selected(Some(index), cx))); + } + + #[gpui_kit::test] + fn search_narrows_the_list_and_resets_to_page_one(cx: &mut TestAppContext) { + let (screen, _db, cx) = setup(cx); + let word = cx.update(|_, cx| screen.read(cx).transactions()[0].description.clone()); + cx.update(|_, cx| screen.update(cx, |s, cx| { s.page = 2; cx.notify(); })); + // Type for real so the input reports the change, as it does for a user. + let search = cx.update(|_, cx| screen.read(cx).filters.search.clone()); + cx.update(|window, cx| search.update(cx, |s, cx| s.focus(window, cx))); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_input(&word.to_uppercase()); + cx.update(|_, cx| { + let s = screen.read(cx); + let shown = s.filtered(cx); + assert!(!shown.is_empty() && shown.len() < 25); + assert!(shown.iter().any(|t| t.description == word)); + assert_eq!(s.page, 1); + }); + } + + #[gpui_kit::test] + fn type_and_account_filters_combine(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let account = commands::get_accounts(&db).unwrap().remove(0); + let (kind, acct) = cx.update(|_, cx| (screen.read(cx).filters.kind.clone(), screen.read(cx).filters.account.clone())); + pick(kind, 2, cx); // Expense + pick(acct, 1, cx); // the first account + cx.update(|_, cx| { + let shown = screen.read(cx).filtered(cx); + assert!(!shown.is_empty()); + assert!(shown.iter().all(|t| t.transaction_type == "expense" && t.account_id == account.id)); + }); + } + + #[gpui_kit::test] + fn clicking_amount_sorts_and_clicking_again_reverses(cx: &mut TestAppContext) { + let (screen, _db, cx) = setup(cx); + let amounts = |screen: &Entity, cx: &mut VisualTestContext| -> Vec { + cx.update(|_, cx| screen.read(cx).filtered(cx).iter().map(|t| signed(t)).collect()) + }; + cx.update(|_, cx| screen.update(cx, |s, cx| s.sort_by(SortKey::Amount, cx))); + let desc = amounts(&screen, cx); + assert!(desc.windows(2).all(|w| w[0] >= w[1]), "largest first"); + cx.update(|_, cx| screen.update(cx, |s, cx| s.sort_by(SortKey::Amount, cx))); + let asc = amounts(&screen, cx); + assert!(asc.windows(2).all(|w| w[0] <= w[1]), "smallest first"); + } + + #[gpui_kit::test] + fn clear_resets_every_filter(cx: &mut TestAppContext) { + let (screen, _db, cx) = setup(cx); + let (search, kind) = cx.update(|_, cx| (screen.read(cx).filters.search.clone(), screen.read(cx).filters.kind.clone())); + type_into(&search, "zzz-no-match", cx); + pick(kind, 1, cx); + cx.update(|_, cx| assert!(screen.read(cx).filtered(cx).is_empty())); + cx.update(|window, cx| screen.update(cx, |s, cx| s.clear_filters(window, cx))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(s.filters.filter(cx).is_empty()); + assert_eq!(s.filtered(cx).len(), 25); + }); + } + + #[gpui_kit::test] + fn new_categories_keep_the_chosen_filters(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let account = commands::get_accounts(&db).unwrap().remove(0); + let acct = cx.update(|_, cx| screen.read(cx).filters.account.clone()); + pick(acct, 1, cx); + commands::create_category( + &db, + chelete_lib::commands::CreateCategoryRequest { + name: "Pets".into(), + parent_id: None, + category_type: "expense".into(), + icon: None, + color: None, + }, + ) + .unwrap(); + cx.update(|_, cx| screen.update(cx, |s, cx| s.reload(cx))); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(!s.filters.is_stale(&s.accounts, &s.categories), "dropdowns rebuilt"); + assert_eq!(s.filters.filter(cx).account_id, Some(account.id.clone()), "selection kept"); + }); + } + #[gpui_kit::test] fn per_page_paginates_the_list(cx: &mut TestAppContext) { let (screen, _db, cx) = setup(cx); From 6db6bc9e0c34a90136518cd7a8ccb2f60ffb3260 Mon Sep 17 00:00:00 2001 From: humber-web Date: Wed, 7 Oct 2026 12:52:31 -0100 Subject: [PATCH 7/7] Check off transaction search in the roadmap Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 81f6106..ba79252 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,7 @@ Features that build directly on what exists today. - Surface "due soon" subscriptions on the Overview dashboard. - One-click "Pay now" to create a real transaction from a subscription. -- [ ] **Transaction search** +- [x] **Transaction search** - Search Recent Transactions by description, merchant, category, or amount. - [ ] **Auto calculate charges for Ecocash**