Fix single event deletion functionality with proper recurring vs non-recurring handling

This commit resolves multiple issues with event deletion:

Backend fixes:
- Fix CalDAV URL construction for DELETE requests (missing slash separator)
- Improve event lookup by href with exact matching and fallback to UID extraction
- Add support for both RFC3339 and simple YYYY-MM-DD date formats in occurrence parsing
- Implement proper logic to distinguish recurring vs non-recurring events in delete_this action
- For non-recurring events: delete entire event from CalDAV server
- For recurring events: add EXDATE to exclude specific occurrences
- Add comprehensive debug logging for troubleshooting deletion issues

Frontend fixes:
- Update callback signatures to support series endpoint parameters (7-parameter tuples)
- Add update_series method to CalendarService for series-specific operations
- Route single occurrence modifications through series endpoint with proper scoping
- Fix all component prop definitions to use new callback signature
- Update all emit calls to pass correct number of parameters

The deletion process now works correctly:
- Single events are completely removed from the calendar
- Recurring event occurrences are properly excluded via EXDATE
- Debug logging helps identify and resolve CalDAV communication issues

🤖 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 18:40:48 -04:00
parent a6aac42c78
commit ee1c6ee299
7 changed files with 267 additions and 93 deletions

View File

@@ -2,7 +2,7 @@ use yew::prelude::*;
use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use web_sys::MouseEvent;
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction, RecurringEditModal, RecurringEditAction};
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction};
use crate::services::{CalendarService, calendar_service::UserInfo};
use crate::models::ical::VEvent;
use chrono::NaiveDate;
@@ -374,7 +374,7 @@ pub fn App() -> Html {
let on_event_update = {
let auth_token = auth_token.clone();
Callback::from(move |(original_event, new_start, new_end, preserve_rrule, until_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)| {
Callback::from(move |(original_event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)| {
web_sys::console::log_1(&format!("Updating event: {} to new times: {} - {}",
original_event.uid,
new_start.format("%Y-%m-%d %H:%M"),
@@ -437,36 +437,67 @@ 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
match calendar_service.update_event(
&token,
&password,
backend_uid,
original_event.summary.unwrap_or_default(),
original_event.description.unwrap_or_default(),
start_date,
start_time,
end_date,
end_time,
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,
recurrence_days,
original_event.calendar_path,
original_event.exdate.clone(),
if preserve_rrule {
Some("update_series".to_string())
} else {
Some("this_and_future".to_string())
},
until_date
).await {
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
} else {
// Use regular endpoint
calendar_service.update_event(
&token,
&password,
backend_uid,
original_event.summary.unwrap_or_default(),
original_event.description.unwrap_or_default(),
start_date,
start_time,
end_date,
end_time,
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,
recurrence_days,
original_event.calendar_path,
original_event.exdate.clone(),
if preserve_rrule {
Some("update_series".to_string())
} else {
Some("this_and_future".to_string())
},
until_date
).await
};
match result {
Ok(_) => {
web_sys::console::log_1(&"Event updated successfully".into());
// Add small delay before reload to let any pending requests complete

View File

@@ -25,7 +25,7 @@ pub struct CalendarProps {
#[prop_or_default]
pub on_create_event_request: Option<Callback<EventCreationData>>,
#[prop_or_default]
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
#[prop_or_default]
pub context_menus_open: bool,
}
@@ -195,9 +195,9 @@ pub fn Calendar(props: &CalendarProps) -> Html {
// Handle drag-to-move event
let on_event_update = {
let on_event_update_request = props.on_event_update_request.clone();
Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)| {
Callback::from(move |(event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)| {
if let Some(callback) = &on_event_update_request {
callback.emit((event, new_start, new_end, preserve_rrule, until_date));
callback.emit((event, new_start, new_end, preserve_rrule, until_date, update_scope, occurrence_date));
}
})
};

View File

@@ -28,7 +28,7 @@ pub struct RouteHandlerProps {
#[prop_or_default]
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
#[prop_or_default]
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
#[prop_or_default]
pub context_menus_open: bool,
}
@@ -106,7 +106,7 @@ pub struct CalendarViewProps {
#[prop_or_default]
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
#[prop_or_default]
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
pub on_event_update_request: Option<Callback<(VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
#[prop_or_default]
pub context_menus_open: bool,
}

View File

@@ -25,7 +25,7 @@ pub struct WeekViewProps {
#[prop_or_default]
pub on_create_event_request: Option<Callback<EventCreationData>>,
#[prop_or_default]
pub on_event_update: Option<Callback<(VEvent, NaiveDateTime, NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)>>,
pub on_event_update: Option<Callback<(VEvent, NaiveDateTime, NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>, Option<String>, Option<String>)>>,
#[prop_or_default]
pub context_menus_open: bool,
#[prop_or_default]
@@ -221,7 +221,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let original_end = original_series.dtend.unwrap_or(original_series.dtstart).with_timezone(&chrono::Local).naive_local();
// Send until_date to backend instead of modifying RRULE on frontend
update_callback.emit((original_series, original_start, original_end, true, Some(until_utc))); // preserve_rrule = true, backend will add UNTIL
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
}
// 2. Create new series starting from this occurrence with modified times
@@ -465,7 +465,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
} else {
// Regular event - proceed with update
if let Some(callback) = &on_event_update {
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None, None, None)); // Regular drag operation - preserve RRULE, no until_date
}
}
},
@@ -501,7 +501,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
} else {
// Regular event - proceed with update
if let Some(callback) = &on_event_update {
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None, None, None)); // Regular drag operation - preserve RRULE, no until_date
}
}
},
@@ -532,7 +532,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
} else {
// Regular event - proceed with update
if let Some(callback) = &on_event_update {
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None)); // Regular drag operation - preserve RRULE, no until_date
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None, None, None)); // Regular drag operation - preserve RRULE, no until_date
}
}
}

View File

@@ -1106,5 +1106,102 @@ impl CalendarService {
}
}
pub async fn update_series(
&self,
token: &str,
password: &str,
series_uid: String,
title: String,
description: String,
start_date: String,
start_time: String,
end_date: String,
end_time: String,
location: String,
all_day: bool,
status: String,
class: String,
priority: Option<u8>,
organizer: String,
attendees: String,
categories: String,
reminder: String,
recurrence: String,
calendar_path: Option<String>,
update_scope: String,
occurrence_date: Option<String>,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
let body = serde_json::json!({
"series_uid": series_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": vec![false; 7], // Default - could be enhanced
"recurrence_interval": 1_u32, // Default interval
"recurrence_end_date": None as Option<String>, // No end date by default
"recurrence_count": None as Option<u32>, // No count limit by default
"calendar_path": calendar_path,
"update_scope": update_scope,
"occurrence_date": occurrence_date
});
let url = format!("{}/calendar/events/series/update", self.base_url);
let body_string = serde_json::to_string(&body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
opts.set_body(&body_string.into());
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request.headers().set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
request.headers().set("X-CalDAV-Password", password)
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
request.headers().set("Content-Type", "application/json")
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Network request failed: {:?}", e))?;
let resp: Response = resp_value.dyn_into()
.map_err(|e| format!("Response cast failed: {:?}", e))?;
let text = JsFuture::from(resp.text()
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
.await
.map_err(|e| format!("Text promise failed: {:?}", e))?;
let text_string = text.as_string()
.ok_or("Response text is not a string")?;
if resp.ok() {
Ok(())
} else {
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
}
}
}