Fix series update to preserve original date when dragging events

- When dragging recurring events, preserve original series start date
- Only update time components, not date, to prevent series from shifting days
- For timed events: use original date with new start/end times from drag
- For all-day events: preserve original date and duration pattern
- Added debug logging to track date preservation behavior
- Maintains event duration for both timed and all-day recurring events

Fixes issue where dragging a recurring event would change the date
for all occurrences instead of just updating the time.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-30 13:44:54 -04:00
parent 78f1db7203
commit 071fc3099f

View File

@@ -38,11 +38,31 @@ pub async fn create_event_series(
return Err(ApiError::BadRequest("Use regular create endpoint for non-recurring events".to_string())); return Err(ApiError::BadRequest("Use regular create endpoint for non-recurring events".to_string()));
} }
// Validate recurrence type // Validate recurrence type - handle both simple strings and RRULE strings
match request.recurrence.to_lowercase().as_str() { let recurrence_freq = if request.recurrence.contains("FREQ=") {
"daily" | "weekly" | "monthly" | "yearly" => {}, // Parse RRULE to extract frequency
_ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())), if request.recurrence.contains("FREQ=DAILY") {
} "daily"
} else if request.recurrence.contains("FREQ=WEEKLY") {
"weekly"
} else if request.recurrence.contains("FREQ=MONTHLY") {
"monthly"
} else if request.recurrence.contains("FREQ=YEARLY") {
"yearly"
} else {
return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string()));
}
} else {
// Handle simple strings
let lower = request.recurrence.to_lowercase();
match lower.as_str() {
"daily" => "daily",
"weekly" => "weekly",
"monthly" => "monthly",
"yearly" => "yearly",
_ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())),
}
};
// Create CalDAV config from token and password // Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?; let config = state.auth_service.caldav_config_from_token(&token, &password)?;
@@ -142,7 +162,7 @@ pub async fn create_event_series(
event.priority = request.priority; event.priority = request.priority;
// Generate the RRULE for recurrence // Generate the RRULE for recurrence
let rrule = build_series_rrule(&request)?; let rrule = build_series_rrule_with_freq(&request, recurrence_freq)?;
event.rrule = Some(rrule); event.rrule = Some(rrule);
println!("🔁 Generated RRULE: {:?}", event.rrule); println!("🔁 Generated RRULE: {:?}", event.rrule);
@@ -195,15 +215,38 @@ pub async fn update_event_series(
_ => return Err(ApiError::BadRequest("Invalid update_scope. Must be: this_only, this_and_future, or all_in_series".to_string())), _ => return Err(ApiError::BadRequest("Invalid update_scope. Must be: this_only, this_and_future, or all_in_series".to_string())),
} }
// Validate recurrence type // Validate recurrence type - handle both simple strings and RRULE strings
match request.recurrence.to_lowercase().as_str() { let recurrence_freq = if request.recurrence.contains("FREQ=") {
"daily" | "weekly" | "monthly" | "yearly" => {}, // Parse RRULE to extract frequency
_ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())), if request.recurrence.contains("FREQ=DAILY") {
} "daily"
} else if request.recurrence.contains("FREQ=WEEKLY") {
"weekly"
} else if request.recurrence.contains("FREQ=MONTHLY") {
"monthly"
} else if request.recurrence.contains("FREQ=YEARLY") {
"yearly"
} else {
return Err(ApiError::BadRequest("Invalid RRULE frequency. Must be DAILY, WEEKLY, MONTHLY, or YEARLY".to_string()));
}
} else {
// Handle simple strings
let lower = request.recurrence.to_lowercase();
match lower.as_str() {
"daily" => "daily",
"weekly" => "weekly",
"monthly" => "monthly",
"yearly" => "yearly",
_ => return Err(ApiError::BadRequest("Invalid recurrence type. Must be daily, weekly, monthly, or yearly".to_string())),
}
};
// Create CalDAV config from token and password // Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?; let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config); let client = CalDAVClient::new(config);
// Use the parsed frequency for further processing (avoiding unused variable warning)
let _freq_for_processing = recurrence_freq;
// Determine which calendar to search (or search all calendars) // Determine which calendar to search (or search all calendars)
let calendar_paths = if let Some(ref path) = request.calendar_path { let calendar_paths = if let Some(ref path) = request.calendar_path {
@@ -236,16 +279,27 @@ pub async fn update_event_series(
println!("📅 Found series event in calendar: {}", calendar_path); println!("📅 Found series event in calendar: {}", calendar_path);
// Parse datetime components for the update // Parse datetime components for the update
let start_date = chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d") // For recurring events, preserve the original series start date and only update the time
// to prevent the entire series from shifting to a different date
let original_start_date = existing_event.dtstart.date_naive();
let start_date = original_start_date; // Always use original series date
// Log what we're doing for debugging
let requested_date = chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d")
.map_err(|_| ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string()))?; .map_err(|_| ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string()))?;
println!("📅 Preserving original series date {} (requested: {})", original_start_date, requested_date);
let (start_datetime, end_datetime) = if request.all_day { let (start_datetime, end_datetime) = if request.all_day {
let start_dt = start_date.and_hms_opt(0, 0, 0) let start_dt = start_date.and_hms_opt(0, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?; .ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
// For all-day events, also preserve the original date pattern
let end_date = if !request.end_date.is_empty() { let end_date = if !request.end_date.is_empty() {
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d") // Calculate the duration from the original event
.map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))? let original_duration_days = existing_event.dtend
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
.unwrap_or(0);
start_date + chrono::Duration::days(original_duration_days)
} else { } else {
start_date start_date
}; };
@@ -272,12 +326,16 @@ pub async fn update_event_series(
}; };
let start_dt = start_date.and_time(start_time); let start_dt = start_date.and_time(start_time);
let end_dt = if !request.end_date.is_empty() { // For timed events, preserve the original date and only update times
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d") let end_dt = if !request.end_time.is_empty() {
.map_err(|_| ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()))?; // Use the new end time with the preserved original date
end_date.and_time(end_time)
} else {
start_date.and_time(end_time) start_date.and_time(end_time)
} else {
// Calculate end time based on original duration
let original_duration = existing_event.dtend
.map(|end| end - existing_event.dtstart)
.unwrap_or_else(|| chrono::Duration::hours(1));
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
}; };
(chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt)) (chrono::Utc.from_utc_datetime(&start_dt), chrono::Utc.from_utc_datetime(&end_dt))
@@ -300,10 +358,10 @@ pub async fn update_event_series(
_ => unreachable!(), // Already validated above _ => unreachable!(), // Already validated above
}; };
// Update the event on the CalDAV server // Update the event on the CalDAV server using the original event's href
// Generate event href from UID let event_href = existing_event.href.as_ref()
let event_href = format!("{}.ics", request.series_uid); .ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?;
client.update_event(&calendar_path, &updated_event, &event_href) client.update_event(&calendar_path, &updated_event, event_href)
.await .await
.map_err(|e| ApiError::Internal(format!("Failed to update event series: {}", e)))?; .map_err(|e| ApiError::Internal(format!("Failed to update event series: {}", e)))?;
@@ -382,15 +440,44 @@ pub async fn delete_event_series(
// Helper functions // Helper functions
fn build_series_rrule(request: &CreateEventSeriesRequest) -> Result<String, ApiError> { fn build_series_rrule(request: &CreateEventSeriesRequest) -> Result<String, ApiError> {
// Extract frequency from request
let recurrence_freq = if request.recurrence.contains("FREQ=") {
// Parse RRULE to extract frequency
if request.recurrence.contains("FREQ=DAILY") {
"daily"
} else if request.recurrence.contains("FREQ=WEEKLY") {
"weekly"
} else if request.recurrence.contains("FREQ=MONTHLY") {
"monthly"
} else if request.recurrence.contains("FREQ=YEARLY") {
"yearly"
} else {
return Err(ApiError::BadRequest("Invalid RRULE frequency".to_string()));
}
} else {
let lower = request.recurrence.to_lowercase();
match lower.as_str() {
"daily" => "daily",
"weekly" => "weekly",
"monthly" => "monthly",
"yearly" => "yearly",
_ => return Err(ApiError::BadRequest("Invalid recurrence type".to_string())),
}
};
build_series_rrule_with_freq(request, recurrence_freq)
}
fn build_series_rrule_with_freq(request: &CreateEventSeriesRequest, freq: &str) -> Result<String, ApiError> {
let mut rrule_parts = Vec::new(); let mut rrule_parts = Vec::new();
// Add frequency // Add frequency
match request.recurrence.to_lowercase().as_str() { match freq {
"daily" => rrule_parts.push("FREQ=DAILY".to_string()), "daily" => rrule_parts.push("FREQ=DAILY".to_string()),
"weekly" => rrule_parts.push("FREQ=WEEKLY".to_string()), "weekly" => rrule_parts.push("FREQ=WEEKLY".to_string()),
"monthly" => rrule_parts.push("FREQ=MONTHLY".to_string()), "monthly" => rrule_parts.push("FREQ=MONTHLY".to_string()),
"yearly" => rrule_parts.push("FREQ=YEARLY".to_string()), "yearly" => rrule_parts.push("FREQ=YEARLY".to_string()),
_ => return Err(ApiError::BadRequest("Invalid recurrence type".to_string())), _ => return Err(ApiError::BadRequest("Invalid recurrence frequency".to_string())),
} }
// Add interval if specified and greater than 1 // Add interval if specified and greater than 1
@@ -401,7 +488,7 @@ fn build_series_rrule(request: &CreateEventSeriesRequest) -> Result<String, ApiE
} }
// Handle weekly recurrence with specific days (BYDAY) // Handle weekly recurrence with specific days (BYDAY)
if request.recurrence.to_lowercase() == "weekly" && request.recurrence_days.len() == 7 { if freq == "weekly" && request.recurrence_days.len() == 7 {
let selected_days: Vec<&str> = request.recurrence_days let selected_days: Vec<&str> = request.recurrence_days
.iter() .iter()
.enumerate() .enumerate()