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(())
|
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 --
|
// -- Exchange Rates --
|
||||||
|
|
||||||
pub fn get_cached_rate(&self, base: &str, target: &str) -> SqlResult<Option<ExchangeRate>> {
|
pub fn get_cached_rate(&self, base: &str, target: &str) -> SqlResult<Option<ExchangeRate>> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::glib;
|
use chrono::Datelike;
|
||||||
|
use gtk::{gio, glib};
|
||||||
use outlay_core::db::Database;
|
use outlay_core::db::Database;
|
||||||
use outlay_core::exchange::ExchangeRateService;
|
use outlay_core::exchange::ExchangeRateService;
|
||||||
use outlay_core::models::{NewTransaction, TransactionType};
|
use outlay_core::models::{NewTransaction, TransactionType};
|
||||||
@@ -12,7 +13,7 @@ pub struct LogView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 toast_overlay = adw::ToastOverlay::new();
|
||||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
|
||||||
@@ -248,6 +249,7 @@ impl LogView {
|
|||||||
let toast_overlay_ref = toast_overlay.clone();
|
let toast_overlay_ref = toast_overlay.clone();
|
||||||
let rate_ref = exchange_rate.clone();
|
let rate_ref = exchange_rate.clone();
|
||||||
let currency_codes_save: Vec<String> = currency_codes.iter().map(|s| s.to_string()).collect();
|
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 |_| {
|
save_button.connect_clicked(move |_| {
|
||||||
let amount_text = amount_row_ref.text();
|
let amount_text = amount_row_ref.text();
|
||||||
@@ -314,6 +316,29 @@ impl LogView {
|
|||||||
let toast = adw::Toast::new(msg);
|
let toast = adw::Toast::new(msg);
|
||||||
toast_overlay_ref.add_toast(toast);
|
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("");
|
amount_row_ref.set_text("");
|
||||||
note_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(
|
fn populate_categories_from_db(
|
||||||
db: &Database,
|
db: &Database,
|
||||||
model: >k::StringList,
|
model: >k::StringList,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ impl MainWindow {
|
|||||||
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
|
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
|
||||||
|
|
||||||
// Log view
|
// Log view
|
||||||
let log_view = LogView::new(db.clone());
|
let log_view = LogView::new(db.clone(), app);
|
||||||
let log_scroll = gtk::ScrolledWindow::builder()
|
let log_scroll = gtk::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
.child(&log_view.container)
|
.child(&log_view.container)
|
||||||
|
|||||||
Reference in New Issue
Block a user