Fix recurring event series modification via drag and drop operations
This commit resolves the "Failed to fetch" errors when updating recurring event series through drag operations by implementing proper request sequencing and fixing time parameter handling. Key fixes: - Eliminate HTTP request cancellation by sequencing operations properly - Add global mutex to prevent CalDAV HTTP race conditions - Implement complete RFC 5545-compliant series splitting for "this_and_future" - Fix frontend to pass dragged times instead of original times - Add comprehensive error handling and request timing logs - Backend now handles both UPDATE (add UNTIL) and CREATE (new series) in single request Technical changes: - Frontend: Remove concurrent CREATE request, pass dragged times to backend - Backend: Implement full this_and_future logic with sequential operations - CalDAV: Add mutex serialization and detailed error tracking - Series: Create new series with occurrence date + dragged times 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -437,32 +437,47 @@ pub fn App() -> Html {
|
||||
let recurrence_str = original_event.rrule.unwrap_or_default();
|
||||
let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence
|
||||
|
||||
// Determine if this is a recurring event that needs series endpoint
|
||||
let has_recurrence = !recurrence_str.is_empty() && recurrence_str.to_uppercase() != "NONE";
|
||||
|
||||
let result = if let Some(scope) = update_scope.as_ref() {
|
||||
// Use series endpoint
|
||||
calendar_service.update_series(
|
||||
&token,
|
||||
&password,
|
||||
backend_uid,
|
||||
original_event.summary.unwrap_or_default(),
|
||||
original_event.description.unwrap_or_default(),
|
||||
start_date.clone(),
|
||||
start_time.clone(),
|
||||
end_date.clone(),
|
||||
end_time.clone(),
|
||||
original_event.location.unwrap_or_default(),
|
||||
original_event.all_day,
|
||||
status_str,
|
||||
class_str,
|
||||
original_event.priority,
|
||||
original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
||||
original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
|
||||
original_event.categories.join(","),
|
||||
reminder_str,
|
||||
recurrence_str,
|
||||
original_event.calendar_path,
|
||||
scope.clone(),
|
||||
occurrence_date,
|
||||
).await
|
||||
// Use series endpoint for recurring event operations
|
||||
if !has_recurrence {
|
||||
web_sys::console::log_1(&"⚠️ Warning: update_scope provided for non-recurring event, using regular endpoint instead".into());
|
||||
// Fall through to regular endpoint
|
||||
None
|
||||
} else {
|
||||
Some(calendar_service.update_series(
|
||||
&token,
|
||||
&password,
|
||||
backend_uid.clone(),
|
||||
original_event.summary.clone().unwrap_or_default(),
|
||||
original_event.description.clone().unwrap_or_default(),
|
||||
start_date.clone(),
|
||||
start_time.clone(),
|
||||
end_date.clone(),
|
||||
end_time.clone(),
|
||||
original_event.location.clone().unwrap_or_default(),
|
||||
original_event.all_day,
|
||||
status_str.clone(),
|
||||
class_str.clone(),
|
||||
original_event.priority,
|
||||
original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
||||
original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
|
||||
original_event.categories.join(","),
|
||||
reminder_str.clone(),
|
||||
recurrence_str.clone(),
|
||||
original_event.calendar_path.clone(),
|
||||
scope.clone(),
|
||||
occurrence_date,
|
||||
).await)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = if let Some(series_result) = result {
|
||||
series_result
|
||||
} else {
|
||||
// Use regular endpoint
|
||||
calendar_service.update_event(
|
||||
@@ -507,19 +522,8 @@ pub fn App() -> Html {
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
// Check if this is a network error that occurred after success
|
||||
let err_str = format!("{}", err);
|
||||
if err_str.contains("Failed to fetch") || err_str.contains("Network request failed") {
|
||||
web_sys::console::log_1(&"Update may have succeeded despite network error, reloading...".into());
|
||||
// Still reload as the update likely succeeded
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
gloo_timers::future::sleep(std::time::Duration::from_millis(200)).await;
|
||||
web_sys::window().unwrap().location().reload().unwrap();
|
||||
});
|
||||
} else {
|
||||
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
|
||||
}
|
||||
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
|
||||
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,55 +216,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
|
||||
|
||||
// Use the original series start time (not the dragged occurrence time)
|
||||
let original_start = original_series.dtstart.with_timezone(&chrono::Local).naive_local();
|
||||
let original_end = original_series.dtend.unwrap_or(original_series.dtstart).with_timezone(&chrono::Local).naive_local();
|
||||
// Use the dragged times for the new series (not the original series times)
|
||||
let new_start = edit.new_start; // The dragged start time
|
||||
let new_end = edit.new_end; // The dragged end time
|
||||
|
||||
// Send until_date to backend instead of modifying RRULE on frontend
|
||||
update_callback.emit((original_series, original_start, original_end, true, Some(until_utc), Some("this_and_future".to_string()), None)); // preserve_rrule = true, backend will add UNTIL
|
||||
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
||||
update_callback.emit((original_series, new_start, new_end, true, Some(until_utc), Some("this_and_future".to_string()), Some(occurrence_date))); // preserve_rrule = true, backend will add UNTIL
|
||||
}
|
||||
|
||||
// 2. Create new series starting from this occurrence with modified times
|
||||
if let Some(create_callback) = &on_create_event_request {
|
||||
// Convert the recurring event to EventCreationData for the create callback
|
||||
let event_data = EventCreationData {
|
||||
title: edit.event.summary.clone().unwrap_or_default(),
|
||||
description: edit.event.description.clone().unwrap_or_default(),
|
||||
start_date: edit.new_start.date(),
|
||||
start_time: edit.new_start.time(),
|
||||
end_date: edit.new_end.date(),
|
||||
end_time: edit.new_end.time(),
|
||||
location: edit.event.location.clone().unwrap_or_default(),
|
||||
all_day: edit.event.all_day,
|
||||
status: EventStatus::Confirmed, // Default status
|
||||
class: EventClass::Public, // Default class
|
||||
priority: edit.event.priority,
|
||||
organizer: edit.event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
|
||||
attendees: edit.event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
|
||||
categories: edit.event.categories.join(","),
|
||||
reminder: ReminderType::None, // Default reminder
|
||||
recurrence: if let Some(rrule) = &edit.event.rrule {
|
||||
if rrule.contains("FREQ=DAILY") {
|
||||
RecurrenceType::Daily
|
||||
} else if rrule.contains("FREQ=WEEKLY") {
|
||||
RecurrenceType::Weekly
|
||||
} else if rrule.contains("FREQ=MONTHLY") {
|
||||
RecurrenceType::Monthly
|
||||
} else if rrule.contains("FREQ=YEARLY") {
|
||||
RecurrenceType::Yearly
|
||||
} else {
|
||||
RecurrenceType::None
|
||||
}
|
||||
} else {
|
||||
RecurrenceType::None
|
||||
},
|
||||
recurrence_days: vec![false; 7], // Default days
|
||||
selected_calendar: edit.event.calendar_path.clone(),
|
||||
};
|
||||
|
||||
// Create the new series
|
||||
create_callback.emit(event_data);
|
||||
}
|
||||
// The backend will handle creating the new series as part of the this_and_future update
|
||||
web_sys::console::log_1(&format!("✅ this_and_future update request sent - backend will handle both UPDATE (add UNTIL) and CREATE (new series) operations").into());
|
||||
},
|
||||
RecurringEditAction::AllEvents => {
|
||||
// Modify the entire series
|
||||
|
||||
@@ -862,8 +862,7 @@ impl CalendarService {
|
||||
start_date, start_time, end_date, end_time, location,
|
||||
all_day, status, class, priority, organizer, attendees,
|
||||
categories, reminder, recurrence, recurrence_days,
|
||||
calendar_path, exception_dates, update_action, until_date,
|
||||
"all_in_series".to_string() // Default scope for backward compatibility
|
||||
calendar_path, exception_dates, update_action, until_date
|
||||
).await
|
||||
}
|
||||
|
||||
@@ -892,8 +891,7 @@ impl CalendarService {
|
||||
calendar_path: Option<String>,
|
||||
exception_dates: Vec<DateTime<Utc>>,
|
||||
update_action: Option<String>,
|
||||
until_date: Option<DateTime<Utc>>,
|
||||
update_scope: String
|
||||
until_date: Option<DateTime<Utc>>
|
||||
) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No global window exists")?;
|
||||
|
||||
@@ -901,74 +899,33 @@ impl CalendarService {
|
||||
opts.set_method("POST");
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
// Determine if this is a series event based on recurrence
|
||||
let is_series = !recurrence.is_empty() && recurrence.to_uppercase() != "NONE";
|
||||
|
||||
let (body, url) = if is_series {
|
||||
// Use series-specific endpoint and payload for recurring events
|
||||
let body = serde_json::json!({
|
||||
"series_uid": event_uid,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"start_date": start_date,
|
||||
"start_time": start_time,
|
||||
"end_date": end_date,
|
||||
"end_time": end_time,
|
||||
"location": location,
|
||||
"all_day": all_day,
|
||||
"status": status,
|
||||
"class": class,
|
||||
"priority": priority,
|
||||
"organizer": organizer,
|
||||
"attendees": attendees,
|
||||
"categories": categories,
|
||||
"reminder": reminder,
|
||||
"recurrence": recurrence,
|
||||
"recurrence_days": recurrence_days,
|
||||
"recurrence_interval": 1_u32, // Default interval
|
||||
"recurrence_end_date": until_date.as_ref().map(|dt| dt.format("%Y-%m-%d").to_string()),
|
||||
"recurrence_count": None as Option<u32>, // No count limit by default
|
||||
"calendar_path": calendar_path,
|
||||
"update_scope": update_scope.clone(),
|
||||
"occurrence_date": if update_scope == "this_only" {
|
||||
// For single occurrence updates, use the original event's start date as occurrence_date
|
||||
Some(start_date.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let url = format!("{}/calendar/events/series/update", self.base_url);
|
||||
(body, url)
|
||||
} else {
|
||||
// Use regular endpoint for non-recurring events
|
||||
let body = serde_json::json!({
|
||||
"uid": event_uid,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"start_date": start_date,
|
||||
"start_time": start_time,
|
||||
"end_date": end_date,
|
||||
"end_time": end_time,
|
||||
"location": location,
|
||||
"all_day": all_day,
|
||||
"status": status,
|
||||
"class": class,
|
||||
"priority": priority,
|
||||
"organizer": organizer,
|
||||
"attendees": attendees,
|
||||
"categories": categories,
|
||||
"reminder": reminder,
|
||||
"recurrence": recurrence,
|
||||
"recurrence_days": recurrence_days,
|
||||
"calendar_path": calendar_path,
|
||||
"update_action": update_action,
|
||||
"occurrence_date": null,
|
||||
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
|
||||
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
|
||||
});
|
||||
let url = format!("{}/calendar/events/update", self.base_url);
|
||||
(body, url)
|
||||
};
|
||||
// Always use regular endpoint - recurring events should use update_series() instead
|
||||
let body = serde_json::json!({
|
||||
"uid": event_uid,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"start_date": start_date,
|
||||
"start_time": start_time,
|
||||
"end_date": end_date,
|
||||
"end_time": end_time,
|
||||
"location": location,
|
||||
"all_day": all_day,
|
||||
"status": status,
|
||||
"class": class,
|
||||
"priority": priority,
|
||||
"organizer": organizer,
|
||||
"attendees": attendees,
|
||||
"categories": categories,
|
||||
"reminder": reminder,
|
||||
"recurrence": recurrence,
|
||||
"recurrence_days": recurrence_days,
|
||||
"calendar_path": calendar_path,
|
||||
"update_action": update_action,
|
||||
"occurrence_date": null,
|
||||
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
|
||||
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
|
||||
});
|
||||
let url = format!("{}/calendar/events/update", self.base_url);
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
@@ -1165,6 +1122,9 @@ impl CalendarService {
|
||||
});
|
||||
|
||||
let url = format!("{}/calendar/events/series/update", self.base_url);
|
||||
|
||||
web_sys::console::log_1(&format!("🔄 update_series: Making request to URL: {}", url).into());
|
||||
web_sys::console::log_1(&format!("🔄 update_series: Request body: {}", serde_json::to_string_pretty(&body).unwrap_or_default()).into());
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("JSON serialization failed: {}", e))?;
|
||||
|
||||
Reference in New Issue
Block a user