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:
@@ -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>> {
|
||||
|
||||
@@ -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)),
|
||||
¬ification,
|
||||
);
|
||||
}
|
||||
|
||||
fn populate_categories_from_db(
|
||||
db: &Database,
|
||||
model: >k::StringList,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user