Compare commits
	
		
			26 Commits
		
	
	
		
			45e16313ba
			...
			print-prev
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ca1ca0c3b1 | ||
|   | 64dbf65beb | ||
|   | 96585440d1 | ||
|   | a297d38276 | ||
|   | 4fdaa9931d | ||
|   | c6c7b38bef | ||
|   | 78db2cc00f | ||
|   | 73d191c5ca | ||
| d930468748 | |||
|   | 91be4436a9 | ||
|   | 4cbc495c48 | ||
|   | 927cd7d2bb | ||
|   | 38b22287c7 | ||
|   | 0de2eee626 | ||
|   | aa7a15e6fa | ||
|   | b0a8ef09a8 | ||
|   | efbaea5ac1 | ||
|   | bbad327ea2 | ||
|   | 72273a3f1c | ||
|   | 8329244c69 | ||
|   | b16603b50b | ||
|   | c6eea88002 | ||
|   | 5876553515 | ||
|   | d73bc78af5 | ||
|   | 393bfecff2 | ||
| aab478202b | 
| @@ -330,13 +330,26 @@ impl CalDAVClient { | ||||
|         event: ical::parser::ical::component::IcalEvent, | ||||
|     ) -> Result<CalendarEvent, CalDAVError> { | ||||
|         let mut properties: HashMap<String, String> = HashMap::new(); | ||||
|         let mut full_properties: HashMap<String, String> = HashMap::new(); | ||||
|  | ||||
|         // Extract all properties from the event | ||||
|         for property in &event.properties { | ||||
|             properties.insert( | ||||
|                 property.name.to_uppercase(), | ||||
|                 property.value.clone().unwrap_or_default(), | ||||
|             ); | ||||
|             let prop_name = property.name.to_uppercase(); | ||||
|             let prop_value = property.value.clone().unwrap_or_default(); | ||||
|              | ||||
|             properties.insert(prop_name.clone(), prop_value.clone()); | ||||
|              | ||||
|             // Build full property string with parameters for timezone parsing | ||||
|             let mut full_prop = format!("{}", prop_name); | ||||
|             if let Some(params) = &property.params { | ||||
|                 for (param_name, param_values) in params { | ||||
|                     if !param_values.is_empty() { | ||||
|                         full_prop.push_str(&format!(";{}={}", param_name, param_values.join(","))); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             full_prop.push_str(&format!(":{}", prop_value)); | ||||
|             full_properties.insert(prop_name, full_prop); | ||||
|         } | ||||
|  | ||||
|         // Required UID field | ||||
| @@ -349,11 +362,11 @@ impl CalDAVClient { | ||||
|         let start = properties | ||||
|             .get("DTSTART") | ||||
|             .ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?; | ||||
|         let start = self.parse_datetime(start, properties.get("DTSTART"))?; | ||||
|         let start = self.parse_datetime(start, full_properties.get("DTSTART"))?; | ||||
|  | ||||
|         // Parse end time (optional - use start time if not present) | ||||
|         let end = if let Some(dtend) = properties.get("DTEND") { | ||||
|             Some(self.parse_datetime(dtend, properties.get("DTEND"))?) | ||||
|             Some(self.parse_datetime(dtend, full_properties.get("DTEND"))?) | ||||
|         } else if let Some(_duration) = properties.get("DURATION") { | ||||
|             // TODO: Parse duration and add to start time | ||||
|             Some(start) | ||||
| @@ -567,12 +580,32 @@ impl CalDAVClient { | ||||
|  | ||||
|         let mut all_calendars = Vec::new(); | ||||
|  | ||||
|         let mut has_valid_caldav_response = false; | ||||
|  | ||||
|         for path in discovery_paths { | ||||
|             println!("Trying discovery path: {}", path); | ||||
|             if let Ok(calendars) = self.discover_calendars_at_path(&path).await { | ||||
|             match self.discover_calendars_at_path(&path).await { | ||||
|                 Ok(calendars) => { | ||||
|                     println!("Found {} calendar(s) at {}", calendars.len(), path); | ||||
|                     has_valid_caldav_response = true; | ||||
|                     all_calendars.extend(calendars); | ||||
|                 } | ||||
|                 Err(CalDAVError::ServerError(status)) => { | ||||
|                     // HTTP error - this might be expected for some paths, continue trying | ||||
|                     println!("Discovery path {} returned HTTP {}, trying next path", path, status); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     // Network or other error - this suggests the server isn't reachable or isn't CalDAV | ||||
|                     println!("Discovery failed for path {}: {:?}", path, e); | ||||
|                     return Err(e); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // If we never got a valid CalDAV response (e.g., all requests failed),  | ||||
|         // this is likely not a CalDAV server | ||||
|         if !has_valid_caldav_response { | ||||
|             return Err(CalDAVError::ServerError(404)); | ||||
|         } | ||||
|  | ||||
|         // Remove duplicates | ||||
| @@ -671,17 +704,40 @@ impl CalDAVClient { | ||||
|         Ok(calendar_paths) | ||||
|     } | ||||
|  | ||||
|     /// Parse iCal datetime format | ||||
|     /// Parse iCal datetime format with timezone support | ||||
|     fn parse_datetime( | ||||
|         &self, | ||||
|         datetime_str: &str, | ||||
|         _original_property: Option<&String>, | ||||
|         original_property: Option<&String>, | ||||
|     ) -> Result<DateTime<Utc>, CalDAVError> { | ||||
|         use chrono::TimeZone; | ||||
|         use chrono_tz::Tz; | ||||
|  | ||||
|         // Handle different iCal datetime formats | ||||
|         // Extract timezone information from the original property if available | ||||
|         let mut timezone_id: Option<&str> = None; | ||||
|         if let Some(prop) = original_property { | ||||
|             // Look for TZID parameter in the property | ||||
|             // Format: DTSTART;TZID=America/Denver:20231225T090000 | ||||
|             if let Some(tzid_start) = prop.find("TZID=") { | ||||
|                 let tzid_part = &prop[tzid_start + 5..]; | ||||
|                 if let Some(tzid_end) = tzid_part.find(':') { | ||||
|                     timezone_id = Some(&tzid_part[..tzid_end]); | ||||
|                 } else if let Some(tzid_end) = tzid_part.find(';') { | ||||
|                     timezone_id = Some(&tzid_part[..tzid_end]); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Clean the datetime string - remove any TZID prefix if present | ||||
|         let cleaned = datetime_str.replace("TZID=", "").trim().to_string(); | ||||
|          | ||||
|         // Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000" | ||||
|         let datetime_part = if let Some(colon_pos) = cleaned.find(':') { | ||||
|             &cleaned[colon_pos + 1..] | ||||
|         } else { | ||||
|             &cleaned | ||||
|         }; | ||||
|  | ||||
|         // Try different parsing formats | ||||
|         let formats = [ | ||||
|             "%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z | ||||
| @@ -690,17 +746,145 @@ impl CalDAVClient { | ||||
|         ]; | ||||
|  | ||||
|         for format in &formats { | ||||
|             if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) { | ||||
|                 return Ok(Utc.from_utc_datetime(&dt)); | ||||
|             // Try parsing as UTC first (if it has Z suffix) | ||||
|             if datetime_part.ends_with('Z') { | ||||
|                 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") { | ||||
|                     return Ok(dt.and_utc()); | ||||
|                 } | ||||
|             if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) { | ||||
|             } | ||||
|              | ||||
|             // Try parsing with timezone offset (e.g., 20231225T120000-0500) | ||||
|             if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") { | ||||
|                 return Ok(dt.with_timezone(&Utc)); | ||||
|             } | ||||
|              | ||||
|             // Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00) | ||||
|             if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") { | ||||
|                 return Ok(dt.with_timezone(&Utc)); | ||||
|             } | ||||
|              | ||||
|             // Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z) | ||||
|             if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") { | ||||
|                 return Ok(dt.with_timezone(&Utc)); | ||||
|             } | ||||
|              | ||||
|             // Try parsing as naive datetime | ||||
|             if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) { | ||||
|                 // If we have timezone information, convert accordingly | ||||
|                 if let Some(tz_id) = timezone_id { | ||||
|                     let tz_result = if tz_id.starts_with("/mozilla.org/") { | ||||
|                         // Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London | ||||
|                         tz_id.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok()) | ||||
|                     } else if tz_id.contains('/') { | ||||
|                         // Standard timezone format: America/New_York, Europe/London | ||||
|                         tz_id.parse::<Tz>().ok() | ||||
|                     } else { | ||||
|                         // Try common abbreviations and Windows timezone names | ||||
|                         match tz_id { | ||||
|                             // 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), | ||||
|                             "N. Central Asia Standard Time" => Some(Tz::Asia__Novosibirsk), | ||||
|                             "North Asia Standard Time" => Some(Tz::Asia__Krasnoyarsk), | ||||
|                             "North Asia East Standard Time" => Some(Tz::Asia__Irkutsk), | ||||
|                             "Yakutsk Standard Time" => Some(Tz::Asia__Yakutsk), | ||||
|                             "Vladivostok Standard Time" => Some(Tz::Asia__Vladivostok), | ||||
|                             "Magadan Standard Time" => Some(Tz::Asia__Magadan), | ||||
|                              | ||||
|                             // Australia & Pacific | ||||
|                             "AUS Eastern Standard Time" => Some(Tz::Australia__Sydney), | ||||
|                             "AUS Central Standard Time" => Some(Tz::Australia__Adelaide), | ||||
|                             "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), | ||||
|                              | ||||
|                             // Africa & Middle East | ||||
|                             "South Africa Standard Time" => Some(Tz::Africa__Johannesburg), | ||||
|                             "Egypt Standard Time" => Some(Tz::Africa__Cairo), | ||||
|                             "Israel Standard Time" => Some(Tz::Asia__Jerusalem), | ||||
|                             "Iran Standard Time" => Some(Tz::Asia__Tehran), | ||||
|                             "Arabic Standard Time" => Some(Tz::Asia__Baghdad), | ||||
|                             "Arab Standard Time" => Some(Tz::Asia__Riyadh), | ||||
|                              | ||||
|                             // South America | ||||
|                             "SA Eastern Standard Time" => Some(Tz::America__Sao_Paulo), | ||||
|                             "Argentina Standard Time" => Some(Tz::America__Buenos_Aires), | ||||
|                             "SA Western Standard Time" => Some(Tz::America__La_Paz), | ||||
|                             "SA Pacific Standard Time" => Some(Tz::America__Bogota), | ||||
|                              | ||||
|                             _ => None, | ||||
|                         } | ||||
|                     }; | ||||
|  | ||||
|                     if let Some(tz) = tz_result { | ||||
|                         // Convert from the specified timezone to UTC | ||||
|                         if let Some(local_dt) = tz.from_local_datetime(&naive_dt).single() { | ||||
|                             return Ok(local_dt.with_timezone(&Utc)); | ||||
|                         } | ||||
|                     } | ||||
|                     // If timezone parsing fails, fall back to UTC | ||||
|                 } | ||||
|                 // No timezone info or parsing failed - treat as UTC | ||||
|                 return Ok(Utc.from_utc_datetime(&naive_dt)); | ||||
|             } | ||||
|              | ||||
|             // Try parsing as date only | ||||
|             if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) { | ||||
|                 return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap())); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Err(CalDAVError::ParseError(format!( | ||||
|             "Unable to parse datetime: {}", | ||||
|             datetime_str | ||||
|             "Unable to parse datetime: {} (cleaned: {}, timezone: {:?})", | ||||
|             datetime_str, datetime_part, timezone_id | ||||
|         ))) | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -845,7 +845,7 @@ fn parse_event_datetime( | ||||
|     time_str: &str, | ||||
|     all_day: bool, | ||||
| ) -> Result<chrono::DateTime<chrono::Utc>, String> { | ||||
|     use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; | ||||
|     use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; | ||||
|  | ||||
|     // Parse the date | ||||
|     let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") | ||||
| @@ -866,11 +866,7 @@ fn parse_event_datetime( | ||||
|         // Combine date and time | ||||
|         let datetime = NaiveDateTime::new(date, time); | ||||
|  | ||||
|         // Treat the datetime as local time and convert to UTC | ||||
|         let local_datetime = Local.from_local_datetime(&datetime) | ||||
|             .single() | ||||
|             .ok_or_else(|| "Ambiguous local datetime".to_string())?; | ||||
|          | ||||
|         Ok(local_datetime.with_timezone(&Utc)) | ||||
|         // Frontend now sends UTC times, so treat as UTC directly | ||||
|         Ok(Utc.from_utc_datetime(&datetime)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -2,7 +2,7 @@ use axum::{ | ||||
|     extract::{Path, State}, | ||||
|     response::Json, | ||||
| }; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use chrono::{DateTime, Utc, Datelike}; | ||||
| use ical::parser::ical::component::IcalEvent; | ||||
| use reqwest::Client; | ||||
| use serde::Serialize; | ||||
| @@ -78,16 +78,74 @@ pub async fn fetch_external_calendar_events( | ||||
|  | ||||
|     // If not fetched from cache, get from external URL | ||||
|     if !fetched_from_cache { | ||||
|         let client = Client::new(); | ||||
|         let response = client | ||||
|             .get(&calendar.url) | ||||
|             .send() | ||||
|             .await | ||||
|             .map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?; | ||||
|         // Log the URL being fetched for debugging | ||||
|         println!("🌍 Fetching calendar URL: {}", calendar.url); | ||||
|          | ||||
|         if !response.status().is_success() { | ||||
|             return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status()))); | ||||
|         let user_agents = vec![ | ||||
|             "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", | ||||
|             "Mozilla/5.0 (compatible; Runway Calendar/1.0)", | ||||
|             "Outlook-iOS/709.2226530.prod.iphone (3.24.1)" | ||||
|         ]; | ||||
|          | ||||
|         let mut response = None; | ||||
|         let mut last_error = None; | ||||
|          | ||||
|         // Try different user agents | ||||
|         for (i, ua) in user_agents.iter().enumerate() { | ||||
|             println!("🔄 Attempt {} with User-Agent: {}", i + 1, ua); | ||||
|              | ||||
|             let client = Client::builder() | ||||
|                 .redirect(reqwest::redirect::Policy::limited(10)) | ||||
|                 .timeout(std::time::Duration::from_secs(30)) | ||||
|                 .user_agent(*ua) | ||||
|                 .build() | ||||
|                 .map_err(|e| ApiError::Internal(format!("Failed to create HTTP client: {}", e)))?; | ||||
|              | ||||
|             let result = client | ||||
|                 .get(&calendar.url) | ||||
|                 .header("Accept", "text/calendar,application/calendar+xml,text/plain,*/*") | ||||
|                 .header("Accept-Charset", "utf-8") | ||||
|                 .header("Cache-Control", "no-cache") | ||||
|                 .send() | ||||
|                 .await; | ||||
|                  | ||||
|             match result { | ||||
|                 Ok(resp) => { | ||||
|                     let status = resp.status(); | ||||
|                     println!("📡 Response status: {}", status); | ||||
|                     if status.is_success() { | ||||
|                         response = Some(resp); | ||||
|                         break; | ||||
|                     } else if status == 400 { | ||||
|                         // Check if this is an Outlook auth error | ||||
|                         let error_body = resp.text().await.unwrap_or_default(); | ||||
|                         if error_body.contains("OwaPage") || error_body.contains("Outlook") { | ||||
|                             println!("🚫 Outlook authentication error detected, trying next approach..."); | ||||
|                             last_error = Some(format!("Outlook auth error: {}", error_body.chars().take(100).collect::<String>())); | ||||
|                             continue; | ||||
|                         } | ||||
|                         last_error = Some(format!("Bad Request: {}", error_body.chars().take(100).collect::<String>())); | ||||
|                     } else { | ||||
|                         last_error = Some(format!("HTTP {}", status)); | ||||
|                     } | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     println!("❌ Request failed: {}", e); | ||||
|                     last_error = Some(format!("Request error: {}", e)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         let response = response.ok_or_else(|| { | ||||
|             ApiError::Internal(format!( | ||||
|                 "Failed to fetch calendar after trying {} different approaches. Last error: {}",  | ||||
|                 user_agents.len(),  | ||||
|                 last_error.unwrap_or("Unknown error".to_string()) | ||||
|             )) | ||||
|         })?; | ||||
|  | ||||
|         // Response is guaranteed to be successful here since we checked in the loop | ||||
|         println!("✅ Successfully fetched calendar data"); | ||||
|          | ||||
|         ics_content = response | ||||
|             .text() | ||||
| @@ -138,6 +196,9 @@ fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::erro | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Deduplicate events based on UID, start time, and summary | ||||
|     // Outlook sometimes includes duplicate events (recurring exceptions may appear multiple times) | ||||
|     events = deduplicate_events(events); | ||||
|  | ||||
|     Ok(events) | ||||
| } | ||||
| @@ -408,3 +469,438 @@ fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<Date | ||||
|  | ||||
|     None | ||||
| } | ||||
|  | ||||
| /// Deduplicate events based on UID, start time, and summary | ||||
| /// Some calendar systems (like Outlook) may include duplicate events in ICS feeds | ||||
| /// This includes both exact duplicates and recurring event instances that would be | ||||
| /// generated by existing RRULE patterns, and events with same title but different | ||||
| /// RRULE patterns that should be consolidated | ||||
| fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     use std::collections::HashMap; | ||||
|      | ||||
|     let original_count = events.len(); | ||||
|      | ||||
|     // First pass: Group by UID and prefer recurring events over single events with same UID | ||||
|     let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new(); | ||||
|      | ||||
|     for event in events.drain(..) { | ||||
|         // Debug logging to understand what's happening | ||||
|         println!("🔍 Event: '{}' at {} (RRULE: {}) - UID: {}",  | ||||
|             event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||
|             event.dtstart.format("%Y-%m-%d %H:%M"), | ||||
|             if event.rrule.is_some() { "Yes" } else { "No" }, | ||||
|             event.uid | ||||
|         ); | ||||
|          | ||||
|         uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event); | ||||
|     } | ||||
|      | ||||
|     let mut uid_deduplicated_events = Vec::new(); | ||||
|      | ||||
|     for (uid, mut events_with_uid) in uid_groups.drain() { | ||||
|         if events_with_uid.len() == 1 { | ||||
|             // Only one event with this UID, keep it | ||||
|             uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap()); | ||||
|         } else { | ||||
|             // Multiple events with same UID - prefer recurring over non-recurring | ||||
|             println!("🔍 Found {} events with UID '{}'", events_with_uid.len(), uid); | ||||
|              | ||||
|             // Sort by preference: recurring events first, then by completeness | ||||
|             events_with_uid.sort_by(|a, b| { | ||||
|                 let a_has_rrule = a.rrule.is_some(); | ||||
|                 let b_has_rrule = b.rrule.is_some(); | ||||
|                  | ||||
|                 match (a_has_rrule, b_has_rrule) { | ||||
|                     (true, false) => std::cmp::Ordering::Less,   // a (recurring) comes first | ||||
|                     (false, true) => std::cmp::Ordering::Greater, // b (recurring) comes first | ||||
|                     _ => { | ||||
|                         // Both same type (both recurring or both single) - compare by completeness | ||||
|                         event_completeness_score(b).cmp(&event_completeness_score(a)) | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             // Keep the first (preferred) event | ||||
|             let preferred_event = events_with_uid.into_iter().next().unwrap(); | ||||
|             println!("🔄 UID dedup: Keeping '{}' (RRULE: {})", | ||||
|                 preferred_event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||
|                 if preferred_event.rrule.is_some() { "Yes" } else { "No" } | ||||
|             ); | ||||
|             uid_deduplicated_events.push(preferred_event); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Second pass: separate recurring and single events from UID-deduplicated set | ||||
|     let mut recurring_events = Vec::new(); | ||||
|     let mut single_events = Vec::new(); | ||||
|      | ||||
|     for event in uid_deduplicated_events.drain(..) { | ||||
|         if event.rrule.is_some() { | ||||
|             recurring_events.push(event); | ||||
|         } else { | ||||
|             single_events.push(event); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Third pass: Group recurring events by normalized title and consolidate different RRULE patterns | ||||
|     let mut title_groups: HashMap<String, Vec<VEvent>> = HashMap::new(); | ||||
|      | ||||
|     for event in recurring_events.drain(..) { | ||||
|         let title = normalize_title(event.summary.as_ref().unwrap_or(&String::new())); | ||||
|         title_groups.entry(title).or_insert_with(Vec::new).push(event); | ||||
|     } | ||||
|      | ||||
|     let mut deduplicated_recurring = Vec::new(); | ||||
|      | ||||
|     for (title, events_with_title) in title_groups.drain() { | ||||
|         if events_with_title.len() == 1 { | ||||
|             // Single event with this title, keep as-is | ||||
|             deduplicated_recurring.push(events_with_title.into_iter().next().unwrap()); | ||||
|         } else { | ||||
|             // Multiple events with same title - consolidate or deduplicate | ||||
|             println!("🔍 Found {} events with title '{}'", events_with_title.len(), title); | ||||
|              | ||||
|             // Check if these are actually different recurring patterns for the same logical event | ||||
|             let consolidated = consolidate_same_title_events(events_with_title); | ||||
|             deduplicated_recurring.extend(consolidated); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Fourth pass: filter single events, removing those that would be generated by recurring events | ||||
|     let mut deduplicated_single = Vec::new(); | ||||
|     let mut seen_single: HashMap<String, usize> = HashMap::new(); | ||||
|      | ||||
|     for event in single_events.drain(..) { | ||||
|         let normalized_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new())); | ||||
|         let dedup_key = format!( | ||||
|             "{}|{}",  | ||||
|             event.dtstart.format("%Y%m%dT%H%M%S"), | ||||
|             normalized_title | ||||
|         ); | ||||
|          | ||||
|         // First check for exact duplicates among single events | ||||
|         if let Some(&existing_index) = seen_single.get(&dedup_key) { | ||||
|             let existing_event: &VEvent = &deduplicated_single[existing_index]; | ||||
|             let current_completeness = event_completeness_score(&event); | ||||
|             let existing_completeness = event_completeness_score(existing_event); | ||||
|              | ||||
|             if current_completeness > existing_completeness { | ||||
|                 println!("🔄 Replacing single event: Keeping '{}' over '{}'", | ||||
|                     event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||
|                     existing_event.summary.as_ref().unwrap_or(&"No Title".to_string()) | ||||
|                 ); | ||||
|                 deduplicated_single[existing_index] = event; | ||||
|             } else { | ||||
|                 println!("🚫 Discarding duplicate single event: Keeping existing '{}'", | ||||
|                     existing_event.summary.as_ref().unwrap_or(&"No Title".to_string()) | ||||
|                 ); | ||||
|             } | ||||
|             continue; | ||||
|         } | ||||
|          | ||||
|         // Check if this single event would be generated by any recurring event | ||||
|         let is_rrule_generated = deduplicated_recurring.iter().any(|recurring_event| { | ||||
|             // Check if this single event matches the recurring event's pattern (use normalized titles) | ||||
|             let single_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new())); | ||||
|             let recurring_title = normalize_title(recurring_event.summary.as_ref().unwrap_or(&String::new())); | ||||
|              | ||||
|             if single_title != recurring_title { | ||||
|                 return false; // Different events | ||||
|             } | ||||
|              | ||||
|             // Check if this single event would be generated by the recurring event | ||||
|             would_event_be_generated_by_rrule(recurring_event, &event) | ||||
|         }); | ||||
|          | ||||
|         if is_rrule_generated { | ||||
|             println!("🚫 Discarding RRULE-generated instance: '{}' at {} would be generated by recurring event", | ||||
|                 event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||
|                 event.dtstart.format("%Y-%m-%d %H:%M") | ||||
|             ); | ||||
|         } else { | ||||
|             // This is a unique single event | ||||
|             seen_single.insert(dedup_key, deduplicated_single.len()); | ||||
|             deduplicated_single.push(event); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Combine recurring and single events | ||||
|     let mut result = deduplicated_recurring; | ||||
|     result.extend(deduplicated_single); | ||||
|      | ||||
|     println!("📊 Deduplication complete: {} -> {} events ({} recurring, {} single)",  | ||||
|         original_count, result.len(),  | ||||
|         result.iter().filter(|e| e.rrule.is_some()).count(), | ||||
|         result.iter().filter(|e| e.rrule.is_none()).count() | ||||
|     ); | ||||
|      | ||||
|     result | ||||
| } | ||||
|  | ||||
| /// Normalize title for grouping similar events | ||||
| fn normalize_title(title: &str) -> String { | ||||
|     title.trim() | ||||
|         .to_lowercase() | ||||
|         .chars() | ||||
|         .filter(|c| c.is_alphanumeric() || c.is_whitespace()) | ||||
|         .collect::<String>() | ||||
|         .split_whitespace() | ||||
|         .collect::<Vec<&str>>() | ||||
|         .join(" ") | ||||
| } | ||||
|  | ||||
| /// Consolidate events with the same title but potentially different RRULE patterns | ||||
| /// This handles cases where calendar systems provide multiple recurring definitions | ||||
| /// for the same logical meeting (e.g., one RRULE for Tuesdays, another for Thursdays) | ||||
| fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     if events.is_empty() { | ||||
|         return events; | ||||
|     } | ||||
|      | ||||
|     // Log the RRULEs we're working with | ||||
|     for event in &events { | ||||
|         if let Some(rrule) = &event.rrule { | ||||
|             println!("🔍 RRULE for '{}': {}",  | ||||
|                 event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||
|                 rrule | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Check if all events have similar time patterns and could be consolidated | ||||
|     let first_event = &events[0]; | ||||
|     let base_time = first_event.dtstart.time(); | ||||
|     let base_duration = if let Some(end) = first_event.dtend { | ||||
|         Some(end.signed_duration_since(first_event.dtstart)) | ||||
|     } else { | ||||
|         None | ||||
|     }; | ||||
|      | ||||
|     // Check if all events have the same time and duration | ||||
|     let can_consolidate = events.iter().all(|event| { | ||||
|         let same_time = event.dtstart.time() == base_time; | ||||
|         let same_duration = match (event.dtend, base_duration) { | ||||
|             (Some(end), Some(base_dur)) => end.signed_duration_since(event.dtstart) == base_dur, | ||||
|             (None, None) => true, | ||||
|             _ => false, | ||||
|         }; | ||||
|         same_time && same_duration | ||||
|     }); | ||||
|      | ||||
|     if !can_consolidate { | ||||
|         println!("🚫 Cannot consolidate events - different times or durations"); | ||||
|         // Just deduplicate exact duplicates | ||||
|         return deduplicate_exact_recurring_events(events); | ||||
|     } | ||||
|      | ||||
|     // Try to detect if these are complementary weekly patterns | ||||
|     let weekly_events: Vec<_> = events.iter() | ||||
|         .filter(|e| e.rrule.as_ref().map_or(false, |r| r.contains("FREQ=WEEKLY"))) | ||||
|         .collect(); | ||||
|      | ||||
|     if weekly_events.len() >= 2 && weekly_events.len() == events.len() { | ||||
|         // All events are weekly - try to consolidate into a single multi-day weekly pattern | ||||
|         if let Some(consolidated) = consolidate_weekly_patterns(&events) { | ||||
|             println!("✅ Successfully consolidated {} weekly patterns into one", events.len()); | ||||
|             return vec![consolidated]; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // If we can't consolidate, just deduplicate exact matches and keep the most complete one | ||||
|     println!("🚫 Cannot consolidate - keeping most complete event"); | ||||
|     let deduplicated = deduplicate_exact_recurring_events(events); | ||||
|      | ||||
|     // If we still have multiple events, keep only the most complete one | ||||
|     if deduplicated.len() > 1 { | ||||
|         let best_event = deduplicated.into_iter() | ||||
|             .max_by_key(|e| event_completeness_score(e)) | ||||
|             .unwrap(); | ||||
|          | ||||
|         println!("🎯 Kept most complete event: '{}'",  | ||||
|             best_event.summary.as_ref().unwrap_or(&"No Title".to_string()) | ||||
|         ); | ||||
|         vec![best_event] | ||||
|     } else { | ||||
|         deduplicated | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Deduplicate exact recurring event matches | ||||
| fn deduplicate_exact_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     use std::collections::HashMap; | ||||
|      | ||||
|     let mut seen: HashMap<String, usize> = HashMap::new(); | ||||
|     let mut deduplicated = Vec::new(); | ||||
|      | ||||
|     for event in events { | ||||
|         let dedup_key = format!( | ||||
|             "{}|{}|{}",  | ||||
|             event.dtstart.format("%Y%m%dT%H%M%S"), | ||||
|             event.summary.as_ref().unwrap_or(&String::new()), | ||||
|             event.rrule.as_ref().unwrap_or(&String::new()) | ||||
|         ); | ||||
|          | ||||
|         if let Some(&existing_index) = seen.get(&dedup_key) { | ||||
|             let existing_event: &VEvent = &deduplicated[existing_index]; | ||||
|             let current_completeness = event_completeness_score(&event); | ||||
|             let existing_completeness = event_completeness_score(existing_event); | ||||
|              | ||||
|             if current_completeness > existing_completeness { | ||||
|                 println!("🔄 Replacing exact duplicate: Keeping more complete event"); | ||||
|                 deduplicated[existing_index] = event; | ||||
|             } | ||||
|         } else { | ||||
|             seen.insert(dedup_key, deduplicated.len()); | ||||
|             deduplicated.push(event); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     deduplicated | ||||
| } | ||||
|  | ||||
| /// Attempt to consolidate multiple weekly RRULE patterns into a single pattern | ||||
| fn consolidate_weekly_patterns(events: &[VEvent]) -> Option<VEvent> { | ||||
|     use std::collections::HashSet; | ||||
|      | ||||
|     let mut all_days = HashSet::new(); | ||||
|     let mut base_event = None; | ||||
|      | ||||
|     for event in events { | ||||
|         let Some(rrule) = &event.rrule else { continue; }; | ||||
|          | ||||
|         if !rrule.contains("FREQ=WEEKLY") { | ||||
|             continue; | ||||
|         } | ||||
|          | ||||
|         // Extract BYDAY if present | ||||
|         if let Some(byday_part) = rrule.split(';').find(|part| part.starts_with("BYDAY=")) { | ||||
|             let days_str = byday_part.strip_prefix("BYDAY=").unwrap_or(""); | ||||
|             for day in days_str.split(',') { | ||||
|                 all_days.insert(day.trim().to_string()); | ||||
|             } | ||||
|         } else { | ||||
|             // If no BYDAY specified, use the weekday from the start date | ||||
|             let weekday = match event.dtstart.weekday() { | ||||
|                 chrono::Weekday::Mon => "MO", | ||||
|                 chrono::Weekday::Tue => "TU",  | ||||
|                 chrono::Weekday::Wed => "WE", | ||||
|                 chrono::Weekday::Thu => "TH", | ||||
|                 chrono::Weekday::Fri => "FR", | ||||
|                 chrono::Weekday::Sat => "SA", | ||||
|                 chrono::Weekday::Sun => "SU", | ||||
|             }; | ||||
|             all_days.insert(weekday.to_string()); | ||||
|         } | ||||
|          | ||||
|         // Use the first event as the base (we already know they have same time/duration) | ||||
|         if base_event.is_none() { | ||||
|             base_event = Some(event.clone()); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     if all_days.is_empty() || base_event.is_none() { | ||||
|         return None; | ||||
|     } | ||||
|      | ||||
|     // Create consolidated RRULE | ||||
|     let mut base = base_event.unwrap(); | ||||
|     let days_list: Vec<_> = all_days.into_iter().collect(); | ||||
|     let byday_str = days_list.join(","); | ||||
|      | ||||
|     // Build new RRULE with consolidated BYDAY | ||||
|     let new_rrule = if let Some(existing_rrule) = &base.rrule { | ||||
|         // Remove existing BYDAY and add our consolidated one | ||||
|         let parts: Vec<_> = existing_rrule.split(';') | ||||
|             .filter(|part| !part.starts_with("BYDAY=")) | ||||
|             .collect(); | ||||
|         format!("{};BYDAY={}", parts.join(";"), byday_str) | ||||
|     } else { | ||||
|         format!("FREQ=WEEKLY;BYDAY={}", byday_str) | ||||
|     }; | ||||
|      | ||||
|     base.rrule = Some(new_rrule); | ||||
|      | ||||
|     println!("🔗 Consolidated weekly pattern: BYDAY={}", byday_str); | ||||
|     Some(base) | ||||
| } | ||||
|  | ||||
| /// Check if a single event would be generated by a recurring event's RRULE | ||||
| fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VEvent) -> bool { | ||||
|     let Some(rrule) = &recurring_event.rrule else { | ||||
|         return false; // No RRULE to check against | ||||
|     }; | ||||
|      | ||||
|     // Parse basic RRULE patterns | ||||
|     if rrule.contains("FREQ=DAILY") { | ||||
|         // Daily recurrence | ||||
|         let interval = extract_interval_from_rrule(rrule).unwrap_or(1); | ||||
|         let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days(); | ||||
|          | ||||
|         if days_diff >= 0 && days_diff % interval as i64 == 0 { | ||||
|             // Check if times match (allowing for timezone differences within same day) | ||||
|             let recurring_time = recurring_event.dtstart.time(); | ||||
|             let single_time = single_event.dtstart.time(); | ||||
|             return recurring_time == single_time; | ||||
|         } | ||||
|     } else if rrule.contains("FREQ=WEEKLY") { | ||||
|         // Weekly recurrence | ||||
|         let interval = extract_interval_from_rrule(rrule).unwrap_or(1); | ||||
|         let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days(); | ||||
|          | ||||
|         // First check if it's the same day of week and time | ||||
|         let recurring_weekday = recurring_event.dtstart.weekday(); | ||||
|         let single_weekday = single_event.dtstart.weekday(); | ||||
|         let recurring_time = recurring_event.dtstart.time(); | ||||
|         let single_time = single_event.dtstart.time(); | ||||
|          | ||||
|         if recurring_weekday == single_weekday && recurring_time == single_time && days_diff >= 0 { | ||||
|             // Calculate how many weeks apart they are | ||||
|             let weeks_diff = days_diff / 7; | ||||
|             // Check if this falls on an interval boundary | ||||
|             return weeks_diff % interval as i64 == 0; | ||||
|         } | ||||
|     } else if rrule.contains("FREQ=MONTHLY") { | ||||
|         // Monthly recurrence - simplified check | ||||
|         let months_diff = (single_event.dtstart.year() - recurring_event.dtstart.year()) * 12  | ||||
|             + (single_event.dtstart.month() as i32 - recurring_event.dtstart.month() as i32); | ||||
|          | ||||
|         if months_diff >= 0 { | ||||
|             let interval = extract_interval_from_rrule(rrule).unwrap_or(1) as i32; | ||||
|             if months_diff % interval == 0 { | ||||
|                 // Same day of month and time | ||||
|                 return recurring_event.dtstart.day() == single_event.dtstart.day() | ||||
|                     && recurring_event.dtstart.time() == single_event.dtstart.time(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     false | ||||
| } | ||||
|  | ||||
| /// Extract INTERVAL value from RRULE string, defaulting to 1 if not found | ||||
| fn extract_interval_from_rrule(rrule: &str) -> Option<u32> { | ||||
|     for part in rrule.split(';') { | ||||
|         if part.starts_with("INTERVAL=") { | ||||
|             return part.strip_prefix("INTERVAL=") | ||||
|                 .and_then(|s| s.parse().ok()); | ||||
|         } | ||||
|     } | ||||
|     Some(1) // Default interval is 1 if not specified | ||||
| } | ||||
|  | ||||
| /// Calculate a completeness score for an event based on how many optional fields are filled | ||||
| fn event_completeness_score(event: &VEvent) -> u32 { | ||||
|     let mut score = 0; | ||||
|      | ||||
|     if event.summary.is_some() { score += 1; } | ||||
|     if event.description.is_some() { score += 1; } | ||||
|     if event.location.is_some() { score += 1; } | ||||
|     if event.dtend.is_some() { score += 1; } | ||||
|     if event.rrule.is_some() { score += 1; } | ||||
|     if !event.categories.is_empty() { score += 1; } | ||||
|     if !event.alarms.is_empty() { score += 1; } | ||||
|     if event.organizer.is_some() { score += 1; } | ||||
|     if !event.attendees.is_empty() { score += 1; } | ||||
|      | ||||
|     score | ||||
| } | ||||
| @@ -130,13 +130,9 @@ pub async fn create_event_series( | ||||
|             .and_hms_opt(23, 59, 59) | ||||
|             .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; | ||||
|  | ||||
|         // Convert from local time to UTC | ||||
|         let start_local = chrono::Local.from_local_datetime(&start_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?; | ||||
|         let end_local = chrono::Local.from_local_datetime(&end_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?; | ||||
|         // Frontend now sends UTC times, so treat as UTC directly | ||||
|         let start_local = chrono::Utc.from_utc_datetime(&start_dt); | ||||
|         let end_local = chrono::Utc.from_utc_datetime(&end_dt); | ||||
|          | ||||
|         ( | ||||
|             start_local.with_timezone(&chrono::Utc), | ||||
| @@ -171,13 +167,9 @@ pub async fn create_event_series( | ||||
|             start_date.and_time(end_time) | ||||
|         }; | ||||
|  | ||||
|         // Convert from local time to UTC | ||||
|         let start_local = chrono::Local.from_local_datetime(&start_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?; | ||||
|         let end_local = chrono::Local.from_local_datetime(&end_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?; | ||||
|         // Frontend now sends UTC times, so treat as UTC directly | ||||
|         let start_local = chrono::Utc.from_utc_datetime(&start_dt); | ||||
|         let end_local = chrono::Utc.from_utc_datetime(&end_dt); | ||||
|          | ||||
|         ( | ||||
|             start_local.with_timezone(&chrono::Utc), | ||||
| @@ -456,13 +448,9 @@ pub async fn update_event_series( | ||||
|             (chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc() | ||||
|         }; | ||||
|  | ||||
|         // Convert from local time to UTC | ||||
|         let start_local = chrono::Local.from_local_datetime(&start_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?; | ||||
|         let end_local = chrono::Local.from_local_datetime(&end_dt) | ||||
|             .single() | ||||
|             .ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?; | ||||
|         // Frontend now sends UTC times, so treat as UTC directly | ||||
|         let start_local = chrono::Utc.from_utc_datetime(&start_dt); | ||||
|         let end_local = chrono::Utc.from_utc_datetime(&end_dt); | ||||
|          | ||||
|         ( | ||||
|             start_local.with_timezone(&chrono::Utc), | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| #!/bin/sh | ||||
|  | ||||
| export BACKEND_API_URL="https://runway.rcjohnstone.com/api" | ||||
| trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml | ||||
| sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/ | ||||
| unset BACKEND_API_URL | ||||
|   | ||||
| @@ -22,14 +22,19 @@ web-sys = { version = "0.3", features = [ | ||||
|     "Document", | ||||
|     "Window", | ||||
|     "Location", | ||||
|     "Navigator", | ||||
|     "DomTokenList", | ||||
|     "Headers", | ||||
|     "Request", | ||||
|     "RequestInit", | ||||
|     "RequestMode", | ||||
|     "Response", | ||||
|     "CssStyleDeclaration", | ||||
|     "MediaQueryList", | ||||
|     "MediaQueryListEvent", | ||||
| ] } | ||||
| wasm-bindgen = "0.2" | ||||
| js-sys = "0.3" | ||||
|  | ||||
| # HTTP client for CalDAV requests | ||||
| reqwest = { version = "0.11", features = ["json"] } | ||||
|   | ||||
| @@ -6,7 +6,7 @@ dist = "dist" | ||||
| BACKEND_API_URL = "http://localhost:3000/api" | ||||
|  | ||||
| [watch] | ||||
| watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "index.html"] | ||||
| watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "print-preview.css", "index.html"] | ||||
| ignore = ["../backend/", "../target/"] | ||||
|  | ||||
| [serve] | ||||
|   | ||||
| @@ -6,8 +6,10 @@ | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <base data-trunk-public-url /> | ||||
|     <link data-trunk rel="css" href="styles.css"> | ||||
|     <link data-trunk rel="css" href="print-preview.css"> | ||||
|     <link data-trunk rel="copy-file" href="styles/google.css"> | ||||
|     <link data-trunk rel="icon" href="favicon.ico"> | ||||
|     <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" /> | ||||
| </head> | ||||
| <body> | ||||
|     <script> | ||||
|   | ||||
							
								
								
									
										1215
									
								
								frontend/print-preview.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1215
									
								
								frontend/print-preview.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,8 +1,9 @@ | ||||
| use crate::components::{ | ||||
|     CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, | ||||
|     EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler,  | ||||
|     Sidebar, Theme, ViewMode, | ||||
|     CalendarContextMenu, CalendarManagementModal, ContextMenu, CreateEventModal, DeleteAction, | ||||
|     EditAction, EventContextMenu, EventModal, EventCreationData,   | ||||
|     MobileWarningModal, RouteHandler, Sidebar, Theme, ViewMode, | ||||
| }; | ||||
| use crate::components::mobile_warning_modal::is_mobile_device; | ||||
| use crate::components::sidebar::{Style}; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||
| @@ -55,11 +56,46 @@ fn get_theme_event_colors() -> Vec<String> { | ||||
|  | ||||
| #[function_component] | ||||
| pub fn App() -> Html { | ||||
|     let auth_token = use_state(|| -> Option<String> { LocalStorage::get("auth_token").ok() }); | ||||
|     let auth_token = use_state(|| -> Option<String> { None }); | ||||
|  | ||||
|     // Validate token on app startup | ||||
|     { | ||||
|         let auth_token = auth_token.clone(); | ||||
|         use_effect_with((), move |_| { | ||||
|             let auth_token = auth_token.clone(); | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 // Check if there's a stored token | ||||
|                 if let Ok(stored_token) = LocalStorage::get::<String>("auth_token") { | ||||
|                     // Verify the stored token | ||||
|                     let auth_service = crate::auth::AuthService::new(); | ||||
|                     match auth_service.verify_token(&stored_token).await { | ||||
|                         Ok(true) => { | ||||
|                             // Token is valid, set it | ||||
|                             web_sys::console::log_1(&"✅ Stored auth token is valid".into()); | ||||
|                             auth_token.set(Some(stored_token)); | ||||
|                         } | ||||
|                         _ => { | ||||
|                             // Token is invalid or verification failed, clear it | ||||
|                             web_sys::console::log_1(&"❌ Stored auth token is invalid, clearing".into()); | ||||
|                             let _ = LocalStorage::delete("auth_token"); | ||||
|                             let _ = LocalStorage::delete("session_token"); | ||||
|                             let _ = LocalStorage::delete("caldav_credentials"); | ||||
|                             auth_token.set(None); | ||||
|                         } | ||||
|                     } | ||||
|                 } else { | ||||
|                     // No stored token | ||||
|                     web_sys::console::log_1(&"ℹ️ No stored auth token found".into()); | ||||
|                     auth_token.set(None); | ||||
|                 } | ||||
|             }); | ||||
|             || () | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     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 calendar_management_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 }); | ||||
| @@ -72,6 +108,9 @@ pub fn App() -> Html { | ||||
|     let create_event_modal_open = use_state(|| false); | ||||
|     let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None }); | ||||
|     let event_edit_scope = use_state(|| -> Option<EditAction> { None }); | ||||
|     let view_event_modal_open = use_state(|| false); | ||||
|     let view_event_modal_event = use_state(|| -> Option<VEvent> { None }); | ||||
|     let refreshing_calendar_id = use_state(|| -> Option<i32> { None }); | ||||
|     let _recurring_edit_modal_open = use_state(|| false); | ||||
|     let _recurring_edit_event = use_state(|| -> Option<VEvent> { None }); | ||||
|     let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None }); | ||||
| @@ -79,7 +118,9 @@ pub fn App() -> Html { | ||||
|     // External calendar state | ||||
|     let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() }); | ||||
|     let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() }); | ||||
|     let external_calendar_modal_open = use_state(|| false); | ||||
|      | ||||
|     // Mobile warning state | ||||
|     let mobile_warning_open = use_state(|| is_mobile_device()); | ||||
|     let refresh_interval = use_state(|| -> Option<Interval> { None }); | ||||
|  | ||||
|     // Calendar view state - load from localStorage if available | ||||
| @@ -117,6 +158,108 @@ pub fn App() -> Html { | ||||
|  | ||||
|     let available_colors = use_state(|| get_theme_event_colors()); | ||||
|  | ||||
|     // Function to refresh calendar data without full page reload | ||||
|     let refresh_calendar_data = { | ||||
|         let user_info = user_info.clone(); | ||||
|         let auth_token = auth_token.clone(); | ||||
|         let external_calendars = external_calendars.clone(); | ||||
|         let external_calendar_events = external_calendar_events.clone(); | ||||
|          | ||||
|         Callback::from(move |_| { | ||||
|             let user_info = user_info.clone(); | ||||
|             let auth_token = auth_token.clone(); | ||||
|             let external_calendars = external_calendars.clone(); | ||||
|             let external_calendar_events = external_calendar_events.clone(); | ||||
|              | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 // Refresh main calendar data if authenticated | ||||
|                 if let Some(token) = (*auth_token).clone() { | ||||
|                     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) => { | ||||
|                                 // Apply saved colors | ||||
|                                 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(); | ||||
|                                                 } | ||||
|                                             } | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                                 // Add timestamp to force re-render | ||||
|                                 info.last_updated = (js_sys::Date::now() / 1000.0) as u64; | ||||
|                                 user_info.set(Some(info)); | ||||
|                             } | ||||
|                             Err(err) => { | ||||
|                                 web_sys::console::log_1( | ||||
|                                     &format!("Failed to refresh main calendar data: {}", err).into(), | ||||
|                                 ); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 // Refresh external calendars data | ||||
|                 match CalendarService::get_external_calendars().await { | ||||
|                     Ok(calendars) => { | ||||
|                         external_calendars.set(calendars.clone()); | ||||
|                          | ||||
|                         // Load events for visible external calendars | ||||
|                         let mut all_external_events = Vec::new(); | ||||
|                         for calendar in calendars { | ||||
|                             if calendar.is_visible { | ||||
|                                 match CalendarService::fetch_external_calendar_events(calendar.id).await { | ||||
|                                     Ok(mut events) => { | ||||
|                                         // Set calendar_path for color matching | ||||
|                                         for event in &mut events { | ||||
|                                             event.calendar_path = Some(format!("external_{}", calendar.id)); | ||||
|                                         } | ||||
|                                         all_external_events.extend(events); | ||||
|                                     } | ||||
|                                     Err(e) => { | ||||
|                                         web_sys::console::log_1( | ||||
|                                             &format!("Failed to fetch events for external calendar {}: {}", calendar.id, e).into(), | ||||
|                                         ); | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         external_calendar_events.set(all_external_events); | ||||
|                     } | ||||
|                     Err(e) => { | ||||
|                         web_sys::console::log_1( | ||||
|                             &format!("Failed to refresh external calendars: {}", e).into(), | ||||
|                         ); | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|             }); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_login = { | ||||
|         let auth_token = auth_token.clone(); | ||||
|         Callback::from(move |token: String| { | ||||
| @@ -134,6 +277,13 @@ pub fn App() -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_mobile_warning_close = { | ||||
|         let mobile_warning_open = mobile_warning_open.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             mobile_warning_open.set(false); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_view_change = { | ||||
|         let current_view = current_view.clone(); | ||||
|         Callback::from(move |new_view: ViewMode| { | ||||
| @@ -417,8 +567,48 @@ pub fn App() -> Html { | ||||
|  | ||||
|     let on_color_change = { | ||||
|         let user_info = user_info.clone(); | ||||
|         let external_calendars = external_calendars.clone(); | ||||
|         let color_picker_open = color_picker_open.clone(); | ||||
|         Callback::from(move |(calendar_path, color): (String, String)| { | ||||
|             if calendar_path.starts_with("external_") { | ||||
|                 // Handle external calendar color change | ||||
|                 if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() { | ||||
|                     let external_calendars = external_calendars.clone(); | ||||
|                     let color = color.clone(); | ||||
|                      | ||||
|                     wasm_bindgen_futures::spawn_local(async move { | ||||
|                         // Find the external calendar to get its current details | ||||
|                         if let Some(cal) = (*external_calendars).iter().find(|c| c.id == id_str) { | ||||
|                             match CalendarService::update_external_calendar( | ||||
|                                 id_str, | ||||
|                                 &cal.name, | ||||
|                                 &cal.url, | ||||
|                                 &color, | ||||
|                                 cal.is_visible, | ||||
|                             ).await { | ||||
|                                 Ok(_) => { | ||||
|                                     // Update the local state | ||||
|                                     let mut updated_calendars = (*external_calendars).clone(); | ||||
|                                     for calendar in &mut updated_calendars { | ||||
|                                         if calendar.id == id_str { | ||||
|                                             calendar.color = color.clone(); | ||||
|                                             break; | ||||
|                                         } | ||||
|                                     } | ||||
|                                     external_calendars.set(updated_calendars); | ||||
|                                      | ||||
|                                     // No need to refresh events - they will automatically pick up the new color | ||||
|                                     // from the calendar when rendered since they use the same calendar_path matching | ||||
|                                 } | ||||
|                                 Err(e) => { | ||||
|                                     web_sys::console::error_1(&format!("Failed to update external calendar color: {}", e).into()); | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     }); | ||||
|                 } | ||||
|             } else { | ||||
|                 // Handle CalDAV calendar color change (existing logic) | ||||
|                 if let Some(mut info) = (*user_info).clone() { | ||||
|                     for calendar in &mut info.calendars { | ||||
|                         if calendar.path == calendar_path { | ||||
| @@ -432,6 +622,7 @@ pub fn App() -> Html { | ||||
|                         let _ = LocalStorage::set("calendar_colors", json); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             color_picker_open.set(None); | ||||
|         }) | ||||
|     }; | ||||
| @@ -493,6 +684,7 @@ pub fn App() -> Html { | ||||
|     let on_event_create = { | ||||
|         let create_event_modal_open = create_event_modal_open.clone(); | ||||
|         let auth_token = auth_token.clone(); | ||||
|         let refresh_calendar_data = refresh_calendar_data.clone(); | ||||
|         Callback::from(move |event_data: EventCreationData| { | ||||
|             // Check if this is an update operation (has original_uid) or a create operation | ||||
|             if let Some(original_uid) = event_data.original_uid.clone() { | ||||
| @@ -503,6 +695,7 @@ pub fn App() -> Html { | ||||
|                 // Handle the update operation using the existing backend update logic | ||||
|                 if let Some(token) = (*auth_token).clone() { | ||||
|                     let event_data_for_update = event_data.clone(); | ||||
|                     let refresh_callback = refresh_calendar_data.clone(); | ||||
|                     wasm_bindgen_futures::spawn_local(async move { | ||||
|                         let calendar_service = CalendarService::new(); | ||||
|  | ||||
| @@ -603,10 +796,8 @@ pub fn App() -> Html { | ||||
|                         match update_result { | ||||
|                             Ok(_) => { | ||||
|                                 web_sys::console::log_1(&"Event updated successfully via modal".into()); | ||||
|                                 // Trigger a page reload to refresh events from all calendars | ||||
|                                 if let Some(window) = web_sys::window() { | ||||
|                                     let _ = window.location().reload(); | ||||
|                                 } | ||||
|                                 // Refresh calendar data without page reload | ||||
|                                 refresh_callback.emit(()); | ||||
|                             } | ||||
|                             Err(err) => { | ||||
|                                 web_sys::console::error_1( | ||||
| @@ -642,6 +833,7 @@ pub fn App() -> Html { | ||||
|             create_event_modal_open.set(false); | ||||
|  | ||||
|             if let Some(_token) = (*auth_token).clone() { | ||||
|                 let refresh_callback = refresh_calendar_data.clone(); | ||||
|                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                     let _calendar_service = CalendarService::new(); | ||||
|  | ||||
| @@ -688,9 +880,8 @@ pub fn App() -> Html { | ||||
|                     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(); | ||||
|                             // Refresh calendar data without page reload | ||||
|                             refresh_callback.emit(()); | ||||
|                         } | ||||
|                         Err(err) => { | ||||
|                             web_sys::console::error_1( | ||||
| @@ -709,6 +900,7 @@ pub fn App() -> Html { | ||||
|  | ||||
|     let on_event_update = { | ||||
|         let auth_token = auth_token.clone(); | ||||
|         let refresh_calendar_data = refresh_calendar_data.clone(); | ||||
|         Callback::from( | ||||
|             move |( | ||||
|                 original_event, | ||||
| @@ -743,6 +935,7 @@ pub fn App() -> Html { | ||||
|                 if let Some(token) = (*auth_token).clone() { | ||||
|                     let original_event = original_event.clone(); | ||||
|                     let backend_uid = backend_uid.clone(); | ||||
|                     let refresh_callback = refresh_calendar_data.clone(); | ||||
|                     wasm_bindgen_futures::spawn_local(async move { | ||||
|                         let calendar_service = CalendarService::new(); | ||||
|  | ||||
| @@ -761,11 +954,30 @@ pub fn App() -> Html { | ||||
|                             String::new() | ||||
|                         }; | ||||
|  | ||||
|                         // Send local time directly to backend (backend will handle UTC conversion) | ||||
|                         let start_date = new_start.format("%Y-%m-%d").to_string(); | ||||
|                         let start_time = new_start.format("%H:%M").to_string(); | ||||
|                         let end_date = new_end.format("%Y-%m-%d").to_string(); | ||||
|                         let end_time = new_end.format("%H:%M").to_string(); | ||||
|                         // Convert local naive datetime to UTC before sending to backend | ||||
|                         use chrono::TimeZone; | ||||
|                         let local_tz = chrono::Local; | ||||
|                          | ||||
|                         let start_utc = local_tz.from_local_datetime(&new_start) | ||||
|                             .single() | ||||
|                             .unwrap_or_else(|| { | ||||
|                                 // Fallback for ambiguous times (DST transitions) | ||||
|                                 local_tz.from_local_datetime(&new_start).earliest().unwrap() | ||||
|                             }) | ||||
|                             .with_timezone(&chrono::Utc); | ||||
|                              | ||||
|                         let end_utc = local_tz.from_local_datetime(&new_end) | ||||
|                             .single() | ||||
|                             .unwrap_or_else(|| { | ||||
|                                 // Fallback for ambiguous times (DST transitions) | ||||
|                                 local_tz.from_local_datetime(&new_end).earliest().unwrap() | ||||
|                             }) | ||||
|                             .with_timezone(&chrono::Utc); | ||||
|  | ||||
|                         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 { | ||||
| @@ -908,14 +1120,8 @@ pub fn App() -> Html { | ||||
|                         match result { | ||||
|                             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(); | ||||
|                                 }); | ||||
|                                 // Refresh calendar data without page reload | ||||
|                                 refresh_callback.emit(()); | ||||
|                             } | ||||
|                             Err(err) => { | ||||
|                                 web_sys::console::error_1( | ||||
| @@ -1002,13 +1208,9 @@ pub fn App() -> 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) | ||||
|                                     })} | ||||
|                                     on_create_external_calendar={Callback::from({ | ||||
|                                         let external_calendar_modal_open = external_calendar_modal_open.clone(); | ||||
|                                         move |_| external_calendar_modal_open.set(true) | ||||
|                                     on_add_calendar={Callback::from({ | ||||
|                                         let calendar_management_modal_open = calendar_management_modal_open.clone(); | ||||
|                                         move |_| calendar_management_modal_open.set(true) | ||||
|                                     })} | ||||
|                                     external_calendars={(*external_calendars).clone()} | ||||
|                                     on_external_calendar_toggle={Callback::from({ | ||||
| @@ -1093,12 +1295,23 @@ pub fn App() -> Html { | ||||
|                                     on_external_calendar_refresh={Callback::from({ | ||||
|                                         let external_calendar_events = external_calendar_events.clone(); | ||||
|                                         let external_calendars = external_calendars.clone(); | ||||
|                                         let refreshing_calendar_id = refreshing_calendar_id.clone(); | ||||
|                                         move |id: i32| { | ||||
|                                             let external_calendar_events = external_calendar_events.clone(); | ||||
|                                             let external_calendars = external_calendars.clone(); | ||||
|                                             let refreshing_calendar_id = refreshing_calendar_id.clone(); | ||||
|                                              | ||||
|                                             // Set loading state | ||||
|                                             refreshing_calendar_id.set(Some(id)); | ||||
|                                              | ||||
|                                             wasm_bindgen_futures::spawn_local(async move { | ||||
|                                                 web_sys::console::log_1(&format!("🔄 Refreshing external calendar {}", id).into()); | ||||
|                                                  | ||||
|                                                 // Force refresh of this specific calendar | ||||
|                                                 if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await { | ||||
|                                                 match CalendarService::fetch_external_calendar_events(id).await { | ||||
|                                                     Ok(mut events) => { | ||||
|                                                         web_sys::console::log_1(&format!("✅ Successfully refreshed calendar {} with {} events", id, events.len()).into()); | ||||
|                                                          | ||||
|                                                         // Set calendar_path for color matching | ||||
|                                                         for event in &mut events { | ||||
|                                                             event.calendar_path = Some(format!("external_{}", id)); | ||||
| @@ -1119,8 +1332,28 @@ pub fn App() -> Html { | ||||
|                                                         external_calendar_events.set(all_events); | ||||
|                                                          | ||||
|                                                         // Update the last_fetched timestamp in calendars list | ||||
|                                                     if let Ok(calendars) = CalendarService::get_external_calendars().await { | ||||
|                                                         match CalendarService::get_external_calendars().await { | ||||
|                                                             Ok(calendars) => { | ||||
|                                                                 external_calendars.set(calendars); | ||||
|                                                                 web_sys::console::log_1(&"✅ Calendar list updated with new timestamps".into()); | ||||
|                                                             } | ||||
|                                                             Err(err) => { | ||||
|                                                                 web_sys::console::error_1(&format!("⚠️ Failed to update calendar list: {}", err).into()); | ||||
|                                                             } | ||||
|                                                         } | ||||
|                                                          | ||||
|                                                         // Clear loading state on success | ||||
|                                                         refreshing_calendar_id.set(None); | ||||
|                                                     } | ||||
|                                                     Err(err) => { | ||||
|                                                         web_sys::console::error_1(&format!("❌ Failed to refresh calendar {}: {}", id, err).into()); | ||||
|                                                         // Show error to user | ||||
|                                                         if let Some(window) = web_sys::window() { | ||||
|                                                             let _ = window.alert_with_message(&format!("Failed to refresh calendar: {}", err)); | ||||
|                                                         } | ||||
|                                                          | ||||
|                                                         // Clear loading state on error | ||||
|                                                         refreshing_calendar_id.set(None); | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             }); | ||||
| @@ -1130,6 +1363,7 @@ pub fn App() -> Html { | ||||
|                                     on_color_change={on_color_change} | ||||
|                                     on_color_picker_toggle={on_color_picker_toggle} | ||||
|                                     available_colors={(*available_colors).clone()} | ||||
|                                     refreshing_calendar_id={(*refreshing_calendar_id).clone()} | ||||
|                                     on_calendar_context_menu={on_calendar_context_menu} | ||||
|                                     on_calendar_visibility_toggle={Callback::from({ | ||||
|                                         let user_info = user_info.clone(); | ||||
| @@ -1188,20 +1422,20 @@ pub fn App() -> Html { | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 <CreateCalendarModal | ||||
|                     is_open={*create_modal_open} | ||||
|                 <CalendarManagementModal | ||||
|                     is_open={*calendar_management_modal_open} | ||||
|                     on_close={Callback::from({ | ||||
|                         let create_modal_open = create_modal_open.clone(); | ||||
|                         move |_| create_modal_open.set(false) | ||||
|                         let calendar_management_modal_open = calendar_management_modal_open.clone(); | ||||
|                         move |_| calendar_management_modal_open.set(false) | ||||
|                     })} | ||||
|                     on_create={Callback::from({ | ||||
|                     on_create_calendar={Callback::from({ | ||||
|                         let auth_token = auth_token.clone(); | ||||
|                         let refresh_calendars = refresh_calendars.clone(); | ||||
|                         let create_modal_open = create_modal_open.clone(); | ||||
|                         let calendar_management_modal_open = calendar_management_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(); | ||||
|                                 let calendar_management_modal_open = calendar_management_modal_open.clone(); | ||||
|  | ||||
|                                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                                     let calendar_service = CalendarService::new(); | ||||
| @@ -1220,17 +1454,41 @@ pub fn App() -> Html { | ||||
|                                         Ok(_) => { | ||||
|                                             web_sys::console::log_1(&"Calendar created successfully!".into()); | ||||
|                                             refresh_calendars.emit(()); | ||||
|                                             create_modal_open.set(false); | ||||
|                                             calendar_management_modal_open.set(false); | ||||
|                                         } | ||||
|                                         Err(err) => { | ||||
|                                             web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into()); | ||||
|                                             create_modal_open.set(false); | ||||
|                                             calendar_management_modal_open.set(false); | ||||
|                                         } | ||||
|                                     } | ||||
|                                 }); | ||||
|                             } | ||||
|                         } | ||||
|                     })} | ||||
|                     on_external_success={Callback::from({ | ||||
|                         let external_calendars = external_calendars.clone(); | ||||
|                         let calendar_management_modal_open = calendar_management_modal_open.clone(); | ||||
|                         move |new_id: i32| { | ||||
|                             // Refresh external calendars list | ||||
|                             let external_calendars = external_calendars.clone(); | ||||
|                             let calendar_management_modal_open = calendar_management_modal_open.clone(); | ||||
|  | ||||
|                             wasm_bindgen_futures::spawn_local(async move { | ||||
|                                 let calendar_service = CalendarService::new(); | ||||
|                                 match CalendarService::get_external_calendars().await { | ||||
|                                     Ok(calendars) => { | ||||
|                                         external_calendars.set(calendars); | ||||
|                                         calendar_management_modal_open.set(false); | ||||
|                                         web_sys::console::log_1(&format!("External calendar {} added successfully!", new_id).into()); | ||||
|                                     } | ||||
|                                     Err(err) => { | ||||
|                                         web_sys::console::error_1(&format!("Failed to refresh external calendars: {}", err).into()); | ||||
|                                         calendar_management_modal_open.set(false); | ||||
|                                     } | ||||
|                                 } | ||||
|                             }); | ||||
|                         } | ||||
|                     })} | ||||
|                     available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()} | ||||
|                 /> | ||||
|  | ||||
| @@ -1303,10 +1561,10 @@ pub fn App() -> Html { | ||||
|                         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(); | ||||
|                         let refresh_calendar_data = refresh_calendar_data.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 refresh_calendar_data = refresh_calendar_data.clone(); | ||||
|                                 let event_context_menu_open = event_context_menu_open.clone(); | ||||
|  | ||||
|                                 // Log the delete action for now - we'll implement different behaviors later | ||||
| @@ -1316,6 +1574,7 @@ pub fn App() -> Html { | ||||
|                                     DeleteAction::DeleteSeries => web_sys::console::log_1(&"Delete entire series".into()), | ||||
|                                 } | ||||
|  | ||||
|                                 let refresh_callback = refresh_calendar_data.clone(); | ||||
|                                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                                     let calendar_service = CalendarService::new(); | ||||
|  | ||||
| @@ -1363,8 +1622,8 @@ pub fn App() -> Html { | ||||
|  | ||||
|                                                 // 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(); | ||||
|                                                 // Refresh calendar data without page reload | ||||
|                                                 refresh_callback.emit(()); | ||||
|                                             } | ||||
|                                             Err(err) => { | ||||
|                                                 web_sys::console::log_1(&format!("Failed to delete event: {}", err).into()); | ||||
| @@ -1378,6 +1637,17 @@ pub fn App() -> Html { | ||||
|                             } | ||||
|                         } | ||||
|                     })} | ||||
|                     on_view_details={Callback::from({ | ||||
|                         let event_context_menu_open = event_context_menu_open.clone(); | ||||
|                         let view_event_modal_open = view_event_modal_open.clone(); | ||||
|                         let view_event_modal_event = view_event_modal_event.clone(); | ||||
|                         move |event: VEvent| { | ||||
|                             // Set the event for viewing (read-only mode) | ||||
|                             view_event_modal_event.set(Some(event)); | ||||
|                             event_context_menu_open.set(false); | ||||
|                             view_event_modal_open.set(true); | ||||
|                         } | ||||
|                     })} | ||||
|                 /> | ||||
|  | ||||
|                 <CalendarContextMenu | ||||
| @@ -1413,59 +1683,28 @@ pub fn App() -> Html { | ||||
|                     available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()} | ||||
|                 /> | ||||
|  | ||||
|                 <ExternalCalendarModal | ||||
|                     is_open={*external_calendar_modal_open} | ||||
|  | ||||
|                 <EventModal | ||||
|                     event={if *view_event_modal_open { (*view_event_modal_event).clone() } else { None }} | ||||
|                     on_close={Callback::from({ | ||||
|                         let external_calendar_modal_open = external_calendar_modal_open.clone(); | ||||
|                         move |_| external_calendar_modal_open.set(false) | ||||
|                     })} | ||||
|                     on_success={Callback::from({ | ||||
|                         let external_calendars = external_calendars.clone(); | ||||
|                         let external_calendar_events = external_calendar_events.clone(); | ||||
|                         move |new_calendar_id: i32| { | ||||
|                             let external_calendars = external_calendars.clone(); | ||||
|                             let external_calendar_events = external_calendar_events.clone(); | ||||
|                             wasm_bindgen_futures::spawn_local(async move { | ||||
|                                 // First, refresh the calendar list to get the new calendar | ||||
|                                 match CalendarService::get_external_calendars().await { | ||||
|                                     Ok(calendars) => { | ||||
|                                         external_calendars.set(calendars.clone()); | ||||
|                                          | ||||
|                                         // Then immediately fetch events for the new calendar if it's visible | ||||
|                                         if let Some(new_calendar) = calendars.iter().find(|c| c.id == new_calendar_id) { | ||||
|                                             if new_calendar.is_visible { | ||||
|                                                 match CalendarService::fetch_external_calendar_events(new_calendar_id).await { | ||||
|                                                     Ok(mut events) => { | ||||
|                                                         // Set calendar_path for color matching | ||||
|                                                         for event in &mut events { | ||||
|                                                             event.calendar_path = Some(format!("external_{}", new_calendar_id)); | ||||
|                                                         } | ||||
|                                                          | ||||
|                                                         // Add the new calendar's events to existing events | ||||
|                                                         let mut all_events = (*external_calendar_events).clone(); | ||||
|                                                         all_events.extend(events); | ||||
|                                                         external_calendar_events.set(all_events); | ||||
|                                                     } | ||||
|                                                     Err(e) => { | ||||
|                                                         web_sys::console::log_1( | ||||
|                                                             &format!("Failed to fetch events for new calendar {}: {}", new_calendar_id, e).into(), | ||||
|                                                         ); | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             } | ||||
|                                         } | ||||
|                                     } | ||||
|                                     Err(err) => { | ||||
|                                         web_sys::console::log_1( | ||||
|                                             &format!("Failed to refresh calendars after creation: {}", err).into(), | ||||
|                                         ); | ||||
|                                     } | ||||
|                                 } | ||||
|                             }); | ||||
|                         let view_event_modal_open = view_event_modal_open.clone(); | ||||
|                         let view_event_modal_event = view_event_modal_event.clone(); | ||||
|                         move |_| { | ||||
|                             view_event_modal_open.set(false); | ||||
|                             view_event_modal_event.set(None); | ||||
|                         } | ||||
|                     })} | ||||
|                 /> | ||||
|                  | ||||
|                 // Mobile warning modal | ||||
|                 <MobileWarningModal | ||||
|                     is_open={*mobile_warning_open} | ||||
|                     on_close={on_mobile_warning_close} | ||||
|                 /> | ||||
|             </div> | ||||
|              | ||||
|             // Hidden print copy that gets shown only during printing | ||||
|             <div id="print-preview-copy" class="print-preview-paper" style="display: none;"></div> | ||||
|         </BrowserRouter> | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -53,6 +53,50 @@ impl AuthService { | ||||
|         self.post_json("/auth/login", &request).await | ||||
|     } | ||||
|  | ||||
|     pub async fn verify_token(&self, token: &str) -> Result<bool, String> { | ||||
|         let window = web_sys::window().ok_or("No global window exists")?; | ||||
|  | ||||
|         let opts = RequestInit::new(); | ||||
|         opts.set_method("GET"); | ||||
|         opts.set_mode(RequestMode::Cors); | ||||
|  | ||||
|         let url = format!("{}/auth/verify", self.base_url); | ||||
|         let request = Request::new_with_str_and_init(&url, &opts) | ||||
|             .map_err(|e| format!("Request creation failed: {:?}", e))?; | ||||
|  | ||||
|         request | ||||
|             .headers() | ||||
|             .set("Authorization", &format!("Bearer {}", token)) | ||||
|             .map_err(|e| format!("Header setting failed: {:?}", e))?; | ||||
|  | ||||
|         let resp_value = JsFuture::from(window.fetch_with_request(&request)) | ||||
|             .await | ||||
|             .map_err(|e| format!("Network request failed: {:?}", e))?; | ||||
|  | ||||
|         let resp: Response = resp_value | ||||
|             .dyn_into() | ||||
|             .map_err(|e| format!("Response cast failed: {:?}", e))?; | ||||
|  | ||||
|         if resp.ok() { | ||||
|             let text = JsFuture::from( | ||||
|                 resp.text() | ||||
|                     .map_err(|e| format!("Text extraction failed: {:?}", e))?, | ||||
|             ) | ||||
|             .await | ||||
|             .map_err(|e| format!("Text promise failed: {:?}", e))?; | ||||
|  | ||||
|             let text_string = text.as_string().ok_or("Response text is not a string")?; | ||||
|              | ||||
|             // Parse the response to get the "valid" field | ||||
|             let response: serde_json::Value = serde_json::from_str(&text_string) | ||||
|                 .map_err(|e| format!("JSON parsing failed: {}", e))?; | ||||
|              | ||||
|             Ok(response.get("valid").and_then(|v| v.as_bool()).unwrap_or(false)) | ||||
|         } else { | ||||
|             Ok(false) // Invalid token | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Helper method for POST requests with JSON body | ||||
|     async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>( | ||||
|         &self, | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| use crate::components::{ | ||||
|     CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, | ||||
|     CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView, | ||||
| }; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||
| @@ -389,6 +389,15 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     // Handle print calendar preview | ||||
|     let show_print_preview = use_state(|| false); | ||||
|     let on_print = { | ||||
|         let show_print_preview = show_print_preview.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             show_print_preview.set(true); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     // Handle drag-to-create event | ||||
|     let on_create_event = { | ||||
|         let show_create_modal = show_create_modal.clone(); | ||||
| @@ -457,6 +466,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                 on_today={on_today} | ||||
|                 time_increment={Some(*time_increment)} | ||||
|                 on_time_increment_toggle={Some(on_time_increment_toggle)} | ||||
|                 on_print={Some(on_print)} | ||||
|             /> | ||||
|  | ||||
|             { | ||||
| @@ -563,6 +573,32 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                     }) | ||||
|                 }} | ||||
|             /> | ||||
|  | ||||
|             // Print preview modal | ||||
|             { | ||||
|                 if *show_print_preview { | ||||
|                     html! { | ||||
|                         <PrintPreviewModal | ||||
|                             on_close={{ | ||||
|                                 let show_print_preview = show_print_preview.clone(); | ||||
|                                 Callback::from(move |_| { | ||||
|                                     show_print_preview.set(false); | ||||
|                                 }) | ||||
|                             }} | ||||
|                             view_mode={props.view.clone()} | ||||
|                             current_date={*current_date} | ||||
|                             selected_date={*selected_date} | ||||
|                             events={(*events).clone()} | ||||
|                             user_info={props.user_info.clone()} | ||||
|                             external_calendars={props.external_calendars.clone()} | ||||
|                             time_increment={*time_increment} | ||||
|                             today={today} | ||||
|                         /> | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! {} | ||||
|                 } | ||||
|             } | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -14,6 +14,8 @@ pub struct CalendarHeaderProps { | ||||
|     pub time_increment: Option<u32>, | ||||
|     #[prop_or_default] | ||||
|     pub on_time_increment_toggle: Option<Callback<MouseEvent>>, | ||||
|     #[prop_or_default] | ||||
|     pub on_print: Option<Callback<MouseEvent>>, | ||||
| } | ||||
|  | ||||
| #[function_component(CalendarHeader)] | ||||
| @@ -39,6 +41,17 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html { | ||||
|                         html! {} | ||||
|                     } | ||||
|                 } | ||||
|                 { | ||||
|                     if let Some(print_callback) = &props.on_print { | ||||
|                         html! { | ||||
|                             <button class="print-button" onclick={print_callback.clone()} title="Print Calendar"> | ||||
|                                 <i class="fas fa-print"></i> | ||||
|                             </button> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     } | ||||
|                 } | ||||
|             </div> | ||||
|             <h2 class="month-year">{title}</h2> | ||||
|             <div class="header-right"> | ||||
|   | ||||
| @@ -55,7 +55,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { | ||||
|                     { | ||||
|                         if props.color_picker_open { | ||||
|                             html! { | ||||
|                                 <div class="color-picker"> | ||||
|                                 <div class="color-picker-dropdown"> | ||||
|                                     { | ||||
|                                         props.available_colors.iter().map(|color| { | ||||
|                                             let color_str = color.clone(); | ||||
|   | ||||
							
								
								
									
										449
									
								
								frontend/src/components/calendar_management_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										449
									
								
								frontend/src/components/calendar_management_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,449 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::HtmlInputElement; | ||||
| use wasm_bindgen::JsCast; | ||||
| use crate::services::calendar_service::CalendarService; | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum CalendarTab { | ||||
|     Create, | ||||
|     External, | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct CalendarManagementModalProps { | ||||
|     pub is_open: bool, | ||||
|     pub on_close: Callback<()>, | ||||
|     pub on_create_calendar: Callback<(String, Option<String>, Option<String>)>, // name, description, color | ||||
|     pub on_external_success: Callback<i32>, // Pass the newly created external calendar ID | ||||
|     pub available_colors: Vec<String>, | ||||
| } | ||||
|  | ||||
| #[function_component(CalendarManagementModal)] | ||||
| pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html { | ||||
|     let active_tab = use_state(|| CalendarTab::Create); | ||||
|      | ||||
|     // Create Calendar state | ||||
|     let calendar_name = use_state(|| String::new()); | ||||
|     let description = use_state(|| String::new()); | ||||
|     let selected_color = use_state(|| None::<String>); | ||||
|     let create_error_message = use_state(|| None::<String>); | ||||
|     let is_creating = use_state(|| false); | ||||
|      | ||||
|     // External Calendar state | ||||
|     let external_name = use_state(|| String::new()); | ||||
|     let external_url = use_state(|| String::new()); | ||||
|     let external_selected_color = use_state(|| Some("#4285f4".to_string())); | ||||
|     let external_is_loading = use_state(|| false); | ||||
|     let external_error_message = use_state(|| None::<String>); | ||||
|  | ||||
|     // Reset state when modal opens | ||||
|     use_effect_with(props.is_open, { | ||||
|         let calendar_name = calendar_name.clone(); | ||||
|         let description = description.clone(); | ||||
|         let selected_color = selected_color.clone(); | ||||
|         let create_error_message = create_error_message.clone(); | ||||
|         let is_creating = is_creating.clone(); | ||||
|         let external_name = external_name.clone(); | ||||
|         let external_url = external_url.clone(); | ||||
|         let external_is_loading = external_is_loading.clone(); | ||||
|         let external_error_message = external_error_message.clone(); | ||||
|         let external_selected_color = external_selected_color.clone(); | ||||
|         let active_tab = active_tab.clone(); | ||||
|          | ||||
|         move |is_open| { | ||||
|             if *is_open { | ||||
|                 // Reset all state when modal opens | ||||
|                 calendar_name.set(String::new()); | ||||
|                 description.set(String::new()); | ||||
|                 selected_color.set(None); | ||||
|                 create_error_message.set(None); | ||||
|                 is_creating.set(false); | ||||
|                 external_name.set(String::new()); | ||||
|                 external_url.set(String::new()); | ||||
|                 external_is_loading.set(false); | ||||
|                 external_error_message.set(None); | ||||
|                 external_selected_color.set(Some("#4285f4".to_string())); | ||||
|                 active_tab.set(CalendarTab::Create); | ||||
|             } | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     let on_tab_click = { | ||||
|         let active_tab = active_tab.clone(); | ||||
|         Callback::from(move |tab: CalendarTab| { | ||||
|             active_tab.set(tab); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             if let Some(target) = e.target() { | ||||
|                 let element = target.dyn_into::<web_sys::Element>().unwrap(); | ||||
|                 if element.class_list().contains("modal-backdrop") { | ||||
|                     on_close.emit(()); | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     // Create Calendar handlers | ||||
|     let on_name_change = { | ||||
|         let calendar_name = calendar_name.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             let input: HtmlInputElement = e.target_unchecked_into(); | ||||
|             calendar_name.set(input.value()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_description_change = { | ||||
|         let description = description.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into(); | ||||
|             description.set(input.value()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_color_select = { | ||||
|         let selected_color = selected_color.clone(); | ||||
|         Callback::from(move |color: String| { | ||||
|             selected_color.set(Some(color)); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_external_color_select = { | ||||
|         let external_selected_color = external_selected_color.clone(); | ||||
|         Callback::from(move |color: String| { | ||||
|             external_selected_color.set(Some(color)); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_create_submit = { | ||||
|         let calendar_name = calendar_name.clone(); | ||||
|         let description = description.clone(); | ||||
|         let selected_color = selected_color.clone(); | ||||
|         let create_error_message = create_error_message.clone(); | ||||
|         let is_creating = is_creating.clone(); | ||||
|         let on_create_calendar = props.on_create_calendar.clone(); | ||||
|  | ||||
|         Callback::from(move |e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|  | ||||
|             let name = (*calendar_name).trim(); | ||||
|             if name.is_empty() { | ||||
|                 create_error_message.set(Some("Calendar name is required".to_string())); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             is_creating.set(true); | ||||
|             create_error_message.set(None); | ||||
|  | ||||
|             let desc = if description.is_empty() { | ||||
|                 None | ||||
|             } else { | ||||
|                 Some((*description).clone()) | ||||
|             }; | ||||
|  | ||||
|             on_create_calendar.emit((name.to_string(), desc, (*selected_color).clone())); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     // External Calendar handlers | ||||
|     let on_external_submit = { | ||||
|         let external_name = external_name.clone(); | ||||
|         let external_url = external_url.clone(); | ||||
|         let external_selected_color = external_selected_color.clone(); | ||||
|         let external_is_loading = external_is_loading.clone(); | ||||
|         let external_error_message = external_error_message.clone(); | ||||
|         let on_close = props.on_close.clone(); | ||||
|         let on_external_success = props.on_external_success.clone(); | ||||
|  | ||||
|         Callback::from(move |e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|              | ||||
|             let name = (*external_name).trim().to_string(); | ||||
|             let url = (*external_url).trim().to_string(); | ||||
|             let color = (*external_selected_color).clone().unwrap_or_else(|| "#4285f4".to_string()); | ||||
|  | ||||
|             // Debug logging to understand the issue | ||||
|             web_sys::console::log_1(&format!("External calendar form submission - Name: '{}', URL: '{}'", name, url).into()); | ||||
|  | ||||
|             if name.is_empty() { | ||||
|                 external_error_message.set(Some("Calendar name is required".to_string())); | ||||
|                 web_sys::console::log_1(&"Validation failed: empty name".into()); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if url.is_empty() { | ||||
|                 external_error_message.set(Some("Calendar URL is required".to_string())); | ||||
|                 web_sys::console::log_1(&"Validation failed: empty URL".into()); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Basic URL validation | ||||
|             if !url.starts_with("http://") && !url.starts_with("https://") { | ||||
|                 external_error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string())); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             external_is_loading.set(true); | ||||
|             external_error_message.set(None); | ||||
|  | ||||
|             let external_is_loading = external_is_loading.clone(); | ||||
|             let external_error_message = external_error_message.clone(); | ||||
|             let on_close = on_close.clone(); | ||||
|             let on_external_success = on_external_success.clone(); | ||||
|  | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 let calendar_service = CalendarService::new(); | ||||
|                  | ||||
|                 match CalendarService::create_external_calendar(&name, &url, &color).await { | ||||
|                     Ok(calendar) => { | ||||
|                         external_is_loading.set(false); | ||||
|                         on_close.emit(()); | ||||
|                         on_external_success.emit(calendar.id); | ||||
|                     } | ||||
|                     Err(e) => { | ||||
|                         external_is_loading.set(false); | ||||
|                         external_error_message.set(Some(format!("Failed to add calendar: {}", e))); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     // External input change handlers | ||||
|     let on_external_name_change = { | ||||
|         let external_name = external_name.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 external_name.set(input.value()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_external_url_change = { | ||||
|         let external_url = external_url.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 external_url.set(input.value()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     html! { | ||||
|         <div class="modal-backdrop" onclick={on_backdrop_click}> | ||||
|             <div class="modal-content calendar-management-modal"> | ||||
|                 <div class="modal-header"> | ||||
|                     <h2>{"Add Calendar"}</h2> | ||||
|                     <button class="modal-close" onclick={ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         Callback::from(move |_: MouseEvent| on_close.emit(())) | ||||
|                     }>{"×"}</button> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="calendar-management-tabs"> | ||||
|                     <button  | ||||
|                         class={if *active_tab == CalendarTab::Create { "tab-button active" } else { "tab-button" }} | ||||
|                         onclick={ | ||||
|                             let on_tab_click = on_tab_click.clone(); | ||||
|                             Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::Create)) | ||||
|                         } | ||||
|                     > | ||||
|                         {"Create Calendar"} | ||||
|                     </button> | ||||
|                     <button  | ||||
|                         class={if *active_tab == CalendarTab::External { "tab-button active" } else { "tab-button" }} | ||||
|                         onclick={ | ||||
|                             let on_tab_click = on_tab_click.clone(); | ||||
|                             Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::External)) | ||||
|                         } | ||||
|                     > | ||||
|                         {"Add External"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="modal-body"> | ||||
|                     { | ||||
|                         match *active_tab { | ||||
|                             CalendarTab::Create => html! { | ||||
|                                 <form onsubmit={on_create_submit}> | ||||
|                                     <div class="form-group"> | ||||
|                                         <label for="calendar-name">{"Calendar Name"}</label> | ||||
|                                         <input | ||||
|                                             type="text" | ||||
|                                             id="calendar-name" | ||||
|                                             value={(*calendar_name).clone()} | ||||
|                                             oninput={on_name_change} | ||||
|                                             placeholder="Enter calendar name" | ||||
|                                             disabled={*is_creating} | ||||
|                                         /> | ||||
|                                     </div> | ||||
|  | ||||
|                                     <div class="form-group"> | ||||
|                                         <label for="calendar-description">{"Description (optional)"}</label> | ||||
|                                         <textarea | ||||
|                                             id="calendar-description" | ||||
|                                             value={(*description).clone()} | ||||
|                                             oninput={on_description_change} | ||||
|                                             placeholder="Enter calendar description" | ||||
|                                             disabled={*is_creating} | ||||
|                                         /> | ||||
|                                     </div> | ||||
|  | ||||
|                                     <div class="form-group"> | ||||
|                                         <label>{"Calendar Color"}</label> | ||||
|                                         <div class="color-picker"> | ||||
|                                             { | ||||
|                                                 props.available_colors.iter().map(|color| { | ||||
|                                                     let is_selected = selected_color.as_ref() == Some(color); | ||||
|                                                     let color_clone = color.clone(); | ||||
|                                                     let on_color_select = on_color_select.clone(); | ||||
|                                                      | ||||
|                                                     html! { | ||||
|                                                         <div | ||||
|                                                             key={color.clone()} | ||||
|                                                             class={if is_selected { "color-option selected" } else { "color-option" }} | ||||
|                                                             style={format!("background-color: {}", color)} | ||||
|                                                             onclick={Callback::from(move |_: MouseEvent| { | ||||
|                                                                 on_color_select.emit(color_clone.clone()); | ||||
|                                                             })} | ||||
|                                                         /> | ||||
|                                                     } | ||||
|                                                 }).collect::<Html>() | ||||
|                                             } | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|  | ||||
|                                     { | ||||
|                                         if let Some(ref error) = *create_error_message { | ||||
|                                             html! { | ||||
|                                                 <div class="error-message"> | ||||
|                                                     {error} | ||||
|                                                 </div> | ||||
|                                             } | ||||
|                                         } else { | ||||
|                                             html! {} | ||||
|                                         } | ||||
|                                     } | ||||
|  | ||||
|                                     <div class="modal-footer"> | ||||
|                                         <button  | ||||
|                                             type="button"  | ||||
|                                             class="cancel-button"  | ||||
|                                             onclick={ | ||||
|                                                 let on_close = props.on_close.clone(); | ||||
|                                                 Callback::from(move |_: MouseEvent| on_close.emit(())) | ||||
|                                             } | ||||
|                                             disabled={*is_creating} | ||||
|                                         > | ||||
|                                             {"Cancel"} | ||||
|                                         </button> | ||||
|                                         <button  | ||||
|                                             type="submit"  | ||||
|                                             class="create-button"  | ||||
|                                             disabled={*is_creating} | ||||
|                                         > | ||||
|                                             {if *is_creating { "Creating..." } else { "Create Calendar" }} | ||||
|                                         </button> | ||||
|                                     </div> | ||||
|                                 </form> | ||||
|                             }, | ||||
|                             CalendarTab::External => html! { | ||||
|                                 <form onsubmit={on_external_submit}> | ||||
|                                     <div class="form-group"> | ||||
|                                         <label for="external-name">{"Calendar Name"}</label> | ||||
|                                         <input | ||||
|                                             type="text" | ||||
|                                             id="external-name" | ||||
|                                             value={(*external_name).clone()} | ||||
|                                             onchange={on_external_name_change} | ||||
|                                             placeholder="Enter calendar name" | ||||
|                                             disabled={*external_is_loading} | ||||
|                                         /> | ||||
|                                     </div> | ||||
|  | ||||
|                                     <div class="form-group"> | ||||
|                                         <label for="external-url">{"Calendar URL"}</label> | ||||
|                                         <input | ||||
|                                             type="url" | ||||
|                                             id="external-url" | ||||
|                                             value={(*external_url).clone()} | ||||
|                                             onchange={on_external_url_change} | ||||
|                                             placeholder="https://example.com/calendar.ics" | ||||
|                                             disabled={*external_is_loading} | ||||
|                                         /> | ||||
|                                         <small class="help-text"> | ||||
|                                             {"Enter the ICS/CalDAV URL for your external calendar"} | ||||
|                                         </small> | ||||
|                                     </div> | ||||
|  | ||||
|                                     <div class="form-group"> | ||||
|                                         <label>{"Calendar Color"}</label> | ||||
|                                         <div class="color-picker"> | ||||
|                                             { | ||||
|                                                 props.available_colors.iter().map(|color| { | ||||
|                                                     let is_selected = external_selected_color.as_ref() == Some(color); | ||||
|                                                     let color_clone = color.clone(); | ||||
|                                                     let on_external_color_select = on_external_color_select.clone(); | ||||
|                                                      | ||||
|                                                     html! { | ||||
|                                                         <div | ||||
|                                                             key={color.clone()} | ||||
|                                                             class={if is_selected { "color-option selected" } else { "color-option" }} | ||||
|                                                             style={format!("background-color: {}", color)} | ||||
|                                                             onclick={Callback::from(move |_: MouseEvent| { | ||||
|                                                                 on_external_color_select.emit(color_clone.clone()); | ||||
|                                                             })} | ||||
|                                                         /> | ||||
|                                                     } | ||||
|                                                 }).collect::<Html>() | ||||
|                                             } | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|  | ||||
|                                     { | ||||
|                                         if let Some(ref error) = *external_error_message { | ||||
|                                             html! { | ||||
|                                                 <div class="error-message"> | ||||
|                                                     {error} | ||||
|                                                 </div> | ||||
|                                             } | ||||
|                                         } else { | ||||
|                                             html! {} | ||||
|                                         } | ||||
|                                     } | ||||
|  | ||||
|                                     <div class="modal-footer"> | ||||
|                                         <button  | ||||
|                                             type="button"  | ||||
|                                             class="cancel-button"  | ||||
|                                             onclick={ | ||||
|                                                 let on_close = props.on_close.clone(); | ||||
|                                                 Callback::from(move |_: MouseEvent| on_close.emit(())) | ||||
|                                             } | ||||
|                                             disabled={*external_is_loading} | ||||
|                                         > | ||||
|                                             {"Cancel"} | ||||
|                                         </button> | ||||
|                                         <button  | ||||
|                                             type="submit"  | ||||
|                                             class="create-button"  | ||||
|                                             disabled={*external_is_loading} | ||||
|                                         > | ||||
|                                             {if *external_is_loading { "Adding..." } else { "Add Calendar" }} | ||||
|                                         </button> | ||||
|                                     </div> | ||||
|                                 </form> | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -24,6 +24,7 @@ pub struct EventContextMenuProps { | ||||
|     pub event: Option<VEvent>, | ||||
|     pub on_edit: Callback<EditAction>, | ||||
|     pub on_delete: Callback<DeleteAction>, | ||||
|     pub on_view_details: Callback<VEvent>, | ||||
|     pub on_close: Callback<()>, | ||||
| } | ||||
|  | ||||
| @@ -91,6 +92,14 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|         .map(|event| event.rrule.is_some()) | ||||
|         .unwrap_or(false); | ||||
|      | ||||
|     // Check if the event is from an external calendar (read-only) | ||||
|     let is_external = props | ||||
|         .event | ||||
|         .as_ref() | ||||
|         .and_then(|event| event.calendar_path.as_ref()) | ||||
|         .map(|path| path.starts_with("external_")) | ||||
|         .unwrap_or(false); | ||||
|  | ||||
|     let create_edit_callback = |action: EditAction| { | ||||
|         let on_edit = props.on_edit.clone(); | ||||
|         let on_close = props.on_close.clone(); | ||||
| @@ -109,6 +118,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let create_view_details_callback = { | ||||
|         let on_view_details = props.on_view_details.clone(); | ||||
|         let on_close = props.on_close.clone(); | ||||
|         let event = props.event.clone(); | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             if let Some(event) = &event { | ||||
|                 on_view_details.emit(event.clone()); | ||||
|             } | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div | ||||
|             ref={menu_ref} | ||||
| @@ -116,7 +137,15 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|             style={style} | ||||
|         > | ||||
|             { | ||||
|                 if is_recurring { | ||||
|                 if is_external { | ||||
|                     // External calendar events are read-only - only show "View Details" | ||||
|                     html! { | ||||
|                         <div class="context-menu-item" onclick={create_view_details_callback}> | ||||
|                             {"View Event Details"} | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else if is_recurring { | ||||
|                     // Regular recurring events - show edit options | ||||
|                     html! { | ||||
|                         <> | ||||
|                             <div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}> | ||||
| @@ -131,6 +160,7 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|                         </> | ||||
|                     } | ||||
|                 } else { | ||||
|                     // Regular single events - show edit option | ||||
|                     html! { | ||||
|                         <div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}> | ||||
|                             {"Edit Event"} | ||||
| @@ -139,6 +169,8 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|                 } | ||||
|             } | ||||
|             { | ||||
|                 if !is_external { | ||||
|                     // Only show delete options for non-external events | ||||
|                     if is_recurring { | ||||
|                         html! { | ||||
|                             <> | ||||
| @@ -160,6 +192,10 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { | ||||
|                             </div> | ||||
|                         } | ||||
|                     } | ||||
|                 } else { | ||||
|                     // No delete options for external events | ||||
|                     html! {} | ||||
|                 } | ||||
|             } | ||||
|         </div> | ||||
|     } | ||||
|   | ||||
| @@ -152,13 +152,50 @@ impl EventCreationData { | ||||
|         Option<u32>, // recurrence_count | ||||
|         Option<String>, // recurrence_until | ||||
|     ) { | ||||
|         use chrono::{Local, TimeZone}; | ||||
|          | ||||
|         // Convert local date/time to UTC for backend | ||||
|         let (utc_start_date, utc_start_time, utc_end_date, utc_end_time) = if self.all_day { | ||||
|             // For all-day events, just use the dates as-is (no time conversion needed) | ||||
|             ( | ||||
|             self.title.clone(), | ||||
|             self.description.clone(), | ||||
|                 self.start_date.format("%Y-%m-%d").to_string(), | ||||
|                 self.start_time.format("%H:%M").to_string(), | ||||
|                 self.end_date.format("%Y-%m-%d").to_string(), | ||||
|                 self.end_time.format("%H:%M").to_string(), | ||||
|             ) | ||||
|         } else { | ||||
|             // Convert local date/time to UTC | ||||
|             let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single(); | ||||
|             let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single(); | ||||
|              | ||||
|             if let (Some(start_dt), Some(end_dt)) = (start_local, end_local) { | ||||
|                 let start_utc = start_dt.with_timezone(&chrono::Utc); | ||||
|                 let end_utc = end_dt.with_timezone(&chrono::Utc); | ||||
|                 ( | ||||
|                     start_utc.format("%Y-%m-%d").to_string(), | ||||
|                     start_utc.format("%H:%M").to_string(), | ||||
|                     end_utc.format("%Y-%m-%d").to_string(), | ||||
|                     end_utc.format("%H:%M").to_string(), | ||||
|                 ) | ||||
|             } else { | ||||
|                 // Fallback if timezone conversion fails - use local time as-is | ||||
|                 web_sys::console::warn_1(&"⚠️ Failed to convert local time to UTC, using local time".into()); | ||||
|                 ( | ||||
|                     self.start_date.format("%Y-%m-%d").to_string(), | ||||
|                     self.start_time.format("%H:%M").to_string(), | ||||
|                     self.end_date.format("%Y-%m-%d").to_string(), | ||||
|                     self.end_time.format("%H:%M").to_string(), | ||||
|                 ) | ||||
|             } | ||||
|         }; | ||||
|          | ||||
|         ( | ||||
|             self.title.clone(), | ||||
|             self.description.clone(), | ||||
|             utc_start_date, | ||||
|             utc_start_time, | ||||
|             utc_end_date, | ||||
|             utc_end_time, | ||||
|             self.location.clone(), | ||||
|             self.all_day, | ||||
|             format!("{:?}", self.status).to_uppercase(), | ||||
|   | ||||
| @@ -145,6 +145,10 @@ pub fn Login(props: &LoginProps) -> Html { | ||||
|                     } | ||||
|                     Err(err) => { | ||||
|                         web_sys::console::log_1(&format!("❌ Login failed: {}", err).into()); | ||||
|                         // Clear any existing invalid tokens | ||||
|                         let _ = LocalStorage::delete("auth_token"); | ||||
|                         let _ = LocalStorage::delete("session_token"); | ||||
|                         let _ = LocalStorage::delete("caldav_credentials"); | ||||
|                         error_message.set(Some(err)); | ||||
|                         is_loading.set(false); | ||||
|                     } | ||||
|   | ||||
							
								
								
									
										96
									
								
								frontend/src/components/mobile_warning_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								frontend/src/components/mobile_warning_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::window; | ||||
| use wasm_bindgen::JsCast; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct MobileWarningModalProps { | ||||
|     pub is_open: bool, | ||||
|     pub on_close: Callback<()>, | ||||
| } | ||||
|  | ||||
| #[function_component(MobileWarningModal)] | ||||
| pub fn mobile_warning_modal(props: &MobileWarningModalProps) -> Html { | ||||
|     let on_backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             if let Some(target) = e.target() { | ||||
|                 let element = target.dyn_into::<web_sys::Element>().unwrap(); | ||||
|                 if element.class_list().contains("modal-overlay") { | ||||
|                     on_close.emit(()); | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     html! { | ||||
|         <div class="modal-overlay mobile-warning-overlay" onclick={on_backdrop_click}> | ||||
|             <div class="modal-content mobile-warning-modal"> | ||||
|                 <div class="modal-header"> | ||||
|                     <h2>{"Desktop Application"}</h2> | ||||
|                     <button class="modal-close" onclick={ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         Callback::from(move |_: MouseEvent| on_close.emit(())) | ||||
|                     }>{"×"}</button> | ||||
|                 </div> | ||||
|                 <div class="modal-body"> | ||||
|                     <div class="mobile-warning-icon"> | ||||
|                         {"💻"} | ||||
|                     </div> | ||||
|                     <p class="mobile-warning-title"> | ||||
|                         {"This calendar application is designed for desktop usage"} | ||||
|                     </p> | ||||
|                     <p class="mobile-warning-description"> | ||||
|                         {"For the best mobile calendar experience, we recommend using dedicated CalDAV apps available on your device's app store:"} | ||||
|                     </p> | ||||
|                     <ul class="mobile-warning-apps"> | ||||
|                         <li> | ||||
|                             <strong>{"iOS:"}</strong> | ||||
|                             {" Calendar (built-in), Calendars 5, Fantastical"} | ||||
|                         </li> | ||||
|                         <li> | ||||
|                             <strong>{"Android:"}</strong> | ||||
|                             {" Google Calendar, DAVx5, CalDAV Sync"} | ||||
|                         </li> | ||||
|                     </ul> | ||||
|                     <p class="mobile-warning-note"> | ||||
|                         {"These apps can sync with the same CalDAV server you're using here."} | ||||
|                     </p> | ||||
|                 </div> | ||||
|                 <div class="modal-footer"> | ||||
|                     <button class="continue-anyway-button" onclick={ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         Callback::from(move |_: MouseEvent| on_close.emit(())) | ||||
|                     }> | ||||
|                         {"Continue Anyway"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Helper function to detect mobile devices | ||||
| pub fn is_mobile_device() -> bool { | ||||
|     if let Some(window) = window() { | ||||
|         let navigator = window.navigator(); | ||||
|         let user_agent = navigator.user_agent().unwrap_or_default(); | ||||
|         let user_agent = user_agent.to_lowercase(); | ||||
|          | ||||
|         // Check for mobile device indicators | ||||
|         user_agent.contains("mobile") | ||||
|             || user_agent.contains("android") | ||||
|             || user_agent.contains("iphone") | ||||
|             || user_agent.contains("ipad") | ||||
|             || user_agent.contains("ipod") | ||||
|             || user_agent.contains("blackberry") | ||||
|             || user_agent.contains("webos") | ||||
|             || user_agent.contains("opera mini") | ||||
|             || user_agent.contains("windows phone") | ||||
|     } else { | ||||
|         false | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,6 @@ | ||||
| pub mod calendar; | ||||
| pub mod calendar_context_menu; | ||||
| pub mod calendar_management_modal; | ||||
| pub mod calendar_header; | ||||
| pub mod calendar_list_item; | ||||
| pub mod context_menu; | ||||
| @@ -10,7 +11,9 @@ pub mod event_form; | ||||
| pub mod event_modal; | ||||
| pub mod external_calendar_modal; | ||||
| pub mod login; | ||||
| pub mod mobile_warning_modal; | ||||
| pub mod month_view; | ||||
| pub mod print_preview_modal; | ||||
| pub mod recurring_edit_modal; | ||||
| pub mod route_handler; | ||||
| pub mod sidebar; | ||||
| @@ -18,18 +21,19 @@ pub mod week_view; | ||||
|  | ||||
| pub use calendar::Calendar; | ||||
| pub use calendar_context_menu::CalendarContextMenu; | ||||
| pub use calendar_management_modal::CalendarManagementModal; | ||||
| pub use calendar_header::CalendarHeader; | ||||
| pub use calendar_list_item::CalendarListItem; | ||||
| pub use context_menu::ContextMenu; | ||||
| pub use create_calendar_modal::CreateCalendarModal; | ||||
| pub use create_event_modal::CreateEventModal; | ||||
| // Re-export event form types for backwards compatibility | ||||
| pub use event_form::EventCreationData; | ||||
| pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu}; | ||||
| pub use event_modal::EventModal; | ||||
| pub use external_calendar_modal::ExternalCalendarModal; | ||||
| pub use login::Login; | ||||
| pub use mobile_warning_modal::MobileWarningModal; | ||||
| pub use month_view::MonthView; | ||||
| pub use print_preview_modal::PrintPreviewModal; | ||||
| pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; | ||||
| pub use route_handler::RouteHandler; | ||||
| pub use sidebar::{Sidebar, Theme, ViewMode}; | ||||
|   | ||||
							
								
								
									
										362
									
								
								frontend/src/components/print_preview_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										362
									
								
								frontend/src/components/print_preview_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,362 @@ | ||||
| use crate::components::{ViewMode, WeekView, MonthView}; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||
| use chrono::NaiveDate; | ||||
| use std::collections::HashMap; | ||||
| use wasm_bindgen::{closure::Closure, JsCast}; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct PrintPreviewModalProps { | ||||
|     pub on_close: Callback<()>, | ||||
|     pub view_mode: ViewMode, | ||||
|     pub current_date: NaiveDate, | ||||
|     pub selected_date: NaiveDate, | ||||
|     pub events: HashMap<NaiveDate, Vec<VEvent>>, | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     pub external_calendars: Vec<ExternalCalendar>, | ||||
|     pub time_increment: u32, | ||||
|     pub today: NaiveDate, | ||||
| } | ||||
|  | ||||
| #[function_component] | ||||
| pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html { | ||||
|     let start_hour = use_state(|| 6u32); | ||||
|     let end_hour = use_state(|| 22u32); | ||||
|     let zoom_level = use_state(|| 0.4f64); // Default 40% zoom | ||||
|  | ||||
|     let close_modal = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_close.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             if e.target() == e.current_target() { | ||||
|                 on_close.emit(()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_start_hour_change = { | ||||
|         let start_hour = start_hour.clone(); | ||||
|         let end_hour = end_hour.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             let target = e.target_dyn_into::<web_sys::HtmlSelectElement>(); | ||||
|             if let Some(select) = target { | ||||
|                 if let Ok(hour) = select.value().parse::<u32>() { | ||||
|                     if hour < *end_hour { | ||||
|                         start_hour.set(hour); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_end_hour_change = { | ||||
|         let start_hour = start_hour.clone(); | ||||
|         let end_hour = end_hour.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             let target = e.target_dyn_into::<web_sys::HtmlSelectElement>(); | ||||
|             if let Some(select) = target { | ||||
|                 if let Ok(hour) = select.value().parse::<u32>() { | ||||
|                     if hour > *start_hour && hour <= 24 { | ||||
|                         end_hour.set(hour); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|  | ||||
|     let format_hour = |hour: u32| -> String { | ||||
|         if hour == 0 { | ||||
|             "12 AM".to_string() | ||||
|         } else if hour < 12 { | ||||
|             format!("{} AM", hour) | ||||
|         } else if hour == 12 { | ||||
|             "12 PM".to_string() | ||||
|         } else { | ||||
|             format!("{} PM", hour - 12) | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     // Calculate dynamic base unit for print preview | ||||
|     let calculate_print_dimensions = |start_hour: u32, end_hour: u32, time_increment: u32| -> (f64, f64, f64) { | ||||
|         let visible_hours = (end_hour - start_hour) as f64; | ||||
|         let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 }; | ||||
|         let header_height = 50.0; // Fixed week header height in print preview | ||||
|         let header_border = 2.0;   // Week header bottom border (2px solid) | ||||
|         let container_spacing = 8.0; // Additional container spacing/margins | ||||
|         let total_overhead = header_height + header_border + container_spacing; | ||||
|         let available_height = 720.0 - total_overhead; // Available for time content | ||||
|         let base_unit = available_height / (visible_hours * slots_per_hour); | ||||
|         let pixels_per_hour = base_unit * slots_per_hour; | ||||
|          | ||||
|         (base_unit, pixels_per_hour, available_height) | ||||
|     }; | ||||
|  | ||||
|     // Calculate print dimensions for the current hour range | ||||
|     let (base_unit, pixels_per_hour, _available_height) = calculate_print_dimensions(*start_hour, *end_hour, props.time_increment); | ||||
|  | ||||
|     // Effect to update print copy whenever modal renders or content changes | ||||
|     { | ||||
|         let start_hour = *start_hour; | ||||
|         let end_hour = *end_hour; | ||||
|         let time_increment = props.time_increment; | ||||
|         let original_base_unit = base_unit; | ||||
|         use_effect(move || { | ||||
|             if let Some(window) = web_sys::window() { | ||||
|                 if let Some(document) = window.document() { | ||||
|                     // Set CSS variables on document root | ||||
|                     if let Some(document_element) = document.document_element() { | ||||
|                         if let Some(html_element) = document_element.dyn_ref::<web_sys::HtmlElement>() { | ||||
|                             let style = html_element.style(); | ||||
|                             let _ = style.set_property("--print-start-hour", &start_hour.to_string()); | ||||
|                             let _ = style.set_property("--print-end-hour", &end_hour.to_string()); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     // Copy content from print-preview-content to the hidden print-preview-copy div | ||||
|                     let copy_content = move || { | ||||
|                         if let Some(preview_content) = document.query_selector(".print-preview-content").ok().flatten() { | ||||
|                             if let Some(print_copy) = document.get_element_by_id("print-preview-copy") { | ||||
|                                 // Clone the preview content | ||||
|                                 if let Some(content_clone) = preview_content.clone_node_with_deep(true).ok() { | ||||
|                                     // Clear the print copy div and add the cloned content | ||||
|                                     print_copy.set_inner_html(""); | ||||
|                                     let _ = print_copy.append_child(&content_clone); | ||||
|                                      | ||||
|                                     // Get the actual rendered height of the print copy div and recalculate base-unit | ||||
|                                     if let Some(print_copy_html) = print_copy.dyn_ref::<web_sys::HtmlElement>() { | ||||
|                                         // Temporarily make visible to measure height, then hide again | ||||
|                                         let original_display = print_copy_html.style().get_property_value("display").unwrap_or_default(); | ||||
|                                         let _ = print_copy_html.style().set_property("display", "block"); | ||||
|                                         let _ = print_copy_html.style().set_property("visibility", "hidden"); | ||||
|                                         let _ = print_copy_html.style().set_property("position", "absolute"); | ||||
|                                         let _ = print_copy_html.style().set_property("top", "-9999px"); | ||||
|                                          | ||||
|                                         // Now measure the height | ||||
|                                         let actual_height = print_copy_html.client_height() as f64; | ||||
|                                          | ||||
|                                         // Restore original display | ||||
|                                         let _ = print_copy_html.style().set_property("display", &original_display); | ||||
|                                         let _ = print_copy_html.style().remove_property("visibility"); | ||||
|                                         let _ = print_copy_html.style().remove_property("position"); | ||||
|                                         let _ = print_copy_html.style().remove_property("top"); | ||||
|                                          | ||||
|                                         // Recalculate base-unit and pixels-per-hour based on actual height | ||||
|                                         let visible_hours = (end_hour - start_hour) as f64; | ||||
|                                         let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 }; | ||||
|                                         let header_height = 50.0; | ||||
|                                         let header_border = 2.0; | ||||
|                                         let container_spacing = 8.0; | ||||
|                                         let total_overhead = header_height + header_border + container_spacing; | ||||
|                                         let available_height = actual_height - total_overhead; | ||||
|                                         let actual_base_unit = available_height / (visible_hours * slots_per_hour); | ||||
|                                         let actual_pixels_per_hour = actual_base_unit * slots_per_hour; | ||||
|                                          | ||||
|                                          | ||||
|                                         // Set CSS variables with recalculated values | ||||
|                                         let style = print_copy_html.style(); | ||||
|                                         let _ = style.set_property("--print-base-unit", &format!("{:.2}", actual_base_unit)); | ||||
|                                         let _ = style.set_property("--print-pixels-per-hour", &format!("{:.2}", actual_pixels_per_hour)); | ||||
|                                         let _ = style.set_property("--print-start-hour", &start_hour.to_string()); | ||||
|                                         let _ = style.set_property("--print-end-hour", &end_hour.to_string()); | ||||
|                                          | ||||
|                                         // Copy data attributes | ||||
|                                         let _ = print_copy.set_attribute("data-start-hour", &start_hour.to_string()); | ||||
|                                         let _ = print_copy.set_attribute("data-end-hour", &end_hour.to_string()); | ||||
|                                          | ||||
|                                         // Recalculate event positions using the new base-unit | ||||
|                                         let events = print_copy.query_selector_all(".week-event").unwrap(); | ||||
|                                         let scale_factor = actual_base_unit / original_base_unit; | ||||
|                                          | ||||
|                                         for i in 0..events.length() { | ||||
|                                             if let Some(event_element) = events.get(i) { | ||||
|                                                 if let Some(event_html) = event_element.dyn_ref::<web_sys::HtmlElement>() { | ||||
|                                                     let event_style = event_html.style(); | ||||
|                                                      | ||||
|                                                     // Get current positioning values and recalculate | ||||
|                                                     if let Ok(current_top) = event_style.get_property_value("top") { | ||||
|                                                         if current_top.ends_with("px") { | ||||
|                                                             if let Ok(top_px) = current_top[..current_top.len()-2].parse::<f64>() { | ||||
|                                                                 let new_top = top_px * scale_factor; | ||||
|                                                                 let _ = event_style.set_property("top", &format!("{:.2}px", new_top)); | ||||
|                                                             } | ||||
|                                                         } | ||||
|                                                     } | ||||
|                                                      | ||||
|                                                     if let Ok(current_height) = event_style.get_property_value("height") { | ||||
|                                                         if current_height.ends_with("px") { | ||||
|                                                             if let Ok(height_px) = current_height[..current_height.len()-2].parse::<f64>() { | ||||
|                                                                 let new_height = height_px * scale_factor; | ||||
|                                                                 let _ = event_style.set_property("height", &format!("{:.2}px", new_height)); | ||||
|                                                             } | ||||
|                                                         } | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             } | ||||
|                                         } | ||||
|                                          | ||||
|                                         web_sys::console::log_1(&format!("Height: {:.2}, Original base-unit: {:.2}, New base-unit: {:.2}, Scale factor: {:.2}",  | ||||
|                                             actual_height, original_base_unit, actual_base_unit, scale_factor).into()); | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     }; | ||||
|  | ||||
|                     // Copy content immediately | ||||
|                     copy_content(); | ||||
|  | ||||
|                     // Also set up a small delay to catch any async rendering | ||||
|                     let copy_callback = Closure::wrap(Box::new(copy_content) as Box<dyn FnMut()>); | ||||
|                     let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( | ||||
|                         copy_callback.as_ref().unchecked_ref(), | ||||
|                         100 | ||||
|                     ); | ||||
|                     copy_callback.forget(); | ||||
|                 } | ||||
|             } | ||||
|             || () | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|     let on_print = { | ||||
|         Callback::from(move |_: MouseEvent| { | ||||
|             if let Some(window) = web_sys::window() { | ||||
|                 // Print copy is already updated by the use_effect, just trigger print | ||||
|                 let _ = window.print(); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class="modal-backdrop print-preview-modal-backdrop" onclick={backdrop_click}> | ||||
|             <div class="modal-content print-preview-modal"> | ||||
|                 <div class="modal-header"> | ||||
|                     <h3>{"Print Preview"}</h3> | ||||
|                     <button class="modal-close" onclick={close_modal.clone()}>{"×"}</button> | ||||
|                 </div> | ||||
|                 <div class="modal-body print-preview-body"> | ||||
|                     <div class="print-preview-controls"> | ||||
|                         { | ||||
|                             if props.view_mode == ViewMode::Week { | ||||
|                                 html! { | ||||
|                                     <> | ||||
|                                         <div class="control-group"> | ||||
|                                             <label for="start-hour">{"Start Hour:"}</label> | ||||
|                                             <select id="start-hour" onchange={on_start_hour_change}> | ||||
|                                                 { | ||||
|                                                     (0..24).map(|hour| { | ||||
|                                                         html! { | ||||
|                                                             <option value={hour.to_string()} selected={hour == *start_hour}> | ||||
|                                                                 {format_hour(hour)} | ||||
|                                                             </option> | ||||
|                                                         } | ||||
|                                                     }).collect::<Html>() | ||||
|                                                 } | ||||
|                                             </select> | ||||
|                                         </div> | ||||
|                                         <div class="control-group"> | ||||
|                                             <label for="end-hour">{"End Hour:"}</label> | ||||
|                                             <select id="end-hour" onchange={on_end_hour_change}> | ||||
|                                                 { | ||||
|                                                     (1..=24).map(|hour| { | ||||
|                                                         html! { | ||||
|                                                             <option value={hour.to_string()} selected={hour == *end_hour}> | ||||
|                                                                 {if hour == 24 { "12 AM".to_string() } else { format_hour(hour) }} | ||||
|                                                             </option> | ||||
|                                                         } | ||||
|                                                     }).collect::<Html>() | ||||
|                                                 } | ||||
|                                             </select> | ||||
|                                         </div> | ||||
|                                         <div class="hour-range-info"> | ||||
|                                             {format!("Will print from {} to {}",  | ||||
|                                                 format_hour(*start_hour),  | ||||
|                                                 if *end_hour == 24 { "12 AM".to_string() } else { format_hour(*end_hour) } | ||||
|                                             )} | ||||
|                                         </div> | ||||
|                                     </> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html! { | ||||
|                                     <div class="month-info"> | ||||
|                                         {"Will print entire month view"} | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         <div class="zoom-display-info"> | ||||
|                             <label>{"Zoom: "}</label> | ||||
|                             <span>{format!("{}%", (*zoom_level * 100.0) as i32)}</span> | ||||
|                             <span class="zoom-hint">{"(scroll to zoom)"}</span> | ||||
|                         </div> | ||||
|                         <div class="preview-actions"> | ||||
|                             <button class="btn-primary" onclick={on_print}>{"Print"}</button> | ||||
|                             <button class="btn-secondary" onclick={close_modal}>{"Cancel"}</button> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="print-preview-display" onwheel={{ | ||||
|                         let zoom_level = zoom_level.clone(); | ||||
|                         Callback::from(move |e: WheelEvent| { | ||||
|                             e.prevent_default(); // Prevent page scroll | ||||
|                             let delta_y = e.delta_y(); | ||||
|                             let zoom_change = if delta_y < 0.0 { 1.1 } else { 1.0 / 1.1 }; | ||||
|                             let new_zoom = (*zoom_level * zoom_change).clamp(0.2, 1.5); | ||||
|                             zoom_level.set(new_zoom); | ||||
|                         }) | ||||
|                     }}> | ||||
|                         <div class="print-preview-paper"  | ||||
|                                      data-start-hour={start_hour.to_string()}  | ||||
|                                      data-end-hour={end_hour.to_string()} | ||||
|                                      style={format!( | ||||
|                                          "--print-start-hour: {}; --print-end-hour: {}; --print-base-unit: {:.2}; --print-pixels-per-hour: {:.2}; transform: scale({}); transform-origin: top center;",  | ||||
|                                          *start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level | ||||
|                                      )}> | ||||
|                             <div class="print-preview-content"> | ||||
|                                 { | ||||
|                                     match props.view_mode { | ||||
|                                         ViewMode::Week => html! { | ||||
|                                             <WeekView | ||||
|                                                 key={format!("week-preview-{}-{}", *start_hour, *end_hour)} | ||||
|                                                 current_date={props.current_date} | ||||
|                                                 today={props.today} | ||||
|                                                 events={props.events.clone()} | ||||
|                                                 on_event_click={Callback::noop()} | ||||
|                                                 user_info={props.user_info.clone()} | ||||
|                                                 external_calendars={props.external_calendars.clone()} | ||||
|                                                 time_increment={props.time_increment} | ||||
|                                                 print_mode={true} | ||||
|                                                 print_pixels_per_hour={Some(pixels_per_hour)} | ||||
|                                                 print_start_hour={Some(*start_hour)} | ||||
|                                             /> | ||||
|                                         }, | ||||
|                                         ViewMode::Month => html! { | ||||
|                                             <MonthView | ||||
|                                                 key={format!("month-preview-{}-{}", *start_hour, *end_hour)} | ||||
|                                                 current_month={props.current_date} | ||||
|                                                 selected_date={Some(props.selected_date)} | ||||
|                                                 today={props.today} | ||||
|                                                 events={props.events.clone()} | ||||
|                                                 on_day_select={None::<Callback<NaiveDate>>} | ||||
|                                                 on_event_click={Callback::noop()} | ||||
|                                                 user_info={props.user_info.clone()} | ||||
|                                                 external_calendars={props.external_calendars.clone()} | ||||
|                                             /> | ||||
|                                         }, | ||||
|                                     } | ||||
|                                 } | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -100,8 +100,7 @@ impl Default for ViewMode { | ||||
| pub struct SidebarProps { | ||||
|     pub user_info: Option<UserInfo>, | ||||
|     pub on_logout: Callback<()>, | ||||
|     pub on_create_calendar: Callback<()>, | ||||
|     pub on_create_external_calendar: Callback<()>, | ||||
|     pub on_add_calendar: Callback<()>, | ||||
|     pub external_calendars: Vec<ExternalCalendar>, | ||||
|     pub on_external_calendar_toggle: Callback<i32>, | ||||
|     pub on_external_calendar_delete: Callback<i32>, | ||||
| @@ -110,6 +109,7 @@ pub struct SidebarProps { | ||||
|     pub on_color_change: Callback<(String, String)>, | ||||
|     pub on_color_picker_toggle: Callback<String>, | ||||
|     pub available_colors: Vec<String>, | ||||
|     pub refreshing_calendar_id: Option<i32>, | ||||
|     pub on_calendar_context_menu: Callback<(MouseEvent, String)>, | ||||
|     pub on_calendar_visibility_toggle: Callback<String>, | ||||
|     pub current_view: ViewMode, | ||||
| @@ -203,9 +203,6 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                     } | ||||
|                 } | ||||
|             </div> | ||||
|             <nav class="sidebar-nav"> | ||||
|                 <Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>> | ||||
|             </nav> | ||||
|             { | ||||
|                 if let Some(ref info) = props.user_info { | ||||
|                     if !info.calendars.is_empty() { | ||||
| @@ -259,7 +256,11 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                                         html! { | ||||
|                                             <li class="external-calendar-item" style="position: relative;"> | ||||
|                                                 <div  | ||||
|                                                     class="external-calendar-info" | ||||
|                                                     class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {  | ||||
|                                                         "external-calendar-info color-picker-active"  | ||||
|                                                     } else {  | ||||
|                                                         "external-calendar-info"  | ||||
|                                                     }} | ||||
|                                                     oncontextmenu={{ | ||||
|                                                         let on_context_menu = on_external_calendar_context_menu.clone(); | ||||
|                                                         let cal_id = cal.id; | ||||
| @@ -276,7 +277,48 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                                                     <span  | ||||
|                                                         class="external-calendar-color"  | ||||
|                                                         style={format!("background-color: {}", cal.color)} | ||||
|                                                         onclick={{ | ||||
|                                                             let on_color_picker_toggle = props.on_color_picker_toggle.clone(); | ||||
|                                                             let external_id = format!("external_{}", cal.id); | ||||
|                                                             Callback::from(move |e: MouseEvent| { | ||||
|                                                                 e.stop_propagation(); | ||||
|                                                                 on_color_picker_toggle.emit(external_id.clone()); | ||||
|                                                             }) | ||||
|                                                         }} | ||||
|                                                     > | ||||
|                                                         { | ||||
|                                                             if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) { | ||||
|                                                                 html! { | ||||
|                                                                     <div class="color-picker-dropdown"> | ||||
|                                                                         { | ||||
|                                                                             props.available_colors.iter().map(|color| { | ||||
|                                                                                 let color_str = color.clone(); | ||||
|                                                                                 let external_id = format!("external_{}", cal.id); | ||||
|                                                                                 let on_color_change = props.on_color_change.clone(); | ||||
|                                                                                  | ||||
|                                                                                 let on_color_select = Callback::from(move |_: MouseEvent| { | ||||
|                                                                                     on_color_change.emit((external_id.clone(), color_str.clone())); | ||||
|                                                                                 }); | ||||
|                                                                                  | ||||
|                                                                                 let is_selected = cal.color == *color; | ||||
|                                                                                  | ||||
|                                                                                 html! { | ||||
|                                                                                     <div | ||||
|                                                                                         key={color.clone()} | ||||
|                                                                                         class={if is_selected { "color-option selected" } else { "color-option" }} | ||||
|                                                                                         style={format!("background-color: {}", color)} | ||||
|                                                                                         onclick={on_color_select} | ||||
|                                                                                     /> | ||||
|                                                                                 } | ||||
|                                                                             }).collect::<Html>() | ||||
|                                                                         } | ||||
|                                                                     </div> | ||||
|                                                                 } | ||||
|                                                             } else { | ||||
|                                                                 html! {} | ||||
|                                                             } | ||||
|                                                         } | ||||
|                                                     </span> | ||||
|                                                     <span class="external-calendar-name">{&cal.name}</span> | ||||
|                                                     <div class="external-calendar-actions"> | ||||
|                                                         { | ||||
| @@ -304,8 +346,15 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                                                                     on_refresh.emit(cal_id); | ||||
|                                                                 }) | ||||
|                                                             }} | ||||
|                                                             disabled={props.refreshing_calendar_id == Some(cal.id)} | ||||
|                                                         > | ||||
|                                                             {"🔄"} | ||||
|                                                             { | ||||
|                                                                 if props.refreshing_calendar_id == Some(cal.id) { | ||||
|                                                                     html! { <i class="fas fa-spinner fa-spin"></i> } | ||||
|                                                                 } else { | ||||
|                                                                     html! { <i class="fas fa-sync-alt"></i> } | ||||
|                                                                 } | ||||
|                                                             } | ||||
|                                                         </button> | ||||
|                                                     </div> | ||||
|                                                 </div> | ||||
| @@ -352,12 +401,8 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|             </div> | ||||
|              | ||||
|             <div class="sidebar-footer"> | ||||
|                 <button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> | ||||
|                     {"+ Create Calendar"} | ||||
|                 </button> | ||||
|                  | ||||
|                 <button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button"> | ||||
|                     {"+ Add External Calendar"} | ||||
|                 <button onclick={props.on_add_calendar.reform(|_| ())} class="add-calendar-button"> | ||||
|                     {"+ Add Calendar"} | ||||
|                 </button> | ||||
|  | ||||
|                 <div class="view-selector"> | ||||
|   | ||||
| @@ -42,6 +42,12 @@ pub struct WeekViewProps { | ||||
|     pub context_menus_open: bool, | ||||
|     #[prop_or_default] | ||||
|     pub time_increment: u32, | ||||
|     #[prop_or_default] | ||||
|     pub print_mode: bool, | ||||
|     #[prop_or_default] | ||||
|     pub print_pixels_per_hour: Option<f64>, | ||||
|     #[prop_or_default] | ||||
|     pub print_start_hour: Option<u32>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| @@ -81,6 +87,31 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|  | ||||
|     let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>); | ||||
|  | ||||
|     // Current time state for time indicator | ||||
|     let current_time = use_state(|| Local::now()); | ||||
|  | ||||
|     // Update current time every 5 seconds | ||||
|     { | ||||
|         let current_time = current_time.clone(); | ||||
|         use_effect_with((), move |_| { | ||||
|             let interval = gloo_timers::callback::Interval::new(5_000, move || { | ||||
|                 current_time.set(Local::now()); | ||||
|             }); | ||||
|              | ||||
|             // Return the interval to keep it alive | ||||
|             move || drop(interval) | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Helper function to calculate current time indicator position | ||||
|     let calculate_current_time_position = |time_increment: u32| -> f64 { | ||||
|         let now = current_time.time(); | ||||
|         let hour = now.hour() as f64; | ||||
|         let minute = now.minute() as f64; | ||||
|         let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 }; | ||||
|         (hour + minute / 60.0) * pixels_per_hour | ||||
|     }; | ||||
|  | ||||
|     // Helper function to get calendar color for an event | ||||
|     let get_event_color = |event: &VEvent| -> String { | ||||
|         if let Some(calendar_path) = &event.calendar_path { | ||||
| @@ -413,13 +444,13 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                     // Time labels | ||||
|                     <div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}> | ||||
|                         { | ||||
|                             time_labels.iter().map(|time| { | ||||
|                             time_labels.iter().enumerate().map(|(hour, time)| { | ||||
|                                 let is_quarter_mode = props.time_increment == 15; | ||||
|                                 html! { | ||||
|                                     <div class={classes!( | ||||
|                                         "time-label", | ||||
|                                         if is_quarter_mode { Some("quarter-mode") } else { None } | ||||
|                                     )}> | ||||
|                                     )} data-hour={hour.to_string()}> | ||||
|                                         {time} | ||||
|                                     </div> | ||||
|                                 } | ||||
| @@ -676,10 +707,10 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                     > | ||||
|                                         // Time slot backgrounds - 24 hour slots to represent full day | ||||
|                                         { | ||||
|                                             (0..24).map(|_hour| { | ||||
|                                             (0..24).map(|hour| { | ||||
|                                                 let slots_per_hour = 60 / props.time_increment; | ||||
|                                                 html! { | ||||
|                                                     <div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}> | ||||
|                                                     <div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })} data-hour={hour.to_string()}> | ||||
|                                                         { | ||||
|                                                             (0..slots_per_hour).map(|_slot| { | ||||
|                                                                 let slot_class = if props.time_increment == 15 { | ||||
| @@ -701,7 +732,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                         <div class="events-container"> | ||||
|                                             { | ||||
|                                                 day_events.iter().enumerate().filter_map(|(event_idx, event)| { | ||||
|                                                     let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment); | ||||
|                                                     let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour); | ||||
|  | ||||
|                                                     // Skip all-day events (they're rendered in the header) | ||||
|                                                     if is_all_day { | ||||
| @@ -730,6 +761,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                         let event_for_drag = event.clone(); | ||||
|                                                         let date_for_drag = *date; | ||||
|                                                         let time_increment = props.time_increment; | ||||
|                                                         let print_pixels_per_hour = props.print_pixels_per_hour; | ||||
|                                                         let print_start_hour = props.print_start_hour; | ||||
|                                                         Callback::from(move |e: MouseEvent| { | ||||
|                                                             e.stop_propagation(); // Prevent drag-to-create from starting on event clicks | ||||
|  | ||||
| @@ -743,7 +776,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 }; | ||||
|  | ||||
|                                                             // Get event's current position in day column coordinates | ||||
|                                                             let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment); | ||||
|                                                             let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment, print_pixels_per_hour, print_start_hour); | ||||
|                                                             let event_start_pixels = event_start_pixels as f64; | ||||
|  | ||||
|                                                             // Convert click position to day column coordinates | ||||
| @@ -1029,7 +1062,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             }; | ||||
|  | ||||
|                                                             // Calculate positions for the preview | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment); | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour); | ||||
|                                                             let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local()); | ||||
|                                                             let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32); | ||||
|  | ||||
| @@ -1059,7 +1092,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                             let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); | ||||
|  | ||||
|                                                             // Calculate positions for the preview | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment); | ||||
|                                                             let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour); | ||||
|  | ||||
|                                                             let new_end_pixels = drag.current_y; | ||||
|                                                             let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0); | ||||
| @@ -1089,6 +1122,29 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|                                                 html! {} | ||||
|                                             } | ||||
|                                         } | ||||
|  | ||||
|                                         // Current time indicator - only show on today | ||||
|                                         { | ||||
|                                             if *date == props.today { | ||||
|                                                 let current_time_position = calculate_current_time_position(props.time_increment); | ||||
|                                                 let current_time_str = current_time.time().format("%I:%M %p").to_string(); | ||||
|                                                  | ||||
|                                                 html! { | ||||
|                                                     <div class="current-time-indicator-container"> | ||||
|                                                         <div  | ||||
|                                                             class="current-time-indicator" | ||||
|                                                             style={format!("top: {}px;", current_time_position)} | ||||
|                                                         > | ||||
|                                                             <div class="current-time-dot"></div> | ||||
|                                                             <div class="current-time-line"></div> | ||||
|                                                             <div class="current-time-label">{current_time_str}</div> | ||||
|                                                         </div> | ||||
|                                                     </div> | ||||
|                                                 } | ||||
|                                             } else { | ||||
|                                                 html! {} | ||||
|                                             } | ||||
|                                         } | ||||
|                                     </div> | ||||
|                                 } | ||||
|                             }).collect::<Html>() | ||||
| @@ -1170,7 +1226,7 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime { | ||||
|     NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap()) | ||||
| } | ||||
|  | ||||
| fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32) -> (f32, f32, bool) { | ||||
| fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) { | ||||
|     // Convert UTC times to local time for display | ||||
|     let local_start = event.dtstart.with_timezone(&Local); | ||||
|     let event_date = local_start.date_naive(); | ||||
| @@ -1190,11 +1246,23 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32 | ||||
|         return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true | ||||
|     } | ||||
|  | ||||
|     // Calculate start position in pixels from midnight | ||||
|     // Calculate start position in pixels | ||||
|     let start_hour = local_start.hour() as f32; | ||||
|     let start_minute = local_start.minute() as f32; | ||||
|     let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 }; | ||||
|     let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour; | ||||
|     let pixels_per_hour = if let Some(print_pph) = print_pixels_per_hour { | ||||
|         print_pph as f32 // Use the dynamic print mode calculation | ||||
|     } else { | ||||
|         if time_increment == 15 { 120.0 } else { 60.0 } // Default values | ||||
|     }; | ||||
|      | ||||
|     // In print mode, offset by the start hour to show relative position within visible range | ||||
|     let hour_offset = if let Some(print_start) = print_start_hour { | ||||
|         print_start as f32 | ||||
|     } else { | ||||
|         0.0 // No offset for normal view (starts at midnight) | ||||
|     }; | ||||
|      | ||||
|     let start_pixels = ((start_hour + start_minute / 60.0) - hour_offset) * pixels_per_hour; | ||||
|  | ||||
|     // Calculate duration and height | ||||
|     let duration_pixels = if let Some(end) = event.dtend { | ||||
| @@ -1203,19 +1271,19 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32 | ||||
|  | ||||
|         // Handle events that span multiple days by capping at midnight | ||||
|         if end_date > date { | ||||
|             // Event continues past midnight, cap at 24:00  | ||||
|             let max_pixels = 24.0 * pixels_per_hour; | ||||
|             max_pixels - start_pixels | ||||
|             // Event continues past midnight, cap at end of visible range | ||||
|             let max_hour = if let Some(_print_start) = print_start_hour { 24.0 } else { 24.0 }; | ||||
|             let max_pixels = (max_hour - hour_offset) * pixels_per_hour; | ||||
|             (max_pixels - start_pixels).max(20.0) | ||||
|         } else { | ||||
|             let end_hour = local_end.hour() as f32; | ||||
|             let end_minute = local_end.minute() as f32; | ||||
|             let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour; | ||||
|             let end_pixels = ((end_hour + end_minute / 60.0) - hour_offset) * pixels_per_hour; | ||||
|             (end_pixels - start_pixels).max(20.0) // Minimum 20px height | ||||
|         } | ||||
|     } else { | ||||
|         pixels_per_hour // Default 1 hour if no end time | ||||
|     }; | ||||
|  | ||||
|     (start_pixels, duration_pixels, false) // is_all_day = false | ||||
| } | ||||
|  | ||||
| @@ -1256,7 +1324,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3 | ||||
|                 return None; | ||||
|             } | ||||
|              | ||||
|             let (_, _, _) = calculate_event_position(event, date, time_increment); | ||||
|             let (_, _, _) = calculate_event_position(event, date, time_increment, None, None); | ||||
|             let local_start = event.dtstart.with_timezone(&Local); | ||||
|             let event_date = local_start.date_naive(); | ||||
|             if event_date == date ||  | ||||
|   | ||||
| @@ -37,6 +37,12 @@ pub struct UserInfo { | ||||
|     pub username: String, | ||||
|     pub server_url: String, | ||||
|     pub calendars: Vec<CalendarInfo>, | ||||
|     #[serde(default = "default_timestamp")] | ||||
|     pub last_updated: u64, | ||||
| } | ||||
|  | ||||
| fn default_timestamp() -> u64 { | ||||
|     0 | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
|   | ||||
							
								
								
									
										1244
									
								
								frontend/styles.css
									
									
									
									
									
								
							
							
						
						
									
										1244
									
								
								frontend/styles.css
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,51 +0,0 @@ | ||||
| /* Base Styles - Always Loaded */ | ||||
| * { | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| body { | ||||
|     font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||||
|     background-color: #f8f9fa; | ||||
|     color: #333; | ||||
|     line-height: 1.6; | ||||
| } | ||||
|  | ||||
| .app { | ||||
|     min-height: 100vh; | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
| } | ||||
|  | ||||
| .login-layout { | ||||
|     min-height: 100vh; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| /* Base Layout */ | ||||
| .main-content { | ||||
|     flex: 1; | ||||
|     margin-left: 280px; | ||||
|     overflow-x: hidden; | ||||
| } | ||||
|  | ||||
| /* Basic Form Elements */ | ||||
| input, select, textarea, button { | ||||
|     font-family: inherit; | ||||
| } | ||||
|  | ||||
| /* Utility Classes */ | ||||
| .loading { | ||||
|     opacity: 0.7; | ||||
| } | ||||
|  | ||||
| .error { | ||||
|     color: #dc3545; | ||||
| } | ||||
|  | ||||
| .success { | ||||
|     color: #28a745; | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user