Files
calendar/frontend/src/components/calendar.rs
Connor Johnstone 6a01a75cce Add visibility toggles for CalDAV calendars with event filtering
Users can now toggle visibility of CalDAV calendars using checkboxes in
the sidebar, matching the behavior of external calendars. Events from
hidden calendars are automatically filtered out of the calendar view.

Changes:
- Add is_visible field to CalendarInfo (frontend & backend)
- Add visibility checkboxes to CalDAV calendar list items
- Implement real-time event filtering based on calendar visibility
- Add CSS styling matching external calendar checkboxes
- Default new calendars to visible state

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 21:37:46 -04:00

569 lines
23 KiB
Rust

use crate::components::{
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
};
use crate::models::ical::VEvent;
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
use chrono::{Datelike, Duration, Local, NaiveDate};
use gloo_storage::{LocalStorage, Storage};
use std::collections::HashMap;
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CalendarProps {
#[prop_or_default]
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub external_calendar_events: Vec<VEvent>,
#[prop_or_default]
pub external_calendars: Vec<ExternalCalendar>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
#[prop_or_default]
pub view: ViewMode,
#[prop_or_default]
pub on_create_event_request: Option<Callback<EventCreationData>>,
#[prop_or_default]
pub on_event_update_request: Option<
Callback<(
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)>,
>,
#[prop_or_default]
pub context_menus_open: bool,
}
#[function_component]
pub fn Calendar(props: &CalendarProps) -> Html {
let today = Local::now().date_naive();
// Event management state
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
let loading = use_state(|| true);
let error = use_state(|| None::<String>);
let refreshing_event_uid = use_state(|| None::<String>);
// Track the currently selected date (the actual day the user has selected)
let selected_date = use_state(|| {
// Try to load saved selected date from localStorage
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_selected_date") {
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
saved_date
} else {
today
}
} else {
// Check for old key for backward compatibility
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_current_month") {
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
saved_date
} else {
today
}
} else {
today
}
}
});
// Track the display date (what to show in the view)
let current_date = use_state(|| match props.view {
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
ViewMode::Week => *selected_date,
});
let selected_event = use_state(|| None::<VEvent>);
// State for create event modal
let show_create_modal = use_state(|| false);
let create_event_data =
use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
// State for time increment snapping (15 or 30 minutes)
let time_increment = use_state(|| {
// Try to load saved time increment from localStorage
if let Ok(saved_increment) = LocalStorage::get::<u32>("calendar_time_increment") {
if saved_increment == 15 || saved_increment == 30 {
saved_increment
} else {
15
}
} else {
15
}
});
// Fetch events when current_date changes
{
let events = events.clone();
let loading = loading.clone();
let error = error.clone();
let current_date = current_date.clone();
let external_events = props.external_calendar_events.clone(); // Clone before the effect
let view = props.view.clone(); // Clone before the effect
use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| {
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
let date = *date; // Clone the date to avoid lifetime issues
let external_events = external_events.clone(); // Clone external events to avoid lifetime issues
let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues
if let Some(token) = auth_token {
let events = events.clone();
let loading = loading.clone();
let error = error.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()
};
let current_year = date.year();
let current_month = date.month();
match calendar_service
.fetch_events_for_month_vevent(
&token,
&password,
current_year,
current_month,
)
.await
{
Ok(vevents) => {
// Filter CalDAV events based on calendar visibility
let mut filtered_events = if let Some(user_info) = user_info.as_ref() {
vevents.into_iter()
.filter(|event| {
if let Some(calendar_path) = event.calendar_path.as_ref() {
// Find the calendar info for this event
user_info.calendars.iter()
.find(|cal| &cal.path == calendar_path)
.map(|cal| cal.is_visible)
.unwrap_or(true) // Default to visible if not found
} else {
true // Show events without calendar path
}
})
.collect()
} else {
vevents // Show all events if no user info
};
// Mark external events as external by adding a special category
let marked_external_events: Vec<VEvent> = external_events
.into_iter()
.map(|mut event| {
// Add a special category to identify external events
event.categories.push("__EXTERNAL_CALENDAR__".to_string());
event
})
.collect();
filtered_events.extend(marked_external_events);
let grouped_events = CalendarService::group_events_by_date(filtered_events);
events.set(grouped_events);
loading.set(false);
}
Err(err) => {
error.set(Some(format!("Failed to load events: {}", err)));
loading.set(false);
}
}
});
} else {
loading.set(false);
error.set(Some("No authentication token found".to_string()));
}
|| ()
});
}
// Handle event click to refresh individual events
let on_event_click = {
let events = events.clone();
let refreshing_event_uid = refreshing_event_uid.clone();
Callback::from(move |event: VEvent| {
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
if let Some(token) = auth_token {
let events = events.clone();
let refreshing_event_uid = refreshing_event_uid.clone();
let uid = event.uid.clone();
refreshing_event_uid.set(Some(uid.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
.refresh_event(&token, &password, &uid)
.await
{
Ok(Some(refreshed_event)) => {
let refreshed_vevent = refreshed_event;
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
if refreshed_vevent.rrule.is_some() {
let new_occurrences =
CalendarService::expand_recurring_events(vec![
refreshed_vevent.clone(),
]);
for occurrence in new_occurrences {
let date = occurrence.get_date();
updated_events
.entry(date)
.or_insert_with(Vec::new)
.push(occurrence);
}
} else {
let date = refreshed_vevent.get_date();
updated_events
.entry(date)
.or_insert_with(Vec::new)
.push(refreshed_vevent);
}
events.set(updated_events);
}
Ok(None) => {
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
events.set(updated_events);
}
Err(_err) => {}
}
refreshing_event_uid.set(None);
});
}
})
};
// Handle view mode changes - adjust current_date format when switching between month/week
{
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
use_effect_with(view, move |view_mode| {
let selected = *selected_date;
let new_display_date = match view_mode {
ViewMode::Month => selected.with_day(1).unwrap_or(selected),
ViewMode::Week => selected, // Show the week containing the selected date
};
current_date.set(new_display_date);
|| {}
});
}
let on_prev = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
Callback::from(move |_: MouseEvent| {
let (new_selected, new_display) = match view {
ViewMode::Month => {
// Go to previous month, select the 1st day
let prev_month = *current_date - Duration::days(1);
let first_of_prev = prev_month.with_day(1).unwrap();
(first_of_prev, first_of_prev)
}
ViewMode::Week => {
// Go to previous week
let prev_week = *selected_date - Duration::weeks(1);
(prev_week, prev_week)
}
};
selected_date.set(new_selected);
current_date.set(new_display);
let _ = LocalStorage::set(
"calendar_selected_date",
new_selected.format("%Y-%m-%d").to_string(),
);
})
};
let on_next = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
Callback::from(move |_: MouseEvent| {
let (new_selected, new_display) = match view {
ViewMode::Month => {
// Go to next month, select the 1st day
let next_month = if current_date.month() == 12 {
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
.unwrap()
};
(next_month, next_month)
}
ViewMode::Week => {
// Go to next week
let next_week = *selected_date + Duration::weeks(1);
(next_week, next_week)
}
};
selected_date.set(new_selected);
current_date.set(new_display);
let _ = LocalStorage::set(
"calendar_selected_date",
new_selected.format("%Y-%m-%d").to_string(),
);
})
};
let on_today = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
Callback::from(move |_| {
let today = Local::now().date_naive();
let (new_selected, new_display) = match view {
ViewMode::Month => {
let first_of_today = today.with_day(1).unwrap();
(today, first_of_today) // Select today, but display the month
}
ViewMode::Week => (today, today), // Select and display today
};
selected_date.set(new_selected);
current_date.set(new_display);
let _ = LocalStorage::set(
"calendar_selected_date",
new_selected.format("%Y-%m-%d").to_string(),
);
})
};
// Handle time increment toggle
let on_time_increment_toggle = {
let time_increment = time_increment.clone();
Callback::from(move |_: MouseEvent| {
let current = *time_increment;
let next = if current == 15 { 30 } else { 15 };
time_increment.set(next);
let _ = LocalStorage::set("calendar_time_increment", next);
})
};
// Handle drag-to-create event
let on_create_event = {
let show_create_modal = show_create_modal.clone();
let create_event_data = create_event_data.clone();
Callback::from(
move |(_date, start_datetime, end_datetime): (
NaiveDate,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
)| {
// For drag-to-create, we don't need the temporary event approach
// Instead, just pass the local times directly via initial_time props
create_event_data.set(Some((
start_datetime.date(),
start_datetime.time(),
end_datetime.time(),
)));
show_create_modal.set(true);
},
)
};
// Handle drag-to-move event
let on_event_update = {
let on_event_update_request = props.on_event_update_request.clone();
Callback::from(
move |(
event,
new_start,
new_end,
preserve_rrule,
until_date,
update_scope,
occurrence_date,
): (
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)| {
if let Some(callback) = &on_event_update_request {
callback.emit((
event,
new_start,
new_end,
preserve_rrule,
until_date,
update_scope,
occurrence_date,
));
}
},
)
};
html! {
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
<CalendarHeader
current_date={*current_date}
view_mode={props.view.clone()}
on_prev={on_prev}
on_next={on_next}
on_today={on_today}
time_increment={Some(*time_increment)}
on_time_increment_toggle={Some(on_time_increment_toggle)}
/>
{
if *loading {
html! {
<div class="calendar-loading">
<p>{"Loading calendar events..."}</p>
</div>
}
} else if let Some(err) = (*error).clone() {
html! {
<div class="calendar-error">
<p>{format!("Error: {}", err)}</p>
</div>
}
} else {
match props.view {
ViewMode::Month => {
let on_day_select = {
let selected_date = selected_date.clone();
Callback::from(move |date: NaiveDate| {
selected_date.set(date);
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
})
};
html! {
<MonthView
current_month={*current_date}
today={today}
events={(*events).clone()}
on_event_click={on_event_click.clone()}
refreshing_event_uid={(*refreshing_event_uid).clone()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
selected_date={Some(*selected_date)}
on_day_select={Some(on_day_select)}
/>
}
},
ViewMode::Week => html! {
<WeekView
current_date={*current_date}
today={today}
events={(*events).clone()}
on_event_click={on_event_click.clone()}
refreshing_event_uid={(*refreshing_event_uid).clone()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
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}
/>
},
}
}
}
// Event details modal
<EventModal
event={(*selected_event).clone()}
on_close={{
let selected_event_clone = selected_event.clone();
Callback::from(move |_| {
selected_event_clone.set(None);
})
}}
/>
// Create event modal
<CreateEventModal
is_open={*show_create_modal}
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
event_to_edit={None}
available_calendars={props.user_info.as_ref().map(|info| info.calendars.clone()).unwrap_or_default()}
initial_start_time={create_event_data.as_ref().map(|(_, start_time, _)| *start_time)}
initial_end_time={create_event_data.as_ref().map(|(_, _, end_time)| *end_time)}
on_close={{
let show_create_modal = show_create_modal.clone();
let create_event_data = create_event_data.clone();
Callback::from(move |_| {
show_create_modal.set(false);
create_event_data.set(None);
})
}}
on_create={{
let show_create_modal = show_create_modal.clone();
let create_event_data = create_event_data.clone();
let on_create_event_request = props.on_create_event_request.clone();
Callback::from(move |event_data: EventCreationData| {
show_create_modal.set(false);
create_event_data.set(None);
// Emit the create event request to parent
if let Some(callback) = &on_create_event_request {
callback.emit(event_data);
}
})
}}
/>
</div>
}
}