From d247c56cfa2c42a43406cd27b22fde01e1c65c45 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 2 Mar 2026 00:28:43 +0200 Subject: [PATCH] Add desktop notifications for budget threshold crossings After saving an expense, checks if the category budget reaches 75%, 90%, or 100% thresholds. Sends gio::Notification for each newly crossed threshold. Tracks sent notifications in database to prevent duplicates within the same month. --- outlay-core/src/db.rs | 52 ++++++++++++++++++++++++++++++++++++++ outlay-gtk/src/log_view.rs | 49 +++++++++++++++++++++++++++++++++-- outlay-gtk/src/window.rs | 2 +- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/outlay-core/src/db.rs b/outlay-core/src/db.rs index 760f726..5a78fcb 100644 --- a/outlay-core/src/db.rs +++ b/outlay-core/src/db.rs @@ -470,6 +470,58 @@ impl Database { Ok(()) } + // -- Budget Notifications -- + + pub fn has_notification_been_sent( + &self, + category_id: i64, + month: &str, + threshold: u32, + ) -> SqlResult { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM budget_notifications + WHERE category_id = ?1 AND month = ?2 AND threshold = ?3", + params![category_id, month, threshold as i64], + |row| row.get(0), + )?; + Ok(count > 0) + } + + pub fn record_notification( + &self, + category_id: i64, + month: &str, + threshold: u32, + ) -> SqlResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + self.conn.execute( + "INSERT OR IGNORE INTO budget_notifications (category_id, month, threshold, notified_at) + VALUES (?1, ?2, ?3, ?4)", + params![category_id, month, threshold as i64, now], + )?; + Ok(()) + } + + pub fn check_budget_thresholds( + &self, + category_id: i64, + month: &str, + ) -> SqlResult> { + let progress = self.get_budget_progress(category_id, month)?; + let mut crossed = Vec::new(); + + if let Some((_, _, pct)) = progress { + for threshold in [75, 90, 100] { + if pct >= threshold as f64 + && !self.has_notification_been_sent(category_id, month, threshold)? + { + crossed.push(threshold); + } + } + } + Ok(crossed) + } + // -- Exchange Rates -- pub fn get_cached_rate(&self, base: &str, target: &str) -> SqlResult> { diff --git a/outlay-gtk/src/log_view.rs b/outlay-gtk/src/log_view.rs index e9cae8a..9d3b0a8 100644 --- a/outlay-gtk/src/log_view.rs +++ b/outlay-gtk/src/log_view.rs @@ -1,5 +1,6 @@ use adw::prelude::*; -use gtk::glib; +use chrono::Datelike; +use gtk::{gio, glib}; use outlay_core::db::Database; use outlay_core::exchange::ExchangeRateService; use outlay_core::models::{NewTransaction, TransactionType}; @@ -12,7 +13,7 @@ pub struct LogView { } impl LogView { - pub fn new(db: Rc) -> Self { + pub fn new(db: Rc, app: &adw::Application) -> Self { let toast_overlay = adw::ToastOverlay::new(); let container = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -248,6 +249,7 @@ impl LogView { let toast_overlay_ref = toast_overlay.clone(); let rate_ref = exchange_rate.clone(); let currency_codes_save: Vec = currency_codes.iter().map(|s| s.to_string()).collect(); + let app_ref = app.clone(); save_button.connect_clicked(move |_| { let amount_text = amount_row_ref.text(); @@ -314,6 +316,29 @@ impl LogView { let toast = adw::Toast::new(msg); toast_overlay_ref.add_toast(toast); + // Check budget notifications for expenses + if txn_type == TransactionType::Expense { + let month_str = format!("{:04}-{:02}", date.year(), date.month()); + if let Ok(thresholds) = db_ref.check_budget_thresholds(category_id, &month_str) { + let cat_name = db_ref + .get_category(category_id) + .map(|c| c.name) + .unwrap_or_else(|_| "Category".to_string()); + let progress = db_ref + .get_budget_progress(category_id, &month_str) + .ok() + .flatten(); + let pct = progress.map(|(_, _, p)| p).unwrap_or(0.0); + + for threshold in thresholds { + Self::send_budget_notification( + &app_ref, &cat_name, pct, threshold, + ); + db_ref.record_notification(category_id, &month_str, threshold).ok(); + } + } + } + amount_row_ref.set_text(""); note_row_ref.set_text(""); @@ -344,6 +369,26 @@ impl LogView { } } + fn send_budget_notification( + app: &adw::Application, + category: &str, + percentage: f64, + threshold: u32, + ) { + let notification = gio::Notification::new("Budget Alert"); + let body = match threshold { + 75 => format!("{} is at {:.0}% of budget", category, percentage), + 90 => format!("{} is at {:.0}% of budget - almost at limit!", category, percentage), + 100 => format!("{} is over budget at {:.0}%!", category, percentage), + _ => return, + }; + notification.set_body(Some(&body)); + app.send_notification( + Some(&format!("budget-{}-{}", category, threshold)), + ¬ification, + ); + } + fn populate_categories_from_db( db: &Database, model: >k::StringList, diff --git a/outlay-gtk/src/window.rs b/outlay-gtk/src/window.rs index 685b4b1..df14939 100644 --- a/outlay-gtk/src/window.rs +++ b/outlay-gtk/src/window.rs @@ -35,7 +35,7 @@ impl MainWindow { content_stack.set_transition_type(gtk::StackTransitionType::Crossfade); // Log view - let log_view = LogView::new(db.clone()); + let log_view = LogView::new(db.clone(), app); let log_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .child(&log_view.container)