Skip to main content

max / goingson

Event snooze and reminders Two paired event features, landed together because they share the same EventRow / sync-trigger / EventResponse plumbing. Snooze (migration 049): - snoozed_until TIMESTAMP column on events + rebuilt sync triggers. - EventRepository::snooze / unsnooze / list_snoozed. - snooze_event / unsnooze_event / list_snoozed_events Tauri commands. - EventResponse surfaces isSnoozed + snoozedUntil. - snooze.js generalised from two item types to three via ITEM_LABEL, apiFor, reloadFor; event detail modal carries Snooze / Unsnooze. Reminders (migration 050): - reminder_offsets_seconds TEXT (JSON array) column + sync trigger rebuild + sync_service/apply.rs column list update. - sanitize_reminder_offsets in commands/event.rs strips negatives, dedupes, sorts, caps at 8 so a misbehaving frontend can't push hundreds of reminders into one event. - check_event_reminders runs each 60 s tick in notifications.rs; tracks (event_id, offset) pairs in-memory; bootstrap-on-first-tick suppresses backfill spam after app restart; skips snoozed events. - REMINDER_PRESETS checkbox group (At time / 5m / 15m / 30m / 1h / 1d) in new + edit event forms; "Reminders: …" line on the detail modal. Known limitations (post-launch follow-ups): - Recurring events fire reminders only against the template's anchor start_time, not virtual instances. - Reminder fire state is in-memory — closing the app around a fire time means you miss it.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-20 23:43 UTC
Commit: c90e8515f8e65b75f2b5a951a600c3d99ddfb184
Parent: 60bfc7e
20 files changed, +736 insertions, -35 deletions
@@ -150,6 +150,9 @@
150 150 commands::delete_event,
151 151 commands::list_upcoming_events,
152 152 commands::get_event_status_indicator,
153 + commands::list_snoozed_events,
154 + commands::snooze_event,
155 + commands::unsnooze_event,
153 156 commands::list_emails,
154 157 commands::list_emails_threaded,
155 158 commands::get_email,
@@ -444,6 +444,9 @@
444 444 commands::list_events_between,
445 445 commands::list_upcoming_events,
446 446 commands::get_event_status_indicator,
447 + commands::list_snoozed_events,
448 + commands::snooze_event,
449 + commands::unsnooze_event,
447 450 // Emails
448 451 commands::list_emails,
449 452 commands::list_emails_threaded,
@@ -5,7 +5,7 @@
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,6 +21,13 @@
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,6 +35,8 @@
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,6 +81,11 @@
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,6 +98,11 @@
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,6 +236,95 @@
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,3 +441,44 @@
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 + }
@@ -649,6 +649,8 @@
649 649 external_source: None,
650 650 external_id: None,
651 651 is_read_only: false,
652 + snoozed_until: None,
653 + reminder_offsets_seconds: Vec::new(),
652 654 };
653 655
654 656 let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap();
@@ -693,6 +695,8 @@
693 695 external_source: None,
694 696 external_id: None,
695 697 is_read_only: false,
698 + snoozed_until: None,
699 + reminder_offsets_seconds: Vec::new(),
696 700 };
697 701
698 702 let range_start = Utc.with_ymd_and_hms(2026, 3, 3, 0, 0, 0).unwrap();