From ac1164fd81fb9607ee00c69240dd6b7f02e86018 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Sat, 13 Sep 2025 22:28:52 -0400 Subject: [PATCH] Fix singleton to series conversion with complete RRULE parameter support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new context menu callback for singleton events to avoid series pipeline - Implement complete RRULE construction with INTERVAL, COUNT, and UNTIL parameters - Update frontend service methods to pass recurrence parameters correctly - Add missing recurrence fields to backend UpdateEventRequest model - Fix parameter ordering in frontend method calls - Ensure singletonβ†’series conversion uses single event pipeline initially This resolves issues where converting singleton events to recurring series would not respect recurrence interval (every N days), count (N occurrences), or until date parameters. πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/handlers/events.rs | 95 +++++++++++++++++++ backend/src/handlers/series.rs | 4 + backend/src/models.rs | 3 + frontend/src/app.rs | 64 ++++++++----- frontend/src/components/create_event_modal.rs | 7 +- frontend/src/components/event_context_menu.rs | 17 +++- frontend/src/components/event_form/types.rs | 6 +- frontend/src/services/calendar_service.rs | 31 +++--- 8 files changed, 183 insertions(+), 44 deletions(-) diff --git a/backend/src/handlers/events.rs b/backend/src/handlers/events.rs index c40fa32..6aefe80 100644 --- a/backend/src/handlers/events.rs +++ b/backend/src/handlers/events.rs @@ -762,6 +762,8 @@ pub async fn update_event( .ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?; // Parse dates and times as local times (no UTC conversion) + println!("πŸ• UPDATE: Received start_date: '{}', start_time: '{}', timezone: '{}'", + request.start_date, request.start_time, request.timezone); let start_datetime = parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; @@ -828,6 +830,99 @@ pub async fn update_event( event.priority = request.priority; + // Process recurrence information to set RRULE + println!("πŸ”„ Processing recurrence: '{}'", request.recurrence); + println!("πŸ”„ Recurrence days: {:?}", request.recurrence_days); + println!("πŸ”„ Recurrence interval: {:?}", request.recurrence_interval); + println!("πŸ”„ Recurrence count: {:?}", request.recurrence_count); + println!("πŸ”„ Recurrence end date: {:?}", request.recurrence_end_date); + + let rrule = if request.recurrence.starts_with("FREQ=") { + // Frontend sent a complete RRULE string, use it directly + if request.recurrence.is_empty() { + None + } else { + Some(request.recurrence.clone()) + } + } else { + // Parse recurrence type and build RRULE with all parameters + let base_rrule = match request.recurrence.to_uppercase().as_str() { + "DAILY" => Some("FREQ=DAILY".to_string()), + "WEEKLY" => { + // Handle weekly recurrence with optional BYDAY parameter + let mut rrule = "FREQ=WEEKLY".to_string(); + + // Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]) + if request.recurrence_days.len() == 7 { + let selected_days: Vec<&str> = request + .recurrence_days + .iter() + .enumerate() + .filter_map(|(i, &selected)| { + if selected { + Some(match i { + 0 => "SU", // Sunday + 1 => "MO", // Monday + 2 => "TU", // Tuesday + 3 => "WE", // Wednesday + 4 => "TH", // Thursday + 5 => "FR", // Friday + 6 => "SA", // Saturday + _ => return None, + }) + } else { + None + } + }) + .collect(); + + if !selected_days.is_empty() { + rrule = format!("{};BYDAY={}", rrule, selected_days.join(",")); + } + } + + Some(rrule) + } + "MONTHLY" => Some("FREQ=MONTHLY".to_string()), + "YEARLY" => Some("FREQ=YEARLY".to_string()), + "NONE" | "" => None, // Clear any existing recurrence + _ => None, + }; + + // Add INTERVAL, COUNT, and UNTIL parameters if specified + if let Some(mut rrule_string) = base_rrule { + // Add INTERVAL parameter (every N days/weeks/months/years) + if let Some(interval) = request.recurrence_interval { + if interval > 1 { + rrule_string = format!("{};INTERVAL={}", rrule_string, interval); + } + } + + // Add COUNT or UNTIL parameter (but not both - COUNT takes precedence) + if let Some(count) = request.recurrence_count { + rrule_string = format!("{};COUNT={}", rrule_string, count); + } else if let Some(end_date) = &request.recurrence_end_date { + // Convert YYYY-MM-DD to YYYYMMDD format for UNTIL + if let Ok(date) = chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") { + rrule_string = format!("{};UNTIL={}", rrule_string, date.format("%Y%m%d")); + } + } + + Some(rrule_string) + } else { + None + } + }; + + event.rrule = rrule.clone(); + println!("πŸ”„ Set event RRULE to: {:?}", rrule); + + if rrule.is_some() { + println!("✨ Converting singleton event to recurring series with RRULE: {}", rrule.as_ref().unwrap()); + } else { + println!("πŸ“ Event remains non-recurring (no RRULE set)"); + } + // Update the event on the CalDAV server println!( "πŸ“ Updating event {} at calendar_path: {}, event_href: {}", diff --git a/backend/src/handlers/series.rs b/backend/src/handlers/series.rs index 5dad060..a05251b 100644 --- a/backend/src/handlers/series.rs +++ b/backend/src/handlers/series.rs @@ -229,6 +229,8 @@ pub async fn update_event_series( "πŸ”„ Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}", request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date ); + println!("πŸ• SERIES: Received start_date: '{}', start_time: '{}', timezone: '{}'", + request.start_date, request.start_time, request.timezone); // Extract and verify token let token = extract_bearer_token(&headers)?; @@ -963,6 +965,8 @@ async fn update_single_occurrence( // Update the modified properties from the request exception_event.dtstart = start_datetime; exception_event.dtend = Some(end_datetime); + exception_event.dtstart_tzid = Some(request.timezone.clone()); + exception_event.dtend_tzid = Some(request.timezone.clone()); exception_event.summary = if request.title.trim().is_empty() { existing_event.summary.clone() // Keep original if empty } else { diff --git a/backend/src/models.rs b/backend/src/models.rs index c0faa4c..c637712 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -147,6 +147,9 @@ pub struct UpdateEventRequest { pub reminder: String, // reminder type pub recurrence: String, // recurrence type pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence + pub recurrence_interval: Option, // Every N days/weeks/months/years + pub recurrence_count: Option, // Number of occurrences + pub recurrence_end_date: Option, // When the series ends (YYYY-MM-DD) pub calendar_path: Option, // Optional - search all calendars if not specified pub update_action: Option, // "update_series" for recurring events pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00") diff --git a/frontend/src/app.rs b/frontend/src/app.rs index f10e9f4..8e9fa0a 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -723,8 +723,12 @@ pub fn App() -> Html { crate::components::event_form::RecurrenceType::Monthly | crate::components::event_form::RecurrenceType::Yearly); + web_sys::console::log_1(&format!("πŸ› FRONTEND DEBUG: is_recurring={}, edit_scope={:?}, original_uid={:?}", + is_recurring, event_data_for_update.edit_scope, event_data_for_update.original_uid).into()); + let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() { - // Use series update endpoint for recurring events + // Only use series endpoint for existing recurring events being edited + // Singletonβ†’series conversion should use regular update_event endpoint let edit_action = event_data_for_update.edit_scope.unwrap(); let scope = match edit_action { crate::components::EditAction::EditAll => "all_in_series".to_string(), @@ -754,12 +758,13 @@ pub fn App() -> Html { params.14, // reminder params.15, // recurrence params.16, // recurrence_days + params.17, // recurrence_interval params.18, // recurrence_count params.19, // recurrence_until - params.17, // calendar_path + params.20, // calendar_path scope, event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date - params.20, // timezone + params.21, // timezone ) .await } else { @@ -786,11 +791,11 @@ pub fn App() -> Html { params.14, // reminder params.15, // recurrence params.16, // recurrence_days - params.17, // calendar_path - vec![], // exception_dates - empty for simple updates - None, // update_action - None for regular updates - None, // until_date - None for regular updates - params.20, // timezone + params.17, // recurrence_interval + params.18, // recurrence_count + params.19, // recurrence_until + params.20, // calendar_path + params.21, // timezone ) .await }; @@ -874,10 +879,11 @@ pub fn App() -> Html { params.14, // reminder params.15, // recurrence params.16, // recurrence_days + params.17, // recurrence_interval params.18, // recurrence_count params.19, // recurrence_until - params.17, // calendar_path - params.20, // timezone + params.20, // calendar_path + params.21, // timezone ) .await; match create_result { @@ -1042,12 +1048,13 @@ pub fn App() -> Html { original_event.categories.join(","), reminder_str.clone(), recurrence_str.clone(), - vec![false; 7], - None, - None, - original_event.calendar_path.clone(), - scope.clone(), - occurrence_date, + vec![false; 7], // recurrence_days + 1, // recurrence_interval - default for drag-and-drop + None, // recurrence_count + None, // recurrence_until + original_event.calendar_path.clone(), // calendar_path + scope.clone(), // update_scope + occurrence_date, // occurrence_date { // Get timezone offset let date = js_sys::Date::new_0(); @@ -1055,7 +1062,7 @@ pub fn App() -> Html { let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign let minutes = (timezone_offset as i32).abs() % 60; format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00 - }, + }, // timezone ) .await, ) @@ -1099,14 +1106,10 @@ pub fn App() -> Html { reminder_str, recurrence_str, recurrence_days, + 1, // recurrence_interval - default to 1 for drag-and-drop + None, // recurrence_count - preserve existing + None, // recurrence_until - preserve existing 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, { // Get timezone offset let date = js_sys::Date::new_0(); @@ -1639,6 +1642,19 @@ pub fn App() -> Html { } } })} + on_edit_singleton={Callback::from({ + let event_context_menu_event = event_context_menu_event.clone(); + let event_context_menu_open = event_context_menu_open.clone(); + let create_event_modal_open = create_event_modal_open.clone(); + let event_edit_scope = event_edit_scope.clone(); + move |event: VEvent| { + // For singleton events, open edit modal WITHOUT setting edit_scope + event_context_menu_event.set(Some(event)); + event_edit_scope.set(None); // Explicitly set to None for singleton edits + event_context_menu_open.set(false); + create_event_modal_open.set(true); + } + })} on_view_details={Callback::from({ let event_context_menu_open = event_context_menu_open.clone(); let view_event_modal_open = view_event_modal_open.clone(); diff --git a/frontend/src/components/create_event_modal.rs b/frontend/src/components/create_event_modal.rs index 478fb03..0230615 100644 --- a/frontend/src/components/create_event_modal.rs +++ b/frontend/src/components/create_event_modal.rs @@ -291,8 +291,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend // Recurrence - Parse RRULE if present recurrence: if let Some(ref rrule_str) = event.rrule { + web_sys::console::log_1(&format!("πŸ› MODAL DEBUG: Event has RRULE: {}", rrule_str).into()); parse_rrule_frequency(rrule_str) } else { + web_sys::console::log_1(&"πŸ› MODAL DEBUG: Event has no RRULE (singleton)".into()); RecurrenceType::None }, recurrence_interval: if let Some(ref rrule_str) = event.rrule { @@ -337,7 +339,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend }, // Edit tracking - edit_scope: None, // Will be set by the modal after creation + edit_scope: { + web_sys::console::log_1(&"πŸ› MODAL DEBUG: Setting edit_scope to None for vevent_to_creation_data".into()); + None // Will be set by the modal after creation + }, changed_fields: vec![], original_uid: Some(event.uid.clone()), // Preserve original UID for editing occurrence_date: Some(start_local.date()), // The occurrence date being edited diff --git a/frontend/src/components/event_context_menu.rs b/frontend/src/components/event_context_menu.rs index 130c108..87fa304 100644 --- a/frontend/src/components/event_context_menu.rs +++ b/frontend/src/components/event_context_menu.rs @@ -26,6 +26,7 @@ pub struct EventContextMenuProps { pub on_delete: Callback, pub on_view_details: Callback, pub on_close: Callback<()>, + pub on_edit_singleton: Callback, // New callback for editing singleton events without scope } #[function_component(EventContextMenu)] @@ -109,6 +110,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { }) }; + let create_singleton_edit_callback = { + let on_edit_singleton = props.on_edit_singleton.clone(); + let on_close = props.on_close.clone(); + let event = props.event.clone(); + Callback::from(move |_: MouseEvent| { + if let Some(event) = &event { + on_edit_singleton.emit(event.clone()); + } + on_close.emit(()); + }) + }; + let create_delete_callback = |action: DeleteAction| { let on_delete = props.on_delete.clone(); let on_close = props.on_close.clone(); @@ -160,9 +173,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { } } else { - // Regular single events - show edit option + // Regular single events - show edit option without setting edit scope html! { -
+
{"Edit Event"}
} diff --git a/frontend/src/components/event_form/types.rs b/frontend/src/components/event_form/types.rs index 3b7dff6..79beced 100644 --- a/frontend/src/components/event_form/types.rs +++ b/frontend/src/components/event_form/types.rs @@ -148,9 +148,10 @@ impl EventCreationData { String, // reminder String, // recurrence Vec, // recurrence_days - Option, // calendar_path + u32, // recurrence_interval Option, // recurrence_count Option, // recurrence_until + Option, // calendar_path String, // timezone ) { @@ -197,9 +198,10 @@ impl EventCreationData { format!("{:?}", self.reminder), format!("{:?}", self.recurrence), self.recurrence_days.clone(), - self.selected_calendar.clone(), + self.recurrence_interval, self.recurrence_count, self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()), + self.selected_calendar.clone(), timezone, ) } diff --git a/frontend/src/services/calendar_service.rs b/frontend/src/services/calendar_service.rs index 4dac139..35d704e 100644 --- a/frontend/src/services/calendar_service.rs +++ b/frontend/src/services/calendar_service.rs @@ -1298,6 +1298,7 @@ impl CalendarService { reminder: String, recurrence: String, recurrence_days: Vec, + recurrence_interval: u32, recurrence_count: Option, recurrence_until: Option, calendar_path: Option, @@ -1332,7 +1333,7 @@ impl CalendarService { "reminder": reminder, "recurrence": recurrence, "recurrence_days": recurrence_days, - "recurrence_interval": 1_u32, // Default interval + "recurrence_interval": recurrence_interval, "recurrence_end_date": recurrence_until, "recurrence_count": recurrence_count, "calendar_path": calendar_path, @@ -1438,10 +1439,10 @@ impl CalendarService { reminder: String, recurrence: String, recurrence_days: Vec, + recurrence_interval: u32, + recurrence_count: Option, + recurrence_until: Option, calendar_path: Option, - exception_dates: Vec, - update_action: Option, - until_date: Option, timezone: String, ) -> Result<(), String> { // Forward to update_event_with_scope with default scope @@ -1466,10 +1467,10 @@ impl CalendarService { reminder, recurrence, recurrence_days, + recurrence_interval, + recurrence_count, + recurrence_until, calendar_path, - exception_dates, - update_action, - until_date, timezone, ) .await @@ -1497,10 +1498,10 @@ impl CalendarService { reminder: String, recurrence: String, recurrence_days: Vec, + recurrence_interval: u32, + recurrence_count: Option, + recurrence_until: Option, calendar_path: Option, - exception_dates: Vec, - update_action: Option, - until_date: Option, timezone: String, ) -> Result<(), String> { let window = web_sys::window().ok_or("No global window exists")?; @@ -1529,11 +1530,10 @@ impl CalendarService { "reminder": reminder, "recurrence": recurrence, "recurrence_days": recurrence_days, + "recurrence_interval": recurrence_interval, + "recurrence_count": recurrence_count, + "recurrence_end_date": recurrence_until, "calendar_path": calendar_path, - "update_action": update_action, - "occurrence_date": null, - "exception_dates": exception_dates.iter().map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::>(), - "until_date": until_date.as_ref().map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()), "timezone": timezone }); let url = format!("{}/calendar/events/update", self.base_url); @@ -1735,6 +1735,7 @@ impl CalendarService { reminder: String, recurrence: String, recurrence_days: Vec, + recurrence_interval: u32, recurrence_count: Option, recurrence_until: Option, calendar_path: Option, @@ -1767,7 +1768,7 @@ impl CalendarService { "reminder": reminder, "recurrence": recurrence, "recurrence_days": recurrence_days, - "recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter + "recurrence_interval": recurrence_interval, "recurrence_end_date": recurrence_until, "recurrence_count": recurrence_count, "calendar_path": calendar_path,