| 5 |
5 |
|
|
| 6 |
6 |
|
use crate::state::{AppState, DESKTOP_USER_ID};
|
| 7 |
7 |
|
use chrono::Utc;
|
| 8 |
|
- |
use goingson_core::{EmailId, TaskId};
|
|
8 |
+ |
use goingson_core::{EmailId, EventId, TaskId};
|
| 9 |
9 |
|
use std::collections::HashSet;
|
| 10 |
10 |
|
use std::sync::Arc;
|
| 11 |
11 |
|
use std::time::Duration;
|
| 21 |
21 |
|
struct NotifiedItems {
|
| 22 |
22 |
|
task_ids: HashSet<TaskId>,
|
| 23 |
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,
|
| 24 |
31 |
|
}
|
| 25 |
32 |
|
|
| 26 |
33 |
|
impl NotifiedItems {
|
| 28 |
35 |
|
Self {
|
| 29 |
36 |
|
task_ids: HashSet::new(),
|
| 30 |
37 |
|
email_ids: HashSet::new(),
|
|
38 |
+ |
event_reminders: HashSet::new(),
|
|
39 |
+ |
reminders_bootstrapped: false,
|
| 31 |
40 |
|
}
|
| 32 |
41 |
|
}
|
| 33 |
42 |
|
}
|
| 72 |
81 |
|
error!(error = %e, "Error checking overdue responses");
|
| 73 |
82 |
|
}
|
| 74 |
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 |
+ |
|
| 75 |
89 |
|
// Clean up old notified IDs periodically. The threshold is high (10k)
|
| 76 |
90 |
|
// because each UUID is only 16 bytes (~160KB total). Clearing too
|
| 77 |
91 |
|
// aggressively can re-trigger notifications for snoozed items whose
|
| 84 |
98 |
|
debug!("Clearing email notification cache");
|
| 85 |
99 |
|
notified.email_ids.clear();
|
| 86 |
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 |
+ |
}
|
| 87 |
106 |
|
}
|
| 88 |
107 |
|
}
|
| 89 |
108 |
|
|
| 217 |
236 |
|
Ok(())
|
| 218 |
237 |
|
}
|
| 219 |
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 |
+ |
|
| 220 |
328 |
|
pub fn send_notification(app: &tauri::AppHandle, title: &str, body: &str) {
|
| 221 |
329 |
|
debug!(title, body, "Sending notification");
|
| 222 |
330 |
|
if let Err(e) = app.notification()
|
| 333 |
441 |
|
assert!(notified.email_ids.contains(&email_id));
|
| 334 |
442 |
|
}
|
| 335 |
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 |
+ |
}
|