Implement shared RFC 5545 VEvent library with workspace restructuring

- Created calendar-models/ shared library with RFC 5545-compliant VEvent structures
- Migrated backend to use shared VEvent with proper field mappings (dtstart/dtend, rrule, exdate, etc.)
- Converted CalDAV client to parse into VEvent structures with structured types
- Updated all CRUD handlers to use VEvent with CalendarUser, Attendee, VAlarm types
- Restructured project as Cargo workspace with frontend/, backend/, calendar-models/
- Updated Trunk configuration for new directory structure
- Fixed all compilation errors and field references throughout codebase
- Updated documentation and build instructions for workspace structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-30 11:45:58 -04:00
parent 6887e0b389
commit 15f2d0c6d9
43 changed files with 1962 additions and 945 deletions

997
frontend/src/app.rs Normal file
View File

@@ -0,0 +1,997 @@
use yew::prelude::*;
use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use web_sys::MouseEvent;
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction};
use crate::services::{CalendarService, calendar_service::UserInfo};
use crate::models::ical::VEvent;
use chrono::NaiveDate;
fn get_theme_event_colors() -> Vec<String> {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Some(root) = document.document_element() {
if let Ok(Some(computed_style)) = window.get_computed_style(&root) {
if let Ok(colors_string) = computed_style.get_property_value("--event-colors") {
if !colors_string.is_empty() {
return colors_string
.split(',')
.map(|color| color.trim().to_string())
.filter(|color| !color.is_empty())
.collect();
}
}
}
}
}
}
vec![
"#3B82F6".to_string(), "#10B981".to_string(), "#F59E0B".to_string(), "#EF4444".to_string(),
"#8B5CF6".to_string(), "#06B6D4".to_string(), "#84CC16".to_string(), "#F97316".to_string(),
"#EC4899".to_string(), "#6366F1".to_string(), "#14B8A6".to_string(), "#F3B806".to_string(),
"#8B5A2B".to_string(), "#6B7280".to_string(), "#DC2626".to_string(), "#7C3AED".to_string()
]
}
#[function_component]
pub fn App() -> Html {
let auth_token = use_state(|| -> Option<String> {
LocalStorage::get("auth_token").ok()
});
let user_info = use_state(|| -> Option<UserInfo> { None });
let color_picker_open = use_state(|| -> Option<String> { None });
let create_modal_open = use_state(|| false);
let context_menu_open = use_state(|| false);
let context_menu_pos = use_state(|| (0i32, 0i32));
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
let event_context_menu_open = use_state(|| false);
let event_context_menu_pos = use_state(|| (0i32, 0i32));
let event_context_menu_event = use_state(|| -> Option<VEvent> { None });
let calendar_context_menu_open = use_state(|| false);
let calendar_context_menu_pos = use_state(|| (0i32, 0i32));
let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None });
let create_event_modal_open = use_state(|| false);
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
// Calendar view state - load from localStorage if available
let current_view = use_state(|| {
// Try to load saved view mode from localStorage
if let Ok(saved_view) = LocalStorage::get::<String>("calendar_view_mode") {
match saved_view.as_str() {
"week" => ViewMode::Week,
_ => ViewMode::Month,
}
} else {
ViewMode::Month // Default to month view
}
});
// Theme state - load from localStorage if available
let current_theme = use_state(|| {
// Try to load saved theme from localStorage
if let Ok(saved_theme) = LocalStorage::get::<String>("calendar_theme") {
Theme::from_value(&saved_theme)
} else {
Theme::Default // Default theme
}
});
let available_colors = use_state(|| get_theme_event_colors());
let on_login = {
let auth_token = auth_token.clone();
Callback::from(move |token: String| {
auth_token.set(Some(token));
})
};
let on_logout = {
let auth_token = auth_token.clone();
let user_info = user_info.clone();
Callback::from(move |_| {
let _ = LocalStorage::delete("auth_token");
auth_token.set(None);
user_info.set(None);
})
};
let on_view_change = {
let current_view = current_view.clone();
Callback::from(move |new_view: ViewMode| {
// Save view mode to localStorage
let view_string = match new_view {
ViewMode::Month => "month",
ViewMode::Week => "week",
};
let _ = LocalStorage::set("calendar_view_mode", view_string);
// Update state
current_view.set(new_view);
})
};
let on_theme_change = {
let current_theme = current_theme.clone();
let available_colors = available_colors.clone();
Callback::from(move |new_theme: Theme| {
// Save theme to localStorage
let _ = LocalStorage::set("calendar_theme", new_theme.value());
// Apply theme to document root
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
if let Some(root) = document.document_element() {
let _ = root.set_attribute("data-theme", new_theme.value());
}
}
// Update state
current_theme.set(new_theme);
// Update available colors after theme change
available_colors.set(get_theme_event_colors());
})
};
// Apply initial theme on mount
{
let current_theme = current_theme.clone();
use_effect_with((), move |_| {
let theme = (*current_theme).clone();
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
if let Some(root) = document.document_element() {
let _ = root.set_attribute("data-theme", theme.value());
}
}
});
}
// Fetch user info when token is available
{
let user_info = user_info.clone();
let auth_token = auth_token.clone();
use_effect_with((*auth_token).clone(), move |token| {
if let Some(token) = token {
let user_info = user_info.clone();
let token = token.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
if !password.is_empty() {
match calendar_service.fetch_user_info(&token, &password).await {
Ok(mut info) => {
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
for saved_cal in &saved_info.calendars {
for cal in &mut info.calendars {
if cal.path == saved_cal.path {
cal.color = saved_cal.color.clone();
}
}
}
}
}
user_info.set(Some(info));
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to fetch user info: {}", err).into());
}
}
}
});
} else {
user_info.set(None);
}
|| ()
});
}
let on_outside_click = {
let color_picker_open = color_picker_open.clone();
let context_menu_open = context_menu_open.clone();
let event_context_menu_open = event_context_menu_open.clone();
let calendar_context_menu_open = calendar_context_menu_open.clone();
Callback::from(move |e: MouseEvent| {
// Check if any context menu or color picker is open
let any_menu_open = color_picker_open.is_some() ||
*context_menu_open ||
*event_context_menu_open ||
*calendar_context_menu_open;
if any_menu_open {
// Prevent the default action and stop event propagation
e.prevent_default();
e.stop_propagation();
}
// Close all open menus/pickers
color_picker_open.set(None);
context_menu_open.set(false);
event_context_menu_open.set(false);
calendar_context_menu_open.set(false);
})
};
// Compute if any context menu is open
let any_context_menu_open = color_picker_open.is_some() ||
*context_menu_open ||
*event_context_menu_open ||
*calendar_context_menu_open;
let on_color_change = {
let user_info = user_info.clone();
let color_picker_open = color_picker_open.clone();
Callback::from(move |(calendar_path, color): (String, String)| {
if let Some(mut info) = (*user_info).clone() {
for calendar in &mut info.calendars {
if calendar.path == calendar_path {
calendar.color = color.clone();
break;
}
}
user_info.set(Some(info.clone()));
if let Ok(json) = serde_json::to_string(&info) {
let _ = LocalStorage::set("calendar_colors", json);
}
}
color_picker_open.set(None);
})
};
let on_color_picker_toggle = {
let color_picker_open = color_picker_open.clone();
Callback::from(move |calendar_path: String| {
if color_picker_open.as_ref() == Some(&calendar_path) {
color_picker_open.set(None);
} else {
color_picker_open.set(Some(calendar_path));
}
})
};
let on_calendar_context_menu = {
let context_menu_open = context_menu_open.clone();
let context_menu_pos = context_menu_pos.clone();
let context_menu_calendar_path = context_menu_calendar_path.clone();
Callback::from(move |(event, calendar_path): (MouseEvent, String)| {
context_menu_open.set(true);
context_menu_pos.set((event.client_x(), event.client_y()));
context_menu_calendar_path.set(Some(calendar_path));
})
};
let on_event_context_menu = {
let event_context_menu_open = event_context_menu_open.clone();
let event_context_menu_pos = event_context_menu_pos.clone();
let event_context_menu_event = event_context_menu_event.clone();
Callback::from(move |(event, calendar_event): (MouseEvent, VEvent)| {
event_context_menu_open.set(true);
event_context_menu_pos.set((event.client_x(), event.client_y()));
event_context_menu_event.set(Some(calendar_event));
})
};
let on_calendar_date_context_menu = {
let calendar_context_menu_open = calendar_context_menu_open.clone();
let calendar_context_menu_pos = calendar_context_menu_pos.clone();
let calendar_context_menu_date = calendar_context_menu_date.clone();
Callback::from(move |(event, date): (MouseEvent, NaiveDate)| {
calendar_context_menu_open.set(true);
calendar_context_menu_pos.set((event.client_x(), event.client_y()));
calendar_context_menu_date.set(Some(date));
})
};
let on_create_event_click = {
let create_event_modal_open = create_event_modal_open.clone();
let selected_date_for_event = selected_date_for_event.clone();
let calendar_context_menu_date = calendar_context_menu_date.clone();
Callback::from(move |_: MouseEvent| {
create_event_modal_open.set(true);
selected_date_for_event.set((*calendar_context_menu_date).clone());
})
};
let on_event_create = {
let create_event_modal_open = create_event_modal_open.clone();
let auth_token = auth_token.clone();
Callback::from(move |event_data: EventCreationData| {
web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into());
create_event_modal_open.set(false);
if let Some(_token) = (*auth_token).clone() {
wasm_bindgen_futures::spawn_local(async move {
let _calendar_service = CalendarService::new();
// Get CalDAV password from storage
let _password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
let params = event_data.to_create_event_params();
let create_result = _calendar_service.create_event(
&_token,
&_password,
params.0, // title
params.1, // description
params.2, // start_date
params.3, // start_time
params.4, // end_date
params.5, // end_time
params.6, // location
params.7, // all_day
params.8, // status
params.9, // class
params.10, // priority
params.11, // organizer
params.12, // attendees
params.13, // categories
params.14, // reminder
params.15, // recurrence
params.16, // recurrence_days
params.17 // calendar_path
).await;
match create_result {
Ok(_) => {
web_sys::console::log_1(&"Event created successfully".into());
// Trigger a page reload to refresh events from all calendars
// TODO: This could be improved to do a more targeted refresh
web_sys::window().unwrap().location().reload().unwrap();
}
Err(err) => {
web_sys::console::error_1(&format!("Failed to create event: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to create event: {}", err)).unwrap();
}
}
});
}
})
};
let on_event_update = {
let auth_token = auth_token.clone();
Callback::from(move |(original_event, new_start, new_end, preserve_rrule, until_date): (VEvent, chrono::NaiveDateTime, chrono::NaiveDateTime, bool, Option<chrono::DateTime<chrono::Utc>>)| {
web_sys::console::log_1(&format!("Updating event: {} to new times: {} - {}",
original_event.uid,
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();
// Get CalDAV password from storage
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
// Convert local times to UTC for backend storage
let start_utc = new_start.and_local_timezone(chrono::Local).unwrap().to_utc();
let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc();
// Format UTC date and time strings for backend
let start_date = start_utc.format("%Y-%m-%d").to_string();
let start_time = start_utc.format("%H:%M").to_string();
let end_date = end_utc.format("%Y-%m-%d").to_string();
let end_time = end_utc.format("%H:%M").to_string();
// Convert existing event data to string formats for the API
let status_str = match original_event.status {
Some(crate::models::ical::EventStatus::Tentative) => "TENTATIVE".to_string(),
Some(crate::models::ical::EventStatus::Confirmed) => "CONFIRMED".to_string(),
Some(crate::models::ical::EventStatus::Cancelled) => "CANCELLED".to_string(),
None => "CONFIRMED".to_string(), // Default status
};
let class_str = match original_event.class {
Some(crate::models::ical::EventClass::Public) => "PUBLIC".to_string(),
Some(crate::models::ical::EventClass::Private) => "PRIVATE".to_string(),
Some(crate::models::ical::EventClass::Confidential) => "CONFIDENTIAL".to_string(),
None => "PUBLIC".to_string(), // Default class
};
// Convert reminders to string format
let reminder_str = if !original_event.alarms.is_empty() {
// Convert from VAlarm to minutes before
"15".to_string() // TODO: Convert VAlarm trigger to minutes
} else {
"".to_string()
};
// Handle recurrence (keep existing)
let recurrence_str = original_event.rrule.unwrap_or_default();
let recurrence_days = vec![false; 7]; // Default - could be enhanced to parse existing recurrence
match calendar_service.update_event(
&token,
&password,
backend_uid,
original_event.summary.unwrap_or_default(),
original_event.description.unwrap_or_default(),
start_date,
start_time,
end_date,
end_time,
original_event.location.unwrap_or_default(),
original_event.all_day,
status_str,
class_str,
original_event.priority,
original_event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
original_event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
original_event.categories.join(","),
reminder_str,
recurrence_str,
recurrence_days,
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
).await {
Ok(_) => {
web_sys::console::log_1(&"Event updated successfully".into());
// Add small delay before reload to let any pending requests complete
wasm_bindgen_futures::spawn_local(async {
gloo_timers::future::sleep(std::time::Duration::from_millis(100)).await;
web_sys::window().unwrap().location().reload().unwrap();
});
}
Err(err) => {
// Check if this is a network error that occurred after success
let err_str = format!("{}", err);
if err_str.contains("Failed to fetch") || err_str.contains("Network request failed") {
web_sys::console::log_1(&"Update may have succeeded despite network error, reloading...".into());
// Still reload as the update likely succeeded
wasm_bindgen_futures::spawn_local(async {
gloo_timers::future::sleep(std::time::Duration::from_millis(200)).await;
web_sys::window().unwrap().location().reload().unwrap();
});
} else {
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
}
}
}
});
}
})
};
let refresh_calendars = {
let auth_token = auth_token.clone();
let user_info = user_info.clone();
Callback::from(move |_| {
if let Some(token) = (*auth_token).clone() {
let user_info = user_info.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.fetch_user_info(&token, &password).await {
Ok(mut info) => {
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
for saved_cal in &saved_info.calendars {
for cal in &mut info.calendars {
if cal.path == saved_cal.path {
cal.color = saved_cal.color.clone();
}
}
}
}
}
user_info.set(Some(info));
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into());
}
}
});
}
})
};
html! {
<BrowserRouter>
<div class="app" onclick={on_outside_click}>
{
if auth_token.is_some() {
html! {
<>
<Sidebar
user_info={(*user_info).clone()}
on_logout={on_logout}
on_create_calendar={Callback::from({
let create_modal_open = create_modal_open.clone();
move |_| create_modal_open.set(true)
})}
color_picker_open={(*color_picker_open).clone()}
on_color_change={on_color_change}
on_color_picker_toggle={on_color_picker_toggle}
available_colors={(*available_colors).clone()}
on_calendar_context_menu={on_calendar_context_menu}
current_view={(*current_view).clone()}
on_view_change={on_view_change}
current_theme={(*current_theme).clone()}
on_theme_change={on_theme_change}
/>
<main class="app-main">
<RouteHandler
auth_token={(*auth_token).clone()}
user_info={(*user_info).clone()}
on_login={on_login.clone()}
on_event_context_menu={Some(on_event_context_menu.clone())}
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
view={(*current_view).clone()}
on_create_event_request={Some(on_event_create.clone())}
on_event_update_request={Some(on_event_update.clone())}
context_menus_open={any_context_menu_open}
/>
</main>
</>
}
} else {
html! {
<div class="login-layout">
<RouteHandler
auth_token={(*auth_token).clone()}
user_info={(*user_info).clone()}
on_login={on_login.clone()}
on_event_context_menu={Some(on_event_context_menu.clone())}
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
on_event_update_request={Some(on_event_update.clone())}
on_create_event_request={Some(on_event_create.clone())}
context_menus_open={any_context_menu_open}
/>
</div>
}
}
}
<CreateCalendarModal
is_open={*create_modal_open}
on_close={Callback::from({
let create_modal_open = create_modal_open.clone();
move |_| create_modal_open.set(false)
})}
on_create={Callback::from({
let auth_token = auth_token.clone();
let refresh_calendars = refresh_calendars.clone();
let create_modal_open = create_modal_open.clone();
move |(name, description, color): (String, Option<String>, Option<String>)| {
if let Some(token) = (*auth_token).clone() {
let refresh_calendars = refresh_calendars.clone();
let create_modal_open = create_modal_open.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.create_calendar(&token, &password, name, description, color).await {
Ok(_) => {
web_sys::console::log_1(&"Calendar created successfully!".into());
refresh_calendars.emit(());
create_modal_open.set(false);
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into());
create_modal_open.set(false);
}
}
});
}
}
})}
available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
/>
<ContextMenu
is_open={*context_menu_open}
x={context_menu_pos.0}
y={context_menu_pos.1}
on_close={Callback::from({
let context_menu_open = context_menu_open.clone();
move |_| context_menu_open.set(false)
})}
on_delete={Callback::from({
let auth_token = auth_token.clone();
let context_menu_calendar_path = context_menu_calendar_path.clone();
let refresh_calendars = refresh_calendars.clone();
move |_: MouseEvent| {
if let (Some(token), Some(calendar_path)) = ((*auth_token).clone(), (*context_menu_calendar_path).clone()) {
let refresh_calendars = refresh_calendars.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.delete_calendar(&token, &password, calendar_path).await {
Ok(_) => {
web_sys::console::log_1(&"Calendar deleted successfully!".into());
refresh_calendars.emit(());
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to delete calendar: {}", err).into());
}
}
});
}
}
})}
/>
<EventContextMenu
is_open={*event_context_menu_open}
x={event_context_menu_pos.0}
y={event_context_menu_pos.1}
event={(*event_context_menu_event).clone()}
on_close={Callback::from({
let event_context_menu_open = event_context_menu_open.clone();
move |_| event_context_menu_open.set(false)
})}
on_edit={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();
move |_| {
// Close the context menu and open the edit modal
event_context_menu_open.set(false);
create_event_modal_open.set(true);
}
})}
on_delete={Callback::from({
let auth_token = auth_token.clone();
let event_context_menu_event = event_context_menu_event.clone();
let event_context_menu_open = event_context_menu_open.clone();
let refresh_calendars = refresh_calendars.clone();
move |delete_action: DeleteAction| {
if let (Some(token), Some(event)) = ((*auth_token).clone(), (*event_context_menu_event).clone()) {
let _refresh_calendars = refresh_calendars.clone();
let event_context_menu_open = event_context_menu_open.clone();
// Log the delete action for now - we'll implement different behaviors later
match delete_action {
DeleteAction::DeleteThis => web_sys::console::log_1(&"Delete this event".into()),
DeleteAction::DeleteFollowing => web_sys::console::log_1(&"Delete following events".into()),
DeleteAction::DeleteSeries => web_sys::console::log_1(&"Delete entire series".into()),
}
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
if let (Some(calendar_path), Some(event_href)) = (&event.calendar_path, &event.href) {
// Convert delete action to string and get occurrence date
let action_str = match delete_action {
DeleteAction::DeleteThis => "delete_this".to_string(),
DeleteAction::DeleteFollowing => "delete_following".to_string(),
DeleteAction::DeleteSeries => "delete_series".to_string(),
};
// Get the occurrence date from the clicked event
let occurrence_date = Some(event.dtstart.date_naive().format("%Y-%m-%d").to_string());
web_sys::console::log_1(&format!("🔄 Delete action: {}", action_str).into());
web_sys::console::log_1(&format!("🔄 Event UID: {}", event.uid).into());
web_sys::console::log_1(&format!("🔄 Event start: {}", event.dtstart).into());
web_sys::console::log_1(&format!("🔄 Occurrence date: {:?}", occurrence_date).into());
match calendar_service.delete_event(
&token,
&password,
calendar_path.clone(),
event_href.clone(),
action_str,
occurrence_date
).await {
Ok(message) => {
web_sys::console::log_1(&format!("Delete response: {}", message).into());
// Show the message to the user to explain what actually happened
if message.contains("Warning") {
web_sys::window().unwrap().alert_with_message(&message).unwrap();
}
// Close the context menu
event_context_menu_open.set(false);
// Force a page reload to refresh the calendar events
web_sys::window().unwrap().location().reload().unwrap();
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to delete event: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to delete event: {}", err)).unwrap();
}
}
} else {
web_sys::console::log_1(&"Missing calendar_path or href - cannot delete event".into());
}
});
}
}
})}
/>
<CalendarContextMenu
is_open={*calendar_context_menu_open}
x={calendar_context_menu_pos.0}
y={calendar_context_menu_pos.1}
on_close={Callback::from({
let calendar_context_menu_open = calendar_context_menu_open.clone();
move |_| calendar_context_menu_open.set(false)
})}
on_create_event={on_create_event_click}
/>
<CreateEventModal
is_open={*create_event_modal_open}
selected_date={(*selected_date_for_event).clone()}
event_to_edit={(*event_context_menu_event).clone()}
on_close={Callback::from({
let create_event_modal_open = create_event_modal_open.clone();
let event_context_menu_event = event_context_menu_event.clone();
move |_| {
create_event_modal_open.set(false);
// Clear the event being edited
event_context_menu_event.set(None);
}
})}
on_create={on_event_create}
on_update={Callback::from({
let auth_token = auth_token.clone();
let create_event_modal_open = create_event_modal_open.clone();
let event_context_menu_event = event_context_menu_event.clone();
move |(original_event, updated_data): (VEvent, EventCreationData)| {
web_sys::console::log_1(&format!("Updating event: {:?}", updated_data).into());
create_event_modal_open.set(false);
event_context_menu_event.set(None);
if let Some(token) = (*auth_token).clone() {
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
// Get CalDAV password from storage
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
// Convert local times to UTC for backend storage
let start_local = updated_data.start_date.and_time(updated_data.start_time);
let end_local = updated_data.end_date.and_time(updated_data.end_time);
let start_utc = start_local.and_local_timezone(chrono::Local).unwrap().to_utc();
let end_utc = end_local.and_local_timezone(chrono::Local).unwrap().to_utc();
// Format UTC date and time strings for backend
let start_date = start_utc.format("%Y-%m-%d").to_string();
let start_time = start_utc.format("%H:%M").to_string();
let end_date = end_utc.format("%Y-%m-%d").to_string();
let end_time = end_utc.format("%H:%M").to_string();
// Convert enums to strings for backend
let status_str = match updated_data.status {
EventStatus::Tentative => "tentative",
EventStatus::Cancelled => "cancelled",
_ => "confirmed",
}.to_string();
let class_str = match updated_data.class {
EventClass::Private => "private",
EventClass::Confidential => "confidential",
_ => "public",
}.to_string();
let reminder_str = match updated_data.reminder {
ReminderType::Minutes15 => "15min",
ReminderType::Minutes30 => "30min",
ReminderType::Hour1 => "1hour",
ReminderType::Hours2 => "2hours",
ReminderType::Day1 => "1day",
ReminderType::Days2 => "2days",
ReminderType::Week1 => "1week",
_ => "none",
}.to_string();
let recurrence_str = match updated_data.recurrence {
RecurrenceType::Daily => "daily",
RecurrenceType::Weekly => "weekly",
RecurrenceType::Monthly => "monthly",
RecurrenceType::Yearly => "yearly",
_ => "none",
}.to_string();
// Check if the calendar has changed
let calendar_changed = original_event.calendar_path.as_ref() != updated_data.selected_calendar.as_ref();
if calendar_changed {
// Calendar changed - need to delete from original and create in new
web_sys::console::log_1(&"Calendar changed - performing delete + create".into());
// First delete from original calendar
if let Some(original_calendar_path) = &original_event.calendar_path {
if let Some(event_href) = &original_event.href {
match calendar_service.delete_event(
&token,
&password,
original_calendar_path.clone(),
event_href.clone(),
"single".to_string(), // delete single occurrence
None
).await {
Ok(_) => {
web_sys::console::log_1(&"Original event deleted successfully".into());
// Now create the event in the new calendar
match calendar_service.create_event(
&token,
&password,
updated_data.title,
updated_data.description,
start_date,
start_time,
end_date,
end_time,
updated_data.location,
updated_data.all_day,
status_str,
class_str,
updated_data.priority,
updated_data.organizer,
updated_data.attendees,
updated_data.categories,
reminder_str,
recurrence_str,
updated_data.recurrence_days,
updated_data.selected_calendar
).await {
Ok(_) => {
web_sys::console::log_1(&"Event moved to new calendar successfully".into());
// Trigger a page reload to refresh events from all calendars
web_sys::window().unwrap().location().reload().unwrap();
}
Err(err) => {
web_sys::console::error_1(&format!("Failed to create event in new calendar: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to move event to new calendar: {}", err)).unwrap();
}
}
}
Err(err) => {
web_sys::console::error_1(&format!("Failed to delete original event: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to delete original event: {}", err)).unwrap();
}
}
} else {
web_sys::console::error_1(&"Original event missing href for deletion".into());
web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing href").unwrap();
}
} else {
web_sys::console::error_1(&"Original event missing calendar_path for deletion".into());
web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing calendar path").unwrap();
}
} else {
// Calendar hasn't changed - normal update
match calendar_service.update_event(
&token,
&password,
original_event.uid,
updated_data.title,
updated_data.description,
start_date,
start_time,
end_date,
end_time,
updated_data.location,
updated_data.all_day,
status_str,
class_str,
updated_data.priority,
updated_data.organizer,
updated_data.attendees,
updated_data.categories,
reminder_str,
recurrence_str,
updated_data.recurrence_days,
updated_data.selected_calendar,
original_event.exdate.clone(),
Some("update_series".to_string()), // This is for event edit modal, preserve original RRULE
None // No until_date for edit modal
).await {
Ok(_) => {
web_sys::console::log_1(&"Event updated successfully".into());
// Trigger a page reload to refresh events from all calendars
web_sys::window().unwrap().location().reload().unwrap();
}
Err(err) => {
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
}
}
}
});
}
}
})}
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
/>
</div>
</BrowserRouter>
}
}