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
@@ -62,6 +62,12 @@ pub struct Event {
62 62 pub external_id: Option<String>,
63 63 /// Whether this event is read-only (synced from external calendar).
64 64 pub is_read_only: bool,
65 + /// If set, the event is snoozed (hidden from main views) until this time.
66 + pub snoozed_until: Option<DateTime<Utc>>,
67 + /// Seconds-before-start_time at which to fire desktop reminder notifications.
68 + /// `[0, 300, 900]` = at time, 5 minutes before, 15 minutes before. Empty = no reminders.
69 + #[serde(default)]
70 + pub reminder_offsets_seconds: Vec<i64>,
65 71 }
66 72
67 73 impl Event {
@@ -142,6 +148,11 @@ impl Event {
142 148 pub fn is_linked_to_task(&self) -> bool {
143 149 self.linked_task_id.is_some()
144 150 }
151 +
152 + /// True if `snoozed_until` is in the future.
153 + pub fn is_snoozed(&self) -> bool {
154 + self.snoozed_until.is_some_and(|t| t > Utc::now())
155 + }
145 156 }
146 157
147 158 // ============ Event DTOs ============
@@ -173,6 +184,9 @@ pub struct NewEvent {
173 184 pub recurrence_rule: Option<RecurrenceRule>,
174 185 /// Block type classification (focus, meeting, break, etc.).
175 186 pub block_type: Option<BlockType>,
187 + /// Seconds-before-start_time at which to fire reminders. Empty = none.
188 + #[serde(default)]
189 + pub reminder_offsets_seconds: Vec<i64>,
176 190 }
177 191
178 192 /// Data for updating an existing event.
@@ -200,6 +214,9 @@ pub struct UpdateEvent {
200 214 pub recurrence_rule: Option<RecurrenceRule>,
201 215 /// Block type classification (focus, meeting, break, etc.).
202 216 pub block_type: Option<BlockType>,
217 + /// Seconds-before-start_time at which to fire reminders. Empty = none.
218 + #[serde(default)]
219 + pub reminder_offsets_seconds: Vec<i64>,
203 220 }
204 221
205 222 impl NewEvent {
@@ -333,6 +350,7 @@ impl NewEventBuilder {
333 350 recurrence: self.recurrence,
334 351 recurrence_rule: self.recurrence_rule,
335 352 block_type: self.block_type,
353 + reminder_offsets_seconds: Vec::new(),
336 354 }
337 355 }
338 356 }
@@ -649,6 +649,8 @@ mod tests {
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 @@ mod tests {
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();
@@ -272,6 +272,15 @@ pub trait EventRepository: Send + Sync {
272 272
273 273 /// Finds an event by external source and ID (for dedup during import).
274 274 async fn find_by_external_id(&self, source: &str, ext_id: &str, user_id: UserId) -> Result<Option<Event>>;
275 +
276 + /// Snoozes an event until `until`. Returns the updated event, or `None` if not found.
277 + async fn snooze(&self, id: EventId, user_id: UserId, until: DateTime<Utc>) -> Result<Option<Event>>;
278 +
279 + /// Clears any snooze on an event. Returns the updated event, or `None` if not found.
280 + async fn unsnooze(&self, id: EventId, user_id: UserId) -> Result<Option<Event>>;
281 +
282 + /// Lists currently snoozed events (snoozed_until is in the future).
283 + async fn list_snoozed(&self, user_id: UserId) -> Result<Vec<Event>>;
275 284 }
276 285
277 286 /// Repository for email message operations.
@@ -273,6 +273,7 @@ mod tests {
273 273 recurrence_rule: None,
274 274 contact_id: None,
275 275 block_type: None,
276 + reminder_offsets_seconds: Vec::new(),
276 277 };
277 278 assert!(event.validate().is_ok());
278 279 }
@@ -293,6 +294,7 @@ mod tests {
293 294 recurrence_rule: None,
294 295 contact_id: None,
295 296 block_type: None,
297 + reminder_offsets_seconds: Vec::new(),
296 298 };
297 299 let err = event.validate().unwrap_err();
298 300 assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
@@ -314,6 +316,7 @@ mod tests {
314 316 recurrence_rule: None,
315 317 contact_id: None,
316 318 block_type: None,
319 + reminder_offsets_seconds: Vec::new(),
317 320 };
318 321 let err = event.validate().unwrap_err();
319 322 assert!(matches!(err, CoreError::Validation { field: "title", .. }));
@@ -439,6 +442,7 @@ mod tests {
439 442 recurrence_rule: None,
440 443 contact_id: None,
441 444 block_type: None,
445 + reminder_offsets_seconds: Vec::new(),
442 446 };
443 447 let err = event.validate().unwrap_err();
444 448 assert!(matches!(err, CoreError::Validation { field: "title", .. }));
@@ -460,6 +464,7 @@ mod tests {
460 464 recurrence_rule: None,
461 465 contact_id: None,
462 466 block_type: None,
467 + reminder_offsets_seconds: Vec::new(),
463 468 };
464 469 let err = event.validate().unwrap_err();
465 470 assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
@@ -530,6 +535,7 @@ mod tests {
530 535 recurrence_rule: None,
531 536 contact_id: None,
532 537 block_type: None,
538 + reminder_offsets_seconds: Vec::new(),
533 539 };
534 540 assert!(event.validate().is_err());
535 541 }
@@ -588,6 +594,7 @@ mod tests {
588 594 recurrence: Recurrence::None,
589 595 recurrence_rule: None,
590 596 block_type: Some(BlockType::Focus),
597 + reminder_offsets_seconds: Vec::new(),
591 598 };
592 599 assert!(event.validate().is_ok());
593 600 }
@@ -607,6 +614,7 @@ mod tests {
607 614 recurrence: Recurrence::None,
608 615 recurrence_rule: None,
609 616 block_type: None,
617 + reminder_offsets_seconds: Vec::new(),
610 618 };
611 619 let err = event.validate().unwrap_err();
612 620 assert!(matches!(err, CoreError::Validation { field: "title", .. }));
@@ -627,6 +635,7 @@ mod tests {
627 635 recurrence: Recurrence::None,
628 636 recurrence_rule: None,
629 637 block_type: None,
638 + reminder_offsets_seconds: Vec::new(),
630 639 };
631 640 let err = event.validate().unwrap_err();
632 641 assert!(matches!(err, CoreError::Validation { field: "title", .. }));
@@ -647,6 +656,7 @@ mod tests {
647 656 recurrence: Recurrence::None,
648 657 recurrence_rule: None,
649 658 block_type: None,
659 + reminder_offsets_seconds: Vec::new(),
650 660 };
651 661 let err = event.validate().unwrap_err();
652 662 assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
@@ -667,6 +677,7 @@ mod tests {
667 677 recurrence: Recurrence::None,
668 678 recurrence_rule: None,
669 679 block_type: None,
680 + reminder_offsets_seconds: Vec::new(),
670 681 };
671 682 let err = event.validate().unwrap_err();
672 683 assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
@@ -7,7 +7,7 @@
7 7 //! - Date range queries for dashboard views
8 8
9 9 use async_trait::async_trait;
10 - use chrono::NaiveDate;
10 + use chrono::{DateTime, NaiveDate, Utc};
11 11 use sqlx::SqlitePool;
12 12 use goingson_core::{
13 13 BlockType, ContactId, CoreError, DbValue, Event, EventId, EventRepository, NewEvent, ParseableEnum,
@@ -21,7 +21,8 @@ const EVENT_SELECT_COLUMNS: &str = r#"e.id, e.user_id, e.project_id, p.name as p
21 21 e.title, e.description, e.start_time, e.end_time, e.location,
22 22 e.linked_task_id, e.recurrence, e.recurrence_rule, e.recurrence_parent_id,
23 23 e.contact_id, ct.display_name as contact_name, e.block_type,
24 - e.external_source, e.external_id, e.is_read_only"#;
24 + e.external_source, e.external_id, e.is_read_only, e.snoozed_until,
25 + e.reminder_offsets_seconds"#;
25 26
26 27 #[derive(Debug, Clone, sqlx::FromRow)]
27 28 struct EventRow {
@@ -44,6 +45,8 @@ struct EventRow {
44 45 pub external_source: Option<String>,
45 46 pub external_id: Option<String>,
46 47 pub is_read_only: i32,
48 + pub snoozed_until: Option<String>,
49 + pub reminder_offsets_seconds: Option<String>,
47 50 }
48 51
49 52 impl TryFrom<EventRow> for Event {
@@ -73,6 +76,11 @@ impl TryFrom<EventRow> for Event {
73 76 external_source: row.external_source,
74 77 external_id: row.external_id,
75 78 is_read_only: row.is_read_only != 0,
79 + snoozed_until: row.snoozed_until.as_deref().map(parse_datetime).transpose()?,
80 + reminder_offsets_seconds: row.reminder_offsets_seconds
81 + .as_deref()
82 + .and_then(|s| serde_json::from_str::<Vec<i64>>(s).ok())
83 + .unwrap_or_default(),
76 84 })
77 85 }
78 86 }
@@ -166,9 +174,14 @@ impl EventRepository for SqliteEventRepository {
166 174
167 175 let recurrence_rule_json = event.recurrence_rule.as_ref()
168 176 .map(|r| serde_json::to_string(r).unwrap_or_default());
177 + let reminder_offsets_json = if event.reminder_offsets_seconds.is_empty() {
178 + None
179 + } else {
180 + Some(serde_json::to_string(&event.reminder_offsets_seconds).unwrap_or_default())
181 + };
169 182
170 183 sqlx::query(
171 - "INSERT INTO events (id, user_id, project_id, title, description, start_time, end_time, location, linked_task_id, recurrence, recurrence_rule, contact_id, block_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
184 + "INSERT INTO events (id, user_id, project_id, title, description, start_time, end_time, location, linked_task_id, recurrence, recurrence_rule, contact_id, block_type, reminder_offsets_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
172 185 )
173 186 .bind(id.to_string())
174 187 .bind(user_id.to_string())
@@ -183,6 +196,7 @@ impl EventRepository for SqliteEventRepository {
183 196 .bind(&recurrence_rule_json)
184 197 .bind(event.contact_id.map(|c| c.to_string()))
185 198 .bind(event.block_type.as_ref().map(|b| b.db_value()))
199 + .bind(&reminder_offsets_json)
186 200 .execute(&self.pool)
187 201 .await
188 202 .map_err(CoreError::database)?;
@@ -197,9 +211,14 @@ impl EventRepository for SqliteEventRepository {
197 211
198 212 let recurrence_rule_json = event.recurrence_rule.as_ref()
199 213 .map(|r| serde_json::to_string(r).unwrap_or_default());
214 + let reminder_offsets_json = if event.reminder_offsets_seconds.is_empty() {
215 + None
216 + } else {
217 + Some(serde_json::to_string(&event.reminder_offsets_seconds).unwrap_or_default())
218 + };
200 219
201 220 let result = sqlx::query(
202 - "UPDATE events SET project_id = ?, title = ?, description = ?, start_time = ?, end_time = ?, location = ?, linked_task_id = ?, recurrence = ?, recurrence_rule = ?, contact_id = ?, block_type = ? WHERE id = ? AND user_id = ?",
221 + "UPDATE events SET project_id = ?, title = ?, description = ?, start_time = ?, end_time = ?, location = ?, linked_task_id = ?, recurrence = ?, recurrence_rule = ?, contact_id = ?, block_type = ?, reminder_offsets_seconds = ? WHERE id = ? AND user_id = ?",
203 222 )
204 223 .bind(event.project_id.map(|p| p.to_string()))
205 224 .bind(&event.title)
@@ -212,6 +231,7 @@ impl EventRepository for SqliteEventRepository {
212 231 .bind(&recurrence_rule_json)
213 232 .bind(event.contact_id.map(|c| c.to_string()))
214 233 .bind(event.block_type.as_ref().map(|b| b.db_value()))
234 + .bind(&reminder_offsets_json)
215 235 .bind(id.to_string())
216 236 .bind(user_id.to_string())
217 237 .execute(&self.pool)
@@ -362,4 +382,55 @@ impl EventRepository for SqliteEventRepository {
362 382 .map_err(CoreError::database)?;
363 383 row.map(Event::try_from).transpose()
364 384 }
385 +
386 + #[tracing::instrument(skip_all)]
387 + async fn snooze(&self, id: EventId, user_id: UserId, until: DateTime<Utc>) -> Result<Option<Event>> {
388 + let until_str = format_datetime(&until);
389 + let result = sqlx::query(
390 + "UPDATE events SET snoozed_until = ? WHERE id = ? AND user_id = ?"
391 + )
392 + .bind(&until_str)
393 + .bind(id.to_string())
394 + .bind(user_id.to_string())
395 + .execute(&self.pool)
396 + .await
397 + .map_err(CoreError::database)?;
398 +
399 + if result.rows_affected() == 0 {
400 + return Ok(None);
401 + }
402 + EventRepository::get_by_id(self, id, user_id).await
403 + }
404 +
405 + #[tracing::instrument(skip_all)]
406 + async fn unsnooze(&self, id: EventId, user_id: UserId) -> Result<Option<Event>> {
407 + let result = sqlx::query(
408 + "UPDATE events SET snoozed_until = NULL WHERE id = ? AND user_id = ?"
409 + )
410 + .bind(id.to_string())
411 + .bind(user_id.to_string())
412 + .execute(&self.pool)
413 + .await
414 + .map_err(CoreError::database)?;
415 +
416 + if result.rows_affected() == 0 {
417 + return Ok(None);
418 + }
419 + EventRepository::get_by_id(self, id, user_id).await
420 + }
421 +
422 + #[tracing::instrument(skip_all)]
423 + async fn list_snoozed(&self, user_id: UserId) -> Result<Vec<Event>> {
424 + let query = format!(
425 + "SELECT {} FROM events e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? LEFT JOIN contacts ct ON ct.id = e.contact_id WHERE e.user_id = ? AND e.snoozed_until IS NOT NULL AND datetime(e.snoozed_until) > datetime('now') ORDER BY e.snoozed_until ASC",
426 + EVENT_SELECT_COLUMNS
427 + );
428 + let rows = sqlx::query_as::<_, EventRow>(&query)
429 + .bind(user_id.to_string())
430 + .bind(user_id.to_string())
431 + .fetch_all(&self.pool)
432 + .await
433 + .map_err(CoreError::database)?;
434 + rows.into_iter().map(Event::try_from).collect()
435 + }
365 436 }
@@ -28,6 +28,7 @@ async fn test_create_and_get_event() {
28 28 recurrence_rule: None,
29 29 contact_id: None,
30 30 block_type: None,
31 + reminder_offsets_seconds: Vec::new(),
31 32 };
32 33
33 34 let created = repo.create(user_id, new_event).await.expect("Failed to create event");
@@ -59,6 +60,7 @@ async fn test_get_upcoming_events() {
59 60 recurrence_rule: None,
60 61 contact_id: None,
61 62 block_type: None,
63 + reminder_offsets_seconds: Vec::new(),
62 64 };
63 65 repo.create(user_id, past).await.expect("Failed to create");
64 66
@@ -77,6 +79,7 @@ async fn test_get_upcoming_events() {
77 79 recurrence_rule: None,
78 80 contact_id: None,
79 81 block_type: None,
82 + reminder_offsets_seconds: Vec::new(),
80 83 };
81 84 repo.create(user_id, future).await.expect("Failed to create");
82 85 }
@@ -115,6 +118,7 @@ async fn test_list_events_for_date() {
115 118 recurrence_rule: None,
116 119 contact_id: None,
117 120 block_type: None,
121 + reminder_offsets_seconds: Vec::new(),
118 122 };
119 123 repo.create(user_id, on_date).await.expect("Failed to create");
120 124
@@ -132,6 +136,7 @@ async fn test_list_events_for_date() {
132 136 recurrence_rule: None,
133 137 contact_id: None,
134 138 block_type: None,
139 + reminder_offsets_seconds: Vec::new(),
135 140 };
136 141 repo.create(user_id, other_date).await.expect("Failed to create");
137 142
@@ -161,6 +166,7 @@ async fn test_update_event() {
161 166 recurrence_rule: None,
162 167 contact_id: None,
163 168 block_type: None,
169 + reminder_offsets_seconds: Vec::new(),
164 170 };
165 171
166 172 let created = repo.create(user_id, new_event).await.expect("Failed to create");
@@ -178,6 +184,7 @@ async fn test_update_event() {
178 184 recurrence_rule: None,
179 185 contact_id: None,
180 186 block_type: None,
187 + reminder_offsets_seconds: Vec::new(),
181 188 };
182 189
183 190 let updated = repo.update(created.id, user_id, update).await.expect("Failed to update");
@@ -207,6 +214,7 @@ async fn test_delete_event() {
207 214 recurrence_rule: None,
208 215 contact_id: None,
209 216 block_type: None,
217 + reminder_offsets_seconds: Vec::new(),
210 218 };
211 219
212 220 let created = repo.create(user_id, new_event).await.expect("Failed to create");
@@ -239,6 +247,7 @@ async fn test_event_with_linked_task() {
239 247 recurrence_rule: None,
240 248 contact_id: None,
241 249 block_type: None,
250 + reminder_offsets_seconds: Vec::new(),
242 251 };
243 252
244 253 let created = repo.create(user_id, new_event).await.expect("Failed to create");
@@ -278,6 +287,7 @@ async fn test_event_ordering() {
278 287 recurrence_rule: None,
279 288 contact_id: None,
280 289 block_type: None,
290 + reminder_offsets_seconds: Vec::new(),
281 291 };
282 292 repo.create(user_id, event).await.expect("Failed to create");
283 293 }
@@ -311,6 +321,7 @@ async fn test_list_all_events() {
311 321 recurrence_rule: None,
312 322 contact_id: None,
313 323 block_type: None,
324 + reminder_offsets_seconds: Vec::new(),
314 325 };
315 326 repo.create(user_id, event).await.expect("Failed to create");
316 327 }
@@ -318,3 +329,123 @@ async fn test_list_all_events() {
318 329 let all = repo.list_all(user_id).await.expect("Failed to list all");
319 330 assert_eq!(all.len(), 5);
320 331 }
332 +
333 + #[tokio::test]
334 + async fn test_event_snooze() {
335 + let pool = common::setup_test_db().await;
336 + let user_id = common::create_test_user(&pool).await;
337 + let repo = SqliteEventRepository::new(pool);
338 +
339 + let start = Utc::now() + Duration::hours(4);
340 + let new_event = NewEvent {
341 + user_id: Some(user_id),
342 + project_id: None,
343 + title: "Snoozable".to_string(),
344 + description: String::new(),
345 + start_time: start,
346 + end_time: Some(start + Duration::hours(1)),
347 + location: None,
348 + linked_task_id: None,
349 + recurrence: Recurrence::None,
350 + recurrence_rule: None,
351 + contact_id: None,
352 + block_type: None,
353 + reminder_offsets_seconds: Vec::new(),
354 + };
355 + let event = repo.create(user_id, new_event).await.expect("Failed to create event");
356 + assert!(event.snoozed_until.is_none());
357 +
358 + let until = Utc::now() + Duration::hours(2);
359 + let snoozed = repo.snooze(event.id, user_id, until).await.expect("Failed to snooze");
360 + assert!(snoozed.as_ref().unwrap().snoozed_until.is_some());
361 + assert!(snoozed.unwrap().is_snoozed());
362 +
363 + let snoozed_list = repo.list_snoozed(user_id).await.expect("Failed to list snoozed");
364 + assert_eq!(snoozed_list.len(), 1);
365 +
366 + let unsnoozed = repo.unsnooze(event.id, user_id).await.expect("Failed to unsnooze");
367 + assert!(unsnoozed.unwrap().snoozed_until.is_none());
368 +
369 + let snoozed_list = repo.list_snoozed(user_id).await.expect("Failed to list snoozed");
370 + assert!(snoozed_list.is_empty());
371 + }
372 +
373 + #[tokio::test]
374 + async fn test_event_snooze_past_time_excluded_from_list() {
375 + let pool = common::setup_test_db().await;
376 + let user_id = common::create_test_user(&pool).await;
377 + let repo = SqliteEventRepository::new(pool);
378 +
379 + let start = Utc::now() + Duration::hours(4);
380 + let new_event = NewEvent {
381 + user_id: Some(user_id),
382 + project_id: None,
383 + title: "Expired snooze".to_string(),
384 + description: String::new(),
385 + start_time: start,
386 + end_time: None,
387 + location: None,
388 + linked_task_id: None,
389 + recurrence: Recurrence::None,
390 + recurrence_rule: None,
391 + contact_id: None,
392 + block_type: None,
393 + reminder_offsets_seconds: Vec::new(),
394 + };
395 + let event = repo.create(user_id, new_event).await.expect("Failed to create event");
396 +
397 + let past_until = Utc::now() - Duration::hours(1);
398 + repo.snooze(event.id, user_id, past_until).await.expect("Failed to snooze");
399 +
400 + // Expired snooze should not appear in list_snoozed
401 + let snoozed_list = repo.list_snoozed(user_id).await.expect("Failed to list snoozed");
402 + assert!(snoozed_list.is_empty());
403 + }
404 +
405 + #[tokio::test]
406 + async fn event_reminder_offsets_roundtrip() {
407 + let pool = common::setup_test_db().await;
408 + let user_id = common::create_test_user(&pool).await;
409 + let repo = SqliteEventRepository::new(pool);
410 +
411 + let start = Utc::now() + Duration::hours(2);
412 + let new_event = NewEvent {
413 + user_id: Some(user_id),
414 + project_id: None,
415 + title: "Reminded".to_string(),
416 + description: String::new(),
417 + start_time: start,
418 + end_time: None,
419 + location: None,
420 + linked_task_id: None,
421 + recurrence: Recurrence::None,
422 + recurrence_rule: None,
423 + contact_id: None,
424 + block_type: None,
425 + reminder_offsets_seconds: vec![0, 300, 900],
426 + };
427 + let created = repo.create(user_id, new_event).await.expect("create");
428 + assert_eq!(created.reminder_offsets_seconds, vec![0, 300, 900]);
429 +
430 + // Round-trip via get_by_id
431 + let fetched = repo.get_by_id(created.id, user_id).await.expect("get").expect("present");
432 + assert_eq!(fetched.reminder_offsets_seconds, vec![0, 300, 900]);
433 +
434 + // Update clears the offsets
435 + let update = goingson_core::UpdateEvent {
436 + project_id: None,
437 + title: "Reminded".to_string(),
438 + description: String::new(),
439 + start_time: start,
440 + end_time: None,
441 + location: None,
442 + linked_task_id: None,
443 + recurrence: Recurrence::None,
444 + recurrence_rule: None,
445 + contact_id: None,
446 + block_type: None,
447 + reminder_offsets_seconds: Vec::new(),
448 + };
449 + let updated = repo.update(created.id, user_id, update).await.expect("update").expect("present");
450 + assert!(updated.reminder_offsets_seconds.is_empty());
451 + }
@@ -0,0 +1,63 @@
1 + -- Event snooze: hide events from main calendar/list views until a date passes.
2 + -- Mirrors the existing task and email snooze pattern. Synced via existing
3 + -- sync_changelog triggers, which must be rebuilt to include the new column.
4 +
5 + ALTER TABLE events ADD COLUMN snoozed_until TEXT;
6 +
7 + -- ── Rebuild event sync triggers to include snoozed_until (18 cols total) ──
8 +
9 + DROP TRIGGER IF EXISTS sync_trg_events_insert;
10 + CREATE TRIGGER IF NOT EXISTS sync_trg_events_insert
11 + AFTER INSERT ON events
12 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
13 + BEGIN
14 + INSERT INTO sync_changelog (table_name, op, row_id, data)
15 + VALUES ('events', 'INSERT', NEW.id, json_object(
16 + 'id', NEW.id,
17 + 'project_id', NEW.project_id,
18 + 'title', NEW.title,
19 + 'description', NEW.description,
20 + 'start_time', NEW.start_time,
21 + 'end_time', NEW.end_time,
22 + 'location', NEW.location,
23 + 'user_id', NEW.user_id,
24 + 'linked_task_id', NEW.linked_task_id,
25 + 'recurrence', NEW.recurrence,
26 + 'recurrence_parent_id', NEW.recurrence_parent_id,
27 + 'recurrence_rule', NEW.recurrence_rule,
28 + 'contact_id', NEW.contact_id,
29 + 'block_type', NEW.block_type,
30 + 'external_source', NEW.external_source,
31 + 'external_id', NEW.external_id,
32 + 'is_read_only', NEW.is_read_only,
33 + 'snoozed_until', NEW.snoozed_until
34 + ));
35 + END;
36 +
37 + DROP TRIGGER IF EXISTS sync_trg_events_update;
38 + CREATE TRIGGER IF NOT EXISTS sync_trg_events_update
39 + AFTER UPDATE ON events
40 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
41 + BEGIN
42 + INSERT INTO sync_changelog (table_name, op, row_id, data)
43 + VALUES ('events', 'UPDATE', NEW.id, json_object(
44 + 'id', NEW.id,
45 + 'project_id', NEW.project_id,
46 + 'title', NEW.title,
47 + 'description', NEW.description,
48 + 'start_time', NEW.start_time,
49 + 'end_time', NEW.end_time,
50 + 'location', NEW.location,
51 + 'user_id', NEW.user_id,
52 + 'linked_task_id', NEW.linked_task_id,
53 + 'recurrence', NEW.recurrence,
54 + 'recurrence_parent_id', NEW.recurrence_parent_id,
55 + 'recurrence_rule', NEW.recurrence_rule,
56 + 'contact_id', NEW.contact_id,
57 + 'block_type', NEW.block_type,
58 + 'external_source', NEW.external_source,
59 + 'external_id', NEW.external_id,
60 + 'is_read_only', NEW.is_read_only,
61 + 'snoozed_until', NEW.snoozed_until
62 + ));
63 + END;
@@ -0,0 +1,66 @@
1 + -- Event reminders: per-event list of seconds before start_time to fire a
2 + -- desktop notification. Stored as a JSON array of non-negative integers,
3 + -- e.g. [0, 300, 900] for "at time", "5 minutes before", and "15 minutes
4 + -- before". NULL or "[]" means no reminders configured.
5 +
6 + ALTER TABLE events ADD COLUMN reminder_offsets_seconds TEXT;
7 +
8 + -- ── Rebuild event sync triggers to include reminder_offsets_seconds (19 cols) ──
9 +
10 + DROP TRIGGER IF EXISTS sync_trg_events_insert;
11 + CREATE TRIGGER IF NOT EXISTS sync_trg_events_insert
12 + AFTER INSERT ON events
13 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
14 + BEGIN
15 + INSERT INTO sync_changelog (table_name, op, row_id, data)
16 + VALUES ('events', 'INSERT', NEW.id, json_object(
17 + 'id', NEW.id,
18 + 'project_id', NEW.project_id,
19 + 'title', NEW.title,
20 + 'description', NEW.description,
21 + 'start_time', NEW.start_time,
22 + 'end_time', NEW.end_time,
23 + 'location', NEW.location,
24 + 'user_id', NEW.user_id,
25 + 'linked_task_id', NEW.linked_task_id,
26 + 'recurrence', NEW.recurrence,
27 + 'recurrence_parent_id', NEW.recurrence_parent_id,
28 + 'recurrence_rule', NEW.recurrence_rule,
29 + 'contact_id', NEW.contact_id,
30 + 'block_type', NEW.block_type,
31 + 'external_source', NEW.external_source,
32 + 'external_id', NEW.external_id,
33 + 'is_read_only', NEW.is_read_only,
34 + 'snoozed_until', NEW.snoozed_until,
35 + 'reminder_offsets_seconds', NEW.reminder_offsets_seconds
36 + ));
37 + END;
38 +
39 + DROP TRIGGER IF EXISTS sync_trg_events_update;
40 + CREATE TRIGGER IF NOT EXISTS sync_trg_events_update
41 + AFTER UPDATE ON events
42 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
43 + BEGIN
44 + INSERT INTO sync_changelog (table_name, op, row_id, data)
45 + VALUES ('events', 'UPDATE', NEW.id, json_object(
46 + 'id', NEW.id,
47 + 'project_id', NEW.project_id,
48 + 'title', NEW.title,
49 + 'description', NEW.description,
50 + 'start_time', NEW.start_time,
51 + 'end_time', NEW.end_time,
52 + 'location', NEW.location,
53 + 'user_id', NEW.user_id,
54 + 'linked_task_id', NEW.linked_task_id,
55 + 'recurrence', NEW.recurrence,
56 + 'recurrence_parent_id', NEW.recurrence_parent_id,
57 + 'recurrence_rule', NEW.recurrence_rule,
58 + 'contact_id', NEW.contact_id,
59 + 'block_type', NEW.block_type,
60 + 'external_source', NEW.external_source,
61 + 'external_id', NEW.external_id,
62 + 'is_read_only', NEW.is_read_only,
63 + 'snoozed_until', NEW.snoozed_until,
64 + 'reminder_offsets_seconds', NEW.reminder_offsets_seconds
65 + ));
66 + END;
@@ -109,6 +109,9 @@ const api = {
109 109 bulkDelete: (ids) => invoke('bulk_delete_events', { ids }),
110 110 listBetween: (start, end) => invoke('list_events_between', { start, end }),
111 111 getStatusIndicator: (leadMinutes) => invoke('get_event_status_indicator', { leadMinutes }),
112 + listSnoozed: () => invoke('list_snoozed_events'),
113 + snooze: (id, until) => invoke('snooze_event', { id, input: { until } }),
114 + unsnooze: (id) => invoke('unsnooze_event', { id }),
112 115 },
113 116
114 117 // Emails — CRUD + threading + status (read/archive/snooze/waiting)
@@ -10,6 +10,43 @@
10 10 const esc = GoingsOn.utils.escapeHtml;
11 11 const escAttr = GoingsOn.utils.escapeAttr;
12 12
13 + // ============ Reminder Presets ============
14 +
15 + // Common lead times users actually want; renders as a checkbox group in
16 + // the event form. Seconds-before-start_time, matching the backend column.
17 + const REMINDER_PRESETS = [
18 + { seconds: 0, label: 'At time of event' },
19 + { seconds: 300, label: '5 minutes before' },
20 + { seconds: 900, label: '15 minutes before' },
21 + { seconds: 1800, label: '30 minutes before' },
22 + { seconds: 3600, label: '1 hour before' },
23 + { seconds: 86400, label: '1 day before' },
24 + ];
25 +
26 + function buildRemindersHtml(event) {
27 + const selected = new Set((event?.reminderOffsetsSeconds || []).map(Number));
28 + const items = REMINDER_PRESETS.map(p => `
29 + <label class="form-checkbox-label reminder-option">
30 + <input type="checkbox" name="reminder_offset_${p.seconds}" value="${p.seconds}" ${selected.has(p.seconds) ? 'checked' : ''}>
31 + <span>${esc(p.label)}</span>
32 + </label>
33 + `).join('');
34 + return `
35 + <div class="form-group reminders-group">
36 + <label class="form-label">Reminders</label>
37 + <div class="form-hint">Desktop notifications fire at the chosen lead times.</div>
38 + <div class="reminder-options">${items}</div>
39 + </div>
40 + `;
41 + }
42 +
43 + function collectReminderOffsets(form) {
44 + if (!form) return [];
45 + return REMINDER_PRESETS
46 + .filter(p => form.elements[`reminder_offset_${p.seconds}`]?.checked)
47 + .map(p => p.seconds);
48 + }
49 +
13 50 // ============ Virtual Scroller Instances ============
14 51 let upcomingEventsScroller = null;
15 52 let pastEventsScroller = null;
@@ -419,6 +456,7 @@
419 456 entityType: 'event',
420 457 isEdit: false,
421 458 fields: getEventFormFields(),
459 + extraContent: buildRemindersHtml(null),
422 460 onSubmit: create,
423 461 });
424 462 GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence');
@@ -438,12 +476,12 @@
438 476 fields: getEventFormFields(null, projectId),
439 477 presetData: { project_id: projectId },
440 478 onSubmit: create,
441 - extraContent: project ? `
479 + extraContent: (project ? `
442 480 <div class="form-group">
443 481 <label class="form-label">Project</label>
444 482 <input type="text" class="form-input" value="${esc(project.name)}" disabled>
445 483 </div>
446 - ` : '',
484 + ` : '') + buildRemindersHtml(null),
447 485 });
448 486 GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence');
449 487 }
@@ -466,6 +504,7 @@
466 504 recurrenceRule,
467 505 contactId: data.contact_id || null,
468 506 blockType: data.block_type || null,
507 + reminderOffsetsSeconds: collectReminderOffsets(form),
469 508 };
470 509
471 510 const reloadFns = [load];
@@ -491,16 +530,35 @@
491 530 const event = await GoingsOn.api.events.get(id);
492 531 if (!event) return;
493 532
533 + const snoozeUntilLabel = event.isSnoozed && event.snoozedUntil
534 + ? GoingsOn.snooze.formatTime(event.snoozedUntil)
535 + : null;
536 + const snoozeButton = event.isSnoozed
537 + ? `<button class="btn btn-secondary" onclick="GoingsOn.snooze.unsnooze('event', '${escAttr(id)}')">Unsnooze</button>`
538 + : `<button class="btn btn-secondary" onclick="GoingsOn.snooze.openModal('event', '${escAttr(id)}')">Snooze</button>`;
539 + const snoozeStatus = snoozeUntilLabel
540 + ? `<p><strong>Snoozed until:</strong> ${esc(snoozeUntilLabel)}</p>`
541 + : '';
542 + const reminders = event.reminderOffsetsSeconds || [];
543 + const reminderLabels = reminders
544 + .map(s => REMINDER_PRESETS.find(p => p.seconds === s)?.label || `${s} seconds before`)
545 + .join(', ');
546 + const reminderStatus = reminders.length
547 + ? `<p><strong>Reminders:</strong> ${esc(reminderLabels)}</p>`
548 + : '';
494 549 const content = `
495 550 <div style="margin-bottom: 1rem;">
496 551 <h3>${esc(event.title)}</h3>
497 552 <div class="markdown-content">${event.descriptionHtml || ''}</div>
498 553 <p><strong>When:</strong> ${event.timeFormatted}</p>
499 554 ${event.location ? `<p><strong>Where:</strong> ${esc(event.location)}</p>` : ''}
555 + ${snoozeStatus}
556 + ${reminderStatus}
500 557 </div>
501 558 <div class="form-actions">
502 559 <button class="btn btn-secondary" style="color: var(--accent-red);" onclick="GoingsOn.events.delete('${escAttr(id)}')">Delete</button>
503 560 <div style="flex: 1"></div>
561 + ${snoozeButton}
504 562 <button class="btn btn-secondary" onclick="GoingsOn.events.openEdit('${escAttr(id)}')">Edit</button>
505 563 <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
506 564 </div>
@@ -570,6 +628,7 @@
570 628 isEdit: true,
571 629 entityId: id,
572 630 fields: getEventFormFields(event),
631 + extraContent: buildRemindersHtml(event),
573 632 onSubmit: (data) => update(id, data),
574 633 });
575 634 GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence');
@@ -596,6 +655,7 @@
596 655 recurrenceRule,
597 656 contactId: data.contact_id || null,
598 657 blockType: data.block_type || null,
658 + reminderOffsetsSeconds: collectReminderOffsets(form),
599 659 };
600 660
601 661 GoingsOn.cache.invalidate('events');
@@ -1,6 +1,6 @@
1 1 /**
2 2 * GoingsOn - Snooze Module
3 - * Snooze functionality for tasks and emails
3 + * Snooze functionality for tasks, emails, and events
4 4 */
5 5
6 6 (function() {
@@ -8,11 +8,25 @@
8 8 const esc = GoingsOn.utils.escapeHtml;
9 9 const escAttr = GoingsOn.utils.escapeAttr;
10 10
11 + const ITEM_LABEL = { task: 'Task', email: 'Email', event: 'Event' };
12 +
13 + function apiFor(itemType) {
14 + if (itemType === 'task') return GoingsOn.api.tasks;
15 + if (itemType === 'email') return GoingsOn.api.emails;
16 + return GoingsOn.api.events;
17 + }
18 +
19 + function reloadFor(itemType) {
20 + if (itemType === 'task') return GoingsOn.tasks.load();
21 + if (itemType === 'email') return GoingsOn.emails.load();
22 + return GoingsOn.events.load();
23 + }
24 +
11 25 // ============ Snooze Functions ============
12 26
13 27 /**
14 28 * Open the snooze modal with pre-computed time options.
15 - * @param {string} itemType - 'task' or 'email'
29 + * @param {string} itemType - 'task', 'email', or 'event'
16 30 * @param {string} id - Item ID to snooze
17 31 */
18 32 async function openSnoozeModal(itemType, id) {
@@ -45,8 +59,8 @@
45 59 </div>
46 60 </div>`;
47 61
48 - const label = itemType === 'task' ? 'Task' : 'Email';
49 - const hintHtml = `<p style="font-size: 0.8rem; color: var(--text-secondary); margin: 0 0 0.75rem 0;">Hide this ${label.toLowerCase()} and bring it back at the chosen time.</p>`;
62 + const label = ITEM_LABEL[itemType] || 'Item';
63 + const hintHtml = `<p class="snooze-hint">Hide this ${label.toLowerCase()} and bring it back at the chosen time.</p>`;
50 64 GoingsOn.ui.openModal(`Snooze ${label}`, hintHtml + optionsHtml);
51 65 }
52 66
@@ -62,22 +76,18 @@
62 76 }
63 77
64 78 /**
65 - * Snooze a task or email until the specified time.
66 - * @param {string} itemType - 'task' or 'email'
79 + * Snooze a task, email, or event until the specified time.
80 + * @param {string} itemType - 'task', 'email', or 'event'
67 81 * @param {string} id - Item ID to snooze
68 82 * @param {string} until - ISO 8601 datetime to snooze until
69 83 */
70 84 async function snoozeItem(itemType, id, until) {
85 + const label = ITEM_LABEL[itemType] || 'Item';
71 86 try {
72 - if (itemType === 'task') {
73 - await GoingsOn.api.tasks.snooze(id, until);
74 - GoingsOn.tasks.load();
75 - } else {
76 - await GoingsOn.api.emails.snooze(id, until);
77 - GoingsOn.emails.load();
78 - }
87 + await apiFor(itemType).snooze(id, until);
88 + reloadFor(itemType);
79 89 GoingsOn.ui.closeModal();
80 - GoingsOn.ui.showToast(`${itemType === 'task' ? 'Task' : 'Email'} snoozed until ${formatSnoozeTime(until)}`);
90 + GoingsOn.ui.showToast(`${label} snoozed until ${formatSnoozeTime(until)}`);
81 91 } catch (err) {
82 92 GoingsOn.ui.showToast('Failed to snooze: ' + GoingsOn.utils.getErrorMessage(err), 'error');
83 93 }
@@ -85,7 +95,7 @@
85 95
86 96 /**
87 97 * Snooze using the custom datetime-local picker value.
88 - * @param {string} itemType - 'task' or 'email'
98 + * @param {string} itemType - 'task', 'email', or 'event'
89 99 * @param {string} id - Item ID to snooze
90 100 */
91 101 async function snoozeItemCustom(itemType, id) {
@@ -104,22 +114,15 @@
104 114 }
105 115
106 116 /**
107 - * Remove snooze from a task or email.
108 - * @param {string} itemType - 'task' or 'email'
117 + * Remove snooze from a task, email, or event.
118 + * @param {string} itemType - 'task', 'email', or 'event'
109 119 * @param {string} id - Item ID to unsnooze
110 120 */
111 121 async function unsnoozeItem(itemType, id) {
112 - const isTask = itemType === 'task';
113 - const label = isTask ? 'Task' : 'Email';
114 -
122 + const label = ITEM_LABEL[itemType] || 'Item';
115 123 try {
116 - if (isTask) {
117 - await GoingsOn.api.tasks.unsnooze(id);
118 - GoingsOn.tasks.load();
119 - } else {
120 - await GoingsOn.api.emails.unsnooze(id);
121 - GoingsOn.emails.load();
122 - }
124 + await apiFor(itemType).unsnooze(id);
125 + reloadFor(itemType);
123 126 GoingsOn.ui.closeModal();
124 127 GoingsOn.ui.showToast(`${label} unsnoozed`);
125 128 } catch (err) {
@@ -202,6 +202,7 @@ pub async fn schedule_task(
202 202 recurrence_rule: None,
203 203 contact_id: task.contact_id,
204 204 block_type: None,
205 + reminder_offsets_seconds: Vec::new(),
205 206 };
206 207 state.events
207 208 .update(existing.id, DESKTOP_USER_ID, update_event)
@@ -220,6 +221,7 @@ pub async fn schedule_task(
220 221 recurrence_rule: None,
221 222 contact_id: task.contact_id,
222 223 block_type: None,
224 + reminder_offsets_seconds: Vec::new(),
223 225 };
224 226 state.events
225 227 .create(DESKTOP_USER_ID, new_event)
@@ -43,6 +43,9 @@ pub struct EventInput {
43 43 pub block_type: Option<String>,
44 44 /// Rich recurrence configuration (JSON).
45 45 pub recurrence_rule: Option<RecurrenceRule>,
46 + /// Seconds-before-start_time to fire reminders. Empty / omitted = none.
47 + #[serde(default)]
48 + pub reminder_offsets_seconds: Vec<i64>,
46 49 }
47 50
48 51 #[derive(Debug, Serialize)]
@@ -85,6 +88,12 @@ pub struct EventResponse {
85 88 pub status: String,
86 89 /// Human-readable status label: "Past", "Happening now", "Today", "Upcoming"
87 90 pub status_label: String,
91 + /// True if `snoozed_until` is in the future.
92 + pub is_snoozed: bool,
93 + /// When the event is snoozed until, if any.
94 + pub snoozed_until: Option<DateTime<Utc>>,
95 + /// Seconds-before-start_time at which reminders fire.
96 + pub reminder_offsets_seconds: Vec<i64>,
88 97 }
89 98
90 99 impl From<Event> for EventResponse {
@@ -151,6 +160,9 @@ impl From<Event> for EventResponse {
151 160 .map(|r| r.display())
152 161 .unwrap_or_default();
153 162 let recurrence_str = e.recurrence.as_str().to_string();
163 + let is_snoozed = e.is_snoozed();
164 + let snoozed_until = e.snoozed_until;
165 + let reminder_offsets_seconds = e.reminder_offsets_seconds.clone();
154 166
155 167 EventResponse {
156 168 id: e.id,
@@ -179,10 +191,23 @@ impl From<Event> for EventResponse {
179 191 end_time_epoch,
180 192 status,
181 193 status_label,
194 + is_snoozed,
195 + snoozed_until,
196 + reminder_offsets_seconds,
182 197 }
183 198 }
184 199 }
185 200
201 + /// Drop negative offsets, dedupe, and cap to a reasonable count so a misbehaving
202 + /// frontend can't push hundreds of reminders into one event.
203 + fn sanitize_reminder_offsets(input: &[i64]) -> Vec<i64> {
204 + let mut offsets: Vec<i64> = input.iter().copied().filter(|s| *s >= 0).collect();
205 + offsets.sort_unstable();
206 + offsets.dedup();
207 + offsets.truncate(8);
208 + offsets
209 + }
210 +
186 211 // ============ Recurrence Expansion ============
187 212
188 213 /// Expand recurring events for a date range and merge with non-recurring events.
@@ -328,6 +353,7 @@ pub async fn create_event(state: State<'_, Arc<AppState>>, input: EventInput) ->
328 353 recurrence_rule: input.recurrence_rule.clone(),
329 354 contact_id: input.contact_id,
330 355 block_type,
356 + reminder_offsets_seconds: sanitize_reminder_offsets(&input.reminder_offsets_seconds),
331 357 };
332 358
333 359 new_event.validate()?;
@@ -373,6 +399,7 @@ pub async fn update_event(state: State<'_, Arc<AppState>>, id: EventId, input: E
373 399 recurrence_rule: input.recurrence_rule.clone(),
374 400 contact_id: input.contact_id,
375 401 block_type,
402 + reminder_offsets_seconds: sanitize_reminder_offsets(&input.reminder_offsets_seconds),
376 403 };
377 404
378 405 update_event.validate()?;
@@ -545,3 +572,76 @@ pub async fn get_event_status_indicator(
545 572 })
546 573 }
547 574 }
575 +
576 + // ============ Snooze Commands ============
577 +
578 + use super::SnoozeInput;
579 +
580 + /// Lists all currently snoozed events.
581 + #[tauri::command]
582 + #[instrument(skip_all)]
583 + pub async fn list_snoozed_events(state: State<'_, Arc<AppState>>) -> Result<Vec<EventResponse>, ApiError> {
584 + let events = state.events.list_snoozed(DESKTOP_USER_ID).await?;
585 + Ok(events.into_iter().map(EventResponse::from).collect())
586 + }
587 +
588 + /// Snoozes an event until the specified date/time.
589 + ///
590 + /// Snoozed events are hidden from the main list view until their snooze expires.
591 + /// The change applies to the template; recurring instances inherit the snooze.
592 + #[tauri::command]
593 + #[instrument(skip_all)]
594 + pub async fn snooze_event(
595 + state: State<'_, Arc<AppState>>,
596 + id: EventId,
597 + input: SnoozeInput,
598 + ) -> Result<EventResponse, ApiError> {
599 + let event = state.events
600 + .snooze(id, DESKTOP_USER_ID, input.until)
601 + .await?
602 + .or_not_found("event", id)?;
603 + Ok(EventResponse::from(event))
604 + }
605 +
606 + /// Removes the snooze from an event.
607 + #[tauri::command]
608 + #[instrument(skip_all)]
609 + pub async fn unsnooze_event(
610 + state: State<'_, Arc<AppState>>,
611 + id: EventId,
612 + ) -> Result<EventResponse, ApiError> {
613 + let event = state.events
614 + .unsnooze(id, DESKTOP_USER_ID)
615 + .await?
616 + .or_not_found("event", id)?;
617 + Ok(EventResponse::from(event))
618 + }
619 +
620 + #[cfg(test)]
621 + mod sanitize_tests {
622 + use super::sanitize_reminder_offsets;
623 +
624 + #[test]
625 + fn drops_negative() {
626 + assert_eq!(sanitize_reminder_offsets(&[-1, 0, 60, -100]), vec![0, 60]);
627 + }
628 +
629 + #[test]
630 + fn dedupes_and_sorts() {
631 + assert_eq!(sanitize_reminder_offsets(&[60, 0, 60, 300]), vec![0, 60, 300]);
632 + }
633 +
634 + #[test]
635 + fn caps_to_eight() {
636 + let many: Vec<i64> = (0..20).map(|i| i * 60).collect();
637 + let out = sanitize_reminder_offsets(&many);
638 + assert_eq!(out.len(), 8);
639 + assert_eq!(out[0], 0);
640 + assert_eq!(out[7], 7 * 60);
641 + }
642 +
643 + #[test]
644 + fn empty_stays_empty() {
645 + assert!(sanitize_reminder_offsets(&[]).is_empty());
646 + }
647 + }
@@ -319,6 +319,7 @@ pub async fn import_ics(
319 319 recurrence: parsed.recurrence,
320 320 recurrence_rule: None,
321 321 block_type: None,
322 + reminder_offsets_seconds: Vec::new(),
322 323 };
323 324
324 325 match state.events.create(DESKTOP_USER_ID, new_event).await {
@@ -74,6 +74,7 @@ async fn update_event() {
74 74 recurrence: Recurrence::None,
75 75 recurrence_rule: None,
76 76 block_type: None,
77 + reminder_offsets_seconds: Vec::new(),
77 78 };
78 79
79 80 let updated = state
@@ -126,6 +126,8 @@ mod tests {
126 126 external_source: None,
127 127 external_id: None,
128 128 is_read_only: false,
129 + snoozed_until: None,
130 + reminder_offsets_seconds: Vec::new(),
129 131 }
130 132 }
131 133
@@ -150,6 +150,9 @@ pub fn build_mobile_app() -> tauri::Builder<tauri::Wry> {
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 @@ fn main() {
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 @@ const CHECK_INTERVAL_SECS: u64 = 60;
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 @@ 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,6 +81,11 @@ pub async fn start_snooze_watcher(app: tauri::AppHandle, cancel: CancellationTok
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 @@ pub async fn start_snooze_watcher(app: tauri::AppHandle, cancel: CancellationTok
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 @@ async fn check_overdue_responses(
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 @@ mod tests {
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 + }
@@ -36,7 +36,8 @@ pub(crate) fn table_columns(table: &str) -> Option<&'static [&'static str]> {
36 36 "events" => Some(&[
37 37 "id", "project_id", "title", "description", "start_time", "end_time", "location",
38 38 "user_id", "linked_task_id", "recurrence", "recurrence_parent_id", "contact_id",
39 - "block_type", "external_source", "external_id", "is_read_only",
39 + "block_type", "external_source", "external_id", "is_read_only", "snoozed_until",
40 + "reminder_offsets_seconds",
40 41 ]),
41 42 "contacts" => Some(&[
42 43 "id", "user_id", "display_name", "nickname", "company", "title", "notes", "tags",