From f88c238b0a4b14c285b31ea29ce691c737ded617 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Wed, 3 Sep 2025 19:11:57 -0400 Subject: [PATCH] Fix external calendar timezone conversion and styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/Cargo.toml | 1 + backend/src/handlers/ics_fetcher.rs | 147 +++++++++- frontend/src/app.rs | 19 +- frontend/src/components/calendar.rs | 19 +- frontend/src/components/month_view.rs | 21 +- frontend/src/components/route_handler.rs | 10 +- frontend/src/components/week_view.rs | 25 +- frontend/styles.css | 326 +++++++++++++++++++++++ 8 files changed, 544 insertions(+), 24 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1c5e46b..274d216 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -22,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.8" uuid = { version = "1.0", features = ["v4", "serde"] } anyhow = "1.0" diff --git a/backend/src/handlers/ics_fetcher.rs b/backend/src/handlers/ics_fetcher.rs index d797cf9..e7465ec 100644 --- a/backend/src/handlers/ics_fetcher.rs +++ b/backend/src/handlers/ics_fetcher.rs @@ -135,8 +135,13 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result Result Result Option> { +fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option> { + use chrono::TimeZone; + use chrono_tz::Tz; + // Try various datetime formats commonly found in ICS files // Format: 20231201T103000Z (UTC) if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%SZ") { return Some(dt.with_timezone(&Utc)); } - - // Format: 20231201T103000 (floating time - assume UTC) - if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") { - return Some(chrono::TimeZone::from_utc_datetime(&Utc, &dt)); - } // Format: 20231201T103000-0500 (with timezone offset) if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S%z") { @@ -229,9 +238,123 @@ fn parse_datetime(datetime_str: &str) -> Option> { return Some(dt.with_timezone(&Utc)); } - // Format: 2023-12-01T10:30:00 (ISO without timezone) - if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") { - return Some(chrono::TimeZone::from_utc_datetime(&Utc, &dt)); + // Handle naive datetime with timezone parameter + let naive_dt = if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") { + Some(dt) + } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") { + Some(dt) + } else { + None + }; + + if let Some(naive_dt) = naive_dt { + // If TZID is provided, try to parse it + if let Some(tzid_str) = tzid { + // Handle common timezone formats + let tz_result = if tzid_str.starts_with("/mozilla.org/") { + // Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London + tzid_str.split('/').last().and_then(|tz_name| tz_name.parse::().ok()) + } else if tzid_str.contains('/') { + // Standard timezone format: America/New_York, Europe/London + tzid_str.parse::().ok() + } else { + // Try common abbreviations and Windows timezone names + match tzid_str { + // Standard abbreviations + "EST" => Some(Tz::America__New_York), + "PST" => Some(Tz::America__Los_Angeles), + "MST" => Some(Tz::America__Denver), + "CST" => Some(Tz::America__Chicago), + + // North America - Windows timezone names to IANA mapping + "Mountain Standard Time" => Some(Tz::America__Denver), + "Eastern Standard Time" => Some(Tz::America__New_York), + "Central Standard Time" => Some(Tz::America__Chicago), + "Pacific Standard Time" => Some(Tz::America__Los_Angeles), + "Mountain Daylight Time" => Some(Tz::America__Denver), + "Eastern Daylight Time" => Some(Tz::America__New_York), + "Central Daylight Time" => Some(Tz::America__Chicago), + "Pacific Daylight Time" => Some(Tz::America__Los_Angeles), + "Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu), + "Alaskan Standard Time" => Some(Tz::America__Anchorage), + "Alaskan Daylight Time" => Some(Tz::America__Anchorage), + "Atlantic Standard Time" => Some(Tz::America__Halifax), + "Newfoundland Standard Time" => Some(Tz::America__St_Johns), + + // Europe + "GMT Standard Time" => Some(Tz::Europe__London), + "Greenwich Standard Time" => Some(Tz::UTC), + "W. Europe Standard Time" => Some(Tz::Europe__Berlin), + "Central Europe Standard Time" => Some(Tz::Europe__Warsaw), + "Romance Standard Time" => Some(Tz::Europe__Paris), + "Central European Standard Time" => Some(Tz::Europe__Belgrade), + "E. Europe Standard Time" => Some(Tz::Europe__Bucharest), + "FLE Standard Time" => Some(Tz::Europe__Helsinki), + "GTB Standard Time" => Some(Tz::Europe__Athens), + "Russian Standard Time" => Some(Tz::Europe__Moscow), + "Turkey Standard Time" => Some(Tz::Europe__Istanbul), + + // Asia + "China Standard Time" => Some(Tz::Asia__Shanghai), + "Tokyo Standard Time" => Some(Tz::Asia__Tokyo), + "Korea Standard Time" => Some(Tz::Asia__Seoul), + "Singapore Standard Time" => Some(Tz::Asia__Singapore), + "India Standard Time" => Some(Tz::Asia__Kolkata), + "Pakistan Standard Time" => Some(Tz::Asia__Karachi), + "Bangladesh Standard Time" => Some(Tz::Asia__Dhaka), + "Thailand Standard Time" => Some(Tz::Asia__Bangkok), + "SE Asia Standard Time" => Some(Tz::Asia__Bangkok), + "Myanmar Standard Time" => Some(Tz::Asia__Yangon), + "Sri Lanka Standard Time" => Some(Tz::Asia__Colombo), + "Nepal Standard Time" => Some(Tz::Asia__Kathmandu), + "Central Asia Standard Time" => Some(Tz::Asia__Almaty), + "West Asia Standard Time" => Some(Tz::Asia__Tashkent), + "Afghanistan Standard Time" => Some(Tz::Asia__Kabul), + "Iran Standard Time" => Some(Tz::Asia__Tehran), + "Arabian Standard Time" => Some(Tz::Asia__Dubai), + "Arab Standard Time" => Some(Tz::Asia__Riyadh), + "Israel Standard Time" => Some(Tz::Asia__Jerusalem), + "Jordan Standard Time" => Some(Tz::Asia__Amman), + "Syria Standard Time" => Some(Tz::Asia__Damascus), + "Middle East Standard Time" => Some(Tz::Asia__Beirut), + "Egypt Standard Time" => Some(Tz::Africa__Cairo), + "South Africa Standard Time" => Some(Tz::Africa__Johannesburg), + "E. Africa Standard Time" => Some(Tz::Africa__Nairobi), + "W. Central Africa Standard Time" => Some(Tz::Africa__Lagos), + + // Asia Pacific + "AUS Eastern Standard Time" => Some(Tz::Australia__Sydney), + "AUS Central Standard Time" => Some(Tz::Australia__Darwin), + "W. Australia Standard Time" => Some(Tz::Australia__Perth), + "Tasmania Standard Time" => Some(Tz::Australia__Hobart), + "New Zealand Standard Time" => Some(Tz::Pacific__Auckland), + "Fiji Standard Time" => Some(Tz::Pacific__Fiji), + "Tonga Standard Time" => Some(Tz::Pacific__Tongatapu), + + // South America + "Argentina Standard Time" => Some(Tz::America__Buenos_Aires), + "E. South America Standard Time" => Some(Tz::America__Sao_Paulo), + "SA Eastern Standard Time" => Some(Tz::America__Cayenne), + "SA Pacific Standard Time" => Some(Tz::America__Bogota), + "SA Western Standard Time" => Some(Tz::America__La_Paz), + "Pacific SA Standard Time" => Some(Tz::America__Santiago), + "Venezuela Standard Time" => Some(Tz::America__Caracas), + "Montevideo Standard Time" => Some(Tz::America__Montevideo), + + // Try parsing as IANA name + _ => tzid_str.parse::().ok() + } + }; + + if let Some(tz) = tz_result { + if let Some(dt_with_tz) = tz.from_local_datetime(&naive_dt).single() { + return Some(dt_with_tz.with_timezone(&Utc)); + } + } + } + + // If no timezone info or parsing failed, treat as UTC (safer than local time assumptions) + return Some(chrono::TimeZone::from_utc_datetime(&Utc, &naive_dt)); } None diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 4c62964..e5a1cba 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -328,7 +328,11 @@ pub fn App() -> Html { let mut all_events = Vec::new(); for calendar in calendars { if calendar.is_visible { - if let Ok(events) = CalendarService::fetch_external_calendar_events(calendar.id).await { + if let Ok(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await { + // Set calendar_path for color matching + for event in &mut events { + event.calendar_path = Some(format!("external_{}", calendar.id)); + } all_events.extend(events); } } @@ -1011,7 +1015,11 @@ pub fn App() -> Html { let mut all_events = Vec::new(); for cal in calendars { if cal.is_visible { - if let Ok(events) = CalendarService::fetch_external_calendar_events(cal.id).await { + if let Ok(mut events) = CalendarService::fetch_external_calendar_events(cal.id).await { + // Set calendar_path for color matching + for event in &mut events { + event.calendar_path = Some(format!("external_{}", cal.id)); + } all_events.extend(events); } } @@ -1039,6 +1047,7 @@ pub fn App() -> Html { user_info={(*user_info).clone()} on_login={on_login.clone()} external_calendar_events={(*external_calendar_events).clone()} + external_calendars={(*external_calendars).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()} @@ -1314,7 +1323,11 @@ pub fn App() -> Html { let mut all_events = Vec::new(); for calendar in calendars { if calendar.is_visible { - if let Ok(events) = CalendarService::fetch_external_calendar_events(calendar.id).await { + if let Ok(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await { + // Set calendar_path for color matching + for event in &mut events { + event.calendar_path = Some(format!("external_{}", calendar.id)); + } all_events.extend(events); } } diff --git a/frontend/src/components/calendar.rs b/frontend/src/components/calendar.rs index 0bf0c9e..c2d8856 100644 --- a/frontend/src/components/calendar.rs +++ b/frontend/src/components/calendar.rs @@ -2,7 +2,7 @@ use crate::components::{ CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, }; use crate::models::ical::VEvent; -use crate::services::{calendar_service::UserInfo, CalendarService}; +use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; use chrono::{Datelike, Duration, Local, NaiveDate}; use gloo_storage::{LocalStorage, Storage}; use std::collections::HashMap; @@ -16,6 +16,8 @@ pub struct CalendarProps { #[prop_or_default] pub external_calendar_events: Vec, #[prop_or_default] + pub external_calendars: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -148,7 +150,18 @@ pub fn Calendar(props: &CalendarProps) -> Html { Ok(vevents) => { // Combine regular events with external calendar events let mut all_events = vevents; - all_events.extend(external_events); + + // Mark external events as external by adding a special category + let marked_external_events: Vec = external_events + .into_iter() + .map(|mut event| { + // Add a special category to identify external events + event.categories.push("__EXTERNAL_CALENDAR__".to_string()); + event + }) + .collect(); + + all_events.extend(marked_external_events); let grouped_events = CalendarService::group_events_by_date(all_events); events.set(grouped_events); @@ -461,6 +474,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { 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)} @@ -476,6 +490,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { 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)} diff --git a/frontend/src/components/month_view.rs b/frontend/src/components/month_view.rs index 9956880..c7cdc62 100644 --- a/frontend/src/components/month_view.rs +++ b/frontend/src/components/month_view.rs @@ -1,5 +1,5 @@ use crate::models::ical::VEvent; -use crate::services::calendar_service::UserInfo; +use crate::services::calendar_service::{UserInfo, ExternalCalendar}; use chrono::{Datelike, NaiveDate, Weekday}; use std::collections::HashMap; use wasm_bindgen::{prelude::*, JsCast}; @@ -17,6 +17,8 @@ pub struct MonthViewProps { #[prop_or_default] pub user_info: Option, #[prop_or_default] + pub external_calendars: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -85,8 +87,20 @@ pub fn month_view(props: &MonthViewProps) -> Html { // Helper function to get calendar color for an event let get_event_color = |event: &VEvent| -> String { - if let Some(user_info) = &props.user_info { - if let Some(calendar_path) = &event.calendar_path { + 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::() { + 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() @@ -194,6 +208,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
diff --git a/frontend/src/components/route_handler.rs b/frontend/src/components/route_handler.rs index d2c7fcb..bf4bf7f 100644 --- a/frontend/src/components/route_handler.rs +++ b/frontend/src/components/route_handler.rs @@ -1,6 +1,6 @@ use crate::components::{Login, ViewMode}; use crate::models::ical::VEvent; -use crate::services::calendar_service::UserInfo; +use crate::services::calendar_service::{UserInfo, ExternalCalendar}; use yew::prelude::*; use yew_router::prelude::*; @@ -22,6 +22,8 @@ pub struct RouteHandlerProps { #[prop_or_default] pub external_calendar_events: Vec, #[prop_or_default] + pub external_calendars: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -51,6 +53,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { let user_info = props.user_info.clone(); let on_login = props.on_login.clone(); let external_calendar_events = props.external_calendar_events.clone(); + let external_calendars = props.external_calendars.clone(); let on_event_context_menu = props.on_event_context_menu.clone(); let on_calendar_context_menu = props.on_calendar_context_menu.clone(); let view = props.view.clone(); @@ -64,6 +67,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { let user_info = user_info.clone(); let on_login = on_login.clone(); let external_calendar_events = external_calendar_events.clone(); + let external_calendars = external_calendars.clone(); let on_event_context_menu = on_event_context_menu.clone(); let on_calendar_context_menu = on_calendar_context_menu.clone(); let view = view.clone(); @@ -92,6 +96,7 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html { , #[prop_or_default] + pub external_calendars: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -147,6 +154,7 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html { , #[prop_or_default] + pub external_calendars: Vec, + #[prop_or_default] pub on_event_context_menu: Option>, #[prop_or_default] pub on_calendar_context_menu: Option>, @@ -81,8 +83,20 @@ pub fn week_view(props: &WeekViewProps) -> Html { // Helper function to get calendar color for an event let get_event_color = |event: &VEvent| -> String { - if let Some(user_info) = &props.user_info { - if let Some(calendar_path) = &event.calendar_path { + 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::() { + 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() @@ -371,6 +385,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
@@ -905,6 +920,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { column_width ) } + data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()} {onclick} {oncontextmenu} onmousedown={onmousedown_event} @@ -992,6 +1008,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
{if duration_pixels > 30.0 { @@ -1025,6 +1042,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
{if new_height > 30.0 { @@ -1052,6 +1070,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
{if new_height > 30.0 { diff --git a/frontend/styles.css b/frontend/styles.css index dc3ea85..44544bd 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -3745,3 +3745,329 @@ body { grid-template-columns: repeat(2, 1fr); } } + +/* ==================== EXTERNAL CALENDARS STYLES ==================== */ + +/* External Calendar Section in Sidebar */ +.external-calendar-list { + margin-bottom: 2rem; +} + +.external-calendar-list h3 { + color: rgba(255, 255, 255, 0.9); + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 1rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding-left: 0.5rem; +} + +.external-calendar-items { + list-style: none; + margin: 0; + padding: 0; +} + +.external-calendar-item { + margin-bottom: 0.5rem; +} + +.external-calendar-info { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 8px; + transition: all 0.2s ease; + cursor: pointer; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.external-calendar-info:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + transform: translateX(2px); +} + +.external-calendar-info input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: rgba(255, 255, 255, 0.8); + cursor: pointer; +} + +.external-calendar-color { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.3); + flex-shrink: 0; +} + +.external-calendar-name { + color: rgba(255, 255, 255, 0.9); + font-size: 0.85rem; + font-weight: 500; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.external-calendar-indicator { + font-size: 0.8rem; + opacity: 0.7; + flex-shrink: 0; +} + +/* Create External Calendar Button */ +.create-external-calendar-button { + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.25); + color: rgba(255, 255, 255, 0.9); + padding: 0.75rem 1rem; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + margin-bottom: 1rem; + font-size: 0.85rem; + font-weight: 500; + backdrop-filter: blur(10px); + width: 100%; + position: relative; +} + +.create-external-calendar-button::before { + content: "📅"; + position: absolute; + left: 1rem; + font-size: 0.8rem; + opacity: 0.8; +} + +.create-external-calendar-button { + padding-left: 2.5rem; +} + +.create-external-calendar-button:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.4); + transform: translateY(-1px); +} + +.create-external-calendar-button:active { + transform: translateY(0); +} + +/* External Calendar Modal */ +.external-calendar-modal { + background: white; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-width: 500px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + animation: modalSlideIn 0.3s ease; +} + +.external-calendar-modal .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2rem 2rem 1rem; + border-bottom: 1px solid #e9ecef; +} + +.external-calendar-modal .modal-header h3 { + margin: 0; + color: #495057; + font-size: 1.5rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.external-calendar-modal .modal-header h3::before { + content: "📅"; + font-size: 1.2rem; + opacity: 0.8; +} + +.external-calendar-modal .modal-close { + background: none; + border: none; + font-size: 1.5rem; + color: #6c757d; + cursor: pointer; + padding: 0.5rem; + border-radius: 50%; + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.external-calendar-modal .modal-close:hover { + background: #f8f9fa; + color: #495057; +} + +.external-calendar-modal .modal-body { + padding: 1.5rem 2rem 2rem; +} + +.external-calendar-modal .form-group { + margin-bottom: 1.5rem; +} + +.external-calendar-modal .form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: #495057; + font-size: 0.9rem; +} + +.external-calendar-modal .form-group input[type="text"], +.external-calendar-modal .form-group input[type="url"] { + width: 100%; + padding: 0.75rem; + border: 1px solid #ced4da; + border-radius: 8px; + font-size: 0.9rem; + transition: all 0.2s ease; + background: white; +} + +.external-calendar-modal .form-group input[type="text"]:focus, +.external-calendar-modal .form-group input[type="url"]:focus { + outline: none; + border-color: var(--primary-color, #667eea); + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1); +} + +.external-calendar-modal .form-group input[type="color"] { + width: 100%; + height: 40px; + padding: 0; + border: 1px solid #ced4da; + border-radius: 8px; + cursor: pointer; + background: none; +} + +.external-calendar-modal .form-help { + display: block; + margin-top: 0.5rem; + font-size: 0.8rem; + color: #6c757d; + font-style: italic; +} + +.external-calendar-modal .modal-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + padding: 1.5rem 2rem; + border-top: 1px solid #e9ecef; + background: #f8f9fa; + border-radius: 0 0 12px 12px; +} + +.external-calendar-modal .btn { + padding: 0.75rem 1.5rem; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: none; + min-width: 100px; +} + +.external-calendar-modal .btn-secondary { + background: #6c757d; + color: white; +} + +.external-calendar-modal .btn-secondary:hover:not(:disabled) { + background: #5a6268; + transform: translateY(-1px); +} + +.external-calendar-modal .btn-primary { + background: var(--primary-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%)); + color: white; +} + +.external-calendar-modal .btn-primary:hover:not(:disabled) { + filter: brightness(1.1); + transform: translateY(-1px); +} + +.external-calendar-modal .btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.external-calendar-modal .error-message { + background: #f8d7da; + color: #721c24; + padding: 0.75rem 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.9rem; + border: 1px solid #f5c6cb; +} + +/* External Calendar Events (Visual Distinction) */ +.event[data-external="true"] { + position: relative; + border-style: dashed !important; + opacity: 0.85; +} + +.event[data-external="true"]::before { + content: "📅"; + position: absolute; + top: 2px; + right: 2px; + font-size: 0.7rem; + opacity: 0.7; + z-index: 1; +} + +/* Mobile Responsive */ +@media (max-width: 768px) { + .external-calendar-modal { + max-height: 95vh; + margin: 1rem; + width: calc(100% - 2rem); + } + + .external-calendar-modal .modal-header, + .external-calendar-modal .modal-body, + .external-calendar-modal .modal-actions { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .external-calendar-info { + padding: 0.5rem; + } + + .external-calendar-name { + font-size: 0.8rem; + } + + .create-external-calendar-button { + font-size: 0.8rem; + padding: 0.5rem 1rem 0.5rem 2rem; + } +}