From 5a12c0e0d0ba6787e42cf0978fa7237dfef8b864 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Sun, 31 Aug 2025 18:49:56 -0400 Subject: [PATCH 1/2] Implement comprehensive event series editing via modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Frontend Changes: - Add EditAction enum (EditThis, EditFuture, EditAll) to event context menu - Update context menu to show 3 edit options for recurring events - Enhance EventCreationData with edit_scope and changed_fields tracking - Update app component to handle EditAction types and pass to modal - Add field change tracking infrastructure to CreateEventModal ## Backend Changes: - Add changed_fields parameter to UpdateEventSeriesRequest for optimization - Existing series endpoint already supports the three update types: - "this_only" - creates exception with EXDATE - "this_and_future" - creates new series with UNTIL on original - "all_in_series" - updates existing series in-place ## Implementation Details: - Event context menu shows single edit option for non-recurring events - Recurring events get three options: "Edit This Event", "Edit This and Future Events", "Edit All Events in Series" - Modal tracks which fields user actually changed for efficient updates - Backend series endpoint already has the logic for all three update scenarios - Full RFC 5545 compliance with proper EXDATE and UNTIL handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/models.rs | 1 + frontend/src/app.rs | 18 +++++-- frontend/src/components/create_event_modal.rs | 47 +++++++++++++++++-- frontend/src/components/event_context_menu.rs | 39 ++++++++++++--- frontend/src/components/mod.rs | 2 +- 5 files changed, 91 insertions(+), 16 deletions(-) diff --git a/backend/src/models.rs b/backend/src/models.rs index 532a92f..853a352 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -201,6 +201,7 @@ pub struct UpdateEventSeriesRequest { // Update scope control pub update_scope: String, // "this_only", "this_and_future", "all_in_series" pub occurrence_date: Option, // ISO date string for specific occurrence being updated + pub changed_fields: Option>, // List of field names that were changed (for optimization) } #[derive(Debug, Serialize)] diff --git a/frontend/src/app.rs b/frontend/src/app.rs index ae9c34d..5c4580f 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -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}; +use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction, EditAction}; use crate::services::{CalendarService, calendar_service::UserInfo}; use crate::models::ical::VEvent; use chrono::NaiveDate; @@ -54,6 +54,7 @@ pub fn App() -> Html { let calendar_context_menu_date = use_state(|| -> Option { None }); let create_event_modal_open = use_state(|| false); let selected_date_for_event = use_state(|| -> Option { None }); + let event_edit_scope = use_state(|| -> Option { None }); let _recurring_edit_modal_open = use_state(|| false); let _recurring_edit_event = use_state(|| -> Option { None }); let _recurring_edit_data = use_state(|| -> Option { None }); @@ -738,8 +739,10 @@ pub fn App() -> Html { 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(); - move |_| { - // Close the context menu and open the edit modal + let event_edit_scope = event_edit_scope.clone(); + move |edit_action: EditAction| { + // Set the edit scope and close the context menu + event_edit_scope.set(Some(edit_action)); event_context_menu_open.set(false); create_event_modal_open.set(true); } @@ -840,13 +843,16 @@ pub fn App() -> Html { is_open={*create_event_modal_open} selected_date={(*selected_date_for_event).clone()} event_to_edit={(*event_context_menu_event).clone()} + edit_scope={(*event_edit_scope).clone()} on_close={Callback::from({ let create_event_modal_open = create_event_modal_open.clone(); let event_context_menu_event = event_context_menu_event.clone(); + let event_edit_scope = event_edit_scope.clone(); move |_| { create_event_modal_open.set(false); - // Clear the event being edited + // Clear the event being edited and edit scope event_context_menu_event.set(None); + event_edit_scope.set(None); } })} on_create={on_event_create} @@ -854,10 +860,12 @@ pub fn App() -> Html { let auth_token = auth_token.clone(); let create_event_modal_open = create_event_modal_open.clone(); let event_context_menu_event = event_context_menu_event.clone(); + let event_edit_scope = event_edit_scope.clone(); move |(original_event, updated_data): (VEvent, EventCreationData)| { - web_sys::console::log_1(&format!("Updating event: {:?}", updated_data).into()); + web_sys::console::log_1(&format!("Updating event: {:?}, edit_scope: {:?}", updated_data, updated_data.edit_scope).into()); create_event_modal_open.set(false); event_context_menu_event.set(None); + event_edit_scope.set(None); if let Some(token) = (*auth_token).clone() { wasm_bindgen_futures::spawn_local(async move { diff --git a/frontend/src/components/create_event_modal.rs b/frontend/src/components/create_event_modal.rs index 3b04ac7..b676d2e 100644 --- a/frontend/src/components/create_event_modal.rs +++ b/frontend/src/components/create_event_modal.rs @@ -4,6 +4,7 @@ use wasm_bindgen::JsCast; use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc, Datelike}; use crate::services::calendar_service::CalendarInfo; use crate::models::ical::VEvent; +use crate::components::EditAction; #[derive(Properties, PartialEq)] pub struct CreateEventModalProps { @@ -18,6 +19,8 @@ pub struct CreateEventModalProps { pub initial_start_time: Option, #[prop_or_default] pub initial_end_time: Option, + #[prop_or_default] + pub edit_scope: Option, } #[derive(Clone, PartialEq, Debug)] @@ -330,6 +333,10 @@ pub struct EventCreationData { pub monthly_by_day: Option, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc. pub monthly_by_monthday: Option, // For monthly: day of month (1-31) pub yearly_by_month: Vec, // For yearly: [Jan, Feb, Mar, ..., Dec] + + // Edit scope and tracking fields + pub edit_scope: Option, + pub changed_fields: Vec, // List of field names that were changed } impl Default for EventCreationData { @@ -365,6 +372,10 @@ impl Default for EventCreationData { monthly_by_day: None, monthly_by_monthday: None, yearly_by_month: vec![false; 12], // [Jan, Feb, ..., Dec] - all false by default + + // Edit scope and tracking defaults + edit_scope: None, + changed_fields: vec![], } } } @@ -566,6 +577,10 @@ impl EventCreationData { } else { vec![false; 12] }, + + // Edit scope and tracking defaults (will be set later if needed) + edit_scope: None, + changed_fields: vec![], } } @@ -593,9 +608,9 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { let active_tab = use_state(|| ModalTab::default()); // Initialize with selected date or event data if provided - use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time), { + use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time, props.edit_scope.clone()), { let event_data = event_data.clone(); - move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time)| { + move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time, edit_scope)| { if *is_open { let mut data = if let Some(event) = event_to_edit { // Pre-populate with event data for editing @@ -625,6 +640,11 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { data.selected_calendar = Some(available_calendars[0].path.clone()); } + // Set edit scope if provided + if let Some(scope) = edit_scope { + data.edit_scope = Some(scope.clone()); + } + event_data.set(data); } || () @@ -644,12 +664,25 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { }) }; + // Helper function to track field changes + let _track_field_change = |data: &mut EventCreationData, field_name: &str| { + if !data.changed_fields.contains(&field_name.to_string()) { + data.changed_fields.push(field_name.to_string()); + } + }; + let on_title_input = { let event_data = event_data.clone(); Callback::from(move |e: InputEvent| { if let Some(input) = e.target_dyn_into::() { let mut data = (*event_data).clone(); - data.title = input.value(); + let new_value = input.value(); + if data.title != new_value { + data.title = new_value; + if !data.changed_fields.contains(&"title".to_string()) { + data.changed_fields.push("title".to_string()); + } + } event_data.set(data); } }) @@ -661,7 +694,13 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html { if let Some(select) = e.target_dyn_into::() { let mut data = (*event_data).clone(); let value = select.value(); - data.selected_calendar = if value.is_empty() { None } else { Some(value) }; + let new_calendar = if value.is_empty() { None } else { Some(value) }; + if data.selected_calendar != new_calendar { + data.selected_calendar = new_calendar; + if !data.changed_fields.contains(&"selected_calendar".to_string()) { + data.changed_fields.push("selected_calendar".to_string()); + } + } event_data.set(data); } }) diff --git a/frontend/src/components/event_context_menu.rs b/frontend/src/components/event_context_menu.rs index ccb5ac4..161f91a 100644 --- a/frontend/src/components/event_context_menu.rs +++ b/frontend/src/components/event_context_menu.rs @@ -9,13 +9,20 @@ pub enum DeleteAction { DeleteSeries, } +#[derive(Clone, PartialEq, Debug)] +pub enum EditAction { + EditThis, + EditFuture, + EditAll, +} + #[derive(Properties, PartialEq)] pub struct EventContextMenuProps { pub is_open: bool, pub x: i32, pub y: i32, pub event: Option, - pub on_edit: Callback<()>, + pub on_edit: Callback, pub on_delete: Callback, pub on_close: Callback<()>, } @@ -38,11 +45,11 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { .map(|event| event.rrule.is_some()) .unwrap_or(false); - let on_edit_click = { + let create_edit_callback = |action: EditAction| { let on_edit = props.on_edit.clone(); let on_close = props.on_close.clone(); Callback::from(move |_: MouseEvent| { - on_edit.emit(()); + on_edit.emit(action.clone()); on_close.emit(()); }) }; @@ -62,9 +69,29 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { class="context-menu" style={style} > -
- {"Edit Event"} -
+ { + if is_recurring { + html! { + <> +
+ {"Edit This Event"} +
+
+ {"Edit This and Future Events"} +
+
+ {"Edit All Events in Series"} +
+ + } + } else { + html! { +
+ {"Edit Event"} +
+ } + } + } { if is_recurring { html! { diff --git a/frontend/src/components/mod.rs b/frontend/src/components/mod.rs index fdf1155..3e01c0d 100644 --- a/frontend/src/components/mod.rs +++ b/frontend/src/components/mod.rs @@ -22,7 +22,7 @@ pub use week_view::WeekView; pub use event_modal::EventModal; pub use create_calendar_modal::CreateCalendarModal; pub use context_menu::ContextMenu; -pub use event_context_menu::{EventContextMenu, DeleteAction}; +pub use event_context_menu::{EventContextMenu, DeleteAction, EditAction}; pub use calendar_context_menu::CalendarContextMenu; pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType}; pub use sidebar::{Sidebar, ViewMode, Theme}; From 51d55521561f4201c4c25a2f3bcec8e25e647d81 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Sun, 31 Aug 2025 19:06:11 -0400 Subject: [PATCH 2/2] Fix modal series editing to use correct backend endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal update flow was calling the regular event update endpoint instead of the series endpoint, preventing proper handling of the three edit types (this event, this and future, all events). ## Changes: - Add logic to detect when edit_scope is set for recurring events - Route to update_series() when edit_scope is present and event has RRULE - Map EditAction enum to backend update_scope strings: - EditThis → "this_only" (creates exception + EXDATE) - EditFuture → "this_and_future" (new series + UNTIL on original) - EditAll → "all_in_series" (update existing series) - Pass occurrence date for single/future edits using original event date - Fall back to regular update_event() for non-recurring events Now the modal properly leverages the existing robust series endpoint that handles RFC 5545 compliant recurring event modifications. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/app.rs | 58 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 5c4580f..95da19b 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -996,8 +996,61 @@ pub fn App() -> Html { web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing calendar path").unwrap(); } } else { - // Calendar hasn't changed - normal update - match calendar_service.update_event( + // Calendar hasn't changed - check if we should use series endpoint + let use_series_endpoint = updated_data.edit_scope.is_some() && original_event.rrule.is_some(); + + if use_series_endpoint { + // Use series endpoint for recurring event modal edits + let update_scope = match updated_data.edit_scope.as_ref().unwrap() { + EditAction::EditThis => "this_only", + EditAction::EditFuture => "this_and_future", + EditAction::EditAll => "all_in_series", + }; + + // For single occurrence edits, we need the occurrence date + let occurrence_date = if update_scope == "this_only" || update_scope == "this_and_future" { + // Use the original event's start date as the occurrence date + Some(original_event.dtstart.format("%Y-%m-%d").to_string()) + } else { + None + }; + + match calendar_service.update_series( + &token, + &password, + original_event.uid, + updated_data.title, + updated_data.description, + start_date, + start_time, + end_date, + end_time, + updated_data.location, + updated_data.all_day, + status_str, + class_str, + updated_data.priority, + updated_data.organizer, + updated_data.attendees, + updated_data.categories, + reminder_str, + recurrence_str, + updated_data.selected_calendar, + update_scope.to_string(), + occurrence_date, + ).await { + Ok(_) => { + web_sys::console::log_1(&"Series updated successfully".into()); + web_sys::window().unwrap().location().reload().unwrap(); + } + Err(err) => { + web_sys::console::error_1(&format!("Failed to update series: {}", err).into()); + web_sys::window().unwrap().alert_with_message(&format!("Failed to update series: {}", err)).unwrap(); + } + } + } else { + // Use regular event endpoint for non-recurring events or legacy updates + match calendar_service.update_event( &token, &password, original_event.uid, @@ -1033,6 +1086,7 @@ pub fn App() -> Html { web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap(); } } + } } }); }