From e56253b9c238b11f7b41146bc46f10b893ade3b3 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Wed, 3 Sep 2025 12:21:46 -0400 Subject: [PATCH] Fix all-day recurring events RFC-5545 compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set all_day flag properly when creating VEvent in series handler - Improve all-day event detection using VALUE=DATE parameter - Add RFC-5545 compliance for exclusive end dates (backend adds 1 day) - Fix end date display in event modal (frontend subtracts 1 day for display) - Fix recurring all-day event expansion to maintain proper end date pattern 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/calendar.rs | 11 ++++++----- backend/src/handlers/events.rs | 16 ++++++++++++++-- backend/src/handlers/series.rs | 1 + frontend/src/components/event_modal.rs | 13 ++++++++++++- frontend/src/services/calendar_service.rs | 12 ++++++++++-- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/backend/src/calendar.rs b/backend/src/calendar.rs index c157697..741b511 100644 --- a/backend/src/calendar.rs +++ b/backend/src/calendar.rs @@ -361,11 +361,12 @@ impl CalDAVClient { None }; - // Determine if it's an all-day event - let all_day = properties - .get("DTSTART") - .map(|s| !s.contains("T")) - .unwrap_or(false); + // Determine if it's an all-day event by checking for VALUE=DATE parameter + let empty_string = String::new(); + let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string); + let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8); + + eprintln!("🔍 DTSTART parsing: '{}' -> all_day: {}", dtstart_raw, all_day); // Parse status let status = properties diff --git a/backend/src/handlers/events.rs b/backend/src/handlers/events.rs index 0010720..0290d6d 100644 --- a/backend/src/handlers/events.rs +++ b/backend/src/handlers/events.rs @@ -458,9 +458,15 @@ pub async fn create_event( parse_event_datetime(&request.start_date, &request.start_time, request.all_day) .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; - let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) + let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; + // For all-day events, add one day to end date for RFC-5545 compliance + // RFC-5545 uses exclusive end dates for all-day events + if request.all_day { + end_datetime = end_datetime + chrono::Duration::days(1); + } + // Validate that end is after start (allow equal times for all-day events) if request.all_day { if end_datetime < start_datetime { @@ -756,9 +762,15 @@ pub async fn update_event( parse_event_datetime(&request.start_date, &request.start_time, request.all_day) .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; - let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) + let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; + // For all-day events, add one day to end date for RFC-5545 compliance + // RFC-5545 uses exclusive end dates for all-day events + if request.all_day { + end_datetime = end_datetime + chrono::Duration::days(1); + } + // Validate that end is after start (allow equal times for all-day events) if request.all_day { if end_datetime < start_datetime { diff --git a/backend/src/handlers/series.rs b/backend/src/handlers/series.rs index 945881b..92082b5 100644 --- a/backend/src/handlers/series.rs +++ b/backend/src/handlers/series.rs @@ -175,6 +175,7 @@ pub async fn create_event_series( // Create the VEvent for the series let mut event = VEvent::new(uid.clone(), start_datetime); event.dtend = Some(end_datetime); + event.all_day = request.all_day; // Set the all_day flag properly event.summary = if request.title.trim().is_empty() { None } else { diff --git a/frontend/src/components/event_modal.rs b/frontend/src/components/event_modal.rs index 941c249..bc1723e 100644 --- a/frontend/src/components/event_modal.rs +++ b/frontend/src/components/event_modal.rs @@ -63,7 +63,7 @@ pub fn EventModal(props: &EventModalProps) -> Html { html! {
{"End:"} - {format_datetime(end, event.all_day)} + {format_datetime_end(end, event.all_day)}
} } else { @@ -221,6 +221,17 @@ fn format_datetime(dt: &DateTime, all_day: bool) -> String { } } +fn format_datetime_end(dt: &DateTime, all_day: bool) -> String { + if all_day { + // For all-day events, subtract one day from end date for display + // RFC-5545 uses exclusive end dates, but users expect inclusive display + let display_date = *dt - chrono::Duration::days(1); + display_date.format("%B %d, %Y").to_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") { diff --git a/frontend/src/services/calendar_service.rs b/frontend/src/services/calendar_service.rs index 7a7d7ea..428bd5f 100644 --- a/frontend/src/services/calendar_service.rs +++ b/frontend/src/services/calendar_service.rs @@ -439,9 +439,17 @@ impl CalendarService { let mut occurrence_event = base_event.clone(); occurrence_event.dtstart = occurrence_datetime; occurrence_event.dtstamp = chrono::Utc::now(); // Update DTSTAMP for each occurrence + - if let Some(end) = base_event.dtend { - occurrence_event.dtend = Some(end + Duration::days(days_diff)); + if let Some(base_end) = base_event.dtend { + if base_event.all_day { + // For all-day events, maintain the RFC-5545 end date pattern + // End date should always be exactly one day after start date + occurrence_event.dtend = Some(occurrence_datetime + Duration::days(1)); + } else { + // For timed events, preserve the original duration + occurrence_event.dtend = Some(base_end + Duration::days(days_diff)); + } } occurrences.push(occurrence_event);