- 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>
304 lines
12 KiB
Rust
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()
|
|
}
|
|
}
|