Skip to main content

max / goingson

15.0 KB · 482 lines History Blame Raw
1 //! Background notification system for snooze expiry alerts.
2 //!
3 //! Periodically checks for tasks and emails that have expired snooze dates
4 //! and sends OS notifications when they resurface.
5
6 use crate::state::{AppState, DESKTOP_USER_ID};
7 use chrono::Utc;
8 use goingson_core::{EmailId, EventId, TaskId};
9 use std::collections::HashSet;
10 use std::sync::Arc;
11 use std::time::Duration;
12 use tauri::Manager;
13 use tauri_plugin_notification::NotificationExt;
14 use tokio_util::sync::CancellationToken;
15 use tracing::{debug, error, info, instrument, warn};
16
17 /// Check interval for snooze expiry (60 seconds)
18 const CHECK_INTERVAL_SECS: u64 = 60;
19
20 /// Tracks which items we've already notified about to avoid duplicates
21 struct NotifiedItems {
22 task_ids: HashSet<TaskId>,
23 email_ids: HashSet<EmailId>,
24 /// (event_id, offset_seconds) pairs that have already fired their reminder.
25 /// One entry per offset because an event can have multiple reminders.
26 event_reminders: HashSet<(EventId, i64)>,
27 /// On first tick, mark currently-eligible reminders as fired without
28 /// notifying — so app restarts don't spam old reminders. After the first
29 /// tick this stays true and the watcher fires reminders normally.
30 reminders_bootstrapped: bool,
31 }
32
33 impl NotifiedItems {
34 fn new() -> Self {
35 Self {
36 task_ids: HashSet::new(),
37 email_ids: HashSet::new(),
38 event_reminders: HashSet::new(),
39 reminders_bootstrapped: false,
40 }
41 }
42 }
43
44 /// Starts the background snooze watcher that checks for expired snoozes
45 /// and sends OS notifications.
46 pub async fn start_snooze_watcher(app: tauri::AppHandle, cancel: CancellationToken) {
47 info!("Starting snooze watcher (interval: {}s)", CHECK_INTERVAL_SECS);
48 let mut notified = NotifiedItems::new();
49 let mut interval = tokio::time::interval(Duration::from_secs(CHECK_INTERVAL_SECS));
50
51 loop {
52 tokio::select! {
53 _ = cancel.cancelled() => {
54 info!("Snooze watcher shutting down");
55 break;
56 }
57 _ = interval.tick() => {}
58 }
59
60 // Get app state
61 let state = match app.try_state::<Arc<AppState>>() {
62 Some(s) => s,
63 None => {
64 debug!("App state not available, skipping snooze check");
65 continue;
66 }
67 };
68
69 // Check for expired task snoozes
70 if let Err(e) = check_task_snoozes(&app, &state, &mut notified).await {
71 error!(error = %e, "Error checking task snoozes");
72 }
73
74 // Check for expired email snoozes
75 if let Err(e) = check_email_snoozes(&app, &state, &mut notified).await {
76 error!(error = %e, "Error checking email snoozes");
77 }
78
79 // Check for overdue waiting responses
80 if let Err(e) = check_overdue_responses(&app, &state, &mut notified).await {
81 error!(error = %e, "Error checking overdue responses");
82 }
83
84 // Check for due event reminders
85 if let Err(e) = check_event_reminders(&app, &state, &mut notified).await {
86 error!(error = %e, "Error checking event reminders");
87 }
88
89 // Clean up old notified IDs periodically. The threshold is high (10k)
90 // because each UUID is only 16 bytes (~160KB total). Clearing too
91 // aggressively can re-trigger notifications for snoozed items whose
92 // unsnooze failed.
93 if notified.task_ids.len() > 10_000 {
94 debug!("Clearing task notification cache");
95 notified.task_ids.clear();
96 }
97 if notified.email_ids.len() > 10_000 {
98 debug!("Clearing email notification cache");
99 notified.email_ids.clear();
100 }
101 if notified.event_reminders.len() > 10_000 {
102 debug!("Clearing event-reminder notification cache");
103 notified.event_reminders.clear();
104 notified.reminders_bootstrapped = false;
105 }
106 }
107 }
108
109 #[instrument(skip_all)]
110 async fn check_task_snoozes(
111 app: &tauri::AppHandle,
112 state: &Arc<AppState>,
113 notified: &mut NotifiedItems,
114 ) -> Result<(), String> {
115 let now = Utc::now();
116
117 // Get all snoozed tasks
118 let snoozed_tasks = state.tasks
119 .list_snoozed(DESKTOP_USER_ID)
120 .await
121 .map_err(|e| e.to_string())?;
122
123 for task in snoozed_tasks {
124 // Check if snooze has expired and we haven't notified yet
125 if let Some(snoozed_until) = task.snoozed_until
126 && snoozed_until <= now && !notified.task_ids.contains(&task.id) {
127 info!(task_id = %task.id, "Task snooze expired, sending notification");
128
129 // Send notification
130 send_notification(
131 app,
132 "Task Resurfaced",
133 &truncate_text(&task.description, 50).to_string(),
134 );
135
136 // Mark as notified
137 notified.task_ids.insert(task.id);
138
139 // Unsnooze the task
140 if let Err(e) = state.tasks.unsnooze(task.id, DESKTOP_USER_ID).await {
141 warn!(task_id = %task.id, error = %e, "Failed to unsnooze task after notification");
142 }
143 }
144 }
145
146 Ok(())
147 }
148
149 async fn check_email_snoozes(
150 app: &tauri::AppHandle,
151 state: &Arc<AppState>,
152 notified: &mut NotifiedItems,
153 ) -> Result<(), String> {
154 let now = Utc::now();
155
156 // Get all snoozed emails
157 let snoozed_emails = state.emails
158 .list_snoozed(DESKTOP_USER_ID)
159 .await
160 .map_err(|e| e.to_string())?;
161
162 for email in snoozed_emails {
163 // Check if snooze has expired and we haven't notified yet
164 if let Some(snoozed_until) = email.snoozed_until
165 && snoozed_until <= now && !notified.email_ids.contains(&email.id) {
166 // Send notification
167 send_notification(
168 app,
169 "Email Resurfaced",
170 &format!("From: {} - {}", truncate_text(&email.from, 20), truncate_text(&email.subject, 40)),
171 );
172
173 // Mark as notified
174 notified.email_ids.insert(email.id);
175
176 // Unsnooze the email
177 if let Err(e) = state.emails.unsnooze(email.id, DESKTOP_USER_ID).await {
178 warn!(email_id = %email.id, error = %e, "Failed to unsnooze email after notification");
179 }
180 }
181 }
182
183 Ok(())
184 }
185
186 async fn check_overdue_responses(
187 app: &tauri::AppHandle,
188 state: &Arc<AppState>,
189 notified: &mut NotifiedItems,
190 ) -> Result<(), String> {
191 let now = Utc::now();
192
193 // Check tasks waiting for response that are overdue
194 let waiting_tasks = state.tasks
195 .list_waiting(DESKTOP_USER_ID)
196 .await
197 .map_err(|e| e.to_string())?;
198
199 for task in waiting_tasks {
200 if let Some(expected_date) = task.expected_response_date {
201 // Notify if response is overdue and we haven't notified yet
202 // Use a unique key combining task ID and expected date to allow re-notification
203 // if the expected date changes
204 if expected_date < now && !notified.task_ids.contains(&task.id) {
205 send_notification(
206 app,
207 "Response Overdue",
208 &format!("Still waiting: {}", truncate_text(&task.description, 50)),
209 );
210 notified.task_ids.insert(task.id);
211 }
212 }
213 }
214
215 // Check emails waiting for response that are overdue
216 let waiting_emails = state.emails
217 .list_waiting(DESKTOP_USER_ID)
218 .await
219 .map_err(|e| e.to_string())?;
220
221 for email in waiting_emails {
222 if let Some(expected_date) = email.expected_response_date
223 && expected_date < now && !notified.email_ids.contains(&email.id) {
224 send_notification(
225 app,
226 "Response Overdue",
227 &format!("No reply from: {} - {}", truncate_text(&email.from, 20), truncate_text(&email.subject, 30)),
228 );
229 notified.email_ids.insert(email.id);
230 }
231 }
232
233 Ok(())
234 }
235
236 /// How far into the future to scan for events with pending reminders.
237 /// 31 days covers the typical max useful offset (e.g. "1 day before") with
238 /// generous headroom. Wider than that and the per-tick query gets expensive
239 /// for users with many calendar events.
240 const REMINDER_LOOKAHEAD_DAYS: i64 = 31;
241
242 #[instrument(skip_all)]
243 async fn check_event_reminders(
244 app: &tauri::AppHandle,
245 state: &Arc<AppState>,
246 notified: &mut NotifiedItems,
247 ) -> Result<(), String> {
248 let now = Utc::now();
249
250 let events = state.events
251 .get_upcoming(DESKTOP_USER_ID, REMINDER_LOOKAHEAD_DAYS)
252 .await
253 .map_err(|e| e.to_string())?;
254
255 for event in events {
256 if event.reminder_offsets_seconds.is_empty() {
257 continue;
258 }
259 // Skip snoozed events — surfacing reminders for them defeats the snooze.
260 if event.is_snoozed() {
261 continue;
262 }
263
264 for offset_seconds in &event.reminder_offsets_seconds {
265 let key = (event.id, *offset_seconds);
266 if notified.event_reminders.contains(&key) {
267 continue;
268 }
269
270 let offset = chrono::Duration::seconds(*offset_seconds);
271 let fire_time = event.start_time - offset;
272 if fire_time > now {
273 // Not yet time
274 continue;
275 }
276 if event.start_time <= now {
277 // Event has already started — don't surface a "5 minutes before"
278 // reminder for something that's already running.
279 notified.event_reminders.insert(key);
280 continue;
281 }
282
283 // On the first tick after launch, mark eligible reminders as fired
284 // without notifying. This avoids spamming old reminders if the app
285 // was closed past several fire times.
286 if !notified.reminders_bootstrapped {
287 notified.event_reminders.insert(key);
288 continue;
289 }
290
291 info!(event_id = %event.id, offset_seconds = *offset_seconds, "Firing event reminder");
292 send_notification(
293 app,
294 &reminder_title(*offset_seconds),
295 &truncate_text(&event.title, 80),
296 );
297 notified.event_reminders.insert(key);
298 }
299 }
300
301 notified.reminders_bootstrapped = true;
302 Ok(())
303 }
304
305 /// Human-readable lead time for a reminder notification title.
306 fn reminder_title(offset_seconds: i64) -> String {
307 if offset_seconds <= 0 {
308 return "Event starting now".to_string();
309 }
310 let mins = offset_seconds / 60;
311 let hours = mins / 60;
312 let days = hours / 24;
313 if days >= 1 && mins % (60 * 24) == 0 {
314 let label = if days == 1 { "day" } else { "days" };
315 return format!("Event in {days} {label}");
316 }
317 if hours >= 1 && mins % 60 == 0 {
318 let label = if hours == 1 { "hour" } else { "hours" };
319 return format!("Event in {hours} {label}");
320 }
321 let label = if mins == 1 { "minute" } else { "minutes" };
322 format!("Event in {mins} {label}")
323 }
324
325 pub fn send_notification(app: &tauri::AppHandle, title: &str, body: &str) {
326 debug!(title, body, "Sending notification");
327 if let Err(e) = app.notification()
328 .builder()
329 .title(title)
330 .body(body)
331 .show()
332 {
333 warn!(error = %e, title, "Failed to send notification");
334 }
335 }
336
337 fn truncate_text(text: &str, max_len: usize) -> String {
338 if text.len() <= max_len {
339 text.to_string()
340 } else {
341 let truncate_to = max_len.saturating_sub(3);
342 let end = text
343 .char_indices()
344 .map(|(i, _)| i)
345 .take_while(|&i| i <= truncate_to)
346 .last()
347 .unwrap_or(0);
348 format!("{}...", &text[..end])
349 }
350 }
351
352 #[cfg(test)]
353 mod tests {
354 use super::*;
355
356 #[test]
357 fn test_truncate_short_text() {
358 let text = "Hello";
359 let result = truncate_text(text, 10);
360 assert_eq!(result, "Hello");
361 }
362
363 #[test]
364 fn test_truncate_exact_length() {
365 let text = "Hello";
366 let result = truncate_text(text, 5);
367 assert_eq!(result, "Hello");
368 }
369
370 #[test]
371 fn test_truncate_long_text() {
372 let text = "This is a very long text that needs truncation";
373 let result = truncate_text(text, 20);
374 assert_eq!(result.len(), 20);
375 assert!(result.ends_with("..."));
376 assert_eq!(result, "This is a very lo...");
377 }
378
379 #[test]
380 fn test_truncate_empty_text() {
381 let text = "";
382 let result = truncate_text(text, 10);
383 assert_eq!(result, "");
384 }
385
386 #[test]
387 fn test_truncate_very_small_max() {
388 let text = "Hello World";
389 let result = truncate_text(text, 4);
390 // With max_len=4, we get 1 char + "..."
391 assert_eq!(result, "H...");
392 }
393
394 #[test]
395 fn test_notified_items_deduplication() {
396 let mut notified = NotifiedItems::new();
397 let task_id = TaskId::new();
398
399 // First check - should pass, add to set
400 assert!(!notified.task_ids.contains(&task_id));
401 notified.task_ids.insert(task_id);
402
403 // Second check - should be blocked
404 assert!(notified.task_ids.contains(&task_id));
405 }
406
407 #[test]
408 fn test_notified_items_cache_clearing() {
409 let mut notified = NotifiedItems::new();
410
411 // Add more than 1000 items
412 for _ in 0..1001 {
413 notified.task_ids.insert(TaskId::new());
414 }
415
416 assert!(notified.task_ids.len() > 1000);
417
418 // Simulate the cache clearing logic
419 if notified.task_ids.len() > 1000 {
420 notified.task_ids.clear();
421 }
422
423 assert!(notified.task_ids.is_empty());
424 }
425
426 #[test]
427 fn test_task_and_email_separate_tracking() {
428 let mut notified = NotifiedItems::new();
429 let uuid = uuid::Uuid::new_v4();
430 let task_id = TaskId::from(uuid);
431 let email_id = EmailId::from(uuid);
432
433 // Same UUID can be in both sets (they're separate entities)
434 notified.task_ids.insert(task_id);
435 notified.email_ids.insert(email_id);
436
437 assert!(notified.task_ids.contains(&task_id));
438 assert!(notified.email_ids.contains(&email_id));
439 }
440 }
441
442 #[cfg(test)]
443 mod reminder_title_tests {
444 use super::reminder_title;
445
446 #[test]
447 fn at_time() {
448 assert_eq!(reminder_title(0), "Event starting now");
449 }
450
451 #[test]
452 fn five_minutes() {
453 assert_eq!(reminder_title(300), "Event in 5 minutes");
454 }
455
456 #[test]
457 fn one_minute_singular() {
458 assert_eq!(reminder_title(60), "Event in 1 minute");
459 }
460
461 #[test]
462 fn one_hour_singular() {
463 assert_eq!(reminder_title(3600), "Event in 1 hour");
464 }
465
466 #[test]
467 fn two_hours() {
468 assert_eq!(reminder_title(7200), "Event in 2 hours");
469 }
470
471 #[test]
472 fn one_day() {
473 assert_eq!(reminder_title(86_400), "Event in 1 day");
474 }
475
476 #[test]
477 fn ninety_minutes_falls_back_to_minutes() {
478 // 90 mins is not a whole number of hours, so we report minutes
479 assert_eq!(reminder_title(5_400), "Event in 90 minutes");
480 }
481 }
482