Files
calendar/frontend/src/components/create_event_modal.rs
Connor Johnstone 089f4ce105 Fix series RRULE updates: editing 'all events' now properly updates original series RRULE
- Backend now updates RRULE when recurrence_count or recurrence_end_date parameters are provided
- Fixed update_entire_series() to modify COUNT/UNTIL instead of preserving original RRULE
- Added comprehensive RRULE parsing functions to extract existing frequency, interval, count, until, and BYDAY components
- Fixed frontend parameter mapping to pass recurrence parameters through update_series calls
- Resolves issue where changing recurring event from 5 to 7 occurrences kept original COUNT=5

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 17:22:26 -04:00

455 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::components::event_form::*;
use crate::components::EditAction;
use crate::models::ical::VEvent;
use crate::services::calendar_service::CalendarInfo;
use gloo_storage::{LocalStorage, Storage};
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CreateEventModalProps {
pub is_open: bool,
pub on_close: Callback<()>,
pub on_create: Callback<EventCreationData>,
pub available_calendars: Vec<CalendarInfo>,
pub selected_date: Option<chrono::NaiveDate>,
pub initial_start_time: Option<chrono::NaiveTime>,
pub initial_end_time: Option<chrono::NaiveTime>,
#[prop_or_default]
pub event_to_edit: Option<VEvent>,
#[prop_or_default]
pub edit_scope: Option<EditAction>,
}
#[function_component(CreateEventModal)]
pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
let active_tab = use_state(|| ModalTab::default());
let event_data = use_state(|| EventCreationData::default());
// Initialize data when modal opens
{
let event_data = event_data.clone();
let is_open = props.is_open;
let event_to_edit = props.event_to_edit.clone();
let selected_date = props.selected_date;
let initial_start_time = props.initial_start_time;
let initial_end_time = props.initial_end_time;
let edit_scope = props.edit_scope.clone();
let available_calendars = props.available_calendars.clone();
use_effect_with(is_open, move |&is_open| {
if is_open {
let mut data = if let Some(event) = &event_to_edit {
// Convert VEvent to EventCreationData for editing
vevent_to_creation_data(event, &available_calendars)
} else if let Some(date) = selected_date {
let mut data = EventCreationData::default();
data.start_date = date;
data.end_date = date;
if let Some(start_time) = initial_start_time {
data.start_time = start_time;
}
if let Some(end_time) = initial_end_time {
data.end_time = end_time;
}
data
} else {
EventCreationData::default()
};
// Set default calendar
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
// For new events, try to use the last used calendar
if event_to_edit.is_none() {
// Try to get last used calendar from localStorage
if let Ok(last_used_calendar) = LocalStorage::get::<String>("last_used_calendar") {
// Check if the last used calendar is still available
if available_calendars.iter().any(|cal| cal.path == last_used_calendar) {
data.selected_calendar = Some(last_used_calendar);
} else {
// Fall back to first available calendar
data.selected_calendar = Some(available_calendars[0].path.clone());
}
} else {
// No last used calendar, use first available
data.selected_calendar = Some(available_calendars[0].path.clone());
}
} else {
// For editing existing events, keep the current calendar as default
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);
}
|| ()
});
}
if !props.is_open {
return html! {};
}
let on_backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
if e.target() == e.current_target() {
on_close.emit(());
}
})
};
let switch_to_tab = {
let active_tab = active_tab.clone();
Callback::from(move |tab: ModalTab| {
active_tab.set(tab);
})
};
let on_save = {
let event_data = event_data.clone();
let on_create = props.on_create.clone();
let event_to_edit = props.event_to_edit.clone();
Callback::from(move |_: MouseEvent| {
let mut data = (*event_data).clone();
// If we're editing an existing event, mark it as an update operation
if let Some(ref original_event) = event_to_edit {
// Set the original UID so the backend knows to update instead of create
data.original_uid = Some(original_event.uid.clone());
}
on_create.emit(data);
})
};
let on_close = props.on_close.clone();
let on_close_header = on_close.clone();
let tab_props = TabProps {
data: event_data.clone(),
available_calendars: props.available_calendars.clone(),
};
html! {
<div class="modal-backdrop" onclick={on_backdrop_click}>
<div class="modal-content create-event-modal">
<div class="modal-header">
<h3>
{if props.event_to_edit.is_some() { "Edit Event" } else { "Create Event" }}
</h3>
<button class="modal-close" onclick={Callback::from(move |_| on_close_header.emit(()))}>
{"×"}
</button>
</div>
<div class="modal-tabs">
<div class="tab-navigation">
<button
class={if *active_tab == ModalTab::BasicDetails { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::BasicDetails))
}}
>
{"Basic"}
</button>
<button
class={if *active_tab == ModalTab::Advanced { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Advanced))
}}
>
{"Advanced"}
</button>
<button
class={if *active_tab == ModalTab::People { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::People))
}}
>
{"People"}
</button>
<button
class={if *active_tab == ModalTab::Categories { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Categories))
}}
>
{"Categories"}
</button>
<button
class={if *active_tab == ModalTab::Location { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Location))
}}
>
{"Location"}
</button>
<button
class={if *active_tab == ModalTab::Reminders { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Reminders))
}}
>
{"Reminders"}
</button>
</div>
<div class="modal-body">
<div class="tab-content">
{
match *active_tab {
ModalTab::BasicDetails => html! { <BasicDetailsTab ..tab_props /> },
ModalTab::Advanced => html! { <AdvancedTab ..tab_props /> },
ModalTab::People => html! { <PeopleTab ..tab_props /> },
ModalTab::Categories => html! { <CategoriesTab ..tab_props /> },
ModalTab::Location => html! { <LocationTab ..tab_props /> },
ModalTab::Reminders => html! { <RemindersTab ..tab_props /> },
}
}
</div>
</div>
</div>
<div class="modal-footer">
<div class="modal-actions">
<button class="btn btn-secondary" onclick={Callback::from(move |_| on_close.emit(()))}>
{"Cancel"}
</button>
<button class="btn btn-primary" onclick={on_save}>
{if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }}
</button>
</div>
</div>
</div>
</div>
}
}
// Convert VEvent to EventCreationData for editing
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
use chrono::Local;
// Convert start datetime from UTC to local
let start_local = event.dtstart.with_timezone(&Local).naive_local();
let end_local = if let Some(dtend) = event.dtend {
dtend.with_timezone(&Local).naive_local()
} else {
// Default to 1 hour after start if no end time
start_local + chrono::Duration::hours(1)
};
EventCreationData {
// Basic event info
title: event.summary.clone().unwrap_or_default(),
description: event.description.clone().unwrap_or_default(),
location: event.location.clone().unwrap_or_default(),
all_day: event.all_day,
// Timing
start_date: start_local.date(),
end_date: end_local.date(),
start_time: start_local.time(),
end_time: end_local.time(),
// Classification
status: match event.status {
Some(crate::models::ical::EventStatus::Tentative) => EventStatus::Tentative,
Some(crate::models::ical::EventStatus::Confirmed) => EventStatus::Confirmed,
Some(crate::models::ical::EventStatus::Cancelled) => EventStatus::Cancelled,
None => EventStatus::Confirmed,
},
class: match event.class {
Some(crate::models::ical::EventClass::Public) => EventClass::Public,
Some(crate::models::ical::EventClass::Private) => EventClass::Private,
Some(crate::models::ical::EventClass::Confidential) => EventClass::Confidential,
None => EventClass::Public,
},
priority: event.priority,
// People
organizer: event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
attendees: event.attendees.iter()
.map(|a| a.cal_address.clone())
.collect::<Vec<_>>()
.join(","),
// Categorization
categories: event.categories.join(","),
// Reminders - TODO: Parse alarm from VEvent if needed
reminder: ReminderType::None,
// Recurrence - Parse RRULE if present
recurrence: if let Some(ref rrule_str) = event.rrule {
parse_rrule_frequency(rrule_str)
} else {
RecurrenceType::None
},
recurrence_interval: if let Some(ref rrule_str) = event.rrule {
parse_rrule_interval(rrule_str)
} else {
1
},
recurrence_until: if let Some(ref rrule_str) = event.rrule {
parse_rrule_until(rrule_str)
} else {
None
},
recurrence_count: if let Some(ref rrule_str) = event.rrule {
parse_rrule_count(rrule_str)
} else {
None
},
recurrence_days: if let Some(ref rrule_str) = event.rrule {
parse_rrule_days(rrule_str)
} else {
vec![false; 7]
},
// Advanced recurrence
monthly_by_day: None,
monthly_by_monthday: None,
yearly_by_month: vec![false; 12],
// Calendar selection - try to find the calendar this event belongs to
selected_calendar: if let Some(ref calendar_path) = event.calendar_path {
if available_calendars.iter().any(|cal| cal.path == *calendar_path) {
Some(calendar_path.clone())
} else if let Some(first_calendar) = available_calendars.first() {
Some(first_calendar.path.clone())
} else {
None
}
} else if let Some(first_calendar) = available_calendars.first() {
Some(first_calendar.path.clone())
} else {
None
},
// Edit tracking
edit_scope: 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
}
}
// Parse RRULE frequency component
fn parse_rrule_frequency(rrule: &str) -> RecurrenceType {
if rrule.contains("FREQ=DAILY") {
RecurrenceType::Daily
} else if rrule.contains("FREQ=WEEKLY") {
RecurrenceType::Weekly
} else if rrule.contains("FREQ=MONTHLY") {
RecurrenceType::Monthly
} else if rrule.contains("FREQ=YEARLY") {
RecurrenceType::Yearly
} else {
RecurrenceType::None
}
}
// Parse RRULE interval component
fn parse_rrule_interval(rrule: &str) -> u32 {
if let Some(start) = rrule.find("INTERVAL=") {
let interval_part = &rrule[start + 9..];
if let Some(end) = interval_part.find(';') {
interval_part[..end].parse().unwrap_or(1)
} else {
interval_part.parse().unwrap_or(1)
}
} else {
1
}
}
// Parse RRULE count component
fn parse_rrule_count(rrule: &str) -> Option<u32> {
if let Some(start) = rrule.find("COUNT=") {
let count_part = &rrule[start + 6..];
if let Some(end) = count_part.find(';') {
count_part[..end].parse().ok()
} else {
count_part.parse().ok()
}
} else {
None
}
}
// Parse RRULE until component
fn parse_rrule_until(rrule: &str) -> Option<chrono::NaiveDate> {
if let Some(start) = rrule.find("UNTIL=") {
let until_part = &rrule[start + 6..];
let until_str = if let Some(end) = until_part.find(';') {
&until_part[..end]
} else {
until_part
};
// UNTIL can be in format YYYYMMDD or YYYYMMDDTHHMMSSZ
let date_part = if until_str.len() >= 8 {
&until_str[..8]
} else {
until_str
};
// Parse YYYYMMDD format
if date_part.len() == 8 {
if let (Ok(year), Ok(month), Ok(day)) = (
date_part[0..4].parse::<i32>(),
date_part[4..6].parse::<u32>(),
date_part[6..8].parse::<u32>(),
) {
chrono::NaiveDate::from_ymd_opt(year, month, day)
} else {
None
}
} else {
None
}
} else {
None
}
}
// Parse RRULE BYDAY component for weekly recurrence
fn parse_rrule_days(rrule: &str) -> Vec<bool> {
let mut days = vec![false; 7]; // [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
if let Some(start) = rrule.find("BYDAY=") {
let byday_part = &rrule[start + 6..];
let byday_str = if let Some(end) = byday_part.find(';') {
&byday_part[..end]
} else {
byday_part
};
// Parse comma-separated day codes: SU,MO,TU,WE,TH,FR,SA
for day_code in byday_str.split(',') {
match day_code.trim() {
"SU" => days[0] = true, // Sunday
"MO" => days[1] = true, // Monday
"TU" => days[2] = true, // Tuesday
"WE" => days[3] = true, // Wednesday
"TH" => days[4] = true, // Thursday
"FR" => days[5] = true, // Friday
"SA" => days[6] = true, // Saturday
_ => {} // Ignore unknown day codes
}
}
}
days
}