- 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>
455 lines
18 KiB
Rust
455 lines
18 KiB
Rust
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
|
||
} |