Add comprehensive reminder/alarm support to calendar events
- Backend: Parse VALARM components from CalDAV iCalendar data - Backend: Add EventReminder struct with minutes_before, action, and description - Backend: Support Display, Email, and Audio reminder types - Backend: Parse ISO 8601 duration triggers (-PT15M, -P1D, etc.) - Frontend: Add reminders field to CalendarEvent structure - Frontend: Display reminders in event modal with human-readable formatting - Frontend: Show reminder timing (15 minutes before, 1 day before) and action type - Fix: Update Trunk.toml to properly copy CSS files to dist directory 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
		| @@ -1,6 +1,6 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use crate::services::CalendarEvent; | ||||
| use crate::services::{CalendarEvent, EventReminder, ReminderAction}; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct EventModalProps { | ||||
| @@ -39,6 +39,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                             <strong>{"Title:"}</strong> | ||||
|                             <span>{event.get_title()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref description) = event.description { | ||||
|                                 html! { | ||||
| @@ -51,22 +52,12 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                         { | ||||
|                             if let Some(ref location) = event.location { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Location:"}</strong> | ||||
|                                         <span>{location}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Start:"}</strong> | ||||
|                             <span>{format_datetime(&event.start, event.all_day)}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref end) = event.end { | ||||
|                                 html! { | ||||
| @@ -79,10 +70,140 @@ pub fn EventModal(props: &EventModalProps) -> Html { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"All Day:"}</strong> | ||||
|                             <span>{if event.all_day { "Yes" } else { "No" }}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref location) = event.location { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Location:"}</strong> | ||||
|                                         <span>{location}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Status:"}</strong> | ||||
|                             <span>{&event.status}</span> | ||||
|                             <span>{event.get_status_display()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Privacy:"}</strong> | ||||
|                             <span>{event.get_class_display()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         <div class="event-detail"> | ||||
|                             <strong>{"Priority:"}</strong> | ||||
|                             <span>{event.get_priority_display()}</span> | ||||
|                         </div> | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref organizer) = event.organizer { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Organizer:"}</strong> | ||||
|                                         <span>{organizer}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if !event.attendees.is_empty() { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Attendees:"}</strong> | ||||
|                                         <span>{event.attendees.join(", ")}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if !event.categories.is_empty() { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Categories:"}</strong> | ||||
|                                         <span>{event.categories.join(", ")}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref recurrence) = event.recurrence_rule { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Repeats:"}</strong> | ||||
|                                         <span>{format_recurrence_rule(recurrence)}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Repeats:"}</strong> | ||||
|                                         <span>{"No"}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if !event.reminders.is_empty() { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Reminders:"}</strong> | ||||
|                                         <span>{format_reminders(&event.reminders)}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Reminders:"}</strong> | ||||
|                                         <span>{"None"}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref created) = event.created { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Created:"}</strong> | ||||
|                                         <span>{format_datetime(created, false)}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                          | ||||
|                         { | ||||
|                             if let Some(ref modified) = event.last_modified { | ||||
|                                 html! { | ||||
|                                     <div class="event-detail"> | ||||
|                                         <strong>{"Last Modified:"}</strong> | ||||
|                                         <span>{format_datetime(modified, false)}</span> | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! {} | ||||
|                             } | ||||
|                         } | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
| @@ -98,4 +219,71 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String { | ||||
|     } else { | ||||
|         dt.format("%B %d, %Y at %I:%M %p").to_string() | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn format_recurrence_rule(rrule: &str) -> String { | ||||
|     // Basic parsing of RRULE to display user-friendly text | ||||
|     if rrule.contains("FREQ=DAILY") { | ||||
|         "Daily".to_string() | ||||
|     } else if rrule.contains("FREQ=WEEKLY") { | ||||
|         "Weekly".to_string() | ||||
|     } else if rrule.contains("FREQ=MONTHLY") { | ||||
|         "Monthly".to_string() | ||||
|     } else if rrule.contains("FREQ=YEARLY") { | ||||
|         "Yearly".to_string() | ||||
|     } else { | ||||
|         // Show the raw rule if we can't parse it | ||||
|         format!("Custom ({})", rrule) | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn format_reminders(reminders: &[EventReminder]) -> String { | ||||
|     if reminders.is_empty() { | ||||
|         return "None".to_string(); | ||||
|     } | ||||
|      | ||||
|     let formatted_reminders: Vec<String> = reminders | ||||
|         .iter() | ||||
|         .map(|reminder| { | ||||
|             let time_text = if reminder.minutes_before == 0 { | ||||
|                 "At event time".to_string() | ||||
|             } else if reminder.minutes_before < 60 { | ||||
|                 format!("{} minutes before", reminder.minutes_before) | ||||
|             } else if reminder.minutes_before == 60 { | ||||
|                 "1 hour before".to_string() | ||||
|             } else if reminder.minutes_before % 60 == 0 { | ||||
|                 format!("{} hours before", reminder.minutes_before / 60) | ||||
|             } else if reminder.minutes_before < 1440 { | ||||
|                 let hours = reminder.minutes_before / 60; | ||||
|                 let minutes = reminder.minutes_before % 60; | ||||
|                 format!("{}h {}m before", hours, minutes) | ||||
|             } else if reminder.minutes_before == 1440 { | ||||
|                 "1 day before".to_string() | ||||
|             } else if reminder.minutes_before % 1440 == 0 { | ||||
|                 format!("{} days before", reminder.minutes_before / 1440) | ||||
|             } else { | ||||
|                 let days = reminder.minutes_before / 1440; | ||||
|                 let remaining_minutes = reminder.minutes_before % 1440; | ||||
|                 let hours = remaining_minutes / 60; | ||||
|                 let minutes = remaining_minutes % 60; | ||||
|                 if hours > 0 { | ||||
|                     format!("{}d {}h before", days, hours) | ||||
|                 } else if minutes > 0 { | ||||
|                     format!("{}d {}m before", days, minutes) | ||||
|                 } else { | ||||
|                     format!("{} days before", days) | ||||
|                 } | ||||
|             }; | ||||
|              | ||||
|             let action_text = match reminder.action { | ||||
|                 ReminderAction::Display => "notification", | ||||
|                 ReminderAction::Email => "email", | ||||
|                 ReminderAction::Audio => "sound", | ||||
|             }; | ||||
|              | ||||
|             format!("{} ({})", time_text, action_text) | ||||
|         }) | ||||
|         .collect(); | ||||
|      | ||||
|     formatted_reminders.join(", ") | ||||
| } | ||||
| @@ -5,6 +5,26 @@ use wasm_bindgen_futures::JsFuture; | ||||
| use web_sys::{Request, RequestInit, RequestMode, Response}; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct EventReminder { | ||||
|     pub minutes_before: i32, | ||||
|     pub action: ReminderAction, | ||||
|     pub description: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub enum ReminderAction { | ||||
|     Display, | ||||
|     Email, | ||||
|     Audio, | ||||
| } | ||||
|  | ||||
| impl Default for ReminderAction { | ||||
|     fn default() -> Self { | ||||
|         ReminderAction::Display | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub struct CalendarEvent { | ||||
|     pub uid: String, | ||||
| @@ -13,8 +33,45 @@ pub struct CalendarEvent { | ||||
|     pub start: DateTime<Utc>, | ||||
|     pub end: Option<DateTime<Utc>>, | ||||
|     pub location: Option<String>, | ||||
|     pub status: String, | ||||
|     pub status: EventStatus, | ||||
|     pub class: EventClass, | ||||
|     pub priority: Option<u8>, | ||||
|     pub organizer: Option<String>, | ||||
|     pub attendees: Vec<String>, | ||||
|     pub categories: Vec<String>, | ||||
|     pub created: Option<DateTime<Utc>>, | ||||
|     pub last_modified: Option<DateTime<Utc>>, | ||||
|     pub recurrence_rule: Option<String>, | ||||
|     pub all_day: bool, | ||||
|     pub reminders: Vec<EventReminder>, | ||||
|     pub etag: Option<String>, | ||||
|     pub href: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub enum EventStatus { | ||||
|     Tentative, | ||||
|     Confirmed, | ||||
|     Cancelled, | ||||
| } | ||||
|  | ||||
| impl Default for EventStatus { | ||||
|     fn default() -> Self { | ||||
|         EventStatus::Confirmed | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub enum EventClass { | ||||
|     Public, | ||||
|     Private, | ||||
|     Confidential, | ||||
| } | ||||
|  | ||||
| impl Default for EventClass { | ||||
|     fn default() -> Self { | ||||
|         EventClass::Public | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl CalendarEvent { | ||||
| @@ -31,6 +88,38 @@ impl CalendarEvent { | ||||
|     pub fn get_title(&self) -> String { | ||||
|         self.summary.clone().unwrap_or_else(|| "Untitled Event".to_string()) | ||||
|     } | ||||
|      | ||||
|     /// Get display string for status | ||||
|     pub fn get_status_display(&self) -> &'static str { | ||||
|         match self.status { | ||||
|             EventStatus::Tentative => "Tentative", | ||||
|             EventStatus::Confirmed => "Confirmed", | ||||
|             EventStatus::Cancelled => "Cancelled", | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Get display string for class | ||||
|     pub fn get_class_display(&self) -> &'static str { | ||||
|         match self.class { | ||||
|             EventClass::Public => "Public", | ||||
|             EventClass::Private => "Private", | ||||
|             EventClass::Confidential => "Confidential", | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Get display string for priority | ||||
|     pub fn get_priority_display(&self) -> String { | ||||
|         match self.priority { | ||||
|             None => "Not set".to_string(), | ||||
|             Some(0) => "Undefined".to_string(), | ||||
|             Some(1) => "High".to_string(), | ||||
|             Some(p) if p <= 4 => "High".to_string(), | ||||
|             Some(5) => "Medium".to_string(), | ||||
|             Some(p) if p <= 8 => "Low".to_string(), | ||||
|             Some(9) => "Low".to_string(), | ||||
|             Some(p) => format!("Priority {}", p), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub struct CalendarService { | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| pub mod calendar_service; | ||||
|  | ||||
| pub use calendar_service::{CalendarService, CalendarEvent}; | ||||
| pub use calendar_service::{CalendarService, CalendarEvent, EventStatus, EventClass, EventReminder, ReminderAction}; | ||||
		Reference in New Issue
	
	Block a user
	 Connor Johnstone
					Connor Johnstone