Skip to main content

max / goingson

15.1 KB · 485 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 if 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
147 Ok(())
148 }
149
150 async fn check_email_snoozes(
151 app: &tauri::AppHandle,
152 state: &Arc<AppState>,
153 notified: &mut NotifiedItems,
154 ) -> Result<(), String> {
155 let now = Utc::now();
156
157 // Get all snoozed emails
158 let snoozed_emails = state.emails
159 .list_snoozed(DESKTOP_USER_ID)
160 .await
161 .map_err(|e| e.to_string())?;
162
163 for email in snoozed_emails {
164 // Check if snooze has expired and we haven't notified yet
165 if let Some(snoozed_until) = email.snoozed_until {
166 if snoozed_until <= now && !notified.email_ids.contains(&email.id) {
167 // Send notification
168 send_notification(
169 app,
170 "Email Resurfaced",
171 &format!("From: {} - {}", truncate_text(&email.from, 20), truncate_text(&email.subject, 40)),
172 );
173
174 // Mark as notified
175 notified.email_ids.insert(email.id);
176
177 // Unsnooze the email
178 if let Err(e) = state.emails.unsnooze(email.id, DESKTOP_USER_ID).await {
179 warn!(email_id = %email.id, error = %e, "Failed to unsnooze email after notification");
180 }
181 }
182 }
183 }
184
185 Ok(())
186 }
187
188 async fn check_overdue_responses(
189 app: &tauri::AppHandle,
190 state: &Arc<AppState>,
191 notified: &mut NotifiedItems,
192 ) -> Result<(), String> {
193 let now = Utc::now();
194
195 // Check tasks waiting for response that are overdue
196 let waiting_tasks = state.tasks
197 .list_waiting(DESKTOP_USER_ID)
198 .await
199 .map_err(|e| e.to_string())?;
200
201 for task in waiting_tasks {
202 if let Some(expected_date) = task.expected_response_date {
203 // Notify if response is overdue and we haven't notified yet
204 // Use a unique key combining task ID and expected date to allow re-notification
205 // if the expected date changes
206 if expected_date < now && !notified.task_ids.contains(&task.id) {
207 send_notification(
208 app,
209 "Response Overdue",
210 &format!("Still waiting: {}", truncate_text(&task.description, 50)),
211 );
212 notified.task_ids.insert(task.id);
213 }
214 }
215 }
216
217 // Check emails waiting for response that are overdue
218 let waiting_emails = state.emails
219 .list_waiting(DESKTOP_USER_ID)
220 .await
221 .map_err(|e| e.to_string())?;
222
223 for email in waiting_emails {
224 if let Some(expected_date) = email.expected_response_date {
225 if expected_date < now && !notified.email_ids.contains(&email.id) {
226 send_notification(
227 app,
228 "Response Overdue",
229 &format!("No reply from: {} - {}", truncate_text(&email.from, 20), truncate_text(&email.subject, 30)),
230 );
231 notified.email_ids.insert(email.id);
232 }
233 }
234 }
235
236 Ok(())
237 }
238
239 /// How far into the future to scan for events with pending reminders.
240 /// 31 days covers the typical max useful offset (e.g. "1 day before") with
241 /// generous headroom. Wider than that and the per-tick query gets expensive
242 /// for users with many calendar events.
243 const REMINDER_LOOKAHEAD_DAYS: i64 = 31;
244
245 #[instrument(skip_all)]
246 async fn check_event_reminders(
247 app: &tauri::AppHandle,
248 state: &Arc<AppState>,
249 notified: &mut NotifiedItems,
250 ) -> Result<(), String> {
251 let now = Utc::now();
252
253 let events = state.events
254 .get_upcoming(DESKTOP_USER_ID, REMINDER_LOOKAHEAD_DAYS)
255 .await
256 .map_err(|e| e.to_string())?;
257
258 for event in events {
259 if event.reminder_offsets_seconds.is_empty() {
260 continue;
261 }
262 // Skip snoozed events — surfacing reminders for them defeats the snooze.
263 if event.is_snoozed() {
264 continue;
265 }
266
267 for offset_seconds in &event.reminder_offsets_seconds {
268 let key = (event.id, *offset_seconds);
269 if notified.event_reminders.contains(&key) {
270 continue;
271 }
272
273 let offset = chrono::Duration::seconds(*offset_seconds);
274 let fire_time = event.start_time - offset;
275 if fire_time > now {
276 // Not yet time
277 continue;
278 }
279 if event.start_time <= now {
280 // Event has already started — don't surface a "5 minutes before"
281 // reminder for something that's already running.
282 notified.event_reminders.insert(key);
283 continue;
284 }
285
286 // On the first tick after launch, mark eligible reminders as fired
287 // without notifying. This avoids spamming old reminders if the app
288 // was closed past several fire times.
289 if !notified.reminders_bootstrapped {
290 notified.event_reminders.insert(key);
291 continue;
292 }
293
294 info!(event_id = %event.id, offset_seconds = *offset_seconds, "Firing event reminder");
295 send_notification(
296 app,
297 &reminder_title(*offset_seconds),
298 &truncate_text(&event.title, 80),
299 );
300 notified.event_reminders.insert(key);
301 }
302 }
303
304 notified.reminders_bootstrapped = true;
305 Ok(())
306 }
307
308 /// Human-readable lead time for a reminder notification title.
309 fn reminder_title(offset_seconds: i64) -> String {
310 if offset_seconds <= 0 {
311 return "Event starting now".to_string();
312 }
313 let mins = offset_seconds / 60;
314 let hours = mins / 60;
315 let days = hours / 24;
316 if days >= 1 && mins % (60 * 24) == 0 {
317 let label = if days == 1 { "day" } else { "days" };
318 return format!("Event in {days} {label}");
319 }
320 if hours >= 1 && mins % 60 == 0 {
321 let label = if hours == 1 { "hour" } else { "hours" };
322 return format!("Event in {hours} {label}");
323 }
324 let label = if mins == 1 { "minute" } else { "minutes" };
325 format!("Event in {mins} {label}")
326 }
327
328 pub fn send_notification(app: &tauri::AppHandle, title: &str, body: &str) {
329 debug!(title, body, "Sending notification");
330 if let Err(e) = app.notification()
331 .builder()
332 .title(title)
333 .body(body)
334 .show()
335 {
336 warn!(error = %e, title, "Failed to send notification");
337 }
338 }
339
340 fn truncate_text(text: &str, max_len: usize) -> String {
341 if text.len() <= max_len {
342 text.to_string()
343 } else {
344 let truncate_to = max_len.saturating_sub(3);
345 let end = text
346 .char_indices()
347 .map(|(i, _)| i)
348 .take_while(|&i| i <= truncate_to)
349 .last()
350 .unwrap_or(0);
351 format!("{}...", &text[..end])
352 }
353 }
354
355 #[cfg(test)]
356 mod tests {
357 use super::*;
358
359 #[test]
360 fn test_truncate_short_text() {
361 let text = "Hello";
362 let result = truncate_text(text, 10);
363 assert_eq!(result, "Hello");
364 }
365
366 #[test]
367 fn test_truncate_exact_length() {
368 let text = "Hello";
369 let result = truncate_text(text, 5);
370 assert_eq!(result, "Hello");
371 }
372
373 #[test]
374 fn test_truncate_long_text() {
375 let text = "This is a very long text that needs truncation";
376 let result = truncate_text(text, 20);
377 assert_eq!(result.len(), 20);
378 assert!(result.ends_with("..."));
379 assert_eq!(result, "This is a very lo...");
380 }
381
382 #[test]
383 fn test_truncate_empty_text() {
384 let text = "";
385 let result = truncate_text(text, 10);
386 assert_eq!(result, "");
387 }
388
389 #[test]
390 fn test_truncate_very_small_max() {
391 let text = "Hello World";
392 let result = truncate_text(text, 4);
393 // With max_len=4, we get 1 char + "..."
394 assert_eq!(result, "H...");
395 }
396
397 #[test]
398 fn test_notified_items_deduplication() {
399 let mut notified = NotifiedItems::new();
400 let task_id = TaskId::new();
401
402 // First check - should pass, add to set
403 assert!(!notified.task_ids.contains(&task_id));
404 notified.task_ids.insert(task_id);
405
406 // Second check - should be blocked
407 assert!(notified.task_ids.contains(&task_id));
408 }
409
410 #[test]
411 fn test_notified_items_cache_clearing() {
412 let mut notified = NotifiedItems::new();
413
414 // Add more than 1000 items
415 for _ in 0..1001 {
416 notified.task_ids.insert(TaskId::new());
417 }
418
419 assert!(notified.task_ids.len() > 1000);
420
421 // Simulate the cache clearing logic
422 if notified.task_ids.len() > 1000 {
423 notified.task_ids.clear();
424 }
425
426 assert!(notified.task_ids.is_empty());
427 }
428
429 #[test]
430 fn test_task_and_email_separate_tracking() {
431 let mut notified = NotifiedItems::new();
432 let uuid = uuid::Uuid::new_v4();
433 let task_id = TaskId::from(uuid);
434 let email_id = EmailId::from(uuid);
435
436 // Same UUID can be in both sets (they're separate entities)
437 notified.task_ids.insert(task_id);
438 notified.email_ids.insert(email_id);
439
440 assert!(notified.task_ids.contains(&task_id));
441 assert!(notified.email_ids.contains(&email_id));
442 }
443 }
444
445 #[cfg(test)]
446 mod reminder_title_tests {
447 use super::reminder_title;
448
449 #[test]
450 fn at_time() {
451 assert_eq!(reminder_title(0), "Event starting now");
452 }
453
454 #[test]
455 fn five_minutes() {
456 assert_eq!(reminder_title(300), "Event in 5 minutes");
457 }
458
459 #[test]
460 fn one_minute_singular() {
461 assert_eq!(reminder_title(60), "Event in 1 minute");
462 }
463
464 #[test]
465 fn one_hour_singular() {
466 assert_eq!(reminder_title(3600), "Event in 1 hour");
467 }
468
469 #[test]
470 fn two_hours() {
471 assert_eq!(reminder_title(7200), "Event in 2 hours");
472 }
473
474 #[test]
475 fn one_day() {
476 assert_eq!(reminder_title(86_400), "Event in 1 day");
477 }
478
479 #[test]
480 fn ninety_minutes_falls_back_to_minutes() {
481 // 90 mins is not a whole number of hours, so we report minutes
482 assert_eq!(reminder_title(5_400), "Event in 90 minutes");
483 }
484 }
485