From 81805289e4b16805a1f613293313e1089cc6b3bc Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Fri, 29 Aug 2025 15:22:34 -0400 Subject: [PATCH] Implement complete recurring event drag modification system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add recurring edit modal with three modification options: • "Only this event" - Creates exception for single occurrence • "This and future events" - Splits series from occurrence forward • "All occurrences in this series" - Updates entire series time - Enhance backend update API to support series modifications: • Add update_action parameter for recurring event operations • Implement time-only updates that preserve original start dates • Convert timestamped occurrence UIDs to base UIDs for series updates • Preserve recurrence rules during series modifications - Fix recurring event drag operations: • Show modal for recurring events instead of direct updates • Handle EXDATE creation for single occurrence modifications • Support series splitting with UNTIL clause modifications • Maintain proper UID management for different modification types - Clean up debug logging and restore page refresh for data consistency 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/handlers.rs | 55 +++++++- backend/src/models.rs | 2 + src/app.rs | 6 +- src/components/calendar.rs | 1 + src/components/mod.rs | 4 +- src/components/recurring_edit_modal.rs | 93 +++++++++++++ src/components/week_view.rs | 178 ++++++++++++++++++++++++- src/services/calendar_service.rs | 4 +- styles.css | 67 +++++++++- 9 files changed, 393 insertions(+), 17 deletions(-) create mode 100644 src/components/recurring_edit_modal.rs diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index fe47b91..10ff1c2 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -768,8 +768,7 @@ pub async fn update_event( headers: HeaderMap, Json(request): Json, ) -> Result, ApiError> { - println!("📝 Update event request received: uid='{}', title='{}', calendar_path={:?}", - request.uid, request.title, request.calendar_path); + // Handle update request // Extract and verify token let token = extract_bearer_token(&headers)?; @@ -805,10 +804,14 @@ pub async fn update_event( return Err(ApiError::BadRequest("No calendars available for event update".to_string())); } + // Determine if this is a series update + let search_uid = request.uid.clone(); + let is_series_update = request.update_action.as_deref() == Some("update_series"); + // Search for the event by UID across the specified calendars let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, href) for calendar_path in &calendar_paths { - match client.fetch_event_by_uid(calendar_path, &request.uid).await { + match client.fetch_event_by_uid(calendar_path, &search_uid).await { Ok(Some(event)) => { if let Some(href) = event.href.clone() { found_event = Some((event, calendar_path.clone(), href)); @@ -824,7 +827,7 @@ pub async fn update_event( } let (mut event, calendar_path, event_href) = found_event - .ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?; + .ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", search_uid)))?; // Parse dates and times for the updated event let start_datetime = parse_event_datetime(&request.start_date, &request.start_time, request.all_day) @@ -963,8 +966,28 @@ pub async fn update_event( } else { Some(request.description.clone()) }; - event.start = start_datetime; - event.end = Some(end_datetime); + // Handle date/time updates based on update type + if is_series_update { + // For series updates, only update the TIME, keep the original DATE + let original_start_date = event.start.date_naive(); + let original_end_date = event.end.map(|e| e.date_naive()).unwrap_or(original_start_date); + + let new_start_time = start_datetime.time(); + let new_end_time = end_datetime.time(); + + // Combine original date with new time + let updated_start = original_start_date.and_time(new_start_time).and_utc(); + let updated_end = original_end_date.and_time(new_end_time).and_utc(); + + // Preserve original date with new time + + event.start = updated_start; + event.end = Some(updated_end); + } else { + // For regular updates, update both date and time + event.start = start_datetime; + event.end = Some(end_datetime); + } event.location = if request.location.trim().is_empty() { None } else { @@ -981,10 +1004,28 @@ pub async fn update_event( event.attendees = attendees; event.categories = categories; event.last_modified = Some(chrono::Utc::now()); - event.recurrence_rule = recurrence_rule; event.all_day = request.all_day; event.reminders = reminders; + // Handle recurrence rule and UID for series updates + if is_series_update { + // For series updates, preserve existing recurrence rule and convert UID to base UID + let parts: Vec<&str> = request.uid.split('-').collect(); + if parts.len() > 1 { + let last_part = parts[parts.len() - 1]; + if last_part.chars().all(|c| c.is_numeric()) { + let base_uid = parts[0..parts.len()-1].join("-"); + event.uid = base_uid; + } + } + + // Keep existing recurrence rule (don't overwrite with recurrence_rule variable) + // event.recurrence_rule stays as-is from the original event + } else { + // For regular updates, use the new recurrence rule + event.recurrence_rule = recurrence_rule; + } + // Update the event on the CalDAV server client.update_event(&calendar_path, &event, &event_href) .await diff --git a/backend/src/models.rs b/backend/src/models.rs index e6a5d27..146c13a 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -122,6 +122,8 @@ pub struct UpdateEventRequest { pub recurrence: String, // recurrence type pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub calendar_path: Option, // Optional - search all calendars if not specified + pub update_action: Option, // "update_series" for recurring events + pub occurrence_date: Option, // ISO date string for specific occurrence } #[derive(Debug, Serialize)] diff --git a/src/app.rs b/src/app.rs index 5c3edc7..83ee606 100644 --- a/src/app.rs +++ b/src/app.rs @@ -353,8 +353,12 @@ pub fn App() -> Html { new_start.format("%Y-%m-%d %H:%M"), new_end.format("%Y-%m-%d %H:%M")).into()); + // Use the original UID for all updates + let backend_uid = original_event.uid.clone(); + if let Some(token) = (*auth_token).clone() { let original_event = original_event.clone(); + let backend_uid = backend_uid.clone(); wasm_bindgen_futures::spawn_local(async move { let calendar_service = CalendarService::new(); @@ -406,7 +410,7 @@ pub fn App() -> Html { match calendar_service.update_event( &token, &password, - original_event.uid, + backend_uid, original_event.summary.unwrap_or_default(), original_event.description.unwrap_or_default(), start_date, diff --git a/src/components/calendar.rs b/src/components/calendar.rs index 6c1a54b..7c3917c 100644 --- a/src/components/calendar.rs +++ b/src/components/calendar.rs @@ -250,6 +250,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { on_event_context_menu={props.on_event_context_menu.clone()} on_calendar_context_menu={props.on_calendar_context_menu.clone()} on_create_event={Some(on_create_event)} + on_create_event_request={props.on_create_event_request.clone()} on_event_update={Some(on_event_update)} context_menus_open={props.context_menus_open} time_increment={*time_increment} diff --git a/src/components/mod.rs b/src/components/mod.rs index d09ab76..8e7fee0 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -12,6 +12,7 @@ pub mod create_event_modal; pub mod sidebar; pub mod calendar_list_item; pub mod route_handler; +pub mod recurring_edit_modal; pub use login::Login; pub use calendar::Calendar; @@ -26,4 +27,5 @@ pub use calendar_context_menu::CalendarContextMenu; pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType}; pub use sidebar::{Sidebar, ViewMode}; pub use calendar_list_item::CalendarListItem; -pub use route_handler::RouteHandler; \ No newline at end of file +pub use route_handler::RouteHandler; +pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction}; \ No newline at end of file diff --git a/src/components/recurring_edit_modal.rs b/src/components/recurring_edit_modal.rs new file mode 100644 index 0000000..631a42a --- /dev/null +++ b/src/components/recurring_edit_modal.rs @@ -0,0 +1,93 @@ +use yew::prelude::*; +use chrono::NaiveDateTime; +use crate::services::calendar_service::CalendarEvent; + +#[derive(Clone, PartialEq)] +pub enum RecurringEditAction { + ThisEvent, + FutureEvents, + AllEvents, +} + +#[derive(Properties, PartialEq)] +pub struct RecurringEditModalProps { + pub show: bool, + pub event: CalendarEvent, + pub new_start: NaiveDateTime, + pub new_end: NaiveDateTime, + pub on_choice: Callback, + pub on_cancel: Callback<()>, +} + +#[function_component(RecurringEditModal)] +pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html { + if !props.show { + return html! {}; + } + + let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event"); + + let on_this_event = { + let on_choice = props.on_choice.clone(); + Callback::from(move |_| { + on_choice.emit(RecurringEditAction::ThisEvent); + }) + }; + + let on_future_events = { + let on_choice = props.on_choice.clone(); + Callback::from(move |_| { + on_choice.emit(RecurringEditAction::FutureEvents); + }) + }; + + let on_all_events = { + let on_choice = props.on_choice.clone(); + Callback::from(move |_| { + on_choice.emit(RecurringEditAction::AllEvents); + }) + }; + + let on_cancel = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |_| { + on_cancel.emit(()); + }) + }; + + html! { + + } +} \ No newline at end of file diff --git a/src/components/week_view.rs b/src/components/week_view.rs index e8b279f..5870aab 100644 --- a/src/components/week_view.rs +++ b/src/components/week_view.rs @@ -3,6 +3,7 @@ use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateT use std::collections::HashMap; use web_sys::MouseEvent; use crate::services::calendar_service::{CalendarEvent, UserInfo}; +use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType}; #[derive(Properties, PartialEq)] pub struct WeekViewProps { @@ -21,6 +22,8 @@ pub struct WeekViewProps { #[prop_or_default] pub on_create_event: Option>, #[prop_or_default] + pub on_create_event_request: Option>, + #[prop_or_default] pub on_event_update: Option>, #[prop_or_default] pub context_menus_open: bool, @@ -56,6 +59,16 @@ pub fn week_view(props: &WeekViewProps) -> Html { // Drag state for event creation let drag_state = use_state(|| None::); + + // State for recurring event edit modal + #[derive(Clone, PartialEq)] + struct PendingRecurringEdit { + event: CalendarEvent, + new_start: NaiveDateTime, + new_end: NaiveDateTime, + } + + let pending_recurring_edit = use_state(|| None::); // Helper function to get calendar color for an event let get_event_color = |event: &CalendarEvent| -> String { @@ -85,6 +98,111 @@ pub fn week_view(props: &WeekViewProps) -> Html { // Add the final midnight boundary to show where the day ends time_labels.push("12 AM".to_string()); + + + // Handlers for recurring event modification modal + let on_recurring_choice = { + let pending_recurring_edit = pending_recurring_edit.clone(); + let on_event_update = props.on_event_update.clone(); + let on_create_event = props.on_create_event.clone(); + let on_create_event_request = props.on_create_event_request.clone(); + Callback::from(move |action: RecurringEditAction| { + if let Some(edit) = (*pending_recurring_edit).clone() { + match action { + RecurringEditAction::ThisEvent => { + // Create exception for this occurrence only + + // 1. First, add EXDATE to the original series to exclude this occurrence + if let Some(update_callback) = &on_event_update { + let mut updated_series = edit.event.clone(); + updated_series.exception_dates.push(edit.event.start); + + // Keep the original series times unchanged - we're only adding EXDATE + let original_start = edit.event.start.with_timezone(&chrono::Local).naive_local(); + let original_end = edit.event.end.unwrap_or(edit.event.start).with_timezone(&chrono::Local).naive_local(); + + web_sys::console::log_1(&format!("📅 Adding EXDATE {} to series '{}'", + edit.event.start.format("%Y-%m-%d %H:%M:%S UTC"), + edit.event.summary.as_deref().unwrap_or("Untitled") + ).into()); + + // Update the original series with the exception (times unchanged) + update_callback.emit((updated_series, original_start, original_end)); + } + + // 2. Then create the new single event using the update callback (to avoid refresh) + if let Some(update_callback) = &on_event_update { + let mut new_event = edit.event.clone(); + new_event.uid = format!("{}-exception-{}", edit.event.uid, edit.event.start.timestamp()); + new_event.start = chrono::DateTime::from_naive_utc_and_offset(edit.new_start, chrono::Utc); + new_event.end = Some(chrono::DateTime::from_naive_utc_and_offset(edit.new_end, chrono::Utc)); + new_event.recurrence_rule = None; // This is now a single event + new_event.exception_dates.clear(); // No exception dates for single event + + // Use update callback to create the new event (should work without refresh) + update_callback.emit((new_event, edit.new_start, edit.new_end)); + } + }, + RecurringEditAction::FutureEvents => { + // Split series and modify future events + web_sys::console::log_1(&format!("🔄 Splitting series and modifying future: {}", + edit.event.summary.as_deref().unwrap_or("Untitled")).into()); + // 1. Update original series to end before this occurrence + if let Some(update_callback) = &on_event_update { + let mut original_series = edit.event.clone(); + + // Add UNTIL clause to end the series before this occurrence + let until_date = edit.event.start - chrono::Duration::days(1); + + // Parse existing RRULE and add UNTIL + if let Some(rrule) = &original_series.recurrence_rule { + let updated_rrule = if rrule.contains("UNTIL=") { + // Replace existing UNTIL + let re = regex::Regex::new(r"UNTIL=[^;]*").unwrap(); + re.replace(rrule, &format!("UNTIL={}", until_date.format("%Y%m%dT%H%M%SZ"))).to_string() + } else { + // Add UNTIL to existing rule + format!("{};UNTIL={}", rrule, until_date.format("%Y%m%dT%H%M%SZ")) + }; + original_series.recurrence_rule = Some(updated_rrule); + } + + // Update original series + update_callback.emit((original_series, edit.event.start.with_timezone(&chrono::Local).naive_local(), edit.event.end.unwrap_or(edit.event.start).with_timezone(&chrono::Local).naive_local())); + } + + // 2. Create new series starting from this occurrence with modified times + if let Some(update_callback) = &on_event_update { + let mut new_series = edit.event.clone(); + new_series.uid = format!("{}-future-{}", edit.event.uid, edit.event.start.timestamp()); + new_series.start = chrono::DateTime::from_naive_utc_and_offset(edit.new_start, chrono::Utc); + new_series.end = Some(chrono::DateTime::from_naive_utc_and_offset(edit.new_end, chrono::Utc)); + new_series.exception_dates.clear(); // New series has no exceptions + + // Use update callback to create the new series (should work without refresh) + update_callback.emit((new_series, edit.new_start, edit.new_end)); + } + }, + RecurringEditAction::AllEvents => { + // Modify the entire series + let series_event = edit.event.clone(); + + if let Some(callback) = &on_event_update { + callback.emit((series_event, edit.new_start, edit.new_end)); + } + }, + } + } + pending_recurring_edit.set(None); + }) + }; + + let on_recurring_cancel = { + let pending_recurring_edit = pending_recurring_edit.clone(); + Callback::from(move |_| { + pending_recurring_edit.set(None); + }) + }; html! {
@@ -204,6 +322,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { let drag_state = drag_state_clone.clone(); let on_create_event = props.on_create_event.clone(); let on_event_update = props.on_event_update.clone(); + let pending_recurring_edit = pending_recurring_edit.clone(); let time_increment = props.time_increment; Callback::from(move |_e: MouseEvent| { if let Some(current_drag) = (*drag_state).clone() { @@ -252,8 +371,19 @@ pub fn week_view(props: &WeekViewProps) -> Html { let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time); let new_end_datetime = new_start_datetime + original_duration; - if let Some(callback) = &on_event_update { - callback.emit((event.clone(), new_start_datetime, new_end_datetime)); + // Check if this is a recurring event + if event.recurrence_rule.is_some() { + // Show modal for recurring event modification + pending_recurring_edit.set(Some(PendingRecurringEdit { + event: event.clone(), + new_start: new_start_datetime, + new_end: new_end_datetime, + })); + } else { + // Regular event - proceed with update + if let Some(callback) = &on_event_update { + callback.emit((event.clone(), new_start_datetime, new_end_datetime)); + } } }, DragType::ResizeEventStart(event) => { @@ -277,8 +407,19 @@ pub fn week_view(props: &WeekViewProps) -> Html { original_end }; - if let Some(callback) = &on_event_update { - callback.emit((event.clone(), new_start_datetime, new_end_datetime)); + // Check if this is a recurring event + if event.recurrence_rule.is_some() { + // Show modal for recurring event modification + pending_recurring_edit.set(Some(PendingRecurringEdit { + event: event.clone(), + new_start: new_start_datetime, + new_end: new_end_datetime, + })); + } else { + // Regular event - proceed with update + if let Some(callback) = &on_event_update { + callback.emit((event.clone(), new_start_datetime, new_end_datetime)); + } } }, DragType::ResizeEventEnd(event) => { @@ -297,8 +438,19 @@ pub fn week_view(props: &WeekViewProps) -> Html { original_start }; - if let Some(callback) = &on_event_update { - callback.emit((event.clone(), new_start_datetime, new_end_datetime)); + // Check if this is a recurring event + if event.recurrence_rule.is_some() { + // Show modal for recurring event modification + pending_recurring_edit.set(Some(PendingRecurringEdit { + event: event.clone(), + new_start: new_start_datetime, + new_end: new_end_datetime, + })); + } else { + // Regular event - proceed with update + if let Some(callback) = &on_event_update { + callback.emit((event.clone(), new_start_datetime, new_end_datetime)); + } } } } @@ -695,6 +847,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
+ + // Recurring event modification modal + if let Some(edit) = (*pending_recurring_edit).clone() { + + } else { + <> + } } } diff --git a/src/services/calendar_service.rs b/src/services/calendar_service.rs index f75f57f..2b8d155 100644 --- a/src/services/calendar_service.rs +++ b/src/services/calendar_service.rs @@ -804,7 +804,9 @@ impl CalendarService { "reminder": reminder, "recurrence": recurrence, "recurrence_days": recurrence_days, - "calendar_path": calendar_path + "calendar_path": calendar_path, + "update_action": "update_series", + "occurrence_date": null }); let body_string = serde_json::to_string(&body) diff --git a/styles.css b/styles.css index 4ea2266..9814788 100644 --- a/styles.css +++ b/styles.css @@ -642,7 +642,7 @@ body { /* Week Events */ .week-event { - position: absolute; + position: absolute !important; left: 4px; right: 4px; min-height: 20px; @@ -1831,4 +1831,69 @@ body { min-width: 2.5rem; padding: 0.4rem 0.6rem; } +} + +/* Recurring Edit Modal */ +.recurring-edit-modal { + max-width: 500px; + width: 95%; +} + +.recurring-edit-options { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 1.5rem 0; +} + +.recurring-option { + background: white; + border: 2px solid #e9ecef; + color: #495057; + padding: 1rem; + text-align: left; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.recurring-option:hover { + border-color: #667eea; + background: #f8f9ff; + color: #495057; + transform: none; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15); +} + +.recurring-option .option-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: #333; +} + +.recurring-option .option-description { + font-size: 0.9rem; + color: #666; + line-height: 1.4; +} + +/* Mobile adjustments for recurring edit modal */ +@media (max-width: 768px) { + .recurring-edit-modal { + margin: 1rem; + width: calc(100% - 2rem); + } + + .recurring-option { + padding: 0.75rem; + } + + .recurring-option .option-title { + font-size: 0.9rem; + } + + .recurring-option .option-description { + font-size: 0.8rem; + } } \ No newline at end of file