Files
calendar/frontend/src/components/month_view.rs
Connor Johnstone f88c238b0a Fix external calendar timezone conversion and styling
- Add comprehensive Windows timezone support for global external calendars
  - Map Windows timezone names (e.g. "Mountain Standard Time") to IANA zones (e.g. "America/Denver")
  - Support 60+ timezone mappings across North America, Europe, Asia, Asia Pacific, Africa, South America
  - Add chrono-tz dependency for proper timezone handling
- Fix external calendar event colors by setting calendar_path for color lookup
- Add visual distinction for external calendar events with dashed borders and calendar emoji
- Update timezone parsing to extract TZID parameters from iCalendar DTSTART/DTEND properties
- Pass external calendar data through component hierarchy for color matching

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 19:11:57 -04:00

304 lines
12 KiB
Rust

use crate::models::ical::VEvent;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use chrono::{Datelike, NaiveDate, Weekday};
use std::collections::HashMap;
use wasm_bindgen::{prelude::*, JsCast};
use web_sys::window;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct MonthViewProps {
pub current_month: NaiveDate,
pub today: NaiveDate,
pub events: HashMap<NaiveDate, Vec<VEvent>>,
pub on_event_click: Callback<VEvent>,
#[prop_or_default]
pub refreshing_event_uid: Option<String>,
#[prop_or_default]
pub user_info: Option<UserInfo>,
#[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 selected_date: Option<NaiveDate>,
#[prop_or_default]
pub on_day_select: Option<Callback<NaiveDate>>,
}
#[function_component(MonthView)]
pub fn month_view(props: &MonthViewProps) -> Html {
let max_events_per_day = use_state(|| 4); // Default to 4 events max
let first_day_of_month = props.current_month.with_day(1).unwrap();
let days_in_month = get_days_in_month(props.current_month);
let first_weekday = first_day_of_month.weekday();
let days_from_prev_month = get_days_from_previous_month(props.current_month, first_weekday);
// Calculate maximum events that can fit based on available height
let calculate_max_events = {
let max_events_per_day = max_events_per_day.clone();
move || {
// Since we're using CSS Grid with equal row heights,
// we can estimate based on typical calendar dimensions
// Typical calendar height is around 600-800px for 6 rows
// Each row gets ~100-133px, minus day number and padding leaves ~70-100px
// Each event is ~18px, so we can fit ~3-4 events + "+n more" indicator
max_events_per_day.set(3);
}
};
// Setup resize handler and initial calculation
{
let calculate_max_events = calculate_max_events.clone();
use_effect_with((), move |_| {
let calculate_max_events_clone = calculate_max_events.clone();
// Initial calculation with a slight delay to ensure DOM is ready
if let Some(window) = window() {
let timeout_closure = Closure::wrap(Box::new(move || {
calculate_max_events_clone();
}) as Box<dyn FnMut()>);
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
timeout_closure.as_ref().unchecked_ref(),
100,
);
timeout_closure.forget();
}
// Setup resize listener
let resize_closure = Closure::wrap(Box::new(move || {
calculate_max_events();
}) as Box<dyn Fn()>);
if let Some(window) = window() {
let _ = window.add_event_listener_with_callback(
"resize",
resize_closure.as_ref().unchecked_ref(),
);
resize_closure.forget(); // Keep the closure alive
}
|| {}
});
}
// Helper function to get calendar color for an event
let get_event_color = |event: &VEvent| -> String {
if let Some(calendar_path) = &event.calendar_path {
// Check external calendars first (path format: "external_{id}")
if calendar_path.starts_with("external_") {
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
if let Some(external_calendar) = props.external_calendars
.iter()
.find(|cal| cal.id == id_str)
{
return external_calendar.color.clone();
}
}
}
// Check regular calendars
else if let Some(user_info) = &props.user_info {
if let Some(calendar) = user_info
.calendars
.iter()
.find(|cal| &cal.path == calendar_path)
{
return calendar.color.clone();
}
}
}
"#3B82F6".to_string()
};
html! {
<div class="calendar-grid">
// Weekday headers
<div class="weekday-header">{"Sun"}</div>
<div class="weekday-header">{"Mon"}</div>
<div class="weekday-header">{"Tue"}</div>
<div class="weekday-header">{"Wed"}</div>
<div class="weekday-header">{"Thu"}</div>
<div class="weekday-header">{"Fri"}</div>
<div class="weekday-header">{"Sat"}</div>
// Days from previous month (grayed out)
{
days_from_prev_month.iter().map(|day| {
html! {
<div class="calendar-day prev-month">{*day}</div>
}
}).collect::<Html>()
}
// Days of the current month
{
(1..=days_in_month).map(|day| {
let date = props.current_month.with_day(day).unwrap();
let is_today = date == props.today;
let is_selected = props.selected_date == Some(date);
let day_events = props.events.get(&date).cloned().unwrap_or_default();
// Calculate visible events and overflow
let max_events = *max_events_per_day as usize;
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
let hidden_count = day_events.len().saturating_sub(max_events);
html! {
<div
class={classes!(
"calendar-day",
if is_today { Some("today") } else { None },
if is_selected { Some("selected") } else { None }
)}
onclick={
if let Some(callback) = &props.on_day_select {
let callback = callback.clone();
Some(Callback::from(move |e: web_sys::MouseEvent| {
e.stop_propagation(); // Prevent other handlers
callback.emit(date);
}))
} else {
None
}
}
oncontextmenu={
if let Some(callback) = &props.on_calendar_context_menu {
let callback = callback.clone();
Some(Callback::from(move |e: web_sys::MouseEvent| {
e.prevent_default();
callback.emit((e, date));
}))
} else {
None
}
}
>
<div class="day-number">{day}</div>
<div class="day-events">
{
visible_events.iter().map(|event| {
let event_color = get_event_color(event);
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
let onclick = {
let on_event_click = props.on_event_click.clone();
let event = (*event).clone();
Callback::from(move |_: web_sys::MouseEvent| {
on_event_click.emit(event.clone());
})
};
let oncontextmenu = {
if let Some(callback) = &props.on_event_context_menu {
let callback = callback.clone();
let event = (*event).clone();
Some(Callback::from(move |e: web_sys::MouseEvent| {
e.prevent_default();
callback.emit((e, event.clone()));
}))
} else {
None
}
};
html! {
<div
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
style={format!("background-color: {}", event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
{onclick}
{oncontextmenu}
>
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
</div>
}
}).collect::<Html>()
}
{
if hidden_count > 0 {
html! {
<div class="more-events-indicator">
{format!("+{} more", hidden_count)}
</div>
}
} else {
html! {}
}
}
</div>
</div>
}
}).collect::<Html>()
}
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
</div>
}
}
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
let total_slots = 42; // 6 rows x 7 days
let used_slots = prev_days_count + current_days_count as usize;
let remaining_slots = if used_slots < total_slots {
total_slots - used_slots
} else {
0
};
(1..=remaining_slots)
.map(|day| {
html! {
<div class="calendar-day next-month">{day}</div>
}
})
.collect::<Html>()
}
fn get_days_in_month(date: NaiveDate) -> u32 {
NaiveDate::from_ymd_opt(
if date.month() == 12 {
date.year() + 1
} else {
date.year()
},
if date.month() == 12 {
1
} else {
date.month() + 1
},
1,
)
.unwrap()
.pred_opt()
.unwrap()
.day()
}
fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday) -> Vec<u32> {
let days_before = match first_weekday {
Weekday::Sun => 0,
Weekday::Mon => 1,
Weekday::Tue => 2,
Weekday::Wed => 3,
Weekday::Thu => 4,
Weekday::Fri => 5,
Weekday::Sat => 6,
};
if days_before == 0 {
vec![]
} else {
let prev_month = if current_month.month() == 1 {
NaiveDate::from_ymd_opt(current_month.year() - 1, 12, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
};
let prev_month_days = get_days_in_month(prev_month);
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
}
}