Fix singleton to series conversion with complete RRULE parameter support
All checks were successful
Build and Push Docker Image / docker (push) Successful in 2m16s

- 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 <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-09-13 22:28:52 -04:00
parent a6092d13ce
commit ac1164fd81
8 changed files with 183 additions and 44 deletions

View File

@@ -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: {}",

View File

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

View File

@@ -147,6 +147,9 @@ pub struct UpdateEventRequest {
pub reminder: String, // reminder type
pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
pub recurrence_count: Option<u32>, // Number of occurrences
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
pub update_action: Option<String>, // "update_series" for recurring events
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ pub struct EventContextMenuProps {
pub on_delete: Callback<DeleteAction>,
pub on_view_details: Callback<VEvent>,
pub on_close: Callback<()>,
pub on_edit_singleton: Callback<VEvent>, // 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! {
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
<div class="context-menu-item" onclick={create_singleton_edit_callback}>
{"Edit Event"}
</div>
}

View File

@@ -148,9 +148,10 @@ impl EventCreationData {
String, // reminder
String, // recurrence
Vec<bool>, // recurrence_days
Option<String>, // calendar_path
u32, // recurrence_interval
Option<u32>, // recurrence_count
Option<String>, // recurrence_until
Option<String>, // 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,
)
}

View File

@@ -1298,6 +1298,7 @@ impl CalendarService {
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
@@ -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<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
exception_dates: Vec<chrono::NaiveDateTime>,
update_action: Option<String>,
until_date: Option<chrono::NaiveDateTime>,
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<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
exception_dates: Vec<chrono::NaiveDateTime>,
update_action: Option<String>,
until_date: Option<chrono::NaiveDateTime>,
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::<Vec<String>>(),
"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<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
@@ -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,