Fix all-day recurring events RFC-5545 compliance

- 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 <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-09-03 12:21:46 -04:00
parent cb8cc7258c
commit e56253b9c2
5 changed files with 43 additions and 10 deletions

View File

@@ -361,11 +361,12 @@ impl CalDAVClient {
None None
}; };
// Determine if it's an all-day event // Determine if it's an all-day event by checking for VALUE=DATE parameter
let all_day = properties let empty_string = String::new();
.get("DTSTART") let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string);
.map(|s| !s.contains("T")) let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8);
.unwrap_or(false);
eprintln!("🔍 DTSTART parsing: '{}' -> all_day: {}", dtstart_raw, all_day);
// Parse status // Parse status
let status = properties let status = properties

View File

@@ -458,9 +458,15 @@ pub async fn create_event(
parse_event_datetime(&request.start_date, &request.start_time, request.all_day) parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; .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)))?; .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) // Validate that end is after start (allow equal times for all-day events)
if request.all_day { if request.all_day {
if end_datetime < start_datetime { 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) parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; .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)))?; .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) // Validate that end is after start (allow equal times for all-day events)
if request.all_day { if request.all_day {
if end_datetime < start_datetime { if end_datetime < start_datetime {

View File

@@ -175,6 +175,7 @@ pub async fn create_event_series(
// Create the VEvent for the series // Create the VEvent for the series
let mut event = VEvent::new(uid.clone(), start_datetime); let mut event = VEvent::new(uid.clone(), start_datetime);
event.dtend = Some(end_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() { event.summary = if request.title.trim().is_empty() {
None None
} else { } else {

View File

@@ -63,7 +63,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! { html! {
<div class="event-detail"> <div class="event-detail">
<strong>{"End:"}</strong> <strong>{"End:"}</strong>
<span>{format_datetime(end, event.all_day)}</span> <span>{format_datetime_end(end, event.all_day)}</span>
</div> </div>
} }
} else { } else {
@@ -221,6 +221,17 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
} }
} }
fn format_datetime_end(dt: &DateTime<Utc>, 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 { fn format_recurrence_rule(rrule: &str) -> String {
// Basic parsing of RRULE to display user-friendly text // Basic parsing of RRULE to display user-friendly text
if rrule.contains("FREQ=DAILY") { if rrule.contains("FREQ=DAILY") {

View File

@@ -439,9 +439,17 @@ impl CalendarService {
let mut occurrence_event = base_event.clone(); let mut occurrence_event = base_event.clone();
occurrence_event.dtstart = occurrence_datetime; occurrence_event.dtstart = occurrence_datetime;
occurrence_event.dtstamp = chrono::Utc::now(); // Update DTSTAMP for each occurrence occurrence_event.dtstamp = chrono::Utc::now(); // Update DTSTAMP for each occurrence
if let Some(end) = base_event.dtend { if let Some(base_end) = base_event.dtend {
occurrence_event.dtend = Some(end + Duration::days(days_diff)); 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); occurrences.push(occurrence_event);