//! Background notification system for snooze expiry alerts. //! //! Periodically checks for tasks and emails that have expired snooze dates //! and sends OS notifications when they resurface. use crate::state::{AppState, DESKTOP_USER_ID}; use chrono::Utc; use goingson_core::{EmailId, EventId, TaskId}; use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; use tauri::Manager; use tauri_plugin_notification::NotificationExt; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, instrument, warn}; /// Check interval for snooze expiry (60 seconds) const CHECK_INTERVAL_SECS: u64 = 60; /// Tracks which items we've already notified about to avoid duplicates struct NotifiedItems { task_ids: HashSet, email_ids: HashSet, /// (event_id, offset_seconds) pairs that have already fired their reminder. /// One entry per offset because an event can have multiple reminders. event_reminders: HashSet<(EventId, i64)>, /// On first tick, mark currently-eligible reminders as fired without /// notifying — so app restarts don't spam old reminders. After the first /// tick this stays true and the watcher fires reminders normally. reminders_bootstrapped: bool, } impl NotifiedItems { fn new() -> Self { Self { task_ids: HashSet::new(), email_ids: HashSet::new(), event_reminders: HashSet::new(), reminders_bootstrapped: false, } } } /// Starts the background snooze watcher that checks for expired snoozes /// and sends OS notifications. pub async fn start_snooze_watcher(app: tauri::AppHandle, cancel: CancellationToken) { info!("Starting snooze watcher (interval: {}s)", CHECK_INTERVAL_SECS); let mut notified = NotifiedItems::new(); let mut interval = tokio::time::interval(Duration::from_secs(CHECK_INTERVAL_SECS)); loop { tokio::select! { _ = cancel.cancelled() => { info!("Snooze watcher shutting down"); break; } _ = interval.tick() => {} } // Get app state let state = match app.try_state::>() { Some(s) => s, None => { debug!("App state not available, skipping snooze check"); continue; } }; // Check for expired task snoozes if let Err(e) = check_task_snoozes(&app, &state, &mut notified).await { error!(error = %e, "Error checking task snoozes"); } // Check for expired email snoozes if let Err(e) = check_email_snoozes(&app, &state, &mut notified).await { error!(error = %e, "Error checking email snoozes"); } // Check for overdue waiting responses if let Err(e) = check_overdue_responses(&app, &state, &mut notified).await { error!(error = %e, "Error checking overdue responses"); } // Check for due event reminders if let Err(e) = check_event_reminders(&app, &state, &mut notified).await { error!(error = %e, "Error checking event reminders"); } // Clean up old notified IDs periodically. The threshold is high (10k) // because each UUID is only 16 bytes (~160KB total). Clearing too // aggressively can re-trigger notifications for snoozed items whose // unsnooze failed. if notified.task_ids.len() > 10_000 { debug!("Clearing task notification cache"); notified.task_ids.clear(); } if notified.email_ids.len() > 10_000 { debug!("Clearing email notification cache"); notified.email_ids.clear(); } if notified.event_reminders.len() > 10_000 { debug!("Clearing event-reminder notification cache"); notified.event_reminders.clear(); notified.reminders_bootstrapped = false; } } } #[instrument(skip_all)] async fn check_task_snoozes( app: &tauri::AppHandle, state: &Arc, notified: &mut NotifiedItems, ) -> Result<(), String> { let now = Utc::now(); // Get all snoozed tasks let snoozed_tasks = state.tasks .list_snoozed(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; for task in snoozed_tasks { // Check if snooze has expired and we haven't notified yet if let Some(snoozed_until) = task.snoozed_until { if snoozed_until <= now && !notified.task_ids.contains(&task.id) { info!(task_id = %task.id, "Task snooze expired, sending notification"); // Send notification send_notification( app, "Task Resurfaced", &truncate_text(&task.description, 50).to_string(), ); // Mark as notified notified.task_ids.insert(task.id); // Unsnooze the task if let Err(e) = state.tasks.unsnooze(task.id, DESKTOP_USER_ID).await { warn!(task_id = %task.id, error = %e, "Failed to unsnooze task after notification"); } } } } Ok(()) } async fn check_email_snoozes( app: &tauri::AppHandle, state: &Arc, notified: &mut NotifiedItems, ) -> Result<(), String> { let now = Utc::now(); // Get all snoozed emails let snoozed_emails = state.emails .list_snoozed(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; for email in snoozed_emails { // Check if snooze has expired and we haven't notified yet if let Some(snoozed_until) = email.snoozed_until { if snoozed_until <= now && !notified.email_ids.contains(&email.id) { // Send notification send_notification( app, "Email Resurfaced", &format!("From: {} - {}", truncate_text(&email.from, 20), truncate_text(&email.subject, 40)), ); // Mark as notified notified.email_ids.insert(email.id); // Unsnooze the email if let Err(e) = state.emails.unsnooze(email.id, DESKTOP_USER_ID).await { warn!(email_id = %email.id, error = %e, "Failed to unsnooze email after notification"); } } } } Ok(()) } async fn check_overdue_responses( app: &tauri::AppHandle, state: &Arc, notified: &mut NotifiedItems, ) -> Result<(), String> { let now = Utc::now(); // Check tasks waiting for response that are overdue let waiting_tasks = state.tasks .list_waiting(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; for task in waiting_tasks { if let Some(expected_date) = task.expected_response_date { // Notify if response is overdue and we haven't notified yet // Use a unique key combining task ID and expected date to allow re-notification // if the expected date changes if expected_date < now && !notified.task_ids.contains(&task.id) { send_notification( app, "Response Overdue", &format!("Still waiting: {}", truncate_text(&task.description, 50)), ); notified.task_ids.insert(task.id); } } } // Check emails waiting for response that are overdue let waiting_emails = state.emails .list_waiting(DESKTOP_USER_ID) .await .map_err(|e| e.to_string())?; for email in waiting_emails { if let Some(expected_date) = email.expected_response_date { if expected_date < now && !notified.email_ids.contains(&email.id) { send_notification( app, "Response Overdue", &format!("No reply from: {} - {}", truncate_text(&email.from, 20), truncate_text(&email.subject, 30)), ); notified.email_ids.insert(email.id); } } } Ok(()) } /// How far into the future to scan for events with pending reminders. /// 31 days covers the typical max useful offset (e.g. "1 day before") with /// generous headroom. Wider than that and the per-tick query gets expensive /// for users with many calendar events. const REMINDER_LOOKAHEAD_DAYS: i64 = 31; #[instrument(skip_all)] async fn check_event_reminders( app: &tauri::AppHandle, state: &Arc, notified: &mut NotifiedItems, ) -> Result<(), String> { let now = Utc::now(); let events = state.events .get_upcoming(DESKTOP_USER_ID, REMINDER_LOOKAHEAD_DAYS) .await .map_err(|e| e.to_string())?; for event in events { if event.reminder_offsets_seconds.is_empty() { continue; } // Skip snoozed events — surfacing reminders for them defeats the snooze. if event.is_snoozed() { continue; } for offset_seconds in &event.reminder_offsets_seconds { let key = (event.id, *offset_seconds); if notified.event_reminders.contains(&key) { continue; } let offset = chrono::Duration::seconds(*offset_seconds); let fire_time = event.start_time - offset; if fire_time > now { // Not yet time continue; } if event.start_time <= now { // Event has already started — don't surface a "5 minutes before" // reminder for something that's already running. notified.event_reminders.insert(key); continue; } // On the first tick after launch, mark eligible reminders as fired // without notifying. This avoids spamming old reminders if the app // was closed past several fire times. if !notified.reminders_bootstrapped { notified.event_reminders.insert(key); continue; } info!(event_id = %event.id, offset_seconds = *offset_seconds, "Firing event reminder"); send_notification( app, &reminder_title(*offset_seconds), &truncate_text(&event.title, 80), ); notified.event_reminders.insert(key); } } notified.reminders_bootstrapped = true; Ok(()) } /// Human-readable lead time for a reminder notification title. fn reminder_title(offset_seconds: i64) -> String { if offset_seconds <= 0 { return "Event starting now".to_string(); } let mins = offset_seconds / 60; let hours = mins / 60; let days = hours / 24; if days >= 1 && mins % (60 * 24) == 0 { let label = if days == 1 { "day" } else { "days" }; return format!("Event in {days} {label}"); } if hours >= 1 && mins % 60 == 0 { let label = if hours == 1 { "hour" } else { "hours" }; return format!("Event in {hours} {label}"); } let label = if mins == 1 { "minute" } else { "minutes" }; format!("Event in {mins} {label}") } pub fn send_notification(app: &tauri::AppHandle, title: &str, body: &str) { debug!(title, body, "Sending notification"); if let Err(e) = app.notification() .builder() .title(title) .body(body) .show() { warn!(error = %e, title, "Failed to send notification"); } } fn truncate_text(text: &str, max_len: usize) -> String { if text.len() <= max_len { text.to_string() } else { let truncate_to = max_len.saturating_sub(3); let end = text .char_indices() .map(|(i, _)| i) .take_while(|&i| i <= truncate_to) .last() .unwrap_or(0); format!("{}...", &text[..end]) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_truncate_short_text() { let text = "Hello"; let result = truncate_text(text, 10); assert_eq!(result, "Hello"); } #[test] fn test_truncate_exact_length() { let text = "Hello"; let result = truncate_text(text, 5); assert_eq!(result, "Hello"); } #[test] fn test_truncate_long_text() { let text = "This is a very long text that needs truncation"; let result = truncate_text(text, 20); assert_eq!(result.len(), 20); assert!(result.ends_with("...")); assert_eq!(result, "This is a very lo..."); } #[test] fn test_truncate_empty_text() { let text = ""; let result = truncate_text(text, 10); assert_eq!(result, ""); } #[test] fn test_truncate_very_small_max() { let text = "Hello World"; let result = truncate_text(text, 4); // With max_len=4, we get 1 char + "..." assert_eq!(result, "H..."); } #[test] fn test_notified_items_deduplication() { let mut notified = NotifiedItems::new(); let task_id = TaskId::new(); // First check - should pass, add to set assert!(!notified.task_ids.contains(&task_id)); notified.task_ids.insert(task_id); // Second check - should be blocked assert!(notified.task_ids.contains(&task_id)); } #[test] fn test_notified_items_cache_clearing() { let mut notified = NotifiedItems::new(); // Add more than 1000 items for _ in 0..1001 { notified.task_ids.insert(TaskId::new()); } assert!(notified.task_ids.len() > 1000); // Simulate the cache clearing logic if notified.task_ids.len() > 1000 { notified.task_ids.clear(); } assert!(notified.task_ids.is_empty()); } #[test] fn test_task_and_email_separate_tracking() { let mut notified = NotifiedItems::new(); let uuid = uuid::Uuid::new_v4(); let task_id = TaskId::from(uuid); let email_id = EmailId::from(uuid); // Same UUID can be in both sets (they're separate entities) notified.task_ids.insert(task_id); notified.email_ids.insert(email_id); assert!(notified.task_ids.contains(&task_id)); assert!(notified.email_ids.contains(&email_id)); } } #[cfg(test)] mod reminder_title_tests { use super::reminder_title; #[test] fn at_time() { assert_eq!(reminder_title(0), "Event starting now"); } #[test] fn five_minutes() { assert_eq!(reminder_title(300), "Event in 5 minutes"); } #[test] fn one_minute_singular() { assert_eq!(reminder_title(60), "Event in 1 minute"); } #[test] fn one_hour_singular() { assert_eq!(reminder_title(3600), "Event in 1 hour"); } #[test] fn two_hours() { assert_eq!(reminder_title(7200), "Event in 2 hours"); } #[test] fn one_day() { assert_eq!(reminder_title(86_400), "Event in 1 day"); } #[test] fn ninety_minutes_falls_back_to_minutes() { // 90 mins is not a whole number of hours, so we report minutes assert_eq!(reminder_title(5_400), "Event in 90 minutes"); } }