max / goingson
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", |