From be42234fd2e62e07249d39bddc9f636d2ec9074a Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 22:39:28 +0200 Subject: [PATCH 01/14] Show subscription status as a coloured dot and toggle it on click Active subscriptions get a green dot in the table and inactive ones a yellow dot; clicking the dot flips the status. The view dialog shows the status too. --- src/ui/subscriptions.rs | 58 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/ui/subscriptions.rs b/src/ui/subscriptions.rs index 4ec217c..cb149cc 100644 --- a/src/ui/subscriptions.rs +++ b/src/ui/subscriptions.rs @@ -13,7 +13,7 @@ use chelete_lib::subscriptions::{filter_by_frequency, frequency_label, next_paym use gpui_kit::base::input::InputState; use gpui_kit::component::date_picker::{DatePicker, DatePickerState}; use gpui_kit::{ - div, prelude::*, px, rems, Context, Entity, EventEmitter, IntoElement, Render, + div, prelude::*, px, rems, App, Context, Entity, EventEmitter, IntoElement, Render, SharedString, Subscription as GpuiSubscription, Window, }; use gpui_omarchy::{ @@ -222,6 +222,32 @@ impl SubscriptionsScreen { cx.notify(); } + fn set_active(&mut self, sub: &Subscription, active: bool, cx: &mut Context) { + let result = commands::update_subscription( + &self.db, + UpdateSubscriptionRequest { + id: sub.id.clone(), + name: None, + amount: None, + currency: None, + frequency: None, + category_id: None, + account_id: None, + start_date: None, + is_active: Some(active), + }, + ); + match result { + Ok(_) => { + self.load(); + let state = if active { "active" } else { "inactive" }; + cx.emit(ScreenEvent::ok(format!("Marked {} as {state}", sub.name))); + } + Err(e) => cx.emit(ScreenEvent::error(format!("Could not update subscription: {e}"))), + } + cx.notify(); + } + fn toggle(&mut self, id: &str, cx: &mut Context) { if !self.selected.remove(id) { self.selected.insert(id.to_string()); @@ -300,6 +326,7 @@ impl Render for SubscriptionsScreen { let screen = cx.entity(); move |_, cx| screen.update(cx, |s, cx| s.toggle_page(cx)) }, cx))) + .child(cell(Some(1.5), "")) .child(cell(None, "Name")) .child(cell(None, "Category")) .child(cell(None, "Account")) @@ -318,7 +345,7 @@ impl Render for SubscriptionsScreen { let checked = self.selected.contains(&s.id); let row_id = s.id.clone(); let key = |prefix: &str| SharedString::from(format!("{prefix}-{}", s.id)); - let (view, edit, pay, del) = (s.clone(), s.clone(), s.clone(), s.id.clone()); + let (view, edit, pay, del, status) = (s.clone(), s.clone(), s.clone(), s.id.clone(), s.clone()); list = list.child( div() .flex() @@ -331,6 +358,10 @@ impl Render for SubscriptionsScreen { let screen = cx.entity(); move |_, cx| screen.update(cx, |s, cx| s.toggle(&row_id, cx)) }, cx))) + .child(cell(Some(1.5), status_dot(s.is_active, cx) + .id(key("status")) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| this.set_active(&status, !status.is_active, cx))))) .child(cell(None, s.name.clone())) .child(cell(None, match &category { Some(c) => div() @@ -424,6 +455,20 @@ impl SubscriptionsScreen { .flex_col() .gap(rems(0.5)) .child(row("Name", s.name.clone())) + .child( + div() + .flex() + .justify_between() + .child(dim("Status", cx)) + .child( + div() + .flex() + .items_center() + .gap(rems(0.375)) + .child(status_dot(s.is_active, cx)) + .child(if s.is_active { "Active" } else { "Inactive" }), + ), + ) .child(row("Amount", format_money(s.amount))) .child(row("Period", frequency_label(&s.frequency))) .child(row("Start Date", s.start_date.clone().unwrap_or_else(|| "—".into()))) @@ -585,6 +630,15 @@ impl Render for SubscriptionForm { } } +/// Green for an active subscription, yellow for an inactive one. +fn status_dot(active: bool, cx: &App) -> gpui_kit::Div { + let t = cx.omarchy(); + div() + .size(rems(0.625)) + .rounded_full() + .bg(if active { t.success } else { t.warning }) +} + #[cfg(test)] mod tests { use super::*; From 90d06136788e783bf65a50b284a8a5c2ec66e3e1 Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 22:39:38 +0200 Subject: [PATCH 02/14] Let the subscription edit form set active or inactive --- src/ui/subscriptions.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/ui/subscriptions.rs b/src/ui/subscriptions.rs index cb149cc..cb2288d 100644 --- a/src/ui/subscriptions.rs +++ b/src/ui/subscriptions.rs @@ -495,6 +495,7 @@ pub struct SubscriptionForm { category: Entity, account: Entity, start_date: Entity, + status: Entity, _subs: Vec, } @@ -515,6 +516,8 @@ impl SubscriptionForm { account_items.extend(accounts.iter().map(|a| (a.id.clone(), a.name.clone()))); let frequency_items = FREQUENCIES.iter().map(|(v, l)| (v.to_string(), l.to_string())).collect(); + let status = if editing.as_ref().map_or(true, |s| s.is_active) { "active" } else { "inactive" }; + let status_items = vec![("active".to_string(), "Active".to_string()), ("inactive".to_string(), "Inactive".to_string())]; let (name, amount, frequency, category, account, start) = match &editing { Some(s) => ( s.name.clone(), @@ -539,6 +542,7 @@ impl SubscriptionForm { category: choice(category_items, Some(&category), window, cx), account: choice(account_items, Some(&account), window, cx), start_date: date_field::single(start, window, cx), + status: choice(status_items, Some(status), window, cx), _subs, } } @@ -583,7 +587,7 @@ impl SubscriptionForm { category_id: Some(category_id), account_id: Some(account_id), start_date: Some(start_date), - is_active: None, + is_active: Some(choice_value(&self.status, cx).as_deref() != Some("inactive")), }, ) .map(|_| format!("Saved subscription {name}")), @@ -613,6 +617,7 @@ impl Render for SubscriptionForm { .child(field("Category", select("sub-category", &self.category, window, cx), cx)) .child(field("Account", select("sub-account", &self.account, window, cx), cx)) .child(field("Start Date", DatePicker::new(&self.start_date).placeholder("Pick a date").cleanable(true), cx)) + .children(editing.then(|| field("Status", select("sub-status", &self.status, window, cx), cx))) .child( div() .flex() @@ -834,6 +839,29 @@ mod tests { }); } + #[gpui_kit::test] + fn subscriptions_can_be_marked_inactive_and_active_again(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let sub = make(&db, "Gym", 3000, "monthly"); + reload(&screen, cx); + cx.update(|_, cx| screen.update(cx, |s, cx| s.set_active(&sub, false, cx))); + cx.update(|_, cx| assert!(!screen.read(cx).subscriptions()[0].is_active)); + + // The edit form starts from the saved status and can flip it back. + let saved = cx.update(|_, cx| screen.read(cx).subscriptions()[0].clone()); + cx.update(|window, cx| screen.update(cx, |s, cx| s.open_form(Some(saved), window, cx))); + let form = form_of(&screen, cx); + let status = cx.update(|_, cx| { + let f = form.read(cx); + assert_eq!(choice_value(&f.status, cx).as_deref(), Some("inactive")); + f.status.clone() + }); + // Items: active, inactive. + cx.update(|_, cx| status.update(cx, |c, cx| c.set_selected(Some(0), cx))); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| assert!(screen.read(cx).subscriptions()[0].is_active)); + } + #[gpui_kit::test] fn open_add_shows_the_form(cx: &mut TestAppContext) { let (screen, _db, cx) = setup(cx); From b80979109693a518caf005f0b8b91d4688f3470f Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 22:39:38 +0200 Subject: [PATCH 03/14] Check off subscription active/inactive status in the roadmap --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 81f6106..9a71501 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ Features that build directly on what exists today. - Set monthly or custom-period budgets for expense categories. - Show progress bars and warnings on Overview and Categories pages. -- [ ] **Ability to mark subscriptions as active/inactive** +- [x] **Ability to mark subscriptions as active/inactive** - Show a green circle to mark if a subscription is active - When I edit a subscription show a dropdown to mark a subscription as active or inactive From 85ffbc490173bc46065c3791ebc51ef4f5e1cd93 Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:02:21 +0200 Subject: [PATCH 04/14] Work out Ecocash charges from the balance left after a payment Expenses from a mobile money or Ecocash account get an optional "Balance left" field. The form previews the charges (balance - amount - balance left) and on save records them as a separate "Ecocash charges" expense, so the account balance matches the confirmation SMS. A balance left that is higher than possible is rejected. --- src/commands/mod.rs | 33 ++++++++ src/ui/transactions.rs | 168 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 194 insertions(+), 7 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a7a7632..07d4849 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -408,6 +408,19 @@ pub fn create_transaction( }) } +/// Mobile money accounts (Ecocash and friends) take a fee on payments and +/// transfers that the confirmation SMS only reveals through the balance left. +pub fn charges_apply(account: &Account) -> bool { + account.account_type == "mobile_money" || account.name.to_lowercase().contains("ecocash") +} + +/// The fee taken when `spent` leaves `left` out of `balance`, or `None` when +/// `left` is more than the payment could have left behind. +pub fn charges_from_balance_left(balance: i64, spent: i64, left: i64) -> Option { + let charges = balance - spent - left; + (charges >= 0).then_some(charges) +} + pub fn update_transaction( state: &DbState, request: UpdateTransactionRequest, @@ -1536,6 +1549,26 @@ mod tests { DbState(Mutex::new(conn)) } + #[test] + fn charges_are_what_the_balance_left_does_not_explain() { + assert_eq!(charges_from_balance_left(100_00, 20_00, 79_40), Some(60)); + assert_eq!(charges_from_balance_left(100_00, 20_00, 80_00), Some(0)); + assert_eq!(charges_from_balance_left(100_00, 20_00, 81_00), None); + } + + #[test] + fn charges_apply_to_mobile_money_and_ecocash_accounts() { + let mut account = get_accounts(&seeded()).unwrap().remove(0); + account.account_type = "bank".into(); + account.name = "Checking".into(); + assert!(!charges_apply(&account)); + account.name = "My EcoCash".into(); + assert!(charges_apply(&account)); + account.name = "Wallet".into(); + account.account_type = "mobile_money".into(); + assert!(charges_apply(&account)); + } + fn csv(name: &str, body: &str) -> String { let dir = std::env::temp_dir().join(format!("chelete-cmd-{name}-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); diff --git a/src/ui/transactions.rs b/src/ui/transactions.rs index 0cdc486..152edba 100644 --- a/src/ui/transactions.rs +++ b/src/ui/transactions.rs @@ -10,7 +10,7 @@ use chelete_lib::commands::{ use chelete_lib::database::DbState; 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::format::{format_balance, format_money, parse_cents}; use chelete_lib::paging::{page_slice, Pager}; use gpui_kit::base::input::InputState; use gpui_kit::component::date_picker::DatePicker; @@ -471,17 +471,27 @@ impl TransactionsScreen { pub struct TransactionForm { db: Arc, + accounts: Vec, categories: Vec, editing: Option, kind: String, amount: Entity, description: Entity, + balance_left: Entity, account: Entity, category: Entity, date: Entity, _subs: Vec, } +/// What the balance left after a mobile money payment says about its fee. +#[derive(Debug, PartialEq)] +enum Charges { + Unknown, + Fee(i64), + TooHigh, +} + impl EventEmitter for TransactionForm {} fn category_items(categories: &[Category], kind: &str) -> Vec<(String, String)> { @@ -511,14 +521,22 @@ impl TransactionForm { let amount_input = text_input_with("0.00", &amount, window, cx); amount_input.update(cx, |s, cx| s.focus(window, cx)); let description_input = text_input_with("e.g. Groceries", &description, window, cx); - let _subs = submit_on_enter(&[&amount_input, &description_input], Self::submit, cx); + let balance_left = text_input_with("From the confirmation SMS", "", window, cx); + let account = choice(accounts.iter().map(|a| (a.id.clone(), a.name.clone())).collect(), account_id.as_deref(), window, cx); + let mut _subs = submit_on_enter(&[&amount_input, &description_input, &balance_left], Self::submit, cx); + // The charges preview depends on these, so redraw when they change. + _subs.push(cx.observe(&amount_input, |_, _, cx| cx.notify())); + _subs.push(cx.observe(&balance_left, |_, _, cx| cx.notify())); + _subs.push(cx.observe(&account, |_, _, cx| cx.notify())); Self { db, + accounts: accounts.to_vec(), categories: categories.to_vec(), kind: kind.clone(), amount: amount_input, description: description_input, - account: choice(accounts.iter().map(|a| (a.id.clone(), a.name.clone())).collect(), account_id.as_deref(), window, cx), + balance_left, + account, category: choice(category_items(categories, &kind), Some(&category_id), window, cx), date: date_field::single(Some(date), window, cx), editing, @@ -536,6 +554,39 @@ impl TransactionForm { cx.notify(); } + /// The account a new expense is paid from, when it takes mobile money + /// charges. Edits are left alone: the balance already includes them. + fn charges_account(&self, cx: &gpui_kit::App) -> Option<&Account> { + if self.editing.is_some() || self.kind != "expense" { + return None; + } + let id = choice_value(&self.account, cx)?; + self.accounts.iter().find(|a| a.id == id).filter(|a| commands::charges_apply(a)) + } + + fn charges(&self, cx: &gpui_kit::App) -> Charges { + let Some(account) = self.charges_account(cx) else { return Charges::Unknown }; + let (Some(spent), Some(left)) = (parse_cents(&text_of(&self.amount, cx)), parse_cents(&text_of(&self.balance_left, cx))) else { + return Charges::Unknown; + }; + match commands::charges_from_balance_left(account.balance, spent, left) { + Some(fee) => Charges::Fee(fee), + None => Charges::TooHigh, + } + } + + /// An expense category for fees, when the user keeps one. + fn charges_category(&self) -> Option { + self.categories + .iter() + .filter(|c| c.category_type == "expense") + .find(|c| { + let name = c.name.to_lowercase(); + name.contains("fee") || name.contains("charge") + }) + .map(|c| c.id.clone()) + } + fn submit(&mut self, cx: &mut Context) { let amount_text = text_of(&self.amount, cx); let cents = match parse_cents(&amount_text) { @@ -545,6 +596,11 @@ impl TransactionForm { let Some(account_id) = choice_value(&self.account, cx) else { return cx.emit(FormEvent::Failed("Add an account first".into())); }; + let charges = match self.charges(cx) { + Charges::TooHigh => return cx.emit(FormEvent::Failed("Balance left is more than the account would have after this payment".into())), + Charges::Fee(fee) if fee > 0 => Some(fee), + _ => None, + }; let category_id = choice_value(&self.category, cx).filter(|c| !c.is_empty()); let description = text_of(&self.description, cx); let date = date_field::picked(&self.date, cx) @@ -556,18 +612,35 @@ impl TransactionForm { None => commands::create_transaction( &self.db, CreateTransactionRequest { - account_id, + account_id: account_id.clone(), category_id, transaction_type: self.kind.clone(), amount: cents, currency: "USD".into(), - description, + description: description.clone(), merchant: None, notes: None, - transaction_date: date, + transaction_date: date.clone(), }, ) - .map(|_| "Transaction added".to_string()), + .and_then(|_| match charges { + None => Ok("Transaction added".to_string()), + Some(fee) => commands::create_transaction( + &self.db, + CreateTransactionRequest { + account_id, + category_id: self.charges_category(), + transaction_type: "expense".into(), + amount: fee, + currency: "USD".into(), + description: "Ecocash charges".into(), + merchant: None, + notes: (!description.is_empty()).then(|| format!("Charges on: {description}")), + transaction_date: date, + }, + ) + .map(|_| format!("Transaction added with {} charges", format_balance(fee))), + }), Some(existing) => commands::update_transaction( &self.db, UpdateTransactionRequest { @@ -620,6 +693,14 @@ impl Render for TransactionForm { .child(field("Account", select("tx-account", &self.account, window, cx), cx)) .child(field("Category", select("tx-category", &self.category, window, cx), cx)), ) + .children(self.charges_account(cx).map(|account| { + let hint = match self.charges(cx) { + Charges::Unknown => dim(format!("Optional. Charges are worked out from the {} balance of {}.", account.name, format_balance(account.balance)), cx), + Charges::Fee(fee) => dim(format!("Charges: {}", format_balance(fee)), cx), + Charges::TooHigh => div().text_color(cx.omarchy().danger).child("More than the account would have after this payment"), + }; + field("Balance left", div().flex().flex_col().gap(rems(0.25)).child(input("tx-balance-left", &self.balance_left, window, cx)).child(hint), cx) + })) .child(field("Date", DatePicker::new(&self.date).placeholder("Pick a date"), cx)) .child( div() @@ -787,6 +868,79 @@ mod tests { assert_eq!(after.balance, account.balance - 4250); } + fn add_ecocash_account(db: &DbState, balance: i64) -> Account { + commands::create_account( + db, + commands::CreateAccountRequest { + name: "Ecocash".into(), + account_type: "mobile_money".into(), + currency: "USD".into(), + balance, + color: None, + icon: None, + }, + ) + .unwrap() + } + + fn open_form_on(screen: &Entity, account: &Account, cx: &mut VisualTestContext) -> Entity { + cx.update(|window, cx| screen.update(cx, |s, cx| { s.reload(cx); s.open_add(window, cx) })); + let form = form_of(screen, cx); + let index = cx.update(|_, cx| form.read(cx).accounts.iter().position(|a| a.id == account.id).unwrap()); + let choice = cx.update(|_, cx| form.read(cx).account.clone()); + cx.update(|_, cx| choice.update(cx, |c, cx| c.set_selected(Some(index), cx))); + form + } + + #[gpui_kit::test] + fn ecocash_balance_left_records_the_charges(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let account = add_ecocash_account(&db, 100_00); + let form = open_form_on(&screen, &account, cx); + let (amount, description, left) = cx.update(|_, cx| { + let f = form.read(cx); + (f.amount.clone(), f.description.clone(), f.balance_left.clone()) + }); + type_into(&amount, "20", cx); + type_into(&description, "Airtime", cx); + type_into(&left, "79.40", cx); + cx.update(|_, cx| assert_eq!(form.read(cx).charges(cx), Charges::Fee(60))); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(!s.has_dialog()); + let fee = s.transactions().iter().find(|t| t.description == "Ecocash charges").expect("charges recorded"); + assert_eq!(fee.amount, 60); + assert_eq!(fee.account_id, account.id); + assert_eq!(fee.notes.as_deref(), Some("Charges on: Airtime")); + }); + let after = commands::get_accounts(&db).unwrap().into_iter().find(|a| a.id == account.id).unwrap(); + assert_eq!(after.balance, 79_40, "balance matches the SMS"); + } + + #[gpui_kit::test] + fn ecocash_balance_left_above_the_possible_is_rejected(cx: &mut TestAppContext) { + let (screen, db, cx) = setup(cx); + let account = add_ecocash_account(&db, 100_00); + let form = open_form_on(&screen, &account, cx); + let (amount, left) = cx.update(|_, cx| (form.read(cx).amount.clone(), form.read(cx).balance_left.clone())); + type_into(&amount, "20", cx); + type_into(&left, "90", cx); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| { + assert!(screen.read(cx).has_dialog()); + assert_eq!(screen.read(cx).transactions().len(), 25); + }); + } + + #[gpui_kit::test] + fn balance_left_is_only_offered_for_mobile_money(cx: &mut TestAppContext) { + let (screen, _db, cx) = setup(cx); + cx.update(|window, cx| screen.update(cx, |s, cx| s.open_add(window, cx))); + let form = form_of(&screen, cx); + cx.update(|_, cx| assert!(form.read(cx).charges_account(cx).is_none(), "seeded first account is a bank")); + } + #[gpui_kit::test] fn zero_or_blank_amount_is_ignored(cx: &mut TestAppContext) { let (screen, _db, cx) = setup(cx); From 7daac4cf7330d439732e722b016eaf09b2d8881e Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:02:21 +0200 Subject: [PATCH 05/14] Check off Ecocash charges in the roadmap --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9a71501..a49ad81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,7 @@ Features that build directly on what exists today. - [ ] **Transaction search** - Search Recent Transactions by description, merchant, category, or amount. -- [ ] **Auto calculate charges for Ecocash** +- [x] **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** From 19326749a8fe2b29dc9129740fb537ee609b0d76 Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:09:14 +0200 Subject: [PATCH 06/14] Search transactions by description, type, category and account A search box beside the date filter narrows the Transactions list. It is case-insensitive and every word typed has to appear in the description, merchant, type, category name or account name, so "expense checking" finds expenses from the Checking account. Searching goes back to page 1 and works together with the date filter. --- src/ui/transactions.rs | 87 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/ui/transactions.rs b/src/ui/transactions.rs index 152edba..1897d3a 100644 --- a/src/ui/transactions.rs +++ b/src/ui/transactions.rs @@ -40,6 +40,7 @@ pub struct TransactionsScreen { accounts: Vec, categories: Vec, date_filter: DateFilterControl, + search: Entity, per_page: Entity, applied_per_page: usize, page: usize, @@ -57,10 +58,15 @@ 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 description, type, category, account", 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,6 +81,7 @@ impl TransactionsScreen { accounts: Vec::new(), categories: Vec::new(), date_filter, + search, per_page, applied_per_page: 25, page: 1, @@ -109,12 +116,32 @@ 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)) + } + fn pager(&self, cx: &gpui_kit::App) -> Pager { Pager::new(self.page, self.applied_per_page, self.filtered(cx).len()) } @@ -319,7 +346,7 @@ impl Render for TransactionsScreen { }; let body = if rows.is_empty() { - let text = if none_at_all { "No transactions yet. Add one to get started." } else { "No transactions match the selected filters." }; + let text = if none_at_all { "No transactions yet. Add one to get started." } else { "No transactions match the search and filters." }; div().py(rems(3.)).flex().justify_center().text_color(t.secondary).child(text).into_any_element() } else { let header = div() @@ -416,7 +443,15 @@ 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 = self + .date_filter + .render(today, window, cx) + .child( + div().w(rems(22.)).child( + input("tx-search", &self.search, window, cx) + .prefix(icon(IconName::Search).size(rems(0.875)).text_color(t.secondary)), + ), + ); div() .id("transactions") @@ -1048,6 +1083,54 @@ 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()) + } + + #[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"); + + 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"); + + 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"); + + 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 search_terms_must_all_match_and_reset_the_page(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"); + } + #[gpui_kit::test] fn per_page_paginates_the_list(cx: &mut TestAppContext) { let (screen, _db, cx) = setup(cx); From 63d84a26f6c9e90d52658fa99e47da330957a879 Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:18:09 +0200 Subject: [PATCH 07/14] Line up the transaction search box with the date dropdown The search box takes the date dropdown's width and stretches to its height, so the two sit level in the filter row. A shorter placeholder fits the narrower box. A test compares their drawn sizes. --- src/ui/date_filter_control.rs | 5 ++++- src/ui/transactions.rs | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/ui/date_filter_control.rs b/src/ui/date_filter_control.rs index 70239bd..7885547 100644 --- a/src/ui/date_filter_control.rs +++ b/src/ui/date_filter_control.rs @@ -7,6 +7,9 @@ use gpui_kit::component::date_picker::{DatePicker, DatePickerState, DateRangePre use gpui_kit::{div, prelude::*, rems, App, Context, Div, Entity, Subscription, Window}; use gpui_omarchy::{select, ChoiceState}; +/// Width of the preset dropdown, for fields that sit beside it. +pub const PRESET_WIDTH: f32 = 13.; + pub struct DateFilterControl { pub preset: Entity, pub range: Entity, @@ -49,7 +52,7 @@ impl DateFilterControl { .flex() .items_center() .gap(rems(0.5)) - .child(div().w(rems(13.)).child(select("date-filter", &self.preset, window, cx))) + .child(div().w(rems(PRESET_WIDTH)).debug_selector(|| "date-filter".into()).child(select("date-filter", &self.preset, window, cx))) .when(custom, |row| { row.child( div().w(rems(22.)).child( diff --git a/src/ui/transactions.rs b/src/ui/transactions.rs index 1897d3a..05cec0b 100644 --- a/src/ui/transactions.rs +++ b/src/ui/transactions.rs @@ -1,7 +1,7 @@ use super::accounts::FormEvent; use super::csv_import::{CsvImportDialog, CsvImportEvent}; use super::date_field; -use super::date_filter_control::DateFilterControl; +use super::date_filter_control::{DateFilterControl, PRESET_WIDTH}; use super::icons::category_icon; use super::widgets::*; use chelete_lib::commands::{ @@ -58,7 +58,7 @@ 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 description, type, category, account", 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(); @@ -447,8 +447,12 @@ impl Render for TransactionsScreen { .date_filter .render(today, window, cx) .child( - div().w(rems(22.)).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)), ), ); @@ -1131,6 +1135,15 @@ mod tests { assert_eq!(search_for(&screen, " ", cx).len(), 25, "blank search shows everything"); } + #[gpui_kit::test] + fn search_box_matches_the_date_dropdown(cx: &mut TestAppContext) { + let (_screen, _db, cx) = setup(cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let date = cx.debug_bounds("date-filter").expect("date dropdown drawn"); + let search = cx.debug_bounds("tx-search").expect("search box drawn"); + assert_eq!(search.size, date.size); + } + #[gpui_kit::test] fn per_page_paginates_the_list(cx: &mut TestAppContext) { let (screen, _db, cx) = setup(cx); From ab486515760fbbff6bb7588a2325cec3bdcd091c Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:20:48 +0200 Subject: [PATCH 08/14] Ask before deleting an account The Delete button on an account card now opens a confirm dialog naming the account. Cancel or Escape keeps it; confirming deletes it and shows a toast. The dialog notes that the account's transactions are kept. --- src/ui/accounts.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index 23e4253..1965634 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,35 @@ 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, + ) + }); + 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, account.id.clone())); + 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()); 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())))); } Err(e) => cx.emit(ScreenEvent::error(format!("Could not delete account: {e}"))), } @@ -115,7 +141,7 @@ impl Render for AccountsScreen { let cards: Vec<_> = list .iter() .map(|a| { - let id = a.id.clone(); + let account = a.clone(); panel(account_type_label(&a.account_type), cx) .w(rems(16.)) .child(div().text_size(rems(0.875)).child(a.name.clone())) @@ -127,7 +153,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(&account, cx))), ) }) .collect(); @@ -138,6 +164,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() @@ -461,6 +488,45 @@ mod tests { }); } + 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() + } + _ => 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); + 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)); + }); + } + + #[gpui_kit::test] + fn cancelling_the_prompt_keeps_the_account(cx: &mut TestAppContext) { + let (screen, cx, _) = setup(cx); + let (account, dialog) = ask_to_delete_first(&screen, 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)); + }); + } + #[gpui_kit::test] fn escape_closes_the_open_form(cx: &mut TestAppContext) { let (screen, cx, _) = setup(cx); From fd790ae2c8ee9a7c996561632dd25522cf7cae38 Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:20:51 +0200 Subject: [PATCH 09/14] Check off account deletion protection in the roadmap --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index a49ad81..c0d2227 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ Features that build directly on what exists today. - [x] **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 e508a034051db7b90ae3c86cab758565851572c6 Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:24:38 +0200 Subject: [PATCH 10/14] Move the balance when a transaction changes account Editing a transaction so it sits on another account left the amount on the old account, because balances only moved when the amount or type changed. Moving transactions off a deleted account this way inflated the new account's balance, and deleting a moved transaction later refunded money that was never taken. Any change of account, amount or type now reverses the old effect and applies the new one. --- src/commands/mod.rs | 77 +++++++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 07d4849..b24f9c0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -430,30 +430,30 @@ pub fn update_transaction( // Get old transaction for balance adjustment let old = get_transaction_by_id(&conn, &request.id)?; - if let Some(amount) = request.amount { - if amount != old.amount || request.transaction_type.as_deref() != Some(&old.transaction_type) { - // Reverse old balance change - let old_change = if old.transaction_type == "income" { - -old.amount - } else { - old.amount - }; - conn.execute( - "UPDATE accounts SET balance = balance + ?1, updated_at = datetime('now') WHERE id = ?2", - params![old_change, old.account_id], - ) - .map_err(|e| e.to_string())?; - - // Apply new balance change - let new_type = request.transaction_type.as_deref().unwrap_or(old.transaction_type.as_str()); - let new_change = if new_type == "income" { amount } else { -amount }; - let account_id = request.account_id.as_deref().unwrap_or(&old.account_id); - conn.execute( - "UPDATE accounts SET balance = balance + ?1, updated_at = datetime('now') WHERE id = ?2", - params![new_change, account_id], - ) - .map_err(|e| e.to_string())?; - } + // Moving to another account, or changing the amount or type, shifts balances. + let amount = request.amount.unwrap_or(old.amount); + let new_type = request.transaction_type.as_deref().unwrap_or(old.transaction_type.as_str()); + let account_id = request.account_id.as_deref().unwrap_or(&old.account_id); + if amount != old.amount || new_type != old.transaction_type || account_id != old.account_id { + // Reverse old balance change + let old_change = if old.transaction_type == "income" { + -old.amount + } else { + old.amount + }; + conn.execute( + "UPDATE accounts SET balance = balance + ?1, updated_at = datetime('now') WHERE id = ?2", + params![old_change, old.account_id], + ) + .map_err(|e| e.to_string())?; + + // Apply new balance change + let new_change = if new_type == "income" { amount } else { -amount }; + conn.execute( + "UPDATE accounts SET balance = balance + ?1, updated_at = datetime('now') WHERE id = ?2", + params![new_change, account_id], + ) + .map_err(|e| e.to_string())?; } // Update fields @@ -1693,6 +1693,35 @@ mod tests { assert_eq!(balance(&db, &id), after_create - 600); } + #[test] + fn moving_a_transaction_to_another_account_moves_its_amount() { + let db = seeded(); + let accounts = get_accounts(&db).unwrap(); + let (a, b) = (accounts[0].id.clone(), accounts[1].id.clone()); + let t = create_transaction(&db, new_txn(&a, "expense", 1000)).unwrap(); + let (sa, sb) = (balance(&db, &a), balance(&db, &b)); + // The edit form sends every field, so the amount and type come back unchanged. + update_transaction( + &db, + UpdateTransactionRequest { + id: t.id.clone(), + account_id: Some(b.clone()), + category_id: None, + transaction_type: Some("expense".into()), + amount: Some(1000), + currency: None, + description: None, + merchant: None, + notes: None, + transaction_date: None, + }, + ) + .unwrap(); + assert_eq!((balance(&db, &a), balance(&db, &b)), (sa + 1000, sb - 1000)); + delete_transactions(&db, vec![t.id]).unwrap(); + assert_eq!((balance(&db, &a), balance(&db, &b)), (sa + 1000, sb), "deleting refunds the account it is on"); + } + #[test] fn transfer_moves_money_and_rejects_the_same_account() { let db = seeded(); From c60f4fc5a12d8c46b17a06f921da816039c341ec Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:28:05 +0200 Subject: [PATCH 11/14] Edit an account's name, type and balance Each account card gets an Edit button beside Delete. It opens the account form filled in with the account's details, focused on the balance, so a balance that has drifted from reality can be corrected. Saving overwrites the stored balance; an invalid amount is rejected and the form stays open. --- src/ui/accounts.rs | 135 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 111 insertions(+), 24 deletions(-) diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index 1965634..9526348 100644 --- a/src/ui/accounts.rs +++ b/src/ui/accounts.rs @@ -1,5 +1,5 @@ use super::widgets::*; -use chelete_lib::commands::{self, Account, CreateAccountRequest}; +use chelete_lib::commands::{self, Account, CreateAccountRequest, UpdateAccountRequest}; use chelete_lib::database::DbState; use chelete_lib::format::{account_type_label, format_balance, parse_cents}; use gpui_kit::base::input::InputState; @@ -14,7 +14,7 @@ use gpui_omarchy::{ use std::sync::Arc; enum Dialog { - Add(Entity), + Form(Entity), Transfer(Entity), Confirm(Entity, String), } @@ -49,9 +49,13 @@ impl AccountsScreen { } pub fn open_add(&mut self, window: &mut Window, cx: &mut Context) { - let form = cx.new(|cx| AddAccountForm::new(self.db.clone(), window, cx)); + self.open_form(None, window, cx); + } + + fn open_form(&mut self, editing: Option, window: &mut Window, cx: &mut Context) { + let form = cx.new(|cx| AccountForm::new(self.db.clone(), editing, window, cx)); self._dialog_sub = Some(cx.subscribe(&form, Self::on_form_event)); - self.dialog = Some(Dialog::Add(form)); + self.dialog = Some(Dialog::Form(form)); cx.notify(); } @@ -142,6 +146,7 @@ impl Render for AccountsScreen { .iter() .map(|a| { let account = a.clone(); + let editing = a.clone(); panel(account_type_label(&a.account_type), cx) .w(rems(16.)) .child(div().text_size(rems(0.875)).child(a.name.clone())) @@ -152,8 +157,17 @@ impl Render for AccountsScreen { .child(format_balance(a.balance)), ) .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))), + div() + .flex() + .gap(rems(0.5)) + .child( + button(SharedString::from(format!("edit-account-{}", a.id)), "Edit", ButtonVariant::Outline, cx) + .on_click(cx.listener(move |this, _, window, cx| this.open_form(Some(editing.clone()), window, cx))), + ) + .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))), + ), ) }) .collect(); @@ -162,7 +176,7 @@ impl Render for AccountsScreen { }; let dialog = self.dialog.as_ref().map(|d| match d { - Dialog::Add(form) => form.clone().into_any_element(), + Dialog::Form(form) => form.clone().into_any_element(), Dialog::Transfer(form) => form.clone().into_any_element(), Dialog::Confirm(dialog, _) => dialog.clone().into_any_element(), }); @@ -189,26 +203,33 @@ pub enum FormEvent { Failed(String), } -pub struct AddAccountForm { +/// Adds a new account, or edits the name, type and balance of `editing`. +pub struct AccountForm { db: Arc, + editing: Option, name: Entity, kind: Entity, balance: Entity, _subs: Vec, } -impl EventEmitter for AddAccountForm {} - -impl AddAccountForm { - fn new(db: Arc, window: &mut Window, cx: &mut Context) -> Self { - let name = text_input("e.g. Main Bank", window, cx); - name.update(cx, |s, cx| s.focus(window, cx)); - let balance = text_input("0.00", window, cx); +impl EventEmitter for AccountForm {} + +impl AccountForm { + fn new(db: Arc, editing: Option, window: &mut Window, cx: &mut Context) -> Self { + let name_value = editing.as_ref().map(|a| a.name.clone()).unwrap_or_default(); + let balance_value = editing.as_ref().map(|a| format!("{:.2}", a.balance as f64 / 100.0)).unwrap_or_default(); + let kind = editing.as_ref().map(|a| a.account_type.clone()).unwrap_or_else(|| "cash".into()); + let name = text_input_with("e.g. Main Bank", &name_value, window, cx); + let balance = text_input_with("0.00", &balance_value, window, cx); + // When editing, the balance is usually what needs correcting. + if editing.is_some() { &balance } else { &name }.update(cx, |s, cx| s.focus(window, cx)); let _subs = submit_on_enter(&[&name, &balance], Self::submit, cx); Self { db, + editing, name, - kind: choice(account_type_options(), Some("cash"), window, cx), + kind: choice(account_type_options(), Some(&kind), window, cx), balance, _subs, } @@ -225,12 +246,29 @@ impl AddAccountForm { } else { match parse_cents(&balance_text) { Some(c) => c, - None => return cx.emit(FormEvent::Failed("Starting balance is not a valid amount".into())), + None => return cx.emit(FormEvent::Failed("Balance is not a valid amount".into())), } }; + let account_type = choice_value(&self.kind, cx).unwrap_or_else(|| "cash".into()); + if let Some(existing) = &self.editing { + let request = UpdateAccountRequest { + id: existing.id.clone(), + name: Some(name), + account_type: Some(account_type), + currency: None, + balance: Some(balance), + color: None, + icon: None, + is_active: None, + }; + return match commands::update_account(&self.db, request) { + Ok(a) => cx.emit(FormEvent::Saved(format!("Saved account {}", a.name))), + Err(e) => cx.emit(FormEvent::Failed(format!("Could not save account: {e}"))), + }; + } let request = CreateAccountRequest { name, - account_type: choice_value(&self.kind, cx).unwrap_or_else(|| "cash".into()), + account_type, currency: "USD".into(), balance, color: None, @@ -243,15 +281,16 @@ impl AddAccountForm { } } -impl Render for AddAccountForm { +impl Render for AccountForm { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let editing = self.editing.is_some(); let body = div() .flex() .flex_col() .gap(rems(0.75)) .child(field("Name", input("account-name", &self.name, window, cx), cx)) .child(field("Type", select("account-type", &self.kind, window, cx), cx)) - .child(field("Starting Balance", input("account-balance", &self.balance, window, cx), cx)) + .child(field(if editing { "Balance" } else { "Starting Balance" }, input("account-balance", &self.balance, window, cx), cx)) .child( div() .flex() @@ -262,11 +301,11 @@ impl Render for AddAccountForm { .on_click(cx.listener(|_, _, _, cx| cx.emit(FormEvent::Cancel))), ) .child( - button("add-submit", "Add", ButtonVariant::Primary, cx) + button("add-submit", if editing { "Save" } else { "Add" }, ButtonVariant::Primary, cx) .on_click(cx.listener(|this, _, _, cx| this.submit(cx))), ), ); - modal("Add Account", body, cx.listener(|_, _, _, cx| cx.emit(FormEvent::Cancel)), cx).on_action(cx.listener(|_, _: &super::ModalCancel, _, cx| cx.emit(FormEvent::Cancel))) + modal(if editing { "Edit Account" } else { "Add Account" }, body, cx.listener(|_, _, _, cx| cx.emit(FormEvent::Cancel)), cx).on_action(cx.listener(|_, _: &super::ModalCancel, _, cx| cx.emit(FormEvent::Cancel))) } } @@ -384,10 +423,10 @@ mod tests { (screen, vcx, events) } - fn open_add(screen: &Entity, cx: &mut VisualTestContext) -> Entity { + fn open_add(screen: &Entity, cx: &mut VisualTestContext) -> Entity { cx.update(|window, cx| screen.update(cx, |s, cx| s.open_add(window, cx))); cx.update(|_, cx| match screen.read(cx).dialog.as_ref() { - Some(Dialog::Add(f)) => f.clone(), + Some(Dialog::Form(f)) => f.clone(), _ => panic!("add form should be open"), }) } @@ -527,6 +566,54 @@ mod tests { }); } + fn open_edit(screen: &Entity, account: &Account, cx: &mut VisualTestContext) -> Entity { + cx.update(|window, cx| screen.update(cx, |s, cx| s.open_form(Some(account.clone()), window, cx))); + cx.update(|_, cx| match screen.read(cx).dialog.as_ref() { + Some(Dialog::Form(f)) => f.clone(), + _ => panic!("edit form should be open"), + }) + } + + #[gpui_kit::test] + fn edit_corrects_the_balance_in_place(cx: &mut TestAppContext) { + let (screen, cx, _) = setup(cx); + let account = cx.update(|_, cx| screen.read(cx).accounts()[1].clone()); + let form = open_edit(&screen, &account, cx); + let (name, balance) = cx.update(|_, cx| { + let f = form.read(cx); + assert_eq!(f.name.read(cx).value().as_ref(), account.name); + assert_eq!(f.balance.read(cx).value().as_ref(), format!("{:.2}", account.balance as f64 / 100.0)); + (f.name.clone(), f.balance.clone()) + }); + type_into(&name, "Renamed", cx); + type_into(&balance, "1419.27", cx); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(!s.has_dialog()); + assert_eq!(s.accounts().len(), 4, "edited, not added"); + let saved = s.accounts().iter().find(|a| a.id == account.id).unwrap(); + assert_eq!((saved.name.as_str(), saved.balance), ("Renamed", 141927)); + assert_eq!(saved.account_type, account.account_type, "type kept"); + }); + } + + #[gpui_kit::test] + fn edit_with_an_invalid_balance_keeps_the_form_open(cx: &mut TestAppContext) { + let (screen, cx, events) = setup(cx); + let account = cx.update(|_, cx| screen.read(cx).accounts()[0].clone()); + let form = open_edit(&screen, &account, cx); + let balance = cx.update(|_, cx| form.read(cx).balance.clone()); + type_into(&balance, "lots", cx); + cx.update(|_, cx| form.update(cx, |f, cx| f.submit(cx))); + cx.update(|_, cx| { + let s = screen.read(cx); + assert!(s.has_dialog()); + assert_eq!(s.accounts().iter().find(|a| a.id == account.id).unwrap().balance, account.balance); + }); + assert!(matches!(events.borrow().last(), Some(ScreenEvent::Toast { ok: false, .. }))); + } + #[gpui_kit::test] fn escape_closes_the_open_form(cx: &mut TestAppContext) { let (screen, cx, _) = setup(cx); From f4c05a0f32a3c7b670d0b099f0ea8bbf7913dc81 Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:28:21 +0200 Subject: [PATCH 12/14] Bring the roadmap up to date Check off correcting account amounts and data export, which the Transactions screen already offers as CSV or JSON. Note that transaction search covers everything but amount so far. --- ROADMAP.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c0d2227..4955afe 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,6 +20,7 @@ Features that build directly on what exists today. - [ ] **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 @@ -27,7 +28,7 @@ Features that build directly on what exists today. - [x] **Protect account deletion** - If a user press delete account prompt them to confirm before deletion -- [ ] **Correct account amounts** +- [x] **Correct account amounts** - Add an Edit button besides the delete account which should allow editing account amounts ## Next @@ -51,7 +52,7 @@ Features that add more insight and automation. Bigger features and platform-level improvements. -- [ ] **Export data** +- [x] **Export data** - Export transactions, accounts, categories, and subscriptions to CSV/JSON. - [ ] **Backup / restore** From 10aaef63427db2eaca843b3aaf24cfa2a4a1e145 Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:31:50 +0200 Subject: [PATCH 13/14] Add a script that writes release notes from commits It lists every commit since the previous tag, leaving out merges, version bumps and roadmap bookkeeping, and ends with a compare link. --- scripts/release-notes.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100755 scripts/release-notes.sh diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..7fe0156 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Print release notes for TAG: one line per commit since the previous tag, +# leaving out merges, version bumps and roadmap bookkeeping. +set -euo pipefail + +TAG="${1:?Usage: $0 }" +PREV=$(git describe --tags --abbrev=0 --match 'v*' "${TAG}^" 2>/dev/null || true) + +echo "## What's Changed" +echo +git log --no-merges --reverse --format='- %s' ${PREV:+$PREV..}"$TAG" \ + | grep -v -i -e '\[skip ci\]' -e '^- chore: release v' -e 'roadmap' || true + +if [ -n "$PREV" ]; then + REPO_URL=$(git remote get-url origin 2>/dev/null | sed -E 's#^git@github.com:#https://github.com/#; s#\.git$##') + echo + echo "**Full Changelog**: ${REPO_URL}/compare/${PREV}...${TAG}" +fi From f35f62630c7d42eea7f14fa781f21b54bc53a627 Mon Sep 17 00:00:00 2001 From: Victor Phos Date: Fri, 9 Oct 2026 23:31:50 +0200 Subject: [PATCH 14/14] Build release notes from commits instead of pull request titles GitHub's generated notes only listed pull request titles, and most of them were the branch name "New UI". The release job now runs release-notes.sh and publishes its output as the release body. --- .github/workflows/release.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f98bf00..8c77604 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,6 +177,15 @@ jobs: needs: [version, build-linux, build-windows] runs-on: ubuntu-latest steps: + # Checkout first: it would wipe the downloaded artifacts otherwise. + - uses: actions/checkout@v4 + with: + ref: v${{ needs.version.outputs.version }} + fetch-depth: 0 + + - name: Write release notes from the commits since the last release + run: ./scripts/release-notes.sh "v${{ needs.version.outputs.version }}" > release-notes.md + - uses: actions/download-artifact@v4 with: path: artifacts @@ -186,5 +195,5 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: v${{ needs.version.outputs.version }} - generate_release_notes: true + body_path: release-notes.md files: artifacts/*