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.
This commit is contained in:
2026-03-02 00:28:43 +02:00
parent 2741df45ad
commit d247c56cfa
3 changed files with 100 additions and 3 deletions

View File

@@ -470,6 +470,58 @@ impl Database {
Ok(())
}
// -- Budget Notifications --
pub fn has_notification_been_sent(
&self,
category_id: i64,
month: &str,
threshold: u32,
) -> SqlResult<bool> {
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<Vec<u32>> {
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<Option<ExchangeRate>> {

View File

@@ -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<Database>) -> Self {
pub fn new(db: Rc<Database>, 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<String> = 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)),
&notification,
);
}
fn populate_categories_from_db(
db: &Database,
model: &gtk::StringList,

View File

@@ -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)