Implement complete recurring event drag modification system

- 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 <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-29 15:22:34 -04:00
parent 9f2f58e23e
commit 81805289e4
9 changed files with 393 additions and 17 deletions

View File

@@ -768,8 +768,7 @@ pub async fn update_event(
headers: HeaderMap,
Json(request): Json<UpdateEventRequest>,
) -> Result<Json<UpdateEventResponse>, 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

View File

@@ -122,6 +122,8 @@ pub struct UpdateEventRequest {
pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
pub update_action: Option<String>, // "update_series" for recurring events
pub occurrence_date: Option<String>, // ISO date string for specific occurrence
}
#[derive(Debug, Serialize)]

View File

@@ -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,

View File

@@ -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}

View File

@@ -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;
pub use route_handler::RouteHandler;
pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction};

View File

@@ -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<RecurringEditAction>,
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! {
<div class="modal-backdrop">
<div class="modal-content recurring-edit-modal">
<div class="modal-header">
<h3>{"Edit Recurring Event"}</h3>
</div>
<div class="modal-body">
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
<p>{"How would you like to apply this change?"}</p>
<div class="recurring-edit-options">
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
<div class="option-title">{"This event only"}</div>
<div class="option-description">{"Change only this occurrence"}</div>
</button>
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
<div class="option-title">{"This and future events"}</div>
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
</button>
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
<div class="option-title">{"All events in series"}</div>
<div class="option-description">{"Change all occurrences in the series"}</div>
</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick={on_cancel}>
{"Cancel"}
</button>
</div>
</div>
</div>
}
}

View File

@@ -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<Callback<(NaiveDate, NaiveDateTime, NaiveDateTime)>>,
#[prop_or_default]
pub on_create_event_request: Option<Callback<EventCreationData>>,
#[prop_or_default]
pub on_event_update: Option<Callback<(CalendarEvent, NaiveDateTime, NaiveDateTime)>>,
#[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::<DragState>);
// 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::<PendingRecurringEdit>);
// 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! {
<div class="week-view-container">
@@ -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 {
</div>
</div>
</div>
// Recurring event modification modal
if let Some(edit) = (*pending_recurring_edit).clone() {
<RecurringEditModal
show={true}
event={edit.event}
new_start={edit.new_start}
new_end={edit.new_end}
on_choice={on_recurring_choice}
on_cancel={on_recurring_cancel}
/>
} else {
<></>
}
</div>
}
}

View File

@@ -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)

View File

@@ -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;
}
}