From 73d4d561ab0b15640aad6fd85a3ebbdb17ad5cf4 Mon Sep 17 00:00:00 2001 From: humber-web Date: Wed, 7 Oct 2026 12:52:31 -0100 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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**