From 1c4857ccad4c0048ca70863a71e2d799477d063b Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Thu, 28 Aug 2025 17:37:30 -0400 Subject: [PATCH] Add comprehensive reminder/alarm support to calendar events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Trunk.toml | 8 +- backend/calendar.db | Bin 0 -> 20480 bytes backend/src/calendar.rs | 99 +++++++++++++- backend/src/debug_caldav.rs | 61 +++++++++ src/components/event_modal.rs | 216 +++++++++++++++++++++++++++++-- src/services/calendar_service.rs | 91 ++++++++++++- src/services/mod.rs | 2 +- test_backend_url.js | 1 + 8 files changed, 458 insertions(+), 20 deletions(-) create mode 100644 backend/calendar.db create mode 100644 backend/src/debug_caldav.rs create mode 100644 test_backend_url.js diff --git a/Trunk.toml b/Trunk.toml index ca0054e..8273999 100644 --- a/Trunk.toml +++ b/Trunk.toml @@ -6,10 +6,14 @@ dist = "dist" BACKEND_API_URL = "http://localhost:3000/api" [watch] -watch = ["src", "Cargo.toml"] +watch = ["src", "Cargo.toml", "styles.css", "index.html"] ignore = ["backend/"] [serve] address = "127.0.0.1" port = 8080 -open = false \ No newline at end of file +open = false + +[[copy]] +from = "styles.css" +to = "dist/" \ No newline at end of file diff --git a/backend/calendar.db b/backend/calendar.db new file mode 100644 index 0000000000000000000000000000000000000000..4048a4d9d70477c9e8b2f9a5fcb1f9599633092c GIT binary patch literal 20480 zcmeI(Z)?*)90%~E*>oMcI2aTk4Dw*2L(`_0G;IgMx;35KvTkb=_vcBuBsW`Wnl|bF zJ)GeC5Z{2W#HZq05TA<=O%zweQ`dvIAB@KpflG&P?uuWzskwP>ZeDyZz7@~MO~UUGfB*y_ z009U<00Izz00bcL4+LJh{OwdK#lL>gr%s!lMl6hmO=7)flq^HCN^7Pe4NpmrW~HA> z%abhQ(319Qn>(f2k+f|bJ-HDynB1X08-?1{&3)UDs=Jm{wN3M8IP+<%Jz;E@M$vf? zdW|NHnxi?tzpxuJ+Gk!P&V0-9)~wAPLn<4Ul5JYjx?QUoRjV;LS+`0%dpFm0+Vv)% z{sY4D^3<)=(-hCOIv%@-dhIv|Z_v|zFdR3AxD9pKO)m@ML&-37YB@1JG&>9(U9amj=jd5Smvhvi zvZgX37YlAdc65zsZqf4;>hxLEALOfpewAHNzuRVMH}IE;vy>%ETiQ;h;=C|TR^B-B zE^X4wZ)nW#Zg(Z?5=}gx2f+}l_DKgb)PFB^TdQ#BcoI*7>=yZb6 zT@BrrL9-L}gU, + /// ETag from CalDAV server for conflict detection pub etag: Option, @@ -88,6 +91,27 @@ impl Default for EventClass { } } +/// Event reminder/alarm information +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EventReminder { + /// How long before the event to trigger the reminder (in minutes) + pub minutes_before: i32, + + /// Type of reminder action + pub action: ReminderAction, + + /// Optional description for the reminder + pub description: Option, +} + +/// Reminder action types +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ReminderAction { + Display, + Email, + Audio, +} + /// CalDAV client for fetching and parsing calendar events pub struct CalDAVClient { config: crate::config::CalDAVConfig, @@ -244,8 +268,8 @@ impl CalDAVClient { let mut properties: HashMap = HashMap::new(); // Extract all properties from the event - for property in event.properties { - properties.insert(property.name.to_uppercase(), property.value.unwrap_or_default()); + for property in &event.properties { + properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default()); } // Required UID field @@ -325,11 +349,82 @@ impl CalDAVClient { last_modified, recurrence_rule: properties.get("RRULE").cloned(), all_day, + reminders: self.parse_alarms(&event)?, etag: None, // Set by caller href: None, // Set by caller }) } + /// Parse VALARM components from an iCal event + fn parse_alarms(&self, event: &ical::parser::ical::component::IcalEvent) -> Result, CalDAVError> { + let mut reminders = Vec::new(); + + for alarm in &event.alarms { + if let Ok(reminder) = self.parse_single_alarm(alarm) { + reminders.push(reminder); + } + } + + Ok(reminders) + } + + /// Parse a single VALARM component into an EventReminder + fn parse_single_alarm(&self, alarm: &ical::parser::ical::component::IcalAlarm) -> Result { + let mut properties: HashMap = HashMap::new(); + + // Extract all properties from the alarm + for property in &alarm.properties { + properties.insert(property.name.to_uppercase(), property.value.clone().unwrap_or_default()); + } + + // Parse ACTION (required) + let action = match properties.get("ACTION").map(|s| s.to_uppercase()) { + Some(ref action_str) if action_str == "DISPLAY" => ReminderAction::Display, + Some(ref action_str) if action_str == "EMAIL" => ReminderAction::Email, + Some(ref action_str) if action_str == "AUDIO" => ReminderAction::Audio, + _ => ReminderAction::Display, // Default + }; + + // Parse TRIGGER (required) + let minutes_before = if let Some(trigger) = properties.get("TRIGGER") { + self.parse_trigger_duration(trigger).unwrap_or(15) // Default 15 minutes + } else { + 15 // Default 15 minutes + }; + + // Get description + let description = properties.get("DESCRIPTION").cloned(); + + Ok(EventReminder { + minutes_before, + action, + description, + }) + } + + /// Parse a TRIGGER duration string into minutes before event + fn parse_trigger_duration(&self, trigger: &str) -> Option { + // Basic parsing of ISO 8601 duration or relative time + // Examples: "-PT15M" (15 minutes before), "-P1D" (1 day before) + + if trigger.starts_with("-PT") && trigger.ends_with("M") { + // Parse "-PT15M" format (minutes) + let minutes_str = &trigger[3..trigger.len()-1]; + minutes_str.parse::().ok() + } else if trigger.starts_with("-PT") && trigger.ends_with("H") { + // Parse "-PT1H" format (hours) + let hours_str = &trigger[3..trigger.len()-1]; + hours_str.parse::().ok().map(|h| h * 60) + } else if trigger.starts_with("-P") && trigger.ends_with("D") { + // Parse "-P1D" format (days) + let days_str = &trigger[2..trigger.len()-1]; + days_str.parse::().ok().map(|d| d * 24 * 60) + } else { + // Try to parse as raw minutes + trigger.parse::().ok().map(|m| m.abs()) + } + } + /// Discover available calendar collections on the server pub async fn discover_calendars(&self) -> Result, CalDAVError> { // First, try to discover user calendars if we have a calendar path in config diff --git a/backend/src/debug_caldav.rs b/backend/src/debug_caldav.rs new file mode 100644 index 0000000..4e929c7 --- /dev/null +++ b/backend/src/debug_caldav.rs @@ -0,0 +1,61 @@ +use crate::calendar::CalDAVClient; +use crate::config::CalDAVConfig; + +pub async fn debug_caldav_fetch() -> Result<(), Box> { + let config = CalDAVConfig::from_env()?; + let client = CalDAVClient::new(config); + + println!("=== DEBUG: CalDAV Fetch ==="); + + // Discover calendars + let calendars = client.discover_calendars().await?; + println!("Found {} calendars: {:?}", calendars.len(), calendars); + + if let Some(calendar_path) = calendars.first() { + println!("Fetching events from: {}", calendar_path); + + // Make the raw REPORT request + let report_body = r#" + + + + + + + + + + +"#; + + let url = format!("{}{}", client.config.server_url.trim_end_matches('/'), calendar_path); + println!("Request URL: {}", url); + + let response = client.http_client + .request(reqwest::Method::from_bytes(b"REPORT").unwrap(), &url) + .header("Authorization", format!("Basic {}", client.config.get_basic_auth())) + .header("Content-Type", "application/xml") + .header("Depth", "1") + .header("User-Agent", "calendar-app/0.1.0") + .body(report_body) + .send() + .await?; + + println!("Response status: {}", response.status()); + let body = response.text().await?; + println!("Response body length: {}", body.len()); + println!("First 500 chars of response: {}", &body[..std::cmp::min(500, body.len())]); + + // Try to parse it + let events = client.parse_calendar_response(&body)?; + println!("Parsed {} events", events.len()); + + for (i, event) in events.iter().enumerate() { + println!("Event {}: {}", i+1, event.summary.as_deref().unwrap_or("No title")); + println!(" Start: {}", event.start); + println!(" UID: {}", event.uid); + } + } + + Ok(()) +} \ No newline at end of file diff --git a/src/components/event_modal.rs b/src/components/event_modal.rs index 3ad351a..6f02ef2 100644 --- a/src/components/event_modal.rs +++ b/src/components/event_modal.rs @@ -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 { {"Title:"} {event.get_title()} + { 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! { -
- {"Location:"} - {location} -
- } - } else { - html! {} - } - } +
{"Start:"} {format_datetime(&event.start, event.all_day)}
+ { if let Some(ref end) = event.end { html! { @@ -79,10 +70,140 @@ pub fn EventModal(props: &EventModalProps) -> Html { html! {} } } + +
+ {"All Day:"} + {if event.all_day { "Yes" } else { "No" }} +
+ + { + if let Some(ref location) = event.location { + html! { +
+ {"Location:"} + {location} +
+ } + } else { + html! {} + } + } +
{"Status:"} - {&event.status} + {event.get_status_display()}
+ +
+ {"Privacy:"} + {event.get_class_display()} +
+ +
+ {"Priority:"} + {event.get_priority_display()} +
+ + { + if let Some(ref organizer) = event.organizer { + html! { +
+ {"Organizer:"} + {organizer} +
+ } + } else { + html! {} + } + } + + { + if !event.attendees.is_empty() { + html! { +
+ {"Attendees:"} + {event.attendees.join(", ")} +
+ } + } else { + html! {} + } + } + + { + if !event.categories.is_empty() { + html! { +
+ {"Categories:"} + {event.categories.join(", ")} +
+ } + } else { + html! {} + } + } + + { + if let Some(ref recurrence) = event.recurrence_rule { + html! { +
+ {"Repeats:"} + {format_recurrence_rule(recurrence)} +
+ } + } else { + html! { +
+ {"Repeats:"} + {"No"} +
+ } + } + } + + { + if !event.reminders.is_empty() { + html! { +
+ {"Reminders:"} + {format_reminders(&event.reminders)} +
+ } + } else { + html! { +
+ {"Reminders:"} + {"None"} +
+ } + } + } + + { + if let Some(ref created) = event.created { + html! { +
+ {"Created:"} + {format_datetime(created, false)} +
+ } + } else { + html! {} + } + } + + { + if let Some(ref modified) = event.last_modified { + html! { +
+ {"Last Modified:"} + {format_datetime(modified, false)} +
+ } + } else { + html! {} + } + } @@ -98,4 +219,71 @@ fn format_datetime(dt: &DateTime, 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 = 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(", ") } \ No newline at end of file diff --git a/src/services/calendar_service.rs b/src/services/calendar_service.rs index 22ad37d..804c627 100644 --- a/src/services/calendar_service.rs +++ b/src/services/calendar_service.rs @@ -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, +} + +#[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, pub end: Option>, pub location: Option, - pub status: String, + pub status: EventStatus, + pub class: EventClass, + pub priority: Option, + pub organizer: Option, + pub attendees: Vec, + pub categories: Vec, + pub created: Option>, + pub last_modified: Option>, + pub recurrence_rule: Option, pub all_day: bool, + pub reminders: Vec, + pub etag: Option, + pub href: Option, +} + +#[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 { diff --git a/src/services/mod.rs b/src/services/mod.rs index cfbc455..ba16cd9 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,3 +1,3 @@ pub mod calendar_service; -pub use calendar_service::{CalendarService, CalendarEvent}; \ No newline at end of file +pub use calendar_service::{CalendarService, CalendarEvent, EventStatus, EventClass, EventReminder, ReminderAction}; \ No newline at end of file diff --git a/test_backend_url.js b/test_backend_url.js new file mode 100644 index 0000000..5f35c4f --- /dev/null +++ b/test_backend_url.js @@ -0,0 +1 @@ +console.log("Backend URL test");