diff --git a/ROADMAP.md b/ROADMAP.md index 4955afe..579eeb1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,9 +18,8 @@ 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. - - Done: description, merchant, type, category and account. Still to do: amount. - [x] **Auto calculate charges for Ecocash** - For payments and tranfers done using Ecocash specify amount spend and left amount then auto calculate charges diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b24f9c0..29b88a8 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 @@ -1752,4 +1778,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(()) } 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), ""); + } +} diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index 9526348..5ebefaa 100644 --- a/src/ui/accounts.rs +++ b/src/ui/accounts.rs @@ -84,15 +84,20 @@ impl AccountsScreen { cx.notify(); } - fn ask_delete(&mut self, account: &Account, cx: &mut Context) { - let dialog = cx.new(|cx| { - ConfirmDialog::new( - format!("Delete {}?", account.name), - "This will remove the account. Its transactions are kept. This action cannot be undone.", - "Delete", - cx, + 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() { @@ -103,16 +108,16 @@ impl AccountsScreen { this._dialog_sub = None; cx.notify(); })); - self.dialog = Some(Dialog::Confirm(dialog, account.id.clone())); + 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()); + 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 {}", name.unwrap_or_else(|| "account".into())))); + cx.emit(ScreenEvent::ok(format!("Deleted account {name}"))); } Err(e) => cx.emit(ScreenEvent::error(format!("Could not delete account: {e}"))), } @@ -145,7 +150,7 @@ impl Render for AccountsScreen { let cards: Vec<_> = list .iter() .map(|a| { - let account = a.clone(); + let id = a.id.clone(); let editing = a.clone(); panel(account_type_label(&a.account_type), cx) .w(rems(16.)) @@ -166,7 +171,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.ask_delete(&account, cx))), + .on_click(cx.listener(move |this, _, _, cx| this.ask_delete(id.clone(), cx))), ), ) }) @@ -515,54 +520,39 @@ mod tests { assert!(matches!(events.borrow().last(), Some(ScreenEvent::Toast { ok: false, .. }))); } - #[gpui_kit::test] - fn delete_removes_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))); - cx.update(|_, cx| { - let accounts = screen.read(cx).accounts(); - assert_eq!(accounts.len(), 3); - assert!(accounts.iter().all(|a| a.id != id)); - }); - } - - fn ask_to_delete_first(screen: &Entity, cx: &mut VisualTestContext) -> (Account, Entity) { - let account = cx.update(|_, cx| screen.read(cx).accounts()[0].clone()); - cx.update(|_, cx| screen.update(cx, |s, cx| s.ask_delete(&account, cx))); - let dialog = cx.update(|_, cx| match screen.read(cx).dialog.as_ref() { - Some(Dialog::Confirm(d, id)) => { - assert_eq!(id, &account.id); - d.clone() - } + 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"), - }); - (account, dialog) + }) } #[gpui_kit::test] - fn delete_asks_before_removing_the_account(cx: &mut TestAppContext) { - let (screen, cx, _) = setup(cx); - let (account, dialog) = ask_to_delete_first(&screen, cx); + 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 != account.id)); + assert!(s.accounts().iter().all(|a| a.id != id)); }); + assert!(matches!(events.borrow().last(), Some(ScreenEvent::Toast { ok: true, .. }))); } #[gpui_kit::test] - fn cancelling_the_prompt_keeps_the_account(cx: &mut TestAppContext) { + fn cancelling_the_delete_keeps_the_account(cx: &mut TestAppContext) { let (screen, cx, _) = setup(cx); - let (account, dialog) = ask_to_delete_first(&screen, cx); + let id = cx.update(|_, cx| screen.read(cx).accounts()[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.has_dialog()); - assert!(s.accounts().iter().any(|a| a.id == account.id)); + assert!(!screen.read(cx).has_dialog()); + assert_eq!(screen.read(cx).accounts().len(), 4); }); } 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); + }); } } 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..ffe445c --- /dev/null +++ b/src/ui/transaction_filter_control.rs @@ -0,0 +1,117 @@ +//! 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::date_filter_control::PRESET_WIDTH; +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::{icon, input, select, ActiveTheme as _, ChoiceState, IconName}; + +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( + // Same size as the date dropdown, stretched to its height so they line up. + div().w(rems(PRESET_WIDTH)).self_stretch().child( + input("tx-search", &self.search, window, cx) + .debug_selector(|| "tx-search".into()) + .h_full() + .py(rems(0.)) + .prefix(icon(IconName::Search).size(rems(0.875)).text_color(cx.omarchy().secondary)), + ), + ) + .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 05cec0b..680fd91 100644 --- a/src/ui/transactions.rs +++ b/src/ui/transactions.rs @@ -1,8 +1,9 @@ use super::accounts::FormEvent; use super::csv_import::{CsvImportDialog, CsvImportEvent}; use super::date_field; -use super::date_filter_control::{DateFilterControl, PRESET_WIDTH}; +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_balance, 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,7 +42,8 @@ pub struct TransactionsScreen { accounts: Vec, categories: Vec, date_filter: DateFilterControl, - search: Entity, + filters: TransactionFilterControl, + sort: Sort, per_page: Entity, applied_per_page: usize, page: usize, @@ -49,6 +52,7 @@ pub struct TransactionsScreen { view_focus: gpui_kit::FocusHandle, mappings_path: PathBuf, _dialog_sub: Option, + _filter_subs: Vec, _subs: Vec, } @@ -58,15 +62,10 @@ impl TransactionsScreen { pub fn new(db: Arc, window: &mut Window, cx: &mut Context) -> Self { let date_filter = DateFilterControl::new(window, cx); let per_page = per_page_choice(25, window, cx); - let search = text_input("Search transactions", window, cx); let mut subs = date_filter.observe(cx, |this: &mut Self, cx| { this.page = 1; cx.notify(); }); - subs.push(cx.observe(&search, |this, _, cx| { - this.page = 1; - cx.notify(); - })); subs.push(cx.observe(&per_page, |this, _, cx| { let n = choice_value(&this.per_page, cx).and_then(|v| v.parse().ok()).unwrap_or(this.applied_per_page); if n != this.applied_per_page { @@ -75,13 +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, - search, + filters, + sort: Sort::default(), per_page, applied_per_page: 25, page: 1, @@ -90,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(); @@ -116,30 +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); - let query = text_of(&self.search, cx).to_lowercase(); - let terms: Vec<&str> = query.split_whitespace().collect(); - self.transactions - .iter() - .filter(|t| is_date_in_range(&t.transaction_date, &start, &end)) - .filter(|t| terms.is_empty() || self.matches_search(t, &terms)) - .collect() - } - - /// Every term has to appear in at least one of the searchable fields. - fn matches_search(&self, t: &Transaction, terms: &[&str]) -> bool { - let haystack = [ - Some(t.description.as_str()), - t.merchant.as_deref(), - Some(t.transaction_type.as_str()), - self.category(&t.category_id).map(|c| c.name.as_str()), - self.account_name(&t.account_id), - ] - .into_iter() - .flatten() - .map(str::to_lowercase) - .collect::>() - .join("\n"); - terms.iter().all(|term| haystack.contains(term)) + 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 { @@ -309,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(); @@ -360,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 { @@ -443,19 +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) - .child( - // Stretch to the date dropdown's height so the two line up. - div().w(rems(PRESET_WIDTH)).self_stretch().child( - input("tx-search", &self.search, window, cx) - .debug_selector(|| "tx-search".into()) - .h_full() - .py(rems(0.)) - .prefix(icon(IconName::Search).size(rems(0.875)).text_color(t.secondary)), - ), - ); + 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") @@ -1087,52 +1106,96 @@ mod tests { cx.update(|_, cx| assert_eq!(screen.read(cx).page, 1)); } - fn search_for(screen: &Entity, text: &str, cx: &mut VisualTestContext) -> Vec { - let search = cx.update(|_, cx| screen.read(cx).search.clone()); - type_into(&search, text, cx); - cx.update(|_, cx| screen.read(cx).filtered(cx).into_iter().cloned().collect()) + 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_matches_description_type_category_and_account(cx: &mut TestAppContext) { - let (screen, db, cx) = setup(cx); - let all = cx.update(|_, cx| screen.read(cx).transactions().to_vec()); - let sample = all.iter().find(|t| t.category_id.is_some()).unwrap().clone(); - - let word = sample.description.split_whitespace().next().unwrap().to_uppercase(); - let shown = search_for(&screen, &word, cx); - assert!(shown.iter().any(|t| t.id == sample.id), "description, case-insensitive"); + 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); + }); + } - let shown = search_for(&screen, "income", cx); - assert!(!shown.is_empty()); - assert!(shown.len() < all.len()); - assert_eq!(shown.len(), all.iter().filter(|t| t.transaction_type == "income").count(), "seeded descriptions do not say income"); + #[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)); + }); + } - let category = commands::get_categories(&db).unwrap().into_iter().find(|c| Some(&c.id) == sample.category_id.as_ref()).unwrap(); - let shown = search_for(&screen, &category.name, cx); - assert!(shown.iter().any(|t| t.id == sample.id), "category name"); + #[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"); + } - let account = commands::get_accounts(&db).unwrap().into_iter().find(|a| a.id == sample.account_id).unwrap(); - let shown = search_for(&screen, &account.name, cx); - assert!(shown.iter().any(|t| t.id == sample.id), "account name"); - assert!(shown.len() < all.len(), "other accounts are left out"); + #[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 search_terms_must_all_match_and_reset_the_page(cx: &mut TestAppContext) { + fn new_categories_keep_the_chosen_filters(cx: &mut TestAppContext) { let (screen, db, cx) = setup(cx); - let sample = cx.update(|_, cx| screen.read(cx).transactions()[0].clone()); - let account = commands::get_accounts(&db).unwrap().into_iter().find(|a| a.id == sample.account_id).unwrap(); - cx.update(|_, cx| screen.update(cx, |s, cx| { s.page = 2; cx.notify(); })); - - let shown = search_for(&screen, &format!("{} {}", sample.transaction_type, account.name), cx); - assert!(shown.iter().any(|t| t.id == sample.id)); - let either = search_for(&screen, &sample.transaction_type, cx).len().min(search_for(&screen, &account.name, cx).len()); - assert!(shown.len() <= either, "both terms narrow the list"); - cx.update(|_, cx| assert_eq!(screen.read(cx).page, 1)); - - assert!(search_for(&screen, "zzz-no-such-thing", cx).is_empty()); - assert_eq!(search_for(&screen, " ", cx).len(), 25, "blank search shows everything"); + 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]