Compare commits
	
		
			21 Commits
		
	
	
		
			ac1164fd81
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 933d7a8c1b | ||
|   | c938f25951 | ||
|   | c612f567b4 | ||
|   | b5b53bb23a | ||
|   | 7e058ba972 | ||
|   | 1f86ea9f71 | ||
|   | ce9914e388 | ||
|   | faf5ce2cfd | ||
|   | 2fee7a15f9 | ||
|   | 7caf3539f7 | ||
|   | 1538869f4a | ||
|   | 7ce7d4c9d9 | ||
|   | 037b733d48 | ||
|   | cb1bb23132 | ||
|   | 5c406569af | ||
|   | 4aca6c7fae | ||
|   | fd80624429 | ||
|   | b530dcaa69 | ||
|   | 0821573041 | ||
|   | 703c9ee2f5 | ||
|   | 5854ad291d | 
| @@ -39,19 +39,13 @@ impl AuthService { | ||||
|             request.username.clone(), | ||||
|             request.password.clone(), | ||||
|         ); | ||||
|         println!("📝 Created CalDAV config"); | ||||
|  | ||||
|         // Test authentication against CalDAV server | ||||
|         let caldav_client = CalDAVClient::new(caldav_config.clone()); | ||||
|         println!("🔗 Created CalDAV client, attempting to discover calendars..."); | ||||
|  | ||||
|         // Try to discover calendars as an authentication test | ||||
|         match caldav_client.discover_calendars().await { | ||||
|             Ok(calendars) => { | ||||
|                 println!( | ||||
|                     "✅ Authentication successful! Found {} calendars", | ||||
|                     calendars.len() | ||||
|                 ); | ||||
|             Ok(_calendars) => { | ||||
|                  | ||||
|                 // Find or create user in database | ||||
|                 let user_repo = UserRepository::new(&self.db); | ||||
|   | ||||
| @@ -167,8 +167,6 @@ impl CalDAVClient { | ||||
|         }; | ||||
|  | ||||
|         let basic_auth = self.config.get_basic_auth(); | ||||
|         println!("🔑 REPORT Basic Auth: Basic {}", basic_auth); | ||||
|         println!("🌐 REPORT URL: {}", url); | ||||
|  | ||||
|         let response = self | ||||
|             .http_client | ||||
| @@ -349,6 +347,8 @@ impl CalDAVClient { | ||||
|                 } | ||||
|             } | ||||
|             full_prop.push_str(&format!(":{}", prop_value)); | ||||
|              | ||||
|              | ||||
|             full_properties.insert(prop_name, full_prop); | ||||
|         } | ||||
|  | ||||
| @@ -358,6 +358,13 @@ impl CalDAVClient { | ||||
|             .ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))? | ||||
|             .clone(); | ||||
|  | ||||
|         // Determine if it's an all-day event FIRST by checking for VALUE=DATE parameter per RFC 5545 | ||||
|         let empty_string = String::new(); | ||||
|         let dtstart_raw = full_properties.get("DTSTART").unwrap_or(&empty_string); | ||||
|         let dtstart_value = properties.get("DTSTART").unwrap_or(&empty_string); | ||||
|         let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_value.contains("T") && dtstart_value.len() == 8); | ||||
|          | ||||
|  | ||||
|         // Parse start time (required) | ||||
|         let start_prop = properties | ||||
|             .get("DTSTART") | ||||
| @@ -375,11 +382,6 @@ impl CalDAVClient { | ||||
|             (None, None) | ||||
|         }; | ||||
|  | ||||
|         // Determine if it's an all-day event by checking for VALUE=DATE parameter | ||||
|         let empty_string = String::new(); | ||||
|         let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string); | ||||
|         let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8); | ||||
|  | ||||
|         // Parse status | ||||
|         let status = properties | ||||
|             .get("STATUS") | ||||
| @@ -582,11 +584,9 @@ impl CalDAVClient { | ||||
|     pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> { | ||||
|         // First, try to discover user calendars if we have a calendar path in config | ||||
|         if let Some(calendar_path) = &self.config.calendar_path { | ||||
|             println!("Using configured calendar path: {}", calendar_path); | ||||
|             return Ok(vec![calendar_path.clone()]); | ||||
|         } | ||||
|  | ||||
|         println!("No calendar path configured, discovering calendars..."); | ||||
|  | ||||
|         // Try different common CalDAV discovery paths | ||||
|         // Note: paths should be relative to the server URL base | ||||
| @@ -599,20 +599,16 @@ impl CalDAVClient { | ||||
|         let mut has_valid_caldav_response = false; | ||||
|  | ||||
|         for path in discovery_paths { | ||||
|             println!("Trying discovery path: {}", path); | ||||
|             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)) => { | ||||
|                 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); | ||||
|                 } | ||||
|             } | ||||
| @@ -669,7 +665,6 @@ impl CalDAVClient { | ||||
|         } | ||||
|  | ||||
|         let body = response.text().await.map_err(CalDAVError::RequestError)?; | ||||
|         println!("Discovery response for {}: {}", path, body); | ||||
|  | ||||
|         let mut calendar_paths = Vec::new(); | ||||
|  | ||||
| @@ -680,7 +675,6 @@ impl CalDAVClient { | ||||
|  | ||||
|                 // Extract href first | ||||
|                 if let Some(href) = self.extract_xml_content(response_content, "href") { | ||||
|                     println!("🔍 Checking resource: {}", href); | ||||
|  | ||||
|                     // Check if this is a calendar collection by looking for supported-calendar-component-set | ||||
|                     // This indicates it's an actual calendar that can contain events | ||||
| @@ -704,14 +698,10 @@ impl CalDAVClient { | ||||
|                             && !href.ends_with("/calendars/") | ||||
|                             && href.ends_with('/') | ||||
|                         { | ||||
|                             println!("📅 Found calendar collection: {}", href); | ||||
|                             calendar_paths.push(href); | ||||
|                         } else { | ||||
|                             println!("❌ Skipping system/root directory: {}", href); | ||||
|                         } | ||||
|                     } else { | ||||
|                         println!("ℹ️  Not a calendar collection: {} (is_calendar: {}, has_collection: {})",  | ||||
|                                href, is_calendar, has_collection); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| @@ -786,7 +776,16 @@ impl CalDAVClient { | ||||
|                 return Ok((dt.naive_utc(), Some("UTC".to_string()))); | ||||
|             } | ||||
|              | ||||
|             // Try parsing as naive datetime | ||||
|             // Special handling for date-only format (all-day events) | ||||
|             if *format == "%Y%m%d" { | ||||
|                 if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) { | ||||
|                     // Convert date to midnight datetime for all-day events | ||||
|                     let naive_dt = date.and_hms_opt(0, 0, 0).unwrap(); | ||||
|                     let tz = timezone_id.unwrap_or_else(|| "UTC".to_string()); | ||||
|                     return Ok((naive_dt, Some(tz))); | ||||
|                 } | ||||
|             } else { | ||||
|                 // Try parsing as naive datetime for time-based formats | ||||
|                 if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) { | ||||
|                     // Per RFC 5545: if no TZID parameter is provided, treat as UTC | ||||
|                     let tz = timezone_id.unwrap_or_else(|| "UTC".to_string()); | ||||
| @@ -796,6 +795,7 @@ impl CalDAVClient { | ||||
|                     return Ok((naive_dt, Some(tz))); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         Err(CalDAVError::ParseError(format!( | ||||
|             "Could not parse datetime: {}", | ||||
|   | ||||
| @@ -82,10 +82,6 @@ pub async fn get_user_info( | ||||
|         .await | ||||
|         .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; | ||||
|  | ||||
|     println!( | ||||
|         "✅ Authentication successful! Found {} calendars", | ||||
|         calendar_paths.len() | ||||
|     ); | ||||
|  | ||||
|     let calendars: Vec<CalendarInfo> = calendar_paths | ||||
|         .iter() | ||||
|   | ||||
| @@ -16,7 +16,7 @@ use crate::{ | ||||
|     AppState, | ||||
| }; | ||||
| use calendar_models::{ | ||||
|     AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent, | ||||
|     Attendee, CalendarUser, EventClass, EventStatus, VEvent, | ||||
| }; | ||||
|  | ||||
| use super::auth::{extract_bearer_token, extract_password_header}; | ||||
| @@ -35,7 +35,6 @@ pub async fn get_calendar_events( | ||||
|     // Extract and verify token | ||||
|     let token = extract_bearer_token(&headers)?; | ||||
|     let password = extract_password_header(&headers)?; | ||||
|     println!("🔑 API call with password length: {}", password.len()); | ||||
|  | ||||
|     // Create CalDAV config from token and password | ||||
|     let config = state | ||||
| @@ -127,7 +126,6 @@ pub async fn get_calendar_events( | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     println!("📅 Returning {} events", all_events.len()); | ||||
|     Ok(Json(all_events)) | ||||
| } | ||||
|  | ||||
| @@ -458,14 +456,11 @@ pub async fn create_event( | ||||
|         parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) | ||||
|             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||
|  | ||||
|     let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|     let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; | ||||
|  | ||||
|     // For all-day events, add one day to end date for RFC-5545 compliance | ||||
|     // RFC-5545 uses exclusive end dates for all-day events | ||||
|     if request.all_day { | ||||
|         end_datetime = end_datetime + chrono::Duration::days(1); | ||||
|     } | ||||
|     // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events | ||||
|     // No additional conversion needed here | ||||
|  | ||||
|     // Validate that end is after start (allow equal times for all-day events) | ||||
|     if request.all_day { | ||||
| @@ -527,19 +522,8 @@ pub async fn create_event( | ||||
|             .collect() | ||||
|     }; | ||||
|  | ||||
|     // Parse alarms - convert from minutes string to EventReminder structs | ||||
|     let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() { | ||||
|         Vec::new() | ||||
|     } else { | ||||
|         match request.reminder.parse::<i32>() { | ||||
|             Ok(minutes) => vec![crate::calendar::EventReminder { | ||||
|                 minutes_before: minutes, | ||||
|                 action: crate::calendar::ReminderAction::Display, | ||||
|                 description: None, | ||||
|             }], | ||||
|             Err(_) => Vec::new(), | ||||
|         } | ||||
|     }; | ||||
|     // Use VAlarms directly from request (no conversion needed) | ||||
|     let alarms = request.alarms; | ||||
|  | ||||
|     // Check if recurrence is already a full RRULE or just a simple type | ||||
|     let rrule = if request.recurrence.starts_with("FREQ=") { | ||||
| @@ -650,21 +634,7 @@ pub async fn create_event( | ||||
|     event.categories = categories; | ||||
|     event.rrule = rrule; | ||||
|     event.all_day = request.all_day; | ||||
|     event.alarms = alarms | ||||
|         .into_iter() | ||||
|         .map(|reminder| VAlarm { | ||||
|             action: AlarmAction::Display, | ||||
|             trigger: AlarmTrigger::Duration(chrono::Duration::minutes( | ||||
|                 -reminder.minutes_before as i64, | ||||
|             )), | ||||
|             duration: None, | ||||
|             repeat: None, | ||||
|             description: reminder.description, | ||||
|             summary: None, | ||||
|             attendees: Vec::new(), | ||||
|             attach: Vec::new(), | ||||
|         }) | ||||
|         .collect(); | ||||
|     event.alarms = alarms; | ||||
|     event.calendar_path = Some(calendar_path.clone()); | ||||
|  | ||||
|     // Create the event on the CalDAV server | ||||
| @@ -768,14 +738,11 @@ pub async fn update_event( | ||||
|         parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) | ||||
|             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||
|  | ||||
|     let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|     let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; | ||||
|  | ||||
|     // For all-day events, add one day to end date for RFC-5545 compliance | ||||
|     // RFC-5545 uses exclusive end dates for all-day events | ||||
|     if request.all_day { | ||||
|         end_datetime = end_datetime + chrono::Duration::days(1); | ||||
|     } | ||||
|     // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events | ||||
|     // No additional conversion needed here | ||||
|  | ||||
|     // Validate that end is after start (allow equal times for all-day events) | ||||
|     if request.all_day { | ||||
|   | ||||
| @@ -485,32 +485,23 @@ fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<Date | ||||
| 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() { | ||||
|     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| { | ||||
| @@ -529,10 +520,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|              | ||||
|             // 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); | ||||
|         } | ||||
|     } | ||||
| @@ -559,13 +546,12 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|      | ||||
|     let mut deduplicated_recurring = Vec::new(); | ||||
|      | ||||
|     for (title, events_with_title) in title_groups.drain() { | ||||
|     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); | ||||
| @@ -592,15 +578,9 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|             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()) | ||||
|                 ); | ||||
|                 // Discarding duplicate single event - keeping existing | ||||
|             } | ||||
|             continue; | ||||
|         } | ||||
| @@ -620,10 +600,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|         }); | ||||
|          | ||||
|         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()); | ||||
| @@ -635,11 +611,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     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 | ||||
| } | ||||
| @@ -665,14 +636,6 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     } | ||||
|      | ||||
|     // 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]; | ||||
| @@ -695,7 +658,6 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     }); | ||||
|      | ||||
|     if !can_consolidate { | ||||
|         println!("🚫 Cannot consolidate events - different times or durations"); | ||||
|         // Just deduplicate exact duplicates | ||||
|         return deduplicate_exact_recurring_events(events); | ||||
|     } | ||||
| @@ -708,13 +670,11 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     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 | ||||
|   | ||||
| @@ -137,13 +137,11 @@ pub async fn create_event_series( | ||||
|     let start_datetime = parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||
|  | ||||
|     let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|     let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; | ||||
|  | ||||
|     // For all-day events, add one day to end date for RFC-5545 compliance | ||||
|     if request.all_day { | ||||
|         end_datetime = end_datetime + chrono::Duration::days(1); | ||||
|     } | ||||
|     // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events | ||||
|     // No additional conversion needed here | ||||
|  | ||||
|     // Generate a unique UID for the series | ||||
|     let uid = format!("series-{}", uuid::Uuid::new_v4().to_string()); | ||||
| @@ -467,13 +465,10 @@ pub async fn update_event_series( | ||||
|     }; | ||||
|  | ||||
|     // Update the event on the CalDAV server using the original event's href | ||||
|     println!("📤 Updating event on CalDAV server..."); | ||||
|     let event_href = existing_event | ||||
|         .href | ||||
|         .as_ref() | ||||
|         .ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?; | ||||
|     println!("📤 Using event href: {}", event_href); | ||||
|     println!("📤 Calendar path: {}", calendar_path); | ||||
|  | ||||
|     match client | ||||
|         .update_event(&calendar_path, &updated_event, event_href) | ||||
| @@ -1028,7 +1023,7 @@ async fn update_single_occurrence( | ||||
|  | ||||
|     println!("✅ Created exception event successfully"); | ||||
|  | ||||
|     // Return the original series (now with EXDATE) - main handler will update it on CalDAV | ||||
|     // Return the modified existing event with EXDATE for the main handler to update on CalDAV | ||||
|     Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception) | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,7 @@ use axum::{ | ||||
|     response::{IntoResponse, Response}, | ||||
|     Json, | ||||
| }; | ||||
| use calendar_models::VAlarm; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| // API request/response types | ||||
| @@ -113,7 +114,7 @@ pub struct CreateEventRequest { | ||||
|     pub organizer: String,             // organizer email | ||||
|     pub attendees: String,             // comma-separated attendee emails | ||||
|     pub categories: String,            // comma-separated categories | ||||
|     pub reminder: String,              // reminder type | ||||
|     pub alarms: Vec<VAlarm>,           // event alarms | ||||
|     pub recurrence: String,            // recurrence type | ||||
|     pub recurrence_days: Vec<bool>,    // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence | ||||
|     pub calendar_path: Option<String>, // Optional - use first calendar if not specified | ||||
| @@ -144,7 +145,7 @@ pub struct UpdateEventRequest { | ||||
|     pub organizer: String,             // organizer email | ||||
|     pub attendees: String,             // comma-separated attendee emails | ||||
|     pub categories: String,            // comma-separated categories | ||||
|     pub reminder: String,              // reminder type | ||||
|     pub alarms: Vec<VAlarm>,           // event alarms | ||||
|     pub recurrence: String,            // recurrence type | ||||
|     pub recurrence_days: Vec<bool>,    // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence | ||||
|     pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years | ||||
| @@ -181,7 +182,7 @@ pub struct CreateEventSeriesRequest { | ||||
|     pub organizer: String,    // organizer email | ||||
|     pub attendees: String,    // comma-separated attendee emails | ||||
|     pub categories: String,   // comma-separated categories | ||||
|     pub reminder: String,     // reminder type | ||||
|     pub alarms: Vec<VAlarm>,  // event alarms | ||||
|  | ||||
|     // Series-specific fields | ||||
|     pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) | ||||
| @@ -219,7 +220,7 @@ pub struct UpdateEventSeriesRequest { | ||||
|     pub organizer: String,    // organizer email | ||||
|     pub attendees: String,    // comma-separated attendee emails | ||||
|     pub categories: String,   // comma-separated categories | ||||
|     pub reminder: String,     // reminder type | ||||
|     pub alarms: Vec<VAlarm>,  // event alarms | ||||
|  | ||||
|     // Series-specific fields | ||||
|     pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) | ||||
|   | ||||
| @@ -32,6 +32,23 @@ web-sys = { version = "0.3", features = [ | ||||
|     "CssStyleDeclaration", | ||||
|     "MediaQueryList", | ||||
|     "MediaQueryListEvent", | ||||
|     # Notification API for browser notifications | ||||
|     "Notification", | ||||
|     "NotificationOptions", | ||||
|     "NotificationPermission", | ||||
|     # Service Worker API for background processing | ||||
|     "ServiceWorkerContainer", | ||||
|     "ServiceWorkerRegistration", | ||||
|     "MessageEvent", | ||||
|     # IndexedDB API for persistent alarm storage | ||||
|     "IdbDatabase", | ||||
|     "IdbObjectStore", | ||||
|     "IdbTransaction", | ||||
|     "IdbRequest", | ||||
|     "IdbKeyRange", | ||||
|     "IdbFactory", | ||||
|     "IdbOpenDbRequest", | ||||
|     "IdbVersionChangeEvent", | ||||
| ] } | ||||
| wasm-bindgen = "0.2" | ||||
| js-sys = "0.3" | ||||
| @@ -73,3 +90,6 @@ gloo-storage = "0.3" | ||||
| gloo-timers = "0.3" | ||||
| wasm-bindgen-futures = "0.4" | ||||
|  | ||||
| # IndexedDB for persistent alarm storage | ||||
| indexed_db_futures = "0.4" | ||||
|  | ||||
|   | ||||
| @@ -8,15 +8,29 @@ | ||||
|     <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="copy-file" href="styles/apple.css"> | ||||
|     <link data-trunk rel="copy-file" href="service-worker.js"> | ||||
|     <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> | ||||
|         console.log("HTML fully loaded, waiting for WASM..."); | ||||
|         window.addEventListener('TrunkApplicationStarted', () => { | ||||
|             console.log("Trunk application started successfully!"); | ||||
|             // Application loaded successfully | ||||
|         }); | ||||
|  | ||||
|         // Register service worker for alarm background processing | ||||
|         if ('serviceWorker' in navigator) { | ||||
|             window.addEventListener('load', () => { | ||||
|                 navigator.serviceWorker.register('/service-worker.js') | ||||
|                     .then((registration) => { | ||||
|                         // Service worker registered successfully | ||||
|                     }) | ||||
|                     .catch((registrationError) => { | ||||
|                         console.log('SW registration failed: ', registrationError); | ||||
|                     }); | ||||
|             }); | ||||
|         } | ||||
|     </script> | ||||
| </body> | ||||
| </html> | ||||
|   | ||||
| @@ -212,6 +212,7 @@ | ||||
|     display: none !important; | ||||
| } | ||||
|  | ||||
|  | ||||
| /* Remove today highlighting in preview */ | ||||
| .print-preview-paper .calendar-day.today, | ||||
| .print-preview-paper .week-day-header.today, | ||||
|   | ||||
							
								
								
									
										150
									
								
								frontend/service-worker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								frontend/service-worker.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,150 @@ | ||||
| // Calendar Alarms Service Worker | ||||
| // Handles background alarm checking when the main app is not active | ||||
|  | ||||
| const SW_VERSION = 'v1.0.0'; | ||||
| const CACHE_NAME = `calendar-alarms-${SW_VERSION}`; | ||||
| const STORAGE_KEY = 'calendar_alarms'; | ||||
|  | ||||
| // Install event | ||||
| self.addEventListener('install', event => { | ||||
|     self.skipWaiting(); // Activate immediately | ||||
| }); | ||||
|  | ||||
| // Activate event | ||||
| self.addEventListener('activate', event => { | ||||
|     event.waitUntil(self.clients.claim()); // Take control immediately | ||||
| }); | ||||
|  | ||||
| // Message handler for communication with main app | ||||
| self.addEventListener('message', event => { | ||||
|     const { type, data } = event.data; | ||||
|      | ||||
|     switch (type) { | ||||
|         case 'CHECK_ALARMS': | ||||
|             handleCheckAlarms(event, data); | ||||
|             break; | ||||
|         case 'SCHEDULE_ALARM': | ||||
|             handleScheduleAlarm(data, event); | ||||
|             break; | ||||
|         case 'REMOVE_ALARM': | ||||
|             handleRemoveAlarm(data, event); | ||||
|             break; | ||||
|         case 'PING': | ||||
|             event.ports[0].postMessage({ type: 'PONG', version: SW_VERSION }); | ||||
|             break; | ||||
|         default: | ||||
|             console.warn('Unknown message type:', type); | ||||
|     } | ||||
| }); | ||||
|  | ||||
| // Handle alarm checking request | ||||
| function handleCheckAlarms(event, data) { | ||||
|     try { | ||||
|         // Main app sends alarms data to check | ||||
|         const allAlarms = data?.alarms || []; | ||||
|         const dueAlarms = checkProvidedAlarms(allAlarms); | ||||
|          | ||||
|         // Send results back to main app | ||||
|         event.ports[0].postMessage({ | ||||
|             type: 'ALARMS_DUE', | ||||
|             data: dueAlarms | ||||
|         }); | ||||
|          | ||||
|     } catch (error) { | ||||
|         console.error('Error checking alarms:', error); | ||||
|         event.ports[0].postMessage({ | ||||
|             type: 'ALARM_CHECK_ERROR', | ||||
|             error: error.message | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Process alarms sent from main app | ||||
| function checkProvidedAlarms(alarms) { | ||||
|     const now = new Date(); | ||||
|     const nowStr = formatDateTimeForComparison(now); | ||||
|      | ||||
|     // Filter alarms that should trigger and are pending | ||||
|     const dueAlarms = alarms.filter(alarm => { | ||||
|         return alarm.status === 'Pending' && alarm.trigger_time <= nowStr; | ||||
|     }); | ||||
|      | ||||
|     return dueAlarms; | ||||
| } | ||||
|  | ||||
| // Handle schedule alarm request (not needed with localStorage approach) | ||||
| function handleScheduleAlarm(alarmData, event) { | ||||
|     // Service worker doesn't handle storage with localStorage approach | ||||
|     // Main app handles all storage operations | ||||
|     event.ports[0].postMessage({ | ||||
|         type: 'ALARM_SCHEDULED', | ||||
|         data: { success: true, alarmId: alarmData.id } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| // Handle remove alarm request (not needed with localStorage approach) | ||||
| function handleRemoveAlarm(alarmData, event) { | ||||
|     // Service worker doesn't handle storage with localStorage approach | ||||
|     // Main app handles all storage operations | ||||
|     event.ports[0].postMessage({ | ||||
|         type: 'ALARM_REMOVED', | ||||
|         data: { success: true, eventUid: alarmData.eventUid } | ||||
|     }); | ||||
| } | ||||
|  | ||||
|  | ||||
| // Format date time for comparison (YYYY-MM-DDTHH:MM:SS) | ||||
| function formatDateTimeForComparison(date) { | ||||
|     return date.getFullYear() + '-' + | ||||
|            String(date.getMonth() + 1).padStart(2, '0') + '-' + | ||||
|            String(date.getDate()).padStart(2, '0') + 'T' + | ||||
|            String(date.getHours()).padStart(2, '0') + ':' + | ||||
|            String(date.getMinutes()).padStart(2, '0') + ':' + | ||||
|            String(date.getSeconds()).padStart(2, '0'); | ||||
| } | ||||
|  | ||||
| // Background alarm checking (runs periodically) | ||||
| // Note: Service worker can't access localStorage, so this just pings the main app | ||||
| setInterval(async () => { | ||||
|     try { | ||||
|         // Notify all clients to check their alarms | ||||
|         const clients = await self.clients.matchAll(); | ||||
|          | ||||
|         clients.forEach(client => { | ||||
|             client.postMessage({ | ||||
|                 type: 'BACKGROUND_ALARM_CHECK_REQUEST' | ||||
|             }); | ||||
|         }); | ||||
|          | ||||
|     } catch (error) { | ||||
|         console.error('Background alarm check failed:', error); | ||||
|     } | ||||
| }, 60000); // Check every minute | ||||
|  | ||||
| // Handle push notifications (for future enhancement) | ||||
| self.addEventListener('push', event => { | ||||
|     console.log('Push notification received:', event); | ||||
|     // Future: Handle server-sent alarm notifications | ||||
| }); | ||||
|  | ||||
| // Handle notification clicks | ||||
| self.addEventListener('notificationclick', event => { | ||||
|     console.log('Notification clicked:', event); | ||||
|      | ||||
|     event.notification.close(); | ||||
|      | ||||
|     // Focus or open the calendar app | ||||
|     event.waitUntil( | ||||
|         self.clients.matchAll().then(clients => { | ||||
|             // Try to focus existing client | ||||
|             for (const client of clients) { | ||||
|                 if (client.url.includes('localhost') || client.url.includes(self.location.origin)) { | ||||
|                     return client.focus(); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // Open new window if no client exists | ||||
|             return self.clients.openWindow('/'); | ||||
|         }) | ||||
|     ); | ||||
| }); | ||||
| @@ -1,12 +1,12 @@ | ||||
| use crate::components::{ | ||||
|     CalendarContextMenu, CalendarManagementModal, ContextMenu, CreateEventModal, DeleteAction, | ||||
|     CalendarContextMenu, CalendarManagementModal, ColorEditorModal, 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}; | ||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService, AlarmScheduler}; | ||||
| use chrono::NaiveDate; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use gloo_timers::callback::Interval; | ||||
| @@ -15,18 +15,44 @@ use web_sys::MouseEvent; | ||||
| use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
|  | ||||
| fn get_theme_event_colors() -> Vec<String> { | ||||
|     if let Some(window) = web_sys::window() { | ||||
|         if let Some(document) = window.document() { | ||||
|             if let Some(root) = document.document_element() { | ||||
|                 if let Ok(Some(computed_style)) = window.get_computed_style(&root) { | ||||
|                     if let Ok(colors_string) = computed_style.get_property_value("--event-colors") { | ||||
|                         if !colors_string.is_empty() { | ||||
|                             return colors_string | ||||
|                                 .split(',') | ||||
|                                 .map(|color| color.trim().to_string()) | ||||
|                                 .filter(|color| !color.is_empty()) | ||||
| fn get_default_event_colors() -> Vec<String> { | ||||
|     vec![ | ||||
|         "#3B82F6".to_string(), // Blue | ||||
|         "#10B981".to_string(), // Emerald | ||||
|         "#F59E0B".to_string(), // Amber | ||||
|         "#EF4444".to_string(), // Red | ||||
|         "#8B5CF6".to_string(), // Violet | ||||
|         "#06B6D4".to_string(), // Cyan | ||||
|         "#84CC16".to_string(), // Lime | ||||
|         "#F97316".to_string(), // Orange | ||||
|         "#EC4899".to_string(), // Pink | ||||
|         "#6366F1".to_string(), // Indigo | ||||
|         "#14B8A6".to_string(), // Teal | ||||
|         "#F3B806".to_string(), // Yellow | ||||
|         "#8B5A2B".to_string(), // Brown | ||||
|         "#6B7280".to_string(), // Gray | ||||
|         "#DC2626".to_string(), // Dark Red | ||||
|         "#7C3AED".to_string(), // Purple | ||||
|     ] | ||||
| } | ||||
|  | ||||
| fn get_event_colors_from_preferences() -> Vec<String> { | ||||
|     // Try to load custom colors from user preferences | ||||
|     if let Some(prefs) = crate::services::preferences::PreferencesService::load_cached() { | ||||
|         if let Some(colors_json) = prefs.calendar_colors { | ||||
|             // Try to parse the JSON structure | ||||
|             if let Ok(colors_data) = serde_json::from_str::<serde_json::Value>(&colors_json) { | ||||
|                 // Check if it has a custom_palette field | ||||
|                 if let Some(custom_palette) = colors_data.get("custom_palette") { | ||||
|                     if let Some(colors_array) = custom_palette.as_array() { | ||||
|                         let custom_colors: Vec<String> = colors_array | ||||
|                             .iter() | ||||
|                             .filter_map(|v| v.as_str().map(|s| s.to_string())) | ||||
|                             .collect(); | ||||
|                          | ||||
|                         // Only use custom colors if we have a reasonable number | ||||
|                         if custom_colors.len() >= 8 { | ||||
|                             return custom_colors; | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
| @@ -34,24 +60,23 @@ fn get_theme_event_colors() -> Vec<String> { | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     vec![ | ||||
|         "#3B82F6".to_string(), | ||||
|         "#10B981".to_string(), | ||||
|         "#F59E0B".to_string(), | ||||
|         "#EF4444".to_string(), | ||||
|         "#8B5CF6".to_string(), | ||||
|         "#06B6D4".to_string(), | ||||
|         "#84CC16".to_string(), | ||||
|         "#F97316".to_string(), | ||||
|         "#EC4899".to_string(), | ||||
|         "#6366F1".to_string(), | ||||
|         "#14B8A6".to_string(), | ||||
|         "#F3B806".to_string(), | ||||
|         "#8B5A2B".to_string(), | ||||
|         "#6B7280".to_string(), | ||||
|         "#DC2626".to_string(), | ||||
|         "#7C3AED".to_string(), | ||||
|     ] | ||||
|     // Fall back to default colors | ||||
|     get_default_event_colors() | ||||
| } | ||||
|  | ||||
| async fn save_custom_colors_to_preferences(colors: Vec<String>) -> Result<(), String> { | ||||
|     // Create the JSON structure for storing custom colors | ||||
|     let colors_json = serde_json::json!({ | ||||
|         "custom_palette": colors | ||||
|     }); | ||||
|      | ||||
|     // Convert to string for preferences storage | ||||
|     let colors_string = serde_json::to_string(&colors_json) | ||||
|         .map_err(|e| format!("Failed to serialize colors: {}", e))?; | ||||
|      | ||||
|     // Update preferences via the preferences service | ||||
|     let preferences_service = crate::services::preferences::PreferencesService::new(); | ||||
|     preferences_service.update_preference("calendar_colors", serde_json::Value::String(colors_string)).await | ||||
| } | ||||
|  | ||||
| #[function_component] | ||||
| @@ -71,7 +96,6 @@ pub fn App() -> Html { | ||||
|                     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)); | ||||
|                         } | ||||
|                         _ => { | ||||
| @@ -97,6 +121,8 @@ pub fn App() -> Html { | ||||
|     let color_picker_open = use_state(|| -> Option<String> { None }); | ||||
|     let calendar_management_modal_open = use_state(|| false); | ||||
|     let context_menu_open = use_state(|| false); | ||||
|     let color_editor_open = use_state(|| false); | ||||
|     let color_editor_data = use_state(|| -> Option<(usize, String)> { None }); // (index, current_color) | ||||
|     let context_menu_pos = use_state(|| (0i32, 0i32)); | ||||
|     let context_menu_calendar_path = use_state(|| -> Option<String> { None }); | ||||
|     let event_context_menu_open = use_state(|| false); | ||||
| @@ -123,6 +149,11 @@ pub fn App() -> Html { | ||||
|     let mobile_warning_open = use_state(|| is_mobile_device()); | ||||
|     let refresh_interval = use_state(|| -> Option<Interval> { None }); | ||||
|      | ||||
|     // Alarm system state | ||||
|     let alarm_scheduler = use_state(|| AlarmScheduler::new()); | ||||
|     let alarm_check_interval = use_state(|| -> Option<Interval> { None }); | ||||
|     let alarm_system_initialized = use_state(|| false); | ||||
|  | ||||
|     // Calendar view state - load from localStorage if available | ||||
|     let current_view = use_state(|| { | ||||
|         // Try to load saved view mode from localStorage | ||||
| @@ -156,7 +187,76 @@ pub fn App() -> Html { | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     let available_colors = use_state(|| get_theme_event_colors()); | ||||
|     let available_colors = use_state(|| get_event_colors_from_preferences()); | ||||
|      | ||||
|     // Refresh colors when preferences might have changed | ||||
|     let refresh_colors = { | ||||
|         let available_colors = available_colors.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             available_colors.set(get_event_colors_from_preferences()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     // Initialize alarm system after user login | ||||
|     { | ||||
|         let auth_token = auth_token.clone(); | ||||
|         let alarm_scheduler = alarm_scheduler.clone(); | ||||
|         let alarm_system_initialized = alarm_system_initialized.clone(); | ||||
|         let alarm_check_interval = alarm_check_interval.clone(); | ||||
|          | ||||
|         use_effect_with((*auth_token).clone(), move |token| { | ||||
|             if token.is_some() && !*alarm_system_initialized { | ||||
|                  | ||||
|                 let alarm_scheduler = alarm_scheduler.clone(); | ||||
|                 let alarm_system_initialized = alarm_system_initialized.clone(); | ||||
|                 let alarm_check_interval = alarm_check_interval.clone(); | ||||
|                  | ||||
|                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                     // Request notification permission | ||||
|                     let scheduler = (*alarm_scheduler).clone(); | ||||
|                     match scheduler.request_notification_permission().await { | ||||
|                         Ok(_permission) => { | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             web_sys::console::warn_1( | ||||
|                                 &format!("⚠️ Failed to request notification permission: {:?}", e).into() | ||||
|                             ); | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     alarm_scheduler.set(scheduler); | ||||
|                     alarm_system_initialized.set(true); | ||||
|                      | ||||
|                     // Set up alarm checking interval (every 30 seconds) | ||||
|                     let interval = { | ||||
|                         let alarm_scheduler_ref = alarm_scheduler.clone(); | ||||
|                         Interval::new(30_000, move || { | ||||
|                             // Get a fresh copy of the current scheduler state each time | ||||
|                             let mut scheduler = (*alarm_scheduler_ref).clone(); | ||||
|                             let triggered_count = scheduler.check_and_trigger_alarms(); | ||||
|                              | ||||
|                             if triggered_count > 0 { | ||||
|                                 web_sys::console::log_1( | ||||
|                                     &format!("🔔 Triggered {} alarm(s)", triggered_count).into() | ||||
|                                 ); | ||||
|                             } | ||||
|                              | ||||
|                             // Update the scheduler state with any changes (like alarm status updates) | ||||
|                             alarm_scheduler_ref.set(scheduler); | ||||
|                         }) | ||||
|                     }; | ||||
|                      | ||||
|                     alarm_check_interval.set(Some(interval)); | ||||
|                 }); | ||||
|             } else if token.is_none() { | ||||
|                 // Clean up alarm system on logout | ||||
|                 alarm_check_interval.set(None); // This will drop and cancel the interval | ||||
|                 alarm_system_initialized.set(false); | ||||
|             } | ||||
|              | ||||
|             || () | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Function to refresh calendar data without full page reload | ||||
|     let refresh_calendar_data = { | ||||
| @@ -164,12 +264,14 @@ pub fn App() -> Html { | ||||
|         let auth_token = auth_token.clone(); | ||||
|         let external_calendars = external_calendars.clone(); | ||||
|         let external_calendar_events = external_calendar_events.clone(); | ||||
|         let refresh_colors = refresh_colors.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(); | ||||
|             let refresh_colors = refresh_colors.clone(); | ||||
|              | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 // Refresh main calendar data if authenticated | ||||
| @@ -193,7 +295,21 @@ pub fn App() -> Html { | ||||
|                     if !password.is_empty() { | ||||
|                         match calendar_service.fetch_user_info(&token, &password).await { | ||||
|                             Ok(mut info) => { | ||||
|                                 // Apply saved colors | ||||
|                                 // Preserve existing calendar settings (colors and visibility) from current state | ||||
|                                 if let Some(current_info) = (*user_info).clone() { | ||||
|                                     for current_cal in ¤t_info.calendars { | ||||
|                                         for cal in &mut info.calendars { | ||||
|                                             if cal.path == current_cal.path { | ||||
|                                                 // Preserve visibility setting | ||||
|                                                 cal.is_visible = current_cal.is_visible; | ||||
|                                                 // Preserve color setting | ||||
|                                                 cal.color = current_cal.color.clone(); | ||||
|                                             } | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                                  | ||||
|                                 // Apply saved colors as fallback for new calendars | ||||
|                                 if let Ok(saved_colors_json) = | ||||
|                                     LocalStorage::get::<String>("calendar_colors") | ||||
|                                 { | ||||
| @@ -202,16 +318,20 @@ pub fn App() -> Html { | ||||
|                                     { | ||||
|                                         for saved_cal in &saved_info.calendars { | ||||
|                                             for cal in &mut info.calendars { | ||||
|                                                 if cal.path == saved_cal.path { | ||||
|                                                 if cal.path == saved_cal.path && cal.color == "#3B82F6" { | ||||
|                                                     // Only apply saved color if it's still the default | ||||
|                                                     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)); | ||||
|                                 // Refresh colors after loading user preferences | ||||
|                                 refresh_colors.emit(()); | ||||
|                             } | ||||
|                             Err(err) => { | ||||
|                                 web_sys::console::log_1( | ||||
| @@ -284,6 +404,71 @@ pub fn App() -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_color_editor_open = { | ||||
|         let color_editor_open = color_editor_open.clone(); | ||||
|         let color_editor_data = color_editor_data.clone(); | ||||
|         Callback::from(move |(index, color): (usize, String)| { | ||||
|             color_editor_data.set(Some((index, color))); | ||||
|             color_editor_open.set(true); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_color_editor_close = { | ||||
|         let color_editor_open = color_editor_open.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             color_editor_open.set(false); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_color_editor_save = { | ||||
|         let available_colors = available_colors.clone(); | ||||
|         let color_editor_open = color_editor_open.clone(); | ||||
|         let refresh_colors = refresh_colors.clone(); | ||||
|         Callback::from(move |(index, new_color): (usize, String)| { | ||||
|             // Update the colors array | ||||
|             let mut colors = (*available_colors).clone(); | ||||
|             if index < colors.len() { | ||||
|                 colors[index] = new_color; | ||||
|                 available_colors.set(colors.clone()); | ||||
|                  | ||||
|                 // Save to preferences asynchronously | ||||
|                 let colors_for_save = colors.clone(); | ||||
|                 let refresh_colors = refresh_colors.clone(); | ||||
|                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                     if let Err(e) = save_custom_colors_to_preferences(colors_for_save).await { | ||||
|                         web_sys::console::log_1(&format!("Failed to save custom colors: {}", e).into()); | ||||
|                     } else { | ||||
|                         // Refresh colors to ensure UI is in sync | ||||
|                         refresh_colors.emit(()); | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|             color_editor_open.set(false); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_color_editor_reset_all = { | ||||
|         let available_colors = available_colors.clone(); | ||||
|         let refresh_colors = refresh_colors.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             // Reset to default colors | ||||
|             let default_colors = get_default_event_colors(); | ||||
|             available_colors.set(default_colors.clone()); | ||||
|              | ||||
|             // Save to preferences asynchronously | ||||
|             let colors_for_save = default_colors.clone(); | ||||
|             let refresh_colors = refresh_colors.clone(); | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 if let Err(e) = save_custom_colors_to_preferences(colors_for_save).await { | ||||
|                     web_sys::console::log_1(&format!("Failed to save default colors: {}", e).into()); | ||||
|                 } else { | ||||
|                     // Refresh colors to ensure UI is in sync | ||||
|                     refresh_colors.emit(()); | ||||
|                 } | ||||
|             }); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_view_change = { | ||||
|         let current_view = current_view.clone(); | ||||
|         Callback::from(move |new_view: ViewMode| { | ||||
| @@ -301,7 +486,6 @@ pub fn App() -> Html { | ||||
|  | ||||
|     let on_theme_change = { | ||||
|         let current_theme = current_theme.clone(); | ||||
|         let available_colors = available_colors.clone(); | ||||
|         Callback::from(move |new_theme: Theme| { | ||||
|             // Save theme to localStorage | ||||
|             let _ = LocalStorage::set("calendar_theme", new_theme.value()); | ||||
| @@ -316,8 +500,7 @@ pub fn App() -> Html { | ||||
|             // Update state | ||||
|             current_theme.set(new_theme); | ||||
|  | ||||
|             // Update available colors after theme change | ||||
|             available_colors.set(get_theme_event_colors()); | ||||
|             // Colors are now unified and don't change with themes | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
| @@ -330,6 +513,11 @@ pub fn App() -> Html { | ||||
|             // Hot-swap stylesheet | ||||
|             if let Some(window) = web_sys::window() { | ||||
|                 if let Some(document) = window.document() { | ||||
|                     // Set data-style attribute on document root | ||||
|                     if let Some(root) = document.document_element() { | ||||
|                         let _ = root.set_attribute("data-style", new_style.value()); | ||||
|                     } | ||||
|  | ||||
|                     // Remove existing style link if it exists | ||||
|                     if let Some(existing_link) = document.get_element_by_id("dynamic-style") { | ||||
|                         existing_link.remove(); | ||||
| @@ -377,6 +565,11 @@ pub fn App() -> Html { | ||||
|             let style = (*current_style).clone(); | ||||
|             if let Some(window) = web_sys::window() { | ||||
|                 if let Some(document) = window.document() { | ||||
|                     // Set data-style attribute on document root | ||||
|                     if let Some(root) = document.document_element() { | ||||
|                         let _ = root.set_attribute("data-style", style.value()); | ||||
|                     } | ||||
|  | ||||
|                     // Create and append stylesheet link for initial style only if it has a path | ||||
|                     if let Some(stylesheet_path) = style.stylesheet_path() { | ||||
|                         if let Ok(link) = document.create_element("link") { | ||||
| @@ -396,6 +589,7 @@ pub fn App() -> Html { | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     // Fetch user info when token is available | ||||
|     { | ||||
|         let user_info = user_info.clone(); | ||||
| @@ -685,7 +879,9 @@ pub fn App() -> Html { | ||||
|         let create_event_modal_open = create_event_modal_open.clone(); | ||||
|         let auth_token = auth_token.clone(); | ||||
|         let refresh_calendar_data = refresh_calendar_data.clone(); | ||||
|         let alarm_scheduler = alarm_scheduler.clone(); | ||||
|         Callback::from(move |event_data: EventCreationData| { | ||||
|             let alarm_scheduler = alarm_scheduler.clone(); | ||||
|             // Check if this is an update operation (has original_uid) or a create operation | ||||
|             if let Some(original_uid) = event_data.original_uid.clone() { | ||||
|                 web_sys::console::log_1(&format!("Updating event via modal: {:?}", event_data).into()); | ||||
| @@ -723,13 +919,10 @@ pub fn App() -> Html { | ||||
|                                                    crate::components::event_form::RecurrenceType::Monthly |  | ||||
|                                                    crate::components::event_form::RecurrenceType::Yearly); | ||||
|                          | ||||
|                         web_sys::console::log_1(&format!("🐛 FRONTEND DEBUG: is_recurring={}, edit_scope={:?}, original_uid={:?}",  | ||||
|                             is_recurring, event_data_for_update.edit_scope, event_data_for_update.original_uid).into()); | ||||
|                          | ||||
|                         let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() { | ||||
|                             // Only use series endpoint for existing recurring events being edited | ||||
|                             // Singleton→series conversion should use regular update_event endpoint | ||||
|                             let edit_action = event_data_for_update.edit_scope.unwrap(); | ||||
|                             let edit_action = event_data_for_update.edit_scope.as_ref().unwrap(); | ||||
|                             let scope = match edit_action { | ||||
|                                 crate::components::EditAction::EditAll => "all_in_series".to_string(), | ||||
|                                 crate::components::EditAction::EditFuture => "this_and_future".to_string(), | ||||
| @@ -803,6 +996,45 @@ pub fn App() -> Html { | ||||
|                         match update_result { | ||||
|                             Ok(_) => { | ||||
|                                 web_sys::console::log_1(&"Event updated successfully via modal".into()); | ||||
|                                  | ||||
|                                 // Re-schedule alarms for the updated event | ||||
|                                 let alarm_scheduler_for_update = alarm_scheduler.clone(); | ||||
|                                 let event_data_for_alarms = event_data_for_update.clone(); | ||||
|                                 let original_uid_for_alarms = original_uid.clone(); | ||||
|                                  | ||||
|                                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                                     let mut scheduler = (*alarm_scheduler_for_update).clone(); | ||||
|                                      | ||||
|                                     // Remove old alarms for this event | ||||
|                                     scheduler.remove_event_alarms(&original_uid_for_alarms); | ||||
|                                      | ||||
|                                     // Schedule new alarms if any exist | ||||
|                                     if !event_data_for_alarms.alarms.is_empty() { | ||||
|                                         let params = event_data_for_alarms.to_create_event_params(); | ||||
|                                          | ||||
|                                         // Parse start date/time for alarm scheduling | ||||
|                                         if let (Ok(start_date), Ok(start_time)) = ( | ||||
|                                             chrono::NaiveDate::parse_from_str(¶ms.2, "%Y-%m-%d"), | ||||
|                                             chrono::NaiveTime::parse_from_str(¶ms.3, "%H:%M") | ||||
|                                         ) { | ||||
|                                             // Create a temporary VEvent for alarm scheduling | ||||
|                                             let start_datetime = start_date.and_time(start_time); | ||||
|                                             let mut temp_event = VEvent::new(original_uid_for_alarms.clone(), start_datetime); | ||||
|                                             temp_event.summary = Some(params.0.clone()); // title | ||||
|                                             temp_event.location = if params.6.is_empty() { None } else { Some(params.6.clone()) }; | ||||
|                                             temp_event.alarms = event_data_for_alarms.alarms.clone(); | ||||
|                                              | ||||
|                                             scheduler.schedule_event_alarms(&temp_event); | ||||
|                                              | ||||
|                                             web_sys::console::log_1( | ||||
|                                                 &format!("🔔 Re-scheduled {} alarm(s) for updated event", temp_event.alarms.len()).into() | ||||
|                                             ); | ||||
|                                         } | ||||
|                                     } | ||||
|                                      | ||||
|                                     alarm_scheduler_for_update.set(scheduler); | ||||
|                                 }); | ||||
|                                  | ||||
|                                 // Refresh calendar data without page reload | ||||
|                                 refresh_callback.emit(()); | ||||
|                             } | ||||
| @@ -860,6 +1092,12 @@ pub fn App() -> Html { | ||||
|                     }; | ||||
|  | ||||
|                     let params = event_data.to_create_event_params(); | ||||
|                     // Clone values we'll need for alarm scheduling | ||||
|                     let title_for_alarms = params.0.clone(); | ||||
|                     let start_date_for_alarms = params.2.clone(); | ||||
|                     let start_time_for_alarms = params.3.clone(); | ||||
|                     let location_for_alarms = params.6.clone(); | ||||
|                      | ||||
|                     let create_result = _calendar_service | ||||
|                         .create_event( | ||||
|                             &_token, &_password, params.0,  // title | ||||
| @@ -889,6 +1127,36 @@ pub fn App() -> Html { | ||||
|                     match create_result { | ||||
|                         Ok(_) => { | ||||
|                             web_sys::console::log_1(&"Event created successfully".into()); | ||||
|                              | ||||
|                             // Schedule alarms for the created event if any exist | ||||
|                             if !event_data.alarms.is_empty() { | ||||
|                                 // Since create_event doesn't return the UID, we need to generate one | ||||
|                                 // The backend should be using the same UUID generation logic | ||||
|                                 let event_uid = uuid::Uuid::new_v4().to_string(); | ||||
|                                  | ||||
|                                 // Parse start date/time for alarm scheduling | ||||
|                                 if let (Ok(start_date), Ok(start_time)) = ( | ||||
|                                     chrono::NaiveDate::parse_from_str(&start_date_for_alarms, "%Y-%m-%d"), | ||||
|                                     chrono::NaiveTime::parse_from_str(&start_time_for_alarms, "%H:%M") | ||||
|                                 ) { | ||||
|                                     // Create a temporary VEvent for alarm scheduling | ||||
|                                     let start_datetime = start_date.and_time(start_time); | ||||
|                                     let mut temp_event = VEvent::new(event_uid.clone(), start_datetime); | ||||
|                                     temp_event.summary = Some(title_for_alarms.clone()); | ||||
|                                     temp_event.location = if location_for_alarms.is_empty() { None } else { Some(location_for_alarms.clone()) }; | ||||
|                                     temp_event.alarms = event_data.alarms.clone(); | ||||
|                                      | ||||
|                                     // Schedule alarms for the new event (synchronously) | ||||
|                                     let mut scheduler = (*alarm_scheduler).clone(); | ||||
|                                     scheduler.schedule_event_alarms(&temp_event); | ||||
|                                     alarm_scheduler.set(scheduler); | ||||
|                                      | ||||
|                                     web_sys::console::log_1( | ||||
|                                         &format!("🔔 Scheduled {} alarm(s) for new event", temp_event.alarms.len()).into() | ||||
|                                     ); | ||||
|                                 } | ||||
|                             } | ||||
|                              | ||||
|                             // Refresh calendar data without page reload | ||||
|                             refresh_callback.emit(()); | ||||
|                         } | ||||
| @@ -915,8 +1183,8 @@ pub fn App() -> Html { | ||||
|                 original_event, | ||||
|                 new_start, | ||||
|                 new_end, | ||||
|                 preserve_rrule, | ||||
|                 until_date, | ||||
|                 _preserve_rrule, | ||||
|                 _until_date, | ||||
|                 update_scope, | ||||
|                 occurrence_date, | ||||
|             ): ( | ||||
| @@ -995,7 +1263,7 @@ pub fn App() -> Html { | ||||
|                         }; | ||||
|  | ||||
|                         // Convert reminders to string format | ||||
|                         let reminder_str = if !original_event.alarms.is_empty() { | ||||
|                         let _reminder_str = if !original_event.alarms.is_empty() { | ||||
|                             // Convert from VAlarm to minutes before | ||||
|                             "15".to_string() // TODO: Convert VAlarm trigger to minutes | ||||
|                         } else { | ||||
| @@ -1046,7 +1314,7 @@ pub fn App() -> Html { | ||||
|                                                 .collect::<Vec<_>>() | ||||
|                                                 .join(","), | ||||
|                                             original_event.categories.join(","), | ||||
|                                             reminder_str.clone(), | ||||
|                                             original_event.alarms.clone(), | ||||
|                                             recurrence_str.clone(), | ||||
|                                             vec![false; 7], // recurrence_days | ||||
|                                             1, // recurrence_interval - default for drag-and-drop | ||||
| @@ -1103,7 +1371,7 @@ pub fn App() -> Html { | ||||
|                                         .collect::<Vec<_>>() | ||||
|                                         .join(","), | ||||
|                                     original_event.categories.join(","), | ||||
|                                     reminder_str, | ||||
|                                     original_event.alarms.clone(), | ||||
|                                     recurrence_str, | ||||
|                                     recurrence_days, | ||||
|                                     1, // recurrence_interval - default to 1 for drag-and-drop | ||||
| @@ -1198,10 +1466,6 @@ pub fn App() -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     // Debug logging | ||||
|     web_sys::console::log_1( | ||||
|         &format!("App rendering: auth_token = {:?}", auth_token.is_some()).into(), | ||||
|     ); | ||||
|  | ||||
|     html! { | ||||
|         <BrowserRouter> | ||||
| @@ -1391,6 +1655,7 @@ pub fn App() -> Html { | ||||
|                                     on_theme_change={on_theme_change} | ||||
|                                     current_style={(*current_style).clone()} | ||||
|                                     on_style_change={on_style_change} | ||||
|                                     on_color_editor_open={on_color_editor_open} | ||||
|                                 /> | ||||
|                                 <main class="app-main"> | ||||
|                                     <RouteHandler | ||||
| @@ -1479,7 +1744,7 @@ pub fn App() -> Html { | ||||
|                             let calendar_management_modal_open = calendar_management_modal_open.clone(); | ||||
|  | ||||
|                             wasm_bindgen_futures::spawn_local(async move { | ||||
|                                 let calendar_service = CalendarService::new(); | ||||
|                                 let _calendar_service = CalendarService::new(); | ||||
|                                 match CalendarService::get_external_calendars().await { | ||||
|                                     Ok(calendars) => { | ||||
|                                         external_calendars.set(calendars); | ||||
| @@ -1719,6 +1984,21 @@ pub fn App() -> Html { | ||||
|                     is_open={*mobile_warning_open} | ||||
|                     on_close={on_mobile_warning_close} | ||||
|                 /> | ||||
|  | ||||
|                 // Color editor modal | ||||
|                 <ColorEditorModal | ||||
|                     is_open={*color_editor_open} | ||||
|                     current_color={color_editor_data.as_ref().map(|(_, color)| color.clone()).unwrap_or_default()} | ||||
|                     color_index={color_editor_data.as_ref().map(|(index, _)| *index).unwrap_or(0)} | ||||
|                     default_color={ | ||||
|                         let default_colors = get_default_event_colors(); | ||||
|                         let index = color_editor_data.as_ref().map(|(index, _)| *index).unwrap_or(0); | ||||
|                         default_colors.get(index).cloned().unwrap_or_else(|| "#3B82F6".to_string()) | ||||
|                     } | ||||
|                     on_close={on_color_editor_close} | ||||
|                     on_save={on_color_editor_save} | ||||
|                     on_reset_all={on_color_editor_reset_all} | ||||
|                 /> | ||||
|             </div> | ||||
|              | ||||
|             // Hidden print copy that gets shown only during printing | ||||
|   | ||||
| @@ -3,7 +3,7 @@ use crate::components::{ | ||||
| }; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||
| use chrono::{Datelike, Duration, Local, NaiveDate}; | ||||
| use chrono::{Datelike, Duration, Local, NaiveDate, Weekday}; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use std::collections::HashMap; | ||||
| use web_sys::MouseEvent; | ||||
| @@ -111,6 +111,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|         use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| { | ||||
|             let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||
|             let date = *date; // Clone the date to avoid lifetime issues | ||||
|             let view_mode = _view.clone(); // Clone the view mode to avoid lifetime issues | ||||
|             let external_events = external_events.clone(); // Clone external events to avoid lifetime issues | ||||
|             let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues | ||||
|              | ||||
| @@ -136,17 +137,67 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                         String::new() | ||||
|                     }; | ||||
|  | ||||
|                     let current_year = date.year(); | ||||
|                     let current_month = date.month(); | ||||
|                     // Determine which months to fetch based on view mode | ||||
|                     let months_to_fetch = match view_mode { | ||||
|                         ViewMode::Month => { | ||||
|                             // For month view, just fetch the current month | ||||
|                             vec![(date.year(), date.month())] | ||||
|                         } | ||||
|                         ViewMode::Week => { | ||||
|                             // For week view, calculate the week bounds and fetch all months that intersect | ||||
|                             let start_of_week = get_start_of_week(date); | ||||
|                             let end_of_week = start_of_week + Duration::days(6); | ||||
|                              | ||||
|                             let mut months = vec![(start_of_week.year(), start_of_week.month())]; | ||||
|                              | ||||
|                             // If the week spans into a different month, add that month too | ||||
|                             if end_of_week.month() != start_of_week.month() || end_of_week.year() != start_of_week.year() { | ||||
|                                 months.push((end_of_week.year(), end_of_week.month())); | ||||
|                             } | ||||
|                              | ||||
|                             months | ||||
|                         } | ||||
|                     }; | ||||
|  | ||||
|                     // Fetch events for all required months | ||||
|                     let mut all_events = Vec::new(); | ||||
|                     for (year, month) in months_to_fetch { | ||||
|                         match calendar_service | ||||
|                             .fetch_events_for_month_vevent( | ||||
|                                 &token, | ||||
|                                 &password, | ||||
|                             current_year, | ||||
|                             current_month, | ||||
|                                 year, | ||||
|                                 month, | ||||
|                             ) | ||||
|                             .await | ||||
|                         { | ||||
|                             Ok(mut month_events) => { | ||||
|                                 all_events.append(&mut month_events); | ||||
|                             } | ||||
|                             Err(err) => { | ||||
|                                 error.set(Some(format!("Failed to load events for {}-{}: {}", year, month, err))); | ||||
|                                 loading.set(false); | ||||
|                                 return; | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     // Deduplicate events that may appear in multiple month fetches | ||||
|                     // This happens when a recurring event spans across month boundaries | ||||
|                     all_events.sort_by(|a, b| { | ||||
|                         // Sort by UID first, then by start time | ||||
|                         match a.uid.cmp(&b.uid) { | ||||
|                             std::cmp::Ordering::Equal => a.dtstart.cmp(&b.dtstart), | ||||
|                             other => other, | ||||
|                         } | ||||
|                     }); | ||||
|                     all_events.dedup_by(|a, b| { | ||||
|                         // Remove duplicates with same UID and start time | ||||
|                         a.uid == b.uid && a.dtstart == b.dtstart | ||||
|                     }); | ||||
|  | ||||
|                     // Process the combined events | ||||
|                     match Ok(all_events) as Result<Vec<VEvent>, String> | ||||
|                     { | ||||
|                         Ok(vevents) => { | ||||
|                             // Filter CalDAV events based on calendar visibility | ||||
| @@ -602,3 +653,18 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Helper function to calculate the start of the week (Sunday) for a given date | ||||
| fn get_start_of_week(date: NaiveDate) -> NaiveDate { | ||||
|     let weekday = date.weekday(); | ||||
|     let days_from_sunday = match weekday { | ||||
|         Weekday::Sun => 0, | ||||
|         Weekday::Mon => 1, | ||||
|         Weekday::Tue => 2, | ||||
|         Weekday::Wed => 3, | ||||
|         Weekday::Thu => 4, | ||||
|         Weekday::Fri => 5, | ||||
|         Weekday::Sat => 6, | ||||
|     }; | ||||
|     date - Duration::days(days_from_sunday) | ||||
| } | ||||
|   | ||||
| @@ -9,6 +9,7 @@ pub struct CalendarListItemProps { | ||||
|     pub on_color_change: Callback<(String, String)>, // (calendar_path, color) | ||||
|     pub on_color_picker_toggle: Callback<String>,    // calendar_path | ||||
|     pub available_colors: Vec<String>, | ||||
|     pub on_color_editor_open: Callback<(usize, String)>, // (index, current_color) | ||||
|     pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) | ||||
|     pub on_visibility_toggle: Callback<String>,      // calendar_path | ||||
| } | ||||
| @@ -66,13 +67,25 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html { | ||||
|                                                 on_color_change.emit((cal_path.clone(), color_str.clone())); | ||||
|                                             }); | ||||
|  | ||||
|                                             let on_color_right_click = { | ||||
|                                                 let on_color_editor_open = props.on_color_editor_open.clone(); | ||||
|                                                 let color_index = props.available_colors.iter().position(|c| c == color).unwrap_or(0); | ||||
|                                                 let color_str = color.clone(); | ||||
|                                                 Callback::from(move |e: MouseEvent| { | ||||
|                                                     e.prevent_default(); | ||||
|                                                     e.stop_propagation(); | ||||
|                                                     on_color_editor_open.emit((color_index, color_str.clone())); | ||||
|                                                 }) | ||||
|                                             }; | ||||
|  | ||||
|                                             let is_selected = props.calendar.color == *color; | ||||
|                                             let class_name = if is_selected { "color-option selected" } else { "color-option" }; | ||||
|  | ||||
|                                             html! { | ||||
|                                                 <div class={class_name} | ||||
|                                                      style={format!("background-color: {}", color)} | ||||
|                                                      onclick={on_color_select}> | ||||
|                                                      onclick={on_color_select} | ||||
|                                                      oncontextmenu={on_color_right_click}> | ||||
|                                                 </div> | ||||
|                                             } | ||||
|                                         }).collect::<Html>() | ||||
|   | ||||
							
								
								
									
										176
									
								
								frontend/src/components/color_editor_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								frontend/src/components/color_editor_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| use yew::prelude::*; | ||||
| use web_sys::HtmlInputElement; | ||||
| use wasm_bindgen::JsCast; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ColorEditorModalProps { | ||||
|     pub is_open: bool, | ||||
|     pub current_color: String, | ||||
|     pub color_index: usize, | ||||
|     pub default_color: String, // Default color for this index | ||||
|     pub on_close: Callback<()>, | ||||
|     pub on_save: Callback<(usize, String)>, // (index, new_color) | ||||
|     pub on_reset_all: Callback<()>, // Reset all colors to defaults | ||||
| } | ||||
|  | ||||
| #[function_component(ColorEditorModal)] | ||||
| pub fn color_editor_modal(props: &ColorEditorModalProps) -> Html { | ||||
|     let selected_color = use_state(|| props.current_color.clone()); | ||||
|      | ||||
|     // Reset selected color when modal opens with new color | ||||
|     { | ||||
|         let selected_color = selected_color.clone(); | ||||
|         use_effect_with(props.current_color.clone(), move |current_color| { | ||||
|             selected_color.set(current_color.clone()); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     let on_color_input = { | ||||
|         let selected_color = selected_color.clone(); | ||||
|         Callback::from(move |e: InputEvent| { | ||||
|             if let Some(input) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 selected_color.set(input.value()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_save_click = { | ||||
|         let selected_color = selected_color.clone(); | ||||
|         let on_save = props.on_save.clone(); | ||||
|         let color_index = props.color_index; | ||||
|         Callback::from(move |_| { | ||||
|             on_save.emit((color_index, (*selected_color).clone())); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             // Only close if clicking the backdrop, not the modal content | ||||
|             if let Some(target) = e.target() { | ||||
|                 if let Some(element) = target.dyn_ref::<web_sys::Element>() { | ||||
|                     if element.class_list().contains("color-editor-backdrop") { | ||||
|                         on_close.emit(()); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     // Predefined color suggestions | ||||
|     let suggested_colors = vec![ | ||||
|         "#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", | ||||
|         "#84CC16", "#F97316", "#EC4899", "#6366F1", "#14B8A6", "#F3B806", | ||||
|         "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED", "#F87171", "#34D399", | ||||
|         "#FBBF24", "#A78BFA", "#60A5FA", "#2DD4BF", "#FB7185", "#FDBA74", | ||||
|     ]; | ||||
|  | ||||
|     html! { | ||||
|         <div class="color-editor-backdrop" onclick={on_backdrop_click}> | ||||
|             <div class="color-editor-modal"> | ||||
|                 <div class="color-editor-header"> | ||||
|                     <h3>{"Edit Color"}</h3> | ||||
|                     <button class="close-button" onclick={Callback::from({ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         move |_| on_close.emit(()) | ||||
|                     })}> | ||||
|                         {"×"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="color-editor-content"> | ||||
|                     <div class="current-color-preview"> | ||||
|                         <div  | ||||
|                             class="color-preview-large" | ||||
|                             style={format!("background-color: {}", *selected_color)} | ||||
|                         ></div> | ||||
|                         <div class="color-preview-info"> | ||||
|                             <span class="color-value">{&*selected_color}</span> | ||||
|                             <button class="reset-this-color-button" onclick={{ | ||||
|                                 let selected_color = selected_color.clone(); | ||||
|                                 let default_color = props.default_color.clone(); | ||||
|                                 Callback::from(move |_| { | ||||
|                                     selected_color.set(default_color.clone()); | ||||
|                                 }) | ||||
|                             }}> | ||||
|                                 {"Reset This Color"} | ||||
|                             </button> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="color-input-section"> | ||||
|                         <label for="color-picker">{"Custom Color:"}</label> | ||||
|                         <div class="color-input-group"> | ||||
|                             <input  | ||||
|                                 type="color"  | ||||
|                                 id="color-picker" | ||||
|                                 value={(*selected_color).clone()} | ||||
|                                 oninput={on_color_input.clone()} | ||||
|                             /> | ||||
|                             <input  | ||||
|                                 type="text"  | ||||
|                                 class="color-text-input" | ||||
|                                 value={(*selected_color).clone()} | ||||
|                                 oninput={on_color_input} | ||||
|                                 placeholder="#000000" | ||||
|                             /> | ||||
|                         </div> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="suggested-colors-section"> | ||||
|                         <label>{"Suggested Colors:"}</label> | ||||
|                         <div class="suggested-colors-grid"> | ||||
|                             { | ||||
|                                 suggested_colors.iter().map(|color| { | ||||
|                                     let color = color.to_string(); | ||||
|                                     let selected_color = selected_color.clone(); | ||||
|                                     let onclick = { | ||||
|                                         let color = color.clone(); | ||||
|                                         Callback::from(move |_| { | ||||
|                                             selected_color.set(color.clone()); | ||||
|                                         }) | ||||
|                                     }; | ||||
|                                      | ||||
|                                     html! { | ||||
|                                         <div  | ||||
|                                             class="suggested-color" | ||||
|                                             style={format!("background-color: {}", color)} | ||||
|                                             onclick={onclick} | ||||
|                                             title={color.clone()} | ||||
|                                         ></div> | ||||
|                                     } | ||||
|                                 }).collect::<Html>() | ||||
|                             } | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="color-editor-footer"> | ||||
|                     <button class="cancel-button" onclick={Callback::from({ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         move |_| on_close.emit(()) | ||||
|                     })}> | ||||
|                         {"Cancel"} | ||||
|                     </button> | ||||
|                     <button class="reset-all-button" onclick={Callback::from({ | ||||
|                         let on_reset_all = props.on_reset_all.clone(); | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         move |_| { | ||||
|                             on_reset_all.emit(()); | ||||
|                             on_close.emit(()); | ||||
|                         } | ||||
|                     })}> | ||||
|                         {"Reset All Colors"} | ||||
|                     </button> | ||||
|                     <button class="save-button" onclick={on_save_click}> | ||||
|                         {"Save"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -257,7 +257,13 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend | ||||
|          | ||||
|         // Timing | ||||
|         start_date: start_local.date(), | ||||
|         end_date: end_local.date(), | ||||
|         end_date: if event.all_day { | ||||
|             // For all-day events, subtract one day to convert from exclusive to inclusive end date | ||||
|             // (UI expects inclusive dates, but iCalendar stores exclusive end dates) | ||||
|             end_local.date() - chrono::Duration::days(1) | ||||
|         } else { | ||||
|             end_local.date() | ||||
|         }, | ||||
|         start_time: start_local.time(), | ||||
|         end_time: end_local.time(), | ||||
|          | ||||
| @@ -286,8 +292,8 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend | ||||
|         // Categorization   | ||||
|         categories: event.categories.join(","), | ||||
|          | ||||
|         // Reminders - TODO: Parse alarm from VEvent if needed | ||||
|         reminder: ReminderType::None, | ||||
|         // Reminders - Use VAlarms from the event | ||||
|         alarms: event.alarms.clone(), | ||||
|          | ||||
|         // Recurrence - Parse RRULE if present | ||||
|         recurrence: if let Some(ref rrule_str) = event.rrule { | ||||
|   | ||||
							
								
								
									
										296
									
								
								frontend/src/components/event_form/add_alarm_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										296
									
								
								frontend/src/components/event_form/add_alarm_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,296 @@ | ||||
| use calendar_models::{VAlarm, AlarmAction, AlarmTrigger}; | ||||
| use chrono::{Duration, DateTime, Utc, NaiveTime}; | ||||
| use wasm_bindgen::JsCast; | ||||
| use web_sys::{HtmlSelectElement, HtmlInputElement}; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum TriggerType { | ||||
|     Relative, // Duration before/after event | ||||
|     Absolute, // Specific date/time | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum RelativeTo { | ||||
|     Start, | ||||
|     End, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum TimeUnit { | ||||
|     Minutes, | ||||
|     Hours, | ||||
|     Days, | ||||
|     Weeks, | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct AddAlarmModalProps { | ||||
|     pub is_open: bool, | ||||
|     pub editing_index: Option<usize>, // If editing an existing alarm | ||||
|     pub initial_alarm: Option<VAlarm>, // For editing mode | ||||
|     pub on_close: Callback<()>, | ||||
|     pub on_save: Callback<VAlarm>, | ||||
| } | ||||
|  | ||||
| #[function_component(AddAlarmModal)] | ||||
| pub fn add_alarm_modal(props: &AddAlarmModalProps) -> Html { | ||||
|     // Form state | ||||
|     let trigger_type = use_state(|| TriggerType::Relative); | ||||
|     let relative_to = use_state(|| RelativeTo::Start); | ||||
|     let time_unit = use_state(|| TimeUnit::Minutes); | ||||
|     let time_value = use_state(|| 15i32); | ||||
|     let before_after = use_state(|| true); // true = before, false = after | ||||
|     let absolute_date = use_state(|| chrono::Local::now().date_naive()); | ||||
|     let absolute_time = use_state(|| NaiveTime::from_hms_opt(9, 0, 0).unwrap()); | ||||
|  | ||||
|     // Initialize form with existing alarm data if editing | ||||
|     { | ||||
|         let trigger_type = trigger_type.clone(); | ||||
|         let time_value = time_value.clone(); | ||||
|          | ||||
|         use_effect_with(props.initial_alarm.clone(), move |initial_alarm| { | ||||
|             if let Some(alarm) = initial_alarm { | ||||
|                 match &alarm.trigger { | ||||
|                     AlarmTrigger::Duration(duration) => { | ||||
|                         trigger_type.set(TriggerType::Relative); | ||||
|                         let minutes = duration.num_minutes().abs(); | ||||
|                         time_value.set(minutes as i32); | ||||
|                     } | ||||
|                     AlarmTrigger::DateTime(_) => { | ||||
|                         trigger_type.set(TriggerType::Absolute); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     let on_trigger_type_change = { | ||||
|         let trigger_type = trigger_type.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let new_type = match target.value().as_str() { | ||||
|                     "absolute" => TriggerType::Absolute, | ||||
|                     _ => TriggerType::Relative, | ||||
|                 }; | ||||
|                 trigger_type.set(new_type); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|  | ||||
|     let on_relative_to_change = { | ||||
|         let relative_to = relative_to.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let new_relative = match target.value().as_str() { | ||||
|                     "end" => RelativeTo::End, | ||||
|                     _ => RelativeTo::Start, | ||||
|                 }; | ||||
|                 relative_to.set(new_relative); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_time_unit_change = { | ||||
|         let time_unit = time_unit.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let new_unit = match target.value().as_str() { | ||||
|                     "hours" => TimeUnit::Hours, | ||||
|                     "days" => TimeUnit::Days, | ||||
|                     "weeks" => TimeUnit::Weeks, | ||||
|                     _ => TimeUnit::Minutes, | ||||
|                 }; | ||||
|                 time_unit.set(new_unit); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_time_value_change = { | ||||
|         let time_value = time_value.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 if let Ok(value) = target.value().parse::<i32>() { | ||||
|                     time_value.set(value.max(1)); // Minimum 1 | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_before_after_change = { | ||||
|         let before_after = before_after.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let is_before = target.value() == "before"; | ||||
|                 before_after.set(is_before); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|  | ||||
|  | ||||
|     let on_save_click = { | ||||
|         let trigger_type = trigger_type.clone(); | ||||
|         let time_unit = time_unit.clone(); | ||||
|         let time_value = time_value.clone(); | ||||
|         let before_after = before_after.clone(); | ||||
|         let absolute_date = absolute_date.clone(); | ||||
|         let absolute_time = absolute_time.clone(); | ||||
|         let on_save = props.on_save.clone(); | ||||
|          | ||||
|         Callback::from(move |_| { | ||||
|             // Create the alarm trigger | ||||
|             let trigger = match *trigger_type { | ||||
|                 TriggerType::Relative => { | ||||
|                     let minutes = match *time_unit { | ||||
|                         TimeUnit::Minutes => *time_value, | ||||
|                         TimeUnit::Hours => *time_value * 60, | ||||
|                         TimeUnit::Days => *time_value * 60 * 24, | ||||
|                         TimeUnit::Weeks => *time_value * 60 * 24 * 7, | ||||
|                     }; | ||||
|                      | ||||
|                     let signed_minutes = if *before_after { -minutes } else { minutes } as i64; | ||||
|                     AlarmTrigger::Duration(Duration::minutes(signed_minutes)) | ||||
|                 } | ||||
|                 TriggerType::Absolute => { | ||||
|                     // Combine date and time to create a DateTime<Utc> | ||||
|                     let naive_datetime = absolute_date.and_time(*absolute_time); | ||||
|                     let utc_datetime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); | ||||
|                     AlarmTrigger::DateTime(utc_datetime) | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             // Create the VAlarm - always use Display action, no custom description | ||||
|             let alarm = VAlarm { | ||||
|                 action: AlarmAction::Display, | ||||
|                 trigger, | ||||
|                 duration: None, | ||||
|                 repeat: None, | ||||
|                 description: None, | ||||
|                 summary: None, | ||||
|                 attendees: Vec::new(), | ||||
|                 attach: Vec::new(), | ||||
|             }; | ||||
|  | ||||
|             on_save.emit(alarm); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             if let Some(target) = e.target() { | ||||
|                 if let Some(element) = target.dyn_ref::<web_sys::Element>() { | ||||
|                     if element.class_list().contains("add-alarm-backdrop") { | ||||
|                         on_close.emit(()); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     html! { | ||||
|         <div class="add-alarm-backdrop" onclick={on_backdrop_click}> | ||||
|             <div class="add-alarm-modal"> | ||||
|                 <div class="add-alarm-header"> | ||||
|                     <h3>{ | ||||
|                         if props.editing_index.is_some() {  | ||||
|                             "Edit Reminder"  | ||||
|                         } else {  | ||||
|                             "Add Reminder"  | ||||
|                         } | ||||
|                     }</h3> | ||||
|                     <button class="close-button" onclick={Callback::from({ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         move |_| on_close.emit(()) | ||||
|                     })}> | ||||
|                         {"×"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="add-alarm-content"> | ||||
|                     // Trigger Type Selection | ||||
|                     <div class="form-group"> | ||||
|                         <label for="trigger-type">{"Trigger Type"}</label> | ||||
|                         <select id="trigger-type" class="form-input" onchange={on_trigger_type_change}> | ||||
|                             <option value="relative" selected={matches!(*trigger_type, TriggerType::Relative)}> | ||||
|                                 {"Relative to event time"} | ||||
|                             </option> | ||||
|                             <option value="absolute" selected={matches!(*trigger_type, TriggerType::Absolute)}> | ||||
|                                 {"Specific date and time"} | ||||
|                             </option> | ||||
|                         </select> | ||||
|                     </div> | ||||
|  | ||||
|                     // Relative Trigger Configuration | ||||
|                     if matches!(*trigger_type, TriggerType::Relative) { | ||||
|                         <div class="form-group"> | ||||
|                             <label>{"When"}</label> | ||||
|                             <div class="relative-time-inputs"> | ||||
|                                 <input  | ||||
|                                     type="number"  | ||||
|                                     class="form-input time-value-input" | ||||
|                                     value={time_value.to_string()} | ||||
|                                     min="1" | ||||
|                                     onchange={on_time_value_change} | ||||
|                                 /> | ||||
|                                 <select class="form-input time-unit-select" onchange={on_time_unit_change}> | ||||
|                                     <option value="minutes" selected={matches!(*time_unit, TimeUnit::Minutes)}>{"minute(s)"}</option> | ||||
|                                     <option value="hours" selected={matches!(*time_unit, TimeUnit::Hours)}>{"hour(s)"}</option> | ||||
|                                     <option value="days" selected={matches!(*time_unit, TimeUnit::Days)}>{"day(s)"}</option> | ||||
|                                     <option value="weeks" selected={matches!(*time_unit, TimeUnit::Weeks)}>{"week(s)"}</option> | ||||
|                                 </select> | ||||
|                                 <select class="form-input before-after-select" onchange={on_before_after_change}> | ||||
|                                     <option value="before" selected={*before_after}>{"before"}</option> | ||||
|                                     <option value="after" selected={!*before_after}>{"after"}</option> | ||||
|                                 </select> | ||||
|                                 <select class="form-input relative-to-select" onchange={on_relative_to_change}> | ||||
|                                     <option value="start" selected={matches!(*relative_to, RelativeTo::Start)}>{"event start"}</option> | ||||
|                                     <option value="end" selected={matches!(*relative_to, RelativeTo::End)}>{"event end"}</option> | ||||
|                                 </select> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|  | ||||
|                     // Absolute Trigger Configuration   | ||||
|                     if matches!(*trigger_type, TriggerType::Absolute) { | ||||
|                         <div class="form-group"> | ||||
|                             <label>{"Date and Time"}</label> | ||||
|                             <div class="absolute-time-inputs"> | ||||
|                                 <input  | ||||
|                                     type="date"  | ||||
|                                     class="form-input" | ||||
|                                     value={absolute_date.format("%Y-%m-%d").to_string()} | ||||
|                                 /> | ||||
|                                 <input  | ||||
|                                     type="time"  | ||||
|                                     class="form-input" | ||||
|                                     value={absolute_time.format("%H:%M").to_string()} | ||||
|                                 /> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|  | ||||
|  | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="add-alarm-footer"> | ||||
|                     <button class="cancel-button" onclick={Callback::from({ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         move |_| on_close.emit(()) | ||||
|                     })}> | ||||
|                         {"Cancel"} | ||||
|                     </button> | ||||
|                     <button class="save-button" onclick={on_save_click}> | ||||
|                         {if props.editing_index.is_some() { "Update" } else { "Add Reminder" }} | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										133
									
								
								frontend/src/components/event_form/alarm_list.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								frontend/src/components/event_form/alarm_list.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| use calendar_models::{VAlarm, AlarmAction, AlarmTrigger}; | ||||
| use chrono::Duration; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct AlarmListProps { | ||||
|     pub alarms: Vec<VAlarm>, | ||||
|     pub on_alarm_delete: Callback<usize>, // Index of alarm to delete | ||||
|     pub on_alarm_edit: Callback<usize>,   // Index of alarm to edit | ||||
| } | ||||
|  | ||||
| #[function_component(AlarmList)] | ||||
| pub fn alarm_list(props: &AlarmListProps) -> Html { | ||||
|     if props.alarms.is_empty() { | ||||
|         return html! { | ||||
|             <div class="alarm-list-empty"> | ||||
|                 <p class="text-muted">{"No reminders set"}</p> | ||||
|                 <p class="text-small">{"Click 'Add Reminder' to create your first reminder"}</p> | ||||
|             </div> | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     html! { | ||||
|         <div class="alarm-list"> | ||||
|             <h6>{"Configured Reminders"}</h6> | ||||
|             <div class="alarm-items"> | ||||
|                 { | ||||
|                     props.alarms.iter().enumerate().map(|(index, alarm)| { | ||||
|                         let alarm_description = format_alarm_description(alarm); | ||||
|                         let action_icon = get_action_icon(&alarm.action); | ||||
|                          | ||||
|                         let on_delete = { | ||||
|                             let on_alarm_delete = props.on_alarm_delete.clone(); | ||||
|                             Callback::from(move |_| { | ||||
|                                 on_alarm_delete.emit(index); | ||||
|                             }) | ||||
|                         }; | ||||
|                          | ||||
|                         let on_edit = { | ||||
|                             let on_alarm_edit = props.on_alarm_edit.clone(); | ||||
|                             Callback::from(move |_| { | ||||
|                                 on_alarm_edit.emit(index); | ||||
|                             }) | ||||
|                         }; | ||||
|  | ||||
|                         html! { | ||||
|                             <div key={index} class="alarm-item"> | ||||
|                                 <div class="alarm-content"> | ||||
|                                     <span class="alarm-icon">{action_icon}</span> | ||||
|                                     <span class="alarm-description">{alarm_description}</span> | ||||
|                                 </div> | ||||
|                                 <div class="alarm-actions"> | ||||
|                                     <button  | ||||
|                                         class="alarm-action-btn edit-btn"  | ||||
|                                         title="Edit reminder" | ||||
|                                         onclick={on_edit} | ||||
|                                     > | ||||
|                                         <i class="fas fa-edit"></i> | ||||
|                                     </button> | ||||
|                                     <button  | ||||
|                                         class="alarm-action-btn delete-btn"  | ||||
|                                         title="Delete reminder" | ||||
|                                         onclick={on_delete} | ||||
|                                     > | ||||
|                                         <i class="fas fa-trash"></i> | ||||
|                                     </button> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         } | ||||
|                     }).collect::<Html>() | ||||
|                 } | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Format alarm description for display | ||||
| fn format_alarm_description(alarm: &VAlarm) -> String { | ||||
|     match &alarm.trigger { | ||||
|         AlarmTrigger::Duration(duration) => { | ||||
|             format_duration_description(duration) | ||||
|         } | ||||
|         AlarmTrigger::DateTime(datetime) => { | ||||
|             format!("At {}", datetime.format("%Y-%m-%d %H:%M UTC")) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Get icon for alarm action - always use bell for consistent notification type | ||||
| fn get_action_icon(_action: &AlarmAction) -> Html { | ||||
|     html! { <i class="fas fa-bell"></i> } | ||||
| } | ||||
|  | ||||
| /// Format duration for human-readable description | ||||
| fn format_duration_description(duration: &Duration) -> String { | ||||
|     let minutes = duration.num_minutes(); | ||||
|      | ||||
|     if minutes == 0 { | ||||
|         return "At event time".to_string(); | ||||
|     } | ||||
|      | ||||
|     let abs_minutes = minutes.abs(); | ||||
|     let before_or_after = if minutes < 0 { "before" } else { "after" }; | ||||
|      | ||||
|     // Convert to human-readable format | ||||
|     if abs_minutes >= 60 * 24 * 7 { | ||||
|         let weeks = abs_minutes / (60 * 24 * 7); | ||||
|         let remainder = abs_minutes % (60 * 24 * 7); | ||||
|         if remainder == 0 { | ||||
|             format!("{} week{} {}", weeks, if weeks == 1 { "" } else { "s" }, before_or_after) | ||||
|         } else { | ||||
|             format!("{} minutes {}", abs_minutes, before_or_after) | ||||
|         } | ||||
|     } else if abs_minutes >= 60 * 24 { | ||||
|         let days = abs_minutes / (60 * 24); | ||||
|         let remainder = abs_minutes % (60 * 24); | ||||
|         if remainder == 0 { | ||||
|             format!("{} day{} {}", days, if days == 1 { "" } else { "s" }, before_or_after) | ||||
|         } else { | ||||
|             format!("{} minutes {}", abs_minutes, before_or_after) | ||||
|         } | ||||
|     } else if abs_minutes >= 60 { | ||||
|         let hours = abs_minutes / 60; | ||||
|         let remainder = abs_minutes % 60; | ||||
|         if remainder == 0 { | ||||
|             format!("{} hour{} {}", hours, if hours == 1 { "" } else { "s" }, before_or_after) | ||||
|         } else { | ||||
|             format!("{} minutes {}", abs_minutes, before_or_after) | ||||
|         } | ||||
|     } else { | ||||
|         format!("{} minute{} {}", abs_minutes, if abs_minutes == 1 { "" } else { "s" }, before_or_after) | ||||
|     } | ||||
| } | ||||
| @@ -99,26 +99,13 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_reminder_change = { | ||||
|         let data = data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target() { | ||||
|                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||
|                     let mut event_data = (*data).clone(); | ||||
|                     event_data.reminder = match select.value().as_str() { | ||||
|                         "15min" => ReminderType::Minutes15, | ||||
|                         "30min" => ReminderType::Minutes30, | ||||
|                         "1hour" => ReminderType::Hour1, | ||||
|                         "1day" => ReminderType::Day1, | ||||
|                         "2days" => ReminderType::Days2, | ||||
|                         "1week" => ReminderType::Week1, | ||||
|                         _ => ReminderType::None, | ||||
|                     }; | ||||
|                     data.set(event_data); | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|     // TODO: Replace with new alarm management UI | ||||
|     // let on_reminder_change = { | ||||
|     //     let data = data.clone(); | ||||
|     //     Callback::from(move |e: Event| { | ||||
|     //         // Will be replaced with VAlarm management | ||||
|     //     }) | ||||
|     // }; | ||||
|  | ||||
|     let on_recurrence_interval_change = { | ||||
|         let data = data.clone(); | ||||
| @@ -321,6 +308,7 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|                 ></textarea> | ||||
|             </div> | ||||
|  | ||||
|             <div class="form-row"> | ||||
|                 <div class="form-group"> | ||||
|                     <label for="event-calendar">{"Calendar"}</label> | ||||
|                     <select | ||||
| @@ -345,18 +333,6 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|                     </select> | ||||
|                 </div> | ||||
|  | ||||
|             <div class="form-group"> | ||||
|                 <label class="checkbox-label"> | ||||
|                     <input | ||||
|                         type="checkbox" | ||||
|                         checked={data.all_day} | ||||
|                         onchange={on_all_day_change} | ||||
|                     /> | ||||
|                     {" All Day"} | ||||
|                 </label> | ||||
|             </div> | ||||
|  | ||||
|             <div class="form-row"> | ||||
|                 <div class="form-group"> | ||||
|                     <label for="event-recurrence-basic">{"Repeat"}</label> | ||||
|                     <select | ||||
| @@ -371,21 +347,6 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|                         <option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="form-group"> | ||||
|                     <label for="event-reminder-basic">{"Reminder"}</label> | ||||
|                     <select | ||||
|                         id="event-reminder-basic" | ||||
|                         class="form-input" | ||||
|                         onchange={on_reminder_change} | ||||
|                     > | ||||
|                         <option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option> | ||||
|                         <option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option> | ||||
|                         <option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option> | ||||
|                         <option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option> | ||||
|                         <option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             // RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder! | ||||
| @@ -659,6 +620,18 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|                 </div> | ||||
|             } | ||||
|  | ||||
|             // All Day checkbox above date/time fields | ||||
|             <div class="form-group"> | ||||
|                 <label class="checkbox-label"> | ||||
|                     <input | ||||
|                         type="checkbox" | ||||
|                         checked={data.all_day} | ||||
|                         onchange={on_all_day_change} | ||||
|                     /> | ||||
|                     {" All Day"} | ||||
|                 </label> | ||||
|             </div> | ||||
|  | ||||
|             // Date and time fields go here AFTER recurrence options | ||||
|             <div class="form-row"> | ||||
|                 <div class="form-group"> | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| // Event form components module | ||||
| pub mod types; | ||||
| pub mod alarm_list; | ||||
| pub mod add_alarm_modal; | ||||
| pub mod basic_details; | ||||
| pub mod advanced; | ||||
| pub mod people; | ||||
| @@ -8,6 +10,8 @@ pub mod location; | ||||
| pub mod reminders; | ||||
|  | ||||
| pub use types::*; | ||||
| pub use alarm_list::AlarmList; | ||||
| pub use add_alarm_modal::AddAlarmModal; | ||||
| pub use basic_details::BasicDetailsTab; | ||||
| pub use advanced::AdvancedTab; | ||||
| pub use people::PeopleTab; | ||||
|   | ||||
| @@ -1,100 +1,116 @@ | ||||
| use super::types::*; | ||||
| // Types are already imported from super::types::* | ||||
| use wasm_bindgen::JsCast; | ||||
| use web_sys::HtmlSelectElement; | ||||
| use super::{types::*, AlarmList, AddAlarmModal}; | ||||
| use calendar_models::VAlarm; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[function_component(RemindersTab)] | ||||
| pub fn reminders_tab(props: &TabProps) -> Html { | ||||
|     let data = &props.data; | ||||
|      | ||||
|     let on_reminder_change = { | ||||
|         let data = data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target() { | ||||
|                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||
|                     let mut event_data = (*data).clone(); | ||||
|                     event_data.reminder = match select.value().as_str() { | ||||
|                         "15min" => ReminderType::Minutes15, | ||||
|                         "30min" => ReminderType::Minutes30, | ||||
|                         "1hour" => ReminderType::Hour1, | ||||
|                         "1day" => ReminderType::Day1, | ||||
|                         "2days" => ReminderType::Days2, | ||||
|                         "1week" => ReminderType::Week1, | ||||
|                         _ => ReminderType::None, | ||||
|     // Modal state | ||||
|     let is_modal_open = use_state(|| false); | ||||
|     let editing_index = use_state(|| None::<usize>); | ||||
|      | ||||
|     // Add alarm callback | ||||
|     let on_add_alarm = { | ||||
|         let is_modal_open = is_modal_open.clone(); | ||||
|         let editing_index = editing_index.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             editing_index.set(None); | ||||
|             is_modal_open.set(true); | ||||
|         }) | ||||
|     }; | ||||
|                     data.set(event_data); | ||||
|                 } | ||||
|      | ||||
|     // Edit alarm callback | ||||
|     let on_alarm_edit = { | ||||
|         let is_modal_open = is_modal_open.clone(); | ||||
|         let editing_index = editing_index.clone(); | ||||
|         Callback::from(move |index: usize| { | ||||
|             editing_index.set(Some(index)); | ||||
|             is_modal_open.set(true); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Delete alarm callback | ||||
|     let on_alarm_delete = { | ||||
|         let data = data.clone(); | ||||
|         Callback::from(move |index: usize| { | ||||
|             let mut current_data = (*data).clone(); | ||||
|             if index < current_data.alarms.len() { | ||||
|                 current_data.alarms.remove(index); | ||||
|                 data.set(current_data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Close modal callback | ||||
|     let on_modal_close = { | ||||
|         let is_modal_open = is_modal_open.clone(); | ||||
|         let editing_index = editing_index.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             is_modal_open.set(false); | ||||
|             editing_index.set(None); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Save alarm callback | ||||
|     let on_alarm_save = { | ||||
|         let data = data.clone(); | ||||
|         let is_modal_open = is_modal_open.clone(); | ||||
|         let editing_index = editing_index.clone(); | ||||
|         Callback::from(move |alarm: VAlarm| { | ||||
|             let mut current_data = (*data).clone(); | ||||
|              | ||||
|             if let Some(index) = *editing_index { | ||||
|                 // Edit existing alarm | ||||
|                 if index < current_data.alarms.len() { | ||||
|                     current_data.alarms[index] = alarm; | ||||
|                 } | ||||
|             } else { | ||||
|                 // Add new alarm | ||||
|                 current_data.alarms.push(alarm); | ||||
|             } | ||||
|              | ||||
|             data.set(current_data); | ||||
|             is_modal_open.set(false); | ||||
|             editing_index.set(None); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Get initial alarm for editing | ||||
|     let initial_alarm = (*editing_index).and_then(|index| { | ||||
|         data.alarms.get(index).cloned() | ||||
|     }); | ||||
|  | ||||
|     html! { | ||||
|         <div class="tab-panel"> | ||||
|             <div class="form-group"> | ||||
|                 <label for="event-reminder-main">{"Primary Reminder"}</label> | ||||
|                 <select | ||||
|                     id="event-reminder-main" | ||||
|                     class="form-input" | ||||
|                     onchange={on_reminder_change} | ||||
|                 <div class="alarm-management-header"> | ||||
|                     <h5>{"Event Reminders"}</h5> | ||||
|                     <button  | ||||
|                         class="add-alarm-button"  | ||||
|                         onclick={on_add_alarm} | ||||
|                         type="button" | ||||
|                     > | ||||
|                     <option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"No reminder"}</option> | ||||
|                     <option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option> | ||||
|                     <option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option> | ||||
|                     <option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option> | ||||
|                     <option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option> | ||||
|                     <option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option> | ||||
|                     <option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option> | ||||
|                 </select> | ||||
|                 <p class="form-help-text">{"Choose when you'd like to be reminded about this event"}</p> | ||||
|                         <i class="fas fa-plus"></i> | ||||
|                         {" Add Reminder"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|                 <p class="form-help-text">{"Configure multiple reminders with custom timing and notification types"}</p> | ||||
|             </div> | ||||
|  | ||||
|             <div class="reminder-types"> | ||||
|                 <h5>{"Reminder & Alarm Types"}</h5> | ||||
|                 <div class="alarm-examples"> | ||||
|                     <div class="alarm-type"> | ||||
|                         <strong>{"Display Alarm"}</strong> | ||||
|                         <p>{"Pop-up notification on your device"}</p> | ||||
|                     </div> | ||||
|                     <div class="alarm-type"> | ||||
|                         <strong>{"Email Reminder"}</strong> | ||||
|                         <p>{"Email notification sent to your address"}</p> | ||||
|                     </div> | ||||
|                     <div class="alarm-type"> | ||||
|                         <strong>{"Audio Alert"}</strong> | ||||
|                         <p>{"Sound notification with custom audio"}</p> | ||||
|                     </div> | ||||
|                     <div class="alarm-type"> | ||||
|                         <strong>{"SMS/Text"}</strong> | ||||
|                         <p>{"Text message reminder (enterprise feature)"}</p> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p> | ||||
|             </div> | ||||
|             <AlarmList | ||||
|                 alarms={data.alarms.clone()} | ||||
|                 on_alarm_delete={on_alarm_delete} | ||||
|                 on_alarm_edit={on_alarm_edit} | ||||
|             /> | ||||
|  | ||||
|             <div class="reminder-info"> | ||||
|                 <h5>{"Advanced Reminder Features"}</h5> | ||||
|                 <ul> | ||||
|                     <li>{"Multiple reminders per event with different timing"}</li> | ||||
|                     <li>{"Custom reminder messages and descriptions"}</li> | ||||
|                     <li>{"Recurring reminders for recurring events"}</li> | ||||
|                     <li>{"Snooze and dismiss functionality"}</li> | ||||
|                     <li>{"Integration with system notifications"}</li> | ||||
|                 </ul> | ||||
|  | ||||
|                 <div class="attachments-section"> | ||||
|                     <h6>{"File Attachments & Documents"}</h6> | ||||
|                     <p>{"Future attachment features will include:"}</p> | ||||
|                     <ul> | ||||
|                         <li>{"Drag-and-drop file uploads"}</li> | ||||
|                         <li>{"Document preview and thumbnails"}</li> | ||||
|                         <li>{"Cloud storage integration (Google Drive, OneDrive)"}</li> | ||||
|                         <li>{"Version control for updated documents"}</li> | ||||
|                         <li>{"Shared access permissions for attendees"}</li> | ||||
|                     </ul> | ||||
|                     <p class="form-help-text">{"Attachment functionality will be implemented in a future release."}</p> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <AddAlarmModal | ||||
|                 is_open={*is_modal_open} | ||||
|                 editing_index={*editing_index} | ||||
|                 initial_alarm={initial_alarm} | ||||
|                 on_close={on_modal_close} | ||||
|                 on_save={on_alarm_save} | ||||
|             /> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| use crate::services::calendar_service::CalendarInfo; | ||||
| use chrono::{Local, NaiveDate, NaiveTime}; | ||||
| use yew::prelude::*; | ||||
| use calendar_models::VAlarm; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum EventStatus { | ||||
| @@ -28,22 +29,6 @@ impl Default for EventClass { | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum ReminderType { | ||||
|     None, | ||||
|     Minutes15, | ||||
|     Minutes30, | ||||
|     Hour1, | ||||
|     Day1, | ||||
|     Days2, | ||||
|     Week1, | ||||
| } | ||||
|  | ||||
| impl Default for ReminderType { | ||||
|     fn default() -> Self { | ||||
|         ReminderType::None | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum RecurrenceType { | ||||
| @@ -104,8 +89,8 @@ pub struct EventCreationData { | ||||
|     // Categorization | ||||
|     pub categories: String, | ||||
|      | ||||
|     // Reminders | ||||
|     pub reminder: ReminderType, | ||||
|     // Reminders/Alarms | ||||
|     pub alarms: Vec<VAlarm>, | ||||
|      | ||||
|     // Recurrence | ||||
|     pub recurrence: RecurrenceType, | ||||
| @@ -145,7 +130,7 @@ impl EventCreationData { | ||||
|         String, // organizer | ||||
|         String, // attendees | ||||
|         String, // categories | ||||
|         String, // reminder | ||||
|         Vec<VAlarm>, // alarms | ||||
|         String, // recurrence | ||||
|         Vec<bool>, // recurrence_days | ||||
|         u32, // recurrence_interval | ||||
| @@ -156,8 +141,9 @@ impl EventCreationData { | ||||
|     ) { | ||||
|          | ||||
|         // Use local date/times and timezone - no UTC conversion | ||||
|         let effective_end_date = if self.end_time == NaiveTime::from_hms_opt(0, 0, 0).unwrap() { | ||||
|             // If end time is midnight (00:00), treat it as beginning of next day | ||||
|         let effective_end_date = if self.all_day { | ||||
|             // For all-day events, add one day to convert from inclusive to exclusive end date | ||||
|             // (iCalendar spec requires exclusive end dates for all-day events) | ||||
|             self.end_date + chrono::Duration::days(1) | ||||
|         } else { | ||||
|             self.end_date | ||||
| @@ -195,7 +181,7 @@ impl EventCreationData { | ||||
|             self.organizer.clone(), | ||||
|             self.attendees.clone(), | ||||
|             self.categories.clone(), | ||||
|             format!("{:?}", self.reminder), | ||||
|             self.alarms.clone(), | ||||
|             format!("{:?}", self.recurrence), | ||||
|             self.recurrence_days.clone(), | ||||
|             self.recurrence_interval, | ||||
| @@ -229,7 +215,7 @@ impl Default for EventCreationData { | ||||
|             organizer: String::new(), | ||||
|             attendees: String::new(), | ||||
|             categories: String::new(), | ||||
|             reminder: ReminderType::default(), | ||||
|             alarms: Vec::new(), | ||||
|             recurrence: RecurrenceType::default(), | ||||
|             recurrence_interval: 1, | ||||
|             recurrence_until: None, | ||||
|   | ||||
| @@ -3,6 +3,7 @@ pub mod calendar_context_menu; | ||||
| pub mod calendar_management_modal; | ||||
| pub mod calendar_header; | ||||
| pub mod calendar_list_item; | ||||
| pub mod color_editor_modal; | ||||
| pub mod context_menu; | ||||
| pub mod create_calendar_modal; | ||||
| pub mod create_event_modal; | ||||
| @@ -24,6 +25,7 @@ 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 color_editor_modal::ColorEditorModal; | ||||
| pub use context_menu::ContextMenu; | ||||
| pub use create_event_modal::CreateEventModal; | ||||
| // Re-export event form types for backwards compatibility | ||||
|   | ||||
| @@ -113,8 +113,14 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|         "#3B82F6".to_string() | ||||
|     }; | ||||
|  | ||||
|      | ||||
|     let weeks_needed = calculate_minimum_weeks_needed(first_weekday, days_in_month); | ||||
|      | ||||
|     // Use calculated weeks with height-based container sizing for proper fit | ||||
|     let dynamic_style = format!("grid-template-rows: var(--weekday-header-height, 50px) repeat({}, 1fr);", weeks_needed); | ||||
|      | ||||
|     html! { | ||||
|         <div class="calendar-grid"> | ||||
|         <div class="calendar-grid" style={dynamic_style}> | ||||
|             // Weekday headers | ||||
|             <div class="weekday-header">{"Sun"}</div> | ||||
|             <div class="weekday-header">{"Mon"}</div> | ||||
| @@ -212,7 +218,10 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                                                 {onclick} | ||||
|                                                 {oncontextmenu} | ||||
|                                             > | ||||
|                                                 {event.summary.as_ref().unwrap_or(&"Untitled".to_string())} | ||||
|                                                 <span class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</span> | ||||
|                                                 if !event.alarms.is_empty() { | ||||
|                                                     <i class="fas fa-bell event-reminder-icon" title="Has reminders"></i> | ||||
|                                                 } | ||||
|                                             </div> | ||||
|                                         } | ||||
|                                     }).collect::<Html>() | ||||
| @@ -234,13 +243,27 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                 }).collect::<Html>() | ||||
|             } | ||||
|  | ||||
|             { render_next_month_days(days_from_prev_month.len(), days_in_month) } | ||||
|             { render_next_month_days(days_from_prev_month.len(), days_in_month, calculate_minimum_weeks_needed(first_weekday, days_in_month)) } | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html { | ||||
|     let total_slots = 42; // 6 rows x 7 days | ||||
| fn calculate_minimum_weeks_needed(first_weekday: Weekday, days_in_month: u32) -> u32 { | ||||
|     let days_before = match first_weekday { | ||||
|         Weekday::Sun => 0, | ||||
|         Weekday::Mon => 1, | ||||
|         Weekday::Tue => 2, | ||||
|         Weekday::Wed => 3, | ||||
|         Weekday::Thu => 4, | ||||
|         Weekday::Fri => 5, | ||||
|         Weekday::Sat => 6, | ||||
|     }; | ||||
|     let total_days_needed = days_before + days_in_month; | ||||
|     (total_days_needed + 6) / 7 // Round up to get number of weeks | ||||
| } | ||||
|  | ||||
| fn render_next_month_days(prev_days_count: usize, current_days_count: u32, weeks_needed: u32) -> Html { | ||||
|     let total_slots = (weeks_needed * 7) as usize; // Dynamic based on weeks needed | ||||
|     let used_slots = prev_days_count + current_days_count as usize; | ||||
|     let remaining_slots = if used_slots < total_slots { | ||||
|         total_slots - used_slots | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| use crate::components::{ViewMode, WeekView, MonthView}; | ||||
| use crate::components::{ViewMode, WeekView, MonthView, CalendarHeader}; | ||||
| 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 web_sys::MouseEvent; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| @@ -88,10 +89,11 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html { | ||||
|     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 calendar_header_height = 80.0; // Calendar header height in print preview | ||||
|         let week_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 total_overhead = calendar_header_height + week_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; | ||||
| @@ -151,10 +153,11 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html { | ||||
|                                         // 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 calendar_header_height = 80.0; // Calendar header height | ||||
|                                         let week_header_height = 50.0; // Week header height | ||||
|                                         let header_border = 2.0; | ||||
|                                         let container_spacing = 8.0; | ||||
|                                         let total_overhead = header_height + header_border + container_spacing; | ||||
|                                         let total_overhead = calendar_header_height + week_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; | ||||
| @@ -320,6 +323,17 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html { | ||||
|                                          *start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level | ||||
|                                      )}> | ||||
|                             <div class="print-preview-content"> | ||||
|                                 <div class={classes!("calendar", match props.view_mode { ViewMode::Week => Some("week-view"), _ => None })}> | ||||
|                                     <CalendarHeader | ||||
|                                         current_date={props.current_date} | ||||
|                                         view_mode={props.view_mode.clone()} | ||||
|                                         on_prev={Callback::from(|_: MouseEvent| {})} | ||||
|                                         on_next={Callback::from(|_: MouseEvent| {})} | ||||
|                                         on_today={Callback::from(|_: MouseEvent| {})} | ||||
|                                         time_increment={Some(props.time_increment)} | ||||
|                                         on_time_increment_toggle={None::<Callback<MouseEvent>>} | ||||
|                                         on_print={None::<Callback<MouseEvent>>} | ||||
|                                     /> | ||||
|                                     { | ||||
|                                         match props.view_mode { | ||||
|                                             ViewMode::Week => html! { | ||||
| @@ -358,5 +372,6 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html { | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -2,17 +2,7 @@ use crate::components::CalendarListItem; | ||||
| use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||
| use web_sys::HtmlSelectElement; | ||||
| use yew::prelude::*; | ||||
| use yew_router::prelude::*; | ||||
|  | ||||
| #[derive(Clone, Routable, PartialEq)] | ||||
| pub enum Route { | ||||
|     #[at("/")] | ||||
|     Home, | ||||
|     #[at("/login")] | ||||
|     Login, | ||||
|     #[at("/calendar")] | ||||
|     Calendar, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum ViewMode { | ||||
| @@ -30,12 +20,17 @@ pub enum Theme { | ||||
|     Dark, | ||||
|     Rose, | ||||
|     Mint, | ||||
|     Midnight, | ||||
|     Charcoal, | ||||
|     Nord, | ||||
|     Dracula, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum Style { | ||||
|     Default, | ||||
|     Google, | ||||
|     Apple, | ||||
| } | ||||
|  | ||||
| impl Theme { | ||||
| @@ -49,6 +44,10 @@ impl Theme { | ||||
|             Theme::Dark => "dark", | ||||
|             Theme::Rose => "rose", | ||||
|             Theme::Mint => "mint", | ||||
|             Theme::Midnight => "midnight", | ||||
|             Theme::Charcoal => "charcoal", | ||||
|             Theme::Nord => "nord", | ||||
|             Theme::Dracula => "dracula", | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -61,6 +60,10 @@ impl Theme { | ||||
|             "dark" => Theme::Dark, | ||||
|             "rose" => Theme::Rose, | ||||
|             "mint" => Theme::Mint, | ||||
|             "midnight" => Theme::Midnight, | ||||
|             "charcoal" => Theme::Charcoal, | ||||
|             "nord" => Theme::Nord, | ||||
|             "dracula" => Theme::Dracula, | ||||
|             _ => Theme::Default, | ||||
|         } | ||||
|     } | ||||
| @@ -71,12 +74,14 @@ impl Style { | ||||
|         match self { | ||||
|             Style::Default => "default", | ||||
|             Style::Google => "google", | ||||
|             Style::Apple => "apple", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn from_value(value: &str) -> Self { | ||||
|         match value { | ||||
|             "google" => Style::Google, | ||||
|             "apple" => Style::Apple, | ||||
|             _ => Style::Default, | ||||
|         } | ||||
|     } | ||||
| @@ -86,6 +91,7 @@ impl Style { | ||||
|         match self { | ||||
|             Style::Default => None, // No additional stylesheet needed - uses base styles.css | ||||
|             Style::Google => Some("google.css"), // Trunk copies to root level | ||||
|             Style::Apple => Some("apple.css"), // Trunk copies to root level | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -109,6 +115,7 @@ pub struct SidebarProps { | ||||
|     pub on_color_change: Callback<(String, String)>, | ||||
|     pub on_color_picker_toggle: Callback<String>, | ||||
|     pub available_colors: Vec<String>, | ||||
|     pub on_color_editor_open: Callback<(usize, String)>, // (index, current_color) | ||||
|     pub refreshing_calendar_id: Option<i32>, | ||||
|     pub on_calendar_context_menu: Callback<(MouseEvent, String)>, | ||||
|     pub on_calendar_visibility_toggle: Callback<String>, | ||||
| @@ -219,6 +226,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                                                     on_color_change={props.on_color_change.clone()} | ||||
|                                                     on_color_picker_toggle={props.on_color_picker_toggle.clone()} | ||||
|                                                     available_colors={props.available_colors.clone()} | ||||
|                                                     on_color_editor_open={props.on_color_editor_open.clone()} | ||||
|                                                     on_context_menu={props.on_calendar_context_menu.clone()} | ||||
|                                                     on_visibility_toggle={props.on_calendar_visibility_toggle.clone()} | ||||
|                                                 /> | ||||
| @@ -254,7 +262,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                                         }; | ||||
|                                          | ||||
|                                         html! { | ||||
|                                             <li class="external-calendar-item" style="position: relative;"> | ||||
|                                             <li class="external-calendar-item"> | ||||
|                                                 <div  | ||||
|                                                     class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {  | ||||
|                                                         "external-calendar-info color-picker-active"  | ||||
| @@ -300,6 +308,17 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                                                                                     on_color_change.emit((external_id.clone(), color_str.clone())); | ||||
|                                                                                 }); | ||||
|  | ||||
|                                                                                 let on_color_right_click = { | ||||
|                                                                                     let on_color_editor_open = props.on_color_editor_open.clone(); | ||||
|                                                                                     let color_index = props.available_colors.iter().position(|c| c == color).unwrap_or(0); | ||||
|                                                                                     let color_str = color.clone(); | ||||
|                                                                                     Callback::from(move |e: MouseEvent| { | ||||
|                                                                                         e.prevent_default(); | ||||
|                                                                                         e.stop_propagation(); | ||||
|                                                                                         on_color_editor_open.emit((color_index, color_str.clone())); | ||||
|                                                                                     }) | ||||
|                                                                                 }; | ||||
|                                                                                  | ||||
|                                                                                 let is_selected = cal.color == *color; | ||||
|                                                                                  | ||||
|                                                                                 html! { | ||||
| @@ -308,6 +327,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                                                                                         class={if is_selected { "color-option selected" } else { "color-option" }} | ||||
|                                                                                         style={format!("background-color: {}", color)} | ||||
|                                                                                         onclick={on_color_select} | ||||
|                                                                                         oncontextmenu={on_color_right_click} | ||||
|                                                                                     /> | ||||
|                                                                                 } | ||||
|                                                                             }).collect::<Html>() | ||||
| @@ -422,6 +442,10 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                         <option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option> | ||||
|                         <option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option> | ||||
|                         <option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option> | ||||
|                         <option value="midnight" selected={matches!(props.current_theme, Theme::Midnight)}>{"Midnight"}</option> | ||||
|                         <option value="charcoal" selected={matches!(props.current_theme, Theme::Charcoal)}>{"Charcoal"}</option> | ||||
|                         <option value="nord" selected={matches!(props.current_theme, Theme::Nord)}>{"Nord"}</option> | ||||
|                         <option value="dracula" selected={matches!(props.current_theme, Theme::Dracula)}>{"Dracula"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|  | ||||
| @@ -429,6 +453,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { | ||||
|                     <select class="style-selector-dropdown" onchange={on_style_change}> | ||||
|                         <option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option> | ||||
|                         <option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option> | ||||
|                         <option value="apple" selected={matches!(props.current_style, Style::Apple)}>{"Apple Calendar"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|  | ||||
|   | ||||
| @@ -348,6 +348,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|  | ||||
|     html! { | ||||
|         <div class="week-view-container"> | ||||
|             // Header with weekday names and dates | ||||
| @@ -967,7 +968,12 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|  | ||||
|                                                                 // Event content | ||||
|                                                                 <div class="event-content"> | ||||
|                                                                     <div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||
|                                                                     <div class="event-title-row"> | ||||
|                                                                         <span class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</span> | ||||
|                                                                         if !event.alarms.is_empty() { | ||||
|                                                                             <i class="fas fa-bell event-reminder-icon" title="Has reminders"></i> | ||||
|                                                                         } | ||||
|                                                                     </div> | ||||
|                                                                     {if !is_all_day && duration_pixels > 30.0 { | ||||
|                                                                         html! { <div class="event-time">{time_display}</div> } | ||||
|                                                                     } else { | ||||
|   | ||||
							
								
								
									
										299
									
								
								frontend/src/services/alarm_scheduler.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								frontend/src/services/alarm_scheduler.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,299 @@ | ||||
| use calendar_models::{VAlarm, AlarmAction, AlarmTrigger, VEvent}; | ||||
| use chrono::{Duration, Local, NaiveDateTime}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::collections::HashMap; | ||||
| use crate::services::{NotificationManager, AlarmNotification}; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub struct ScheduledAlarm { | ||||
|     pub id: String,                    // Unique alarm ID | ||||
|     pub event_uid: String,             // Event this alarm belongs to | ||||
|     pub event_summary: String,         // Event title for notification | ||||
|     pub event_location: Option<String>, // Event location for notification | ||||
|     pub event_start: NaiveDateTime,    // Event start time (local) | ||||
|     pub trigger_time: NaiveDateTime,   // When alarm should trigger (local) | ||||
|     pub alarm_action: AlarmAction,     // Type of alarm | ||||
|     pub status: AlarmStatus,           // Current status | ||||
|     pub created_at: NaiveDateTime,     // When alarm was scheduled | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub enum AlarmStatus { | ||||
|     Pending,    // Waiting to trigger | ||||
|     Triggered,  // Has been triggered | ||||
|     Dismissed,  // User dismissed | ||||
|     Expired,    // Past due (event ended) | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct AlarmScheduler { | ||||
|     scheduled_alarms: HashMap<String, ScheduledAlarm>, | ||||
|     notification_manager: NotificationManager, | ||||
| } | ||||
|  | ||||
| const ALARMS_STORAGE_KEY: &str = "scheduled_alarms"; | ||||
|  | ||||
| impl AlarmScheduler { | ||||
|     pub fn new() -> Self { | ||||
|         let mut scheduler = Self { | ||||
|             scheduled_alarms: HashMap::new(), | ||||
|             notification_manager: NotificationManager::new(), | ||||
|         }; | ||||
|          | ||||
|         // Load alarms from localStorage | ||||
|         scheduler.load_alarms_from_storage(); | ||||
|         scheduler | ||||
|     } | ||||
|      | ||||
|     /// Load alarms from localStorage | ||||
|     fn load_alarms_from_storage(&mut self) { | ||||
|         if let Ok(alarms) = LocalStorage::get::<HashMap<String, ScheduledAlarm>>(ALARMS_STORAGE_KEY) { | ||||
|             self.scheduled_alarms = alarms; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Save alarms to localStorage | ||||
|     fn save_alarms_to_storage(&self) { | ||||
|         if let Err(e) = LocalStorage::set(ALARMS_STORAGE_KEY, &self.scheduled_alarms) { | ||||
|             web_sys::console::error_1( | ||||
|                 &format!("Failed to save alarms to localStorage: {:?}", e).into() | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Schedule alarms for an event | ||||
|     pub fn schedule_event_alarms(&mut self, event: &VEvent) { | ||||
|         // Check notification permission before scheduling | ||||
|         let permission = NotificationManager::get_permission(); | ||||
|         if permission != web_sys::NotificationPermission::Granted && !event.alarms.is_empty() { | ||||
|             // Try to force request permission asynchronously | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 let _ = NotificationManager::force_request_permission().await; | ||||
|             }); | ||||
|         } | ||||
|          | ||||
|         // Remove any existing alarms for this event | ||||
|         self.remove_event_alarms(&event.uid); | ||||
|  | ||||
|         // Get event details | ||||
|         let event_summary = event.summary.as_ref().unwrap_or(&"Untitled Event".to_string()).clone(); | ||||
|         let event_location = event.location.clone(); | ||||
|         let event_start = event.dtstart; | ||||
|  | ||||
|         // Schedule each alarm | ||||
|         for alarm in &event.alarms { | ||||
|             if let Some(scheduled_alarm) = self.create_scheduled_alarm( | ||||
|                 event, | ||||
|                 alarm, | ||||
|                 &event_summary, | ||||
|                 &event_location, | ||||
|                 event_start, | ||||
|             ) { | ||||
|                 self.scheduled_alarms.insert(scheduled_alarm.id.clone(), scheduled_alarm); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Save to localStorage | ||||
|         self.save_alarms_to_storage(); | ||||
|     } | ||||
|  | ||||
|     /// Create a scheduled alarm from a VAlarm | ||||
|     fn create_scheduled_alarm( | ||||
|         &self, | ||||
|         event: &VEvent, | ||||
|         valarm: &VAlarm, | ||||
|         event_summary: &str, | ||||
|         event_location: &Option<String>, | ||||
|         event_start: NaiveDateTime, | ||||
|     ) -> Option<ScheduledAlarm> { | ||||
|         // Only handle Display alarms for now | ||||
|         if valarm.action != AlarmAction::Display { | ||||
|             return None; | ||||
|         } | ||||
|  | ||||
|         // Calculate trigger time | ||||
|         let trigger_time = match &valarm.trigger { | ||||
|             AlarmTrigger::Duration(duration) => { | ||||
|                 // Duration relative to event start | ||||
|                 let trigger_time = event_start + *duration; | ||||
|                  | ||||
|                 // Ensure trigger time is not in the past (with 30 second tolerance) | ||||
|                 let now = Local::now().naive_local(); | ||||
|                 if trigger_time < now - Duration::seconds(30) { | ||||
|                     web_sys::console::warn_1( | ||||
|                         &format!("Skipping past alarm for event: {} (trigger: {})",  | ||||
|                             event_summary,  | ||||
|                             trigger_time.format("%Y-%m-%d %H:%M:%S") | ||||
|                         ).into() | ||||
|                     ); | ||||
|                     return None; | ||||
|                 } | ||||
|                  | ||||
|                 trigger_time | ||||
|             } | ||||
|             AlarmTrigger::DateTime(datetime) => { | ||||
|                 // Absolute datetime - convert to local time | ||||
|                 let local_trigger = datetime.with_timezone(&Local).naive_local(); | ||||
|                  | ||||
|                 // Ensure trigger time is not in the past | ||||
|                 let now = Local::now().naive_local(); | ||||
|                 if local_trigger < now - Duration::seconds(30) { | ||||
|                     web_sys::console::warn_1( | ||||
|                         &format!("Skipping past absolute alarm for event: {} (trigger: {})",  | ||||
|                             event_summary,  | ||||
|                             local_trigger.format("%Y-%m-%d %H:%M:%S") | ||||
|                         ).into() | ||||
|                     ); | ||||
|                     return None; | ||||
|                 } | ||||
|                  | ||||
|                 local_trigger | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         // Generate unique alarm ID | ||||
|         let alarm_id = format!("{}_{}", event.uid, trigger_time.and_utc().timestamp()); | ||||
|  | ||||
|         Some(ScheduledAlarm { | ||||
|             id: alarm_id, | ||||
|             event_uid: event.uid.clone(), | ||||
|             event_summary: event_summary.to_string(), | ||||
|             event_location: event_location.clone(), | ||||
|             event_start, | ||||
|             trigger_time, | ||||
|             alarm_action: valarm.action.clone(), | ||||
|             status: AlarmStatus::Pending, | ||||
|             created_at: Local::now().naive_local(), | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /// Remove all alarms for an event | ||||
|     pub fn remove_event_alarms(&mut self, event_uid: &str) { | ||||
|         let alarm_ids: Vec<String> = self.scheduled_alarms | ||||
|             .iter() | ||||
|             .filter(|(_, alarm)| alarm.event_uid == event_uid) | ||||
|             .map(|(id, _)| id.clone()) | ||||
|             .collect(); | ||||
|  | ||||
|         for alarm_id in alarm_ids { | ||||
|             self.scheduled_alarms.remove(&alarm_id); | ||||
|         } | ||||
|  | ||||
|         // Also close any active notifications for this event | ||||
|         self.notification_manager.close_notification(event_uid); | ||||
|          | ||||
|         // Save to localStorage | ||||
|         self.save_alarms_to_storage(); | ||||
|     } | ||||
|  | ||||
|     /// Check for alarms that should trigger now and trigger them | ||||
|     pub fn check_and_trigger_alarms(&mut self) -> usize { | ||||
|         // Reload alarms from localStorage to ensure we have the latest data | ||||
|         self.load_alarms_from_storage(); | ||||
|          | ||||
|         let now = Local::now().naive_local(); | ||||
|         let mut triggered_count = 0; | ||||
|  | ||||
|         // Find alarms that should trigger (within 30 seconds tolerance) | ||||
|         let alarms_to_trigger: Vec<ScheduledAlarm> = self.scheduled_alarms | ||||
|             .values() | ||||
|             .filter(|alarm| { | ||||
|                 alarm.status == AlarmStatus::Pending &&  | ||||
|                 alarm.trigger_time <= now + Duration::seconds(30) && | ||||
|                 alarm.trigger_time >= now - Duration::seconds(30) | ||||
|             }) | ||||
|             .cloned() | ||||
|             .collect(); | ||||
|  | ||||
|         for alarm in alarms_to_trigger { | ||||
|             if self.trigger_alarm(&alarm) { | ||||
|                 // Mark alarm as triggered | ||||
|                 if let Some(scheduled_alarm) = self.scheduled_alarms.get_mut(&alarm.id) { | ||||
|                     scheduled_alarm.status = AlarmStatus::Triggered; | ||||
|                 } | ||||
|                 triggered_count += 1; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Clean up expired alarms (events that ended more than 1 hour ago) | ||||
|         self.cleanup_expired_alarms(); | ||||
|          | ||||
|         // Save to localStorage if any changes were made | ||||
|         if triggered_count > 0 { | ||||
|             self.save_alarms_to_storage(); | ||||
|         } | ||||
|  | ||||
|         triggered_count | ||||
|     } | ||||
|  | ||||
|     /// Trigger a specific alarm | ||||
|     fn trigger_alarm(&mut self, alarm: &ScheduledAlarm) -> bool { | ||||
|         // Don't trigger if already showing notification for this event | ||||
|         if self.notification_manager.has_notification(&alarm.event_uid) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         let alarm_notification = AlarmNotification { | ||||
|             event_uid: alarm.event_uid.clone(), | ||||
|             event_summary: alarm.event_summary.clone(), | ||||
|             event_location: alarm.event_location.clone(), | ||||
|             alarm_time: alarm.event_start, | ||||
|         }; | ||||
|  | ||||
|         match self.notification_manager.show_alarm_notification(alarm_notification) { | ||||
|             Ok(()) => true, | ||||
|             Err(err) => { | ||||
|                 web_sys::console::error_1( | ||||
|                     &format!("Failed to trigger alarm: {:?}", err).into() | ||||
|                 ); | ||||
|                 false | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Clean up expired alarms | ||||
|     fn cleanup_expired_alarms(&mut self) { | ||||
|         let now = Local::now().naive_local(); | ||||
|         let cutoff_time = now - Duration::hours(1); | ||||
|  | ||||
|         let expired_alarm_ids: Vec<String> = self.scheduled_alarms | ||||
|             .iter() | ||||
|             .filter(|(_, alarm)| { | ||||
|                 // Mark as expired if event ended more than 1 hour ago | ||||
|                 alarm.event_start < cutoff_time | ||||
|             }) | ||||
|             .map(|(id, _)| id.clone()) | ||||
|             .collect(); | ||||
|  | ||||
|         for alarm_id in &expired_alarm_ids { | ||||
|             if let Some(alarm) = self.scheduled_alarms.get_mut(alarm_id) { | ||||
|                 alarm.status = AlarmStatus::Expired; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Remove expired alarms from memory | ||||
|         let had_expired = !expired_alarm_ids.is_empty(); | ||||
|         for alarm_id in expired_alarm_ids { | ||||
|             self.scheduled_alarms.remove(&alarm_id); | ||||
|         } | ||||
|          | ||||
|         // Save to localStorage if any expired alarms were removed | ||||
|         if had_expired { | ||||
|             self.save_alarms_to_storage(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /// Request notification permission | ||||
|     pub async fn request_notification_permission(&self) -> Result<web_sys::NotificationPermission, wasm_bindgen::JsValue> { | ||||
|         NotificationManager::request_permission().await | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| impl Default for AlarmScheduler { | ||||
|     fn default() -> Self { | ||||
|         Self::new() | ||||
|     } | ||||
| } | ||||
| @@ -6,8 +6,8 @@ use wasm_bindgen::JsCast; | ||||
| use wasm_bindgen_futures::JsFuture; | ||||
| use web_sys::{Request, RequestInit, RequestMode, Response}; | ||||
|  | ||||
| // Import RFC 5545 compliant VEvent from shared library | ||||
| use calendar_models::VEvent; | ||||
| // Import RFC 5545 compliant VEvent and VAlarm from shared library | ||||
| use calendar_models::{VEvent, VAlarm}; | ||||
|  | ||||
| // Create type alias for backward compatibility | ||||
| pub type CalendarEvent = VEvent; | ||||
| @@ -247,6 +247,8 @@ impl CalendarService { | ||||
|         if resp.ok() { | ||||
|             let events: Vec<CalendarEvent> = serde_json::from_str(&text_string) | ||||
|                 .map_err(|e| format!("JSON parsing failed: {}", e))?; | ||||
|              | ||||
|              | ||||
|             Ok(events) | ||||
|         } else { | ||||
|             Err(format!( | ||||
| @@ -277,14 +279,15 @@ impl CalendarService { | ||||
|  | ||||
|     /// Convert UTC events to local timezone for display | ||||
|     fn convert_utc_to_local(mut event: VEvent) -> VEvent { | ||||
|         // All-day events should not have timezone conversions applied | ||||
|         if event.all_day { | ||||
|             return event; | ||||
|         } | ||||
|          | ||||
|         // Check if event times are in UTC (legacy events from before timezone migration) | ||||
|         let is_utc_event = event.dtstart_tzid.as_ref().map_or(true, |tz| tz == "UTC"); | ||||
|          | ||||
|         if is_utc_event { | ||||
|             web_sys::console::log_1(&format!( | ||||
|                 "🕐 Converting UTC event '{}' to local time",  | ||||
|                 event.summary.as_deref().unwrap_or("Untitled") | ||||
|             ).into()); | ||||
|              | ||||
|             // Get current timezone offset (convert from UTC to local) | ||||
|             let date = js_sys::Date::new_0(); | ||||
| @@ -314,6 +317,11 @@ impl CalendarService { | ||||
|                 event.last_modified = Some(modified_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64)); | ||||
|                 event.last_modified_tzid = None; | ||||
|             } | ||||
|              | ||||
|             // Convert EXDATE entries from UTC to local time | ||||
|             event.exdate = event.exdate.into_iter() | ||||
|                 .map(|exdate| exdate + chrono::Duration::minutes(-timezone_offset_minutes as i64)) | ||||
|                 .collect(); | ||||
|         } | ||||
|          | ||||
|         event | ||||
| @@ -330,38 +338,8 @@ impl CalendarService { | ||||
|             // Convert UTC events to local time for proper display | ||||
|             let event = Self::convert_utc_to_local(event); | ||||
|             if let Some(ref rrule) = event.rrule { | ||||
|                 web_sys::console::log_1( | ||||
|                     &format!( | ||||
|                         "📅 Processing recurring VEvent '{}' with RRULE: {}", | ||||
|                         event.summary.as_deref().unwrap_or("Untitled"), | ||||
|                         rrule | ||||
|                     ) | ||||
|                     .into(), | ||||
|                 ); | ||||
|  | ||||
|                 // Log if event has exception dates | ||||
|                 if !event.exdate.is_empty() { | ||||
|                     web_sys::console::log_1( | ||||
|                         &format!( | ||||
|                             "📅 VEvent '{}' has {} exception dates: {:?}", | ||||
|                             event.summary.as_deref().unwrap_or("Untitled"), | ||||
|                             event.exdate.len(), | ||||
|                             event.exdate | ||||
|                         ) | ||||
|                         .into(), | ||||
|                     ); | ||||
|                 } | ||||
|  | ||||
|                 // Generate occurrences for recurring events using VEvent | ||||
|                 let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range); | ||||
|                 web_sys::console::log_1( | ||||
|                     &format!( | ||||
|                         "📅 Generated {} occurrences for VEvent '{}'", | ||||
|                         occurrences.len(), | ||||
|                         event.summary.as_deref().unwrap_or("Untitled") | ||||
|                     ) | ||||
|                     .into(), | ||||
|                 ); | ||||
|                 expanded_events.extend(occurrences); | ||||
|             } else { | ||||
|                 // Non-recurring event - add as-is | ||||
| @@ -383,7 +361,6 @@ impl CalendarService { | ||||
|  | ||||
|         // Parse RRULE components | ||||
|         let rrule_upper = rrule.to_uppercase(); | ||||
|         web_sys::console::log_1(&format!("🔄 Parsing RRULE: {}", rrule_upper).into()); | ||||
|  | ||||
|         let components: HashMap<String, String> = rrule_upper | ||||
|             .split(';') | ||||
| @@ -438,8 +415,7 @@ impl CalendarService { | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         if let Some(until) = until_date { | ||||
|             web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into()); | ||||
|         if let Some(_until) = until_date { | ||||
|         } | ||||
|  | ||||
|         let start_date = base_event.dtstart.date(); | ||||
| @@ -453,10 +429,6 @@ impl CalendarService { | ||||
|                 let current_datetime = base_event.dtstart | ||||
|                     + Duration::days(current_date.signed_duration_since(start_date).num_days()); | ||||
|                 if current_datetime > until { | ||||
|                     web_sys::console::log_1( | ||||
|                         &format!("🛑 Stopping at {} due to UNTIL {}", current_datetime, until) | ||||
|                             .into(), | ||||
|                     ); | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
| @@ -468,25 +440,14 @@ impl CalendarService { | ||||
|  | ||||
|                 // Check if this occurrence is in the exception dates (EXDATE) | ||||
|                 let is_exception = base_event.exdate.iter().any(|exception_date| { | ||||
|                     // Compare dates ignoring sub-second precision | ||||
|                     let exception_naive = exception_date.and_utc(); | ||||
|                     let occurrence_naive = occurrence_datetime.and_utc(); | ||||
|                     // EXDATE from server is in local time, but stored as NaiveDateTime | ||||
|                     // We need to compare both as local time (naive datetimes) instead of UTC | ||||
|                     let exception_naive = *exception_date; | ||||
|                     let occurrence_naive = occurrence_datetime; | ||||
|  | ||||
|                     // Check if dates match (within a minute to handle minor time differences) | ||||
|                     let diff = occurrence_naive - exception_naive; | ||||
|                     let matches = diff.num_seconds().abs() < 60; | ||||
|  | ||||
|                     if matches { | ||||
|                         web_sys::console::log_1( | ||||
|                             &format!( | ||||
|                                 "🚫 Excluding occurrence {} due to EXDATE {}", | ||||
|                                 occurrence_naive, exception_naive | ||||
|                             ) | ||||
|                             .into(), | ||||
|                         ); | ||||
|                     } | ||||
|  | ||||
|                     matches | ||||
|                     diff.num_seconds().abs() < 60 | ||||
|                 }); | ||||
|  | ||||
|                 if !is_exception { | ||||
| @@ -653,13 +614,6 @@ impl CalendarService { | ||||
|                     let days_diff = occurrence_date.signed_duration_since(start_date).num_days(); | ||||
|                     let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); | ||||
|                     if occurrence_datetime > until { | ||||
|                         web_sys::console::log_1( | ||||
|                             &format!( | ||||
|                                 "🛑 Stopping at {} due to UNTIL {}", | ||||
|                                 occurrence_datetime, until | ||||
|                             ) | ||||
|                             .into(), | ||||
|                         ); | ||||
|                         return occurrences; | ||||
|                     } | ||||
|                 } | ||||
| @@ -670,22 +624,11 @@ impl CalendarService { | ||||
|  | ||||
|                 // Check if this occurrence is in the exception dates (EXDATE) | ||||
|                 let is_exception = base_event.exdate.iter().any(|exception_date| { | ||||
|                     let exception_naive = exception_date.and_utc(); | ||||
|                     let occurrence_naive = occurrence_datetime.and_utc(); | ||||
|                     // Compare as local time (naive datetimes) instead of UTC | ||||
|                     let exception_naive = *exception_date; | ||||
|                     let occurrence_naive = occurrence_datetime; | ||||
|                     let diff = occurrence_naive - exception_naive; | ||||
|                     let matches = diff.num_seconds().abs() < 60; | ||||
|  | ||||
|                     if matches { | ||||
|                         web_sys::console::log_1( | ||||
|                             &format!( | ||||
|                                 "🚫 Excluding occurrence {} due to EXDATE {}", | ||||
|                                 occurrence_naive, exception_naive | ||||
|                             ) | ||||
|                             .into(), | ||||
|                         ); | ||||
|                     } | ||||
|  | ||||
|                     matches | ||||
|                     diff.num_seconds().abs() < 60 | ||||
|                 }); | ||||
|  | ||||
|                 if !is_exception { | ||||
| @@ -779,13 +722,6 @@ impl CalendarService { | ||||
|                             occurrence_date.signed_duration_since(start_date).num_days(); | ||||
|                         let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); | ||||
|                         if occurrence_datetime > until { | ||||
|                             web_sys::console::log_1( | ||||
|                                 &format!( | ||||
|                                     "🛑 Stopping at {} due to UNTIL {}", | ||||
|                                     occurrence_datetime, until | ||||
|                                 ) | ||||
|                                 .into(), | ||||
|                             ); | ||||
|                             return occurrences; | ||||
|                         } | ||||
|                     } | ||||
| @@ -1295,7 +1231,7 @@ impl CalendarService { | ||||
|         organizer: String, | ||||
|         attendees: String, | ||||
|         categories: String, | ||||
|         reminder: String, | ||||
|         alarms: Vec<VAlarm>, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_interval: u32, | ||||
| @@ -1330,7 +1266,7 @@ impl CalendarService { | ||||
|                 "organizer": organizer, | ||||
|                 "attendees": attendees, | ||||
|                 "categories": categories, | ||||
|                 "reminder": reminder, | ||||
|                 "alarms": alarms, | ||||
|                 "recurrence": recurrence, | ||||
|                 "recurrence_days": recurrence_days, | ||||
|                 "recurrence_interval": recurrence_interval, | ||||
| @@ -1358,7 +1294,7 @@ impl CalendarService { | ||||
|                 "organizer": organizer, | ||||
|                 "attendees": attendees, | ||||
|                 "categories": categories, | ||||
|                 "reminder": reminder, | ||||
|                 "alarms": alarms, | ||||
|                 "recurrence": recurrence, | ||||
|                 "recurrence_days": recurrence_days, | ||||
|                 "calendar_path": calendar_path, | ||||
| @@ -1436,7 +1372,7 @@ impl CalendarService { | ||||
|         organizer: String, | ||||
|         attendees: String, | ||||
|         categories: String, | ||||
|         reminder: String, | ||||
|         alarms: Vec<VAlarm>, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_interval: u32, | ||||
| @@ -1464,7 +1400,7 @@ impl CalendarService { | ||||
|             organizer, | ||||
|             attendees, | ||||
|             categories, | ||||
|             reminder, | ||||
|             alarms, | ||||
|             recurrence, | ||||
|             recurrence_days, | ||||
|             recurrence_interval, | ||||
| @@ -1495,7 +1431,7 @@ impl CalendarService { | ||||
|         organizer: String, | ||||
|         attendees: String, | ||||
|         categories: String, | ||||
|         reminder: String, | ||||
|         alarms: Vec<VAlarm>, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_interval: u32, | ||||
| @@ -1527,7 +1463,7 @@ impl CalendarService { | ||||
|             "organizer": organizer, | ||||
|             "attendees": attendees, | ||||
|             "categories": categories, | ||||
|             "reminder": reminder, | ||||
|             "alarms": alarms, | ||||
|             "recurrence": recurrence, | ||||
|             "recurrence_days": recurrence_days, | ||||
|             "recurrence_interval": recurrence_interval, | ||||
| @@ -1732,7 +1668,7 @@ impl CalendarService { | ||||
|         organizer: String, | ||||
|         attendees: String, | ||||
|         categories: String, | ||||
|         reminder: String, | ||||
|         alarms: Vec<VAlarm>, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_interval: u32, | ||||
| @@ -1765,7 +1701,7 @@ impl CalendarService { | ||||
|             "organizer": organizer, | ||||
|             "attendees": attendees, | ||||
|             "categories": categories, | ||||
|             "reminder": reminder, | ||||
|             "alarms": alarms, | ||||
|             "recurrence": recurrence, | ||||
|             "recurrence_days": recurrence_days, | ||||
|             "recurrence_interval": recurrence_interval, | ||||
| @@ -2146,7 +2082,6 @@ impl CalendarService { | ||||
|         #[derive(Deserialize)] | ||||
|         struct ExternalCalendarEventsResponse { | ||||
|             events: Vec<VEvent>, | ||||
|             last_fetched: chrono::DateTime<chrono::Utc>, | ||||
|         } | ||||
|  | ||||
|         let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json) | ||||
|   | ||||
| @@ -1,4 +1,8 @@ | ||||
| pub mod calendar_service; | ||||
| pub mod preferences; | ||||
| pub mod notification_manager; | ||||
| pub mod alarm_scheduler; | ||||
|  | ||||
| pub use calendar_service::CalendarService; | ||||
| pub use notification_manager::{NotificationManager, AlarmNotification}; | ||||
| pub use alarm_scheduler::AlarmScheduler; | ||||
|   | ||||
							
								
								
									
										189
									
								
								frontend/src/services/notification_manager.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								frontend/src/services/notification_manager.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,189 @@ | ||||
| use web_sys::{window, Notification, NotificationOptions, NotificationPermission}; | ||||
| use wasm_bindgen::prelude::*; | ||||
| use wasm_bindgen_futures::JsFuture; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct NotificationManager { | ||||
|     // Track displayed notifications to prevent duplicates | ||||
|     active_notifications: HashMap<String, Notification>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct AlarmNotification { | ||||
|     pub event_uid: String, | ||||
|     pub event_summary: String, | ||||
|     pub event_location: Option<String>, | ||||
|     pub alarm_time: chrono::NaiveDateTime, | ||||
| } | ||||
|  | ||||
| impl NotificationManager { | ||||
|     pub fn new() -> Self { | ||||
|         Self { | ||||
|             active_notifications: HashMap::new(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Check if the browser supports notifications | ||||
|     pub fn is_supported() -> bool { | ||||
|         // Check if the Notification constructor exists on the window | ||||
|         if let Some(window) = window() { | ||||
|             let has_notification = js_sys::Reflect::has(&window, &"Notification".into()).unwrap_or(false); | ||||
|              | ||||
|             // Additional check - try to access Notification directly via JsValue | ||||
|             let window_js: &wasm_bindgen::JsValue = window.as_ref(); | ||||
|             let direct_check = js_sys::Reflect::get(window_js, &"Notification".into()).unwrap_or(wasm_bindgen::JsValue::UNDEFINED); | ||||
|             let has_direct = !direct_check.is_undefined(); | ||||
|              | ||||
|             // Use either check | ||||
|             has_notification || has_direct | ||||
|         } else { | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Get current notification permission status | ||||
|     pub fn get_permission() -> NotificationPermission { | ||||
|         if Self::is_supported() { | ||||
|             Notification::permission() | ||||
|         } else { | ||||
|             NotificationPermission::Denied | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Force request notification permission (even if previously denied) | ||||
|     pub async fn force_request_permission() -> Result<NotificationPermission, JsValue> { | ||||
|         if !Self::is_supported() { | ||||
|             return Ok(NotificationPermission::Denied); | ||||
|         } | ||||
|  | ||||
|         // Always request permission, regardless of current status | ||||
|         let promise = Notification::request_permission()?; | ||||
|         let js_value = JsFuture::from(promise).await?; | ||||
|          | ||||
|         // Convert JS string back to NotificationPermission | ||||
|         if let Some(permission_str) = js_value.as_string() { | ||||
|             match permission_str.as_str() { | ||||
|                 "granted" => Ok(NotificationPermission::Granted), | ||||
|                 "denied" => Ok(NotificationPermission::Denied), | ||||
|                 _ => Ok(NotificationPermission::Default), | ||||
|             } | ||||
|         } else { | ||||
|             Ok(NotificationPermission::Denied) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Request notification permission from the user | ||||
|     pub async fn request_permission() -> Result<NotificationPermission, JsValue> { | ||||
|         if !Self::is_supported() { | ||||
|             return Ok(NotificationPermission::Denied); | ||||
|         } | ||||
|  | ||||
|         // Check current permission status | ||||
|         let current_permission = Notification::permission(); | ||||
|         if current_permission != NotificationPermission::Default { | ||||
|             return Ok(current_permission); | ||||
|         } | ||||
|  | ||||
|         // Request permission | ||||
|         let promise = Notification::request_permission()?; | ||||
|         let js_value = JsFuture::from(promise).await?; | ||||
|          | ||||
|         // Convert JS string back to NotificationPermission | ||||
|         if let Some(permission_str) = js_value.as_string() { | ||||
|             match permission_str.as_str() { | ||||
|                 "granted" => Ok(NotificationPermission::Granted), | ||||
|                 "denied" => Ok(NotificationPermission::Denied), | ||||
|                 _ => Ok(NotificationPermission::Default), | ||||
|             } | ||||
|         } else { | ||||
|             Ok(NotificationPermission::Denied) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Display a notification for an alarm | ||||
|     pub fn show_alarm_notification(&mut self, alarm: AlarmNotification) -> Result<(), JsValue> { | ||||
|         // Check permission | ||||
|         if Self::get_permission() != NotificationPermission::Granted { | ||||
|             return Ok(()); // Don't error, just skip | ||||
|         } | ||||
|  | ||||
|         // Check if notification already exists for this event | ||||
|         if self.active_notifications.contains_key(&alarm.event_uid) { | ||||
|             return Ok(()); // Already showing notification for this event | ||||
|         } | ||||
|  | ||||
|         // Create notification options | ||||
|         let options = NotificationOptions::new(); | ||||
|          | ||||
|         // Set notification body with time and location | ||||
|         let body = if let Some(location) = &alarm.event_location { | ||||
|             format!("📅 {}\n📍 {}",  | ||||
|                 alarm.alarm_time.format("%H:%M"),  | ||||
|                 location | ||||
|             ) | ||||
|         } else { | ||||
|             format!("📅 {}", alarm.alarm_time.format("%H:%M")) | ||||
|         }; | ||||
|         options.set_body(&body); | ||||
|  | ||||
|         // Set icon | ||||
|         options.set_icon("/favicon.ico"); | ||||
|          | ||||
|         // Set tag to prevent duplicates | ||||
|         options.set_tag(&alarm.event_uid); | ||||
|          | ||||
|         // Set require interaction to keep notification visible | ||||
|         options.set_require_interaction(true); | ||||
|  | ||||
|         // Create and show notification | ||||
|         let notification = Notification::new_with_options(&alarm.event_summary, &options)?; | ||||
|          | ||||
|         // Store reference to track active notifications | ||||
|         self.active_notifications.insert(alarm.event_uid.clone(), notification.clone()); | ||||
|  | ||||
|         // Set up click handler to focus the calendar app | ||||
|         let _event_uid = alarm.event_uid.clone(); | ||||
|         let onclick_closure = Closure::wrap(Box::new(move |_event: web_sys::Event| { | ||||
|             // Focus the window when notification is clicked | ||||
|             if let Some(window) = window() { | ||||
|                 let _ = window.focus(); | ||||
|             } | ||||
|              | ||||
|         }) as Box<dyn FnMut(_)>); | ||||
|          | ||||
|         notification.set_onclick(Some(onclick_closure.as_ref().unchecked_ref())); | ||||
|         onclick_closure.forget(); // Keep closure alive | ||||
|  | ||||
|         // Set up close handler to clean up tracking | ||||
|         let event_uid_close = alarm.event_uid.clone(); | ||||
|         let mut active_notifications_close = self.active_notifications.clone(); | ||||
|         let onclose_closure = Closure::wrap(Box::new(move |_event: web_sys::Event| { | ||||
|             active_notifications_close.remove(&event_uid_close); | ||||
|         }) as Box<dyn FnMut(_)>); | ||||
|          | ||||
|         notification.set_onclose(Some(onclose_closure.as_ref().unchecked_ref())); | ||||
|         onclose_closure.forget(); // Keep closure alive | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Close notification for a specific event | ||||
|     pub fn close_notification(&mut self, event_uid: &str) { | ||||
|         if let Some(notification) = self.active_notifications.remove(event_uid) { | ||||
|             notification.close(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /// Check if notification exists for event | ||||
|     pub fn has_notification(&self, event_uid: &str) -> bool { | ||||
|         self.active_notifications.contains_key(event_uid) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for NotificationManager { | ||||
|     fn default() -> Self { | ||||
|         Self::new() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1680
									
								
								frontend/styles.css
									
									
									
									
									
								
							
							
						
						
									
										1680
									
								
								frontend/styles.css
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										691
									
								
								frontend/styles/apple.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										691
									
								
								frontend/styles/apple.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,691 @@ | ||||
| /* Apple Calendar-inspired styles */ | ||||
|  | ||||
| /* Override CSS Variables for Apple Calendar Style */ | ||||
| :root { | ||||
|     /* Apple-style spacing */ | ||||
|     --spacing-xs: 4px; | ||||
|     --spacing-sm: 8px; | ||||
|     --spacing-md: 12px; | ||||
|     --spacing-lg: 16px; | ||||
|     --spacing-xl: 24px; | ||||
|      | ||||
|     /* Apple-style borders and radius */ | ||||
|     --border-radius-small: 6px; | ||||
|     --border-radius-medium: 10px; | ||||
|     --border-radius-large: 16px; | ||||
|      | ||||
|     /* Apple-style shadows */ | ||||
|     --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); | ||||
|     --shadow-md: 0 3px 6px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.12); | ||||
|     --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15), 0 3px 6px rgba(0, 0, 0, 0.10); | ||||
| } | ||||
|  | ||||
| /* Theme-aware Apple style colors - use theme colors but with Apple aesthetic */ | ||||
| [data-style="apple"] { | ||||
|     /* Use theme background and text colors */ | ||||
|     --apple-bg-primary: var(--background-secondary); | ||||
|     --apple-bg-secondary: var(--background-primary); | ||||
|     --apple-text-primary: var(--text-primary); | ||||
|     --apple-text-secondary: var(--text-secondary); | ||||
|     --apple-text-tertiary: var(--text-secondary); | ||||
|     --apple-text-inverse: var(--text-inverse); | ||||
|     --apple-border-primary: var(--border-primary); | ||||
|     --apple-border-secondary: var(--border-secondary); | ||||
|     --apple-accent: var(--primary-color); | ||||
|     --apple-hover-bg: var(--background-tertiary); | ||||
|     --apple-today-accent: var(--primary-color); | ||||
|      | ||||
|     /* Apple font family */ | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
| } | ||||
|  | ||||
| /* Theme-specific Apple style adjustments */ | ||||
| [data-style="apple"][data-theme="default"] { | ||||
|     --apple-bg-tertiary: rgba(248, 249, 250, 0.8); | ||||
|     --apple-bg-sidebar: rgba(246, 246, 246, 0.7); | ||||
|     --apple-accent-bg: rgba(102, 126, 234, 0.1); | ||||
|     --apple-today-bg: rgba(102, 126, 234, 0.15); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="ocean"] { | ||||
|     --apple-bg-tertiary: rgba(224, 247, 250, 0.8); | ||||
|     --apple-bg-sidebar: rgba(224, 247, 250, 0.7); | ||||
|     --apple-accent-bg: rgba(0, 105, 148, 0.1); | ||||
|     --apple-today-bg: rgba(0, 105, 148, 0.15); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="forest"] { | ||||
|     --apple-bg-tertiary: rgba(232, 245, 232, 0.8); | ||||
|     --apple-bg-sidebar: rgba(232, 245, 232, 0.7); | ||||
|     --apple-accent-bg: rgba(6, 95, 70, 0.1); | ||||
|     --apple-today-bg: rgba(6, 95, 70, 0.15); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="sunset"] { | ||||
|     --apple-bg-tertiary: rgba(255, 243, 224, 0.8); | ||||
|     --apple-bg-sidebar: rgba(255, 243, 224, 0.7); | ||||
|     --apple-accent-bg: rgba(234, 88, 12, 0.1); | ||||
|     --apple-today-bg: rgba(234, 88, 12, 0.15); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="purple"] { | ||||
|     --apple-bg-tertiary: rgba(243, 229, 245, 0.8); | ||||
|     --apple-bg-sidebar: rgba(243, 229, 245, 0.7); | ||||
|     --apple-accent-bg: rgba(124, 58, 237, 0.1); | ||||
|     --apple-today-bg: rgba(124, 58, 237, 0.15); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="dark"] { | ||||
|     --apple-bg-tertiary: rgba(31, 41, 55, 0.9); | ||||
|     --apple-bg-sidebar: rgba(44, 44, 46, 0.8); | ||||
|     --apple-accent-bg: rgba(55, 65, 81, 0.3); | ||||
|     --apple-today-bg: rgba(55, 65, 81, 0.4); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="rose"] { | ||||
|     --apple-bg-tertiary: rgba(252, 228, 236, 0.8); | ||||
|     --apple-bg-sidebar: rgba(252, 228, 236, 0.7); | ||||
|     --apple-accent-bg: rgba(225, 29, 72, 0.1); | ||||
|     --apple-today-bg: rgba(225, 29, 72, 0.15); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="mint"] { | ||||
|     --apple-bg-tertiary: rgba(224, 242, 241, 0.8); | ||||
|     --apple-bg-sidebar: rgba(224, 242, 241, 0.7); | ||||
|     --apple-accent-bg: rgba(16, 185, 129, 0.1); | ||||
|     --apple-today-bg: rgba(16, 185, 129, 0.15); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="midnight"] { | ||||
|     --apple-bg-tertiary: rgba(21, 27, 38, 0.9); | ||||
|     --apple-bg-sidebar: rgba(21, 27, 38, 0.8); | ||||
|     --apple-accent-bg: rgba(76, 154, 255, 0.15); | ||||
|     --apple-today-bg: rgba(76, 154, 255, 0.2); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="charcoal"] { | ||||
|     --apple-bg-tertiary: rgba(26, 26, 26, 0.9); | ||||
|     --apple-bg-sidebar: rgba(26, 26, 26, 0.8); | ||||
|     --apple-accent-bg: rgba(74, 222, 128, 0.15); | ||||
|     --apple-today-bg: rgba(74, 222, 128, 0.2); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="nord"] { | ||||
|     --apple-bg-tertiary: rgba(59, 66, 82, 0.9); | ||||
|     --apple-bg-sidebar: rgba(59, 66, 82, 0.8); | ||||
|     --apple-accent-bg: rgba(136, 192, 208, 0.15); | ||||
|     --apple-today-bg: rgba(136, 192, 208, 0.2); | ||||
| } | ||||
|  | ||||
| [data-style="apple"][data-theme="dracula"] { | ||||
|     --apple-bg-tertiary: rgba(68, 71, 90, 0.9); | ||||
|     --apple-bg-sidebar: rgba(68, 71, 90, 0.8); | ||||
|     --apple-accent-bg: rgba(189, 147, 249, 0.15); | ||||
|     --apple-today-bg: rgba(189, 147, 249, 0.2); | ||||
| } | ||||
|  | ||||
| /* Apple-style body and base styles */ | ||||
| [data-style="apple"] body { | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     background: var(--apple-bg-secondary); | ||||
|     color: var(--apple-text-primary); | ||||
|     font-weight: 400; | ||||
|     line-height: 1.47; | ||||
|     letter-spacing: -0.022em; | ||||
| } | ||||
|  | ||||
| /* Apple-style sidebar with glassmorphism */ | ||||
| [data-style="apple"] .app-sidebar { | ||||
|     background: var(--apple-bg-sidebar); | ||||
|     backdrop-filter: blur(20px); | ||||
|     -webkit-backdrop-filter: blur(20px); | ||||
|     border-right: 1px solid var(--apple-border-primary); | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     box-shadow: none; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .sidebar-header { | ||||
|     background: transparent; | ||||
|     border-bottom: 1px solid var(--apple-border-primary); | ||||
|     padding: 20px 16px 16px 16px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .sidebar-header h1 { | ||||
|     font-size: 24px; | ||||
|     font-weight: 700; | ||||
|     color: var(--apple-text-primary); | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     letter-spacing: -0.04em; | ||||
|     margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .user-info { | ||||
|     color: var(--apple-text-primary); | ||||
|     font-size: 15px; | ||||
|     line-height: 1.4; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .user-info .username { | ||||
|     font-weight: 600; | ||||
|     color: var(--apple-text-primary); | ||||
|     font-size: 16px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .user-info .server-url { | ||||
|     color: var(--apple-text-secondary); | ||||
|     font-size: 13px; | ||||
|     font-weight: 400; | ||||
| } | ||||
|  | ||||
| /* Apple-style buttons */ | ||||
| [data-style="apple"] .create-calendar-button { | ||||
|     background: var(--apple-accent); | ||||
|     color: var(--apple-text-inverse); | ||||
|     border: none; | ||||
|     border-radius: 8px; | ||||
|     padding: 10px 16px; | ||||
|     font-weight: 600; | ||||
|     font-size: 15px; | ||||
|     cursor: pointer; | ||||
|     box-shadow: var(--shadow-sm); | ||||
|     transition: all 0.2s ease; | ||||
|     font-family: inherit; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .create-calendar-button:hover { | ||||
|     transform: translateY(-1px); | ||||
|     box-shadow: var(--shadow-md); | ||||
|     background: var(--apple-accent); | ||||
|     filter: brightness(1.1); | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .logout-button { | ||||
|     background: var(--apple-bg-primary); | ||||
|     color: var(--apple-accent); | ||||
|     border: 1px solid var(--apple-border-primary); | ||||
|     border-radius: 8px; | ||||
|     padding: 8px 16px; | ||||
|     font-weight: 500; | ||||
|     font-size: 15px; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     font-family: inherit; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .logout-button:hover { | ||||
|     background: var(--apple-hover-bg); | ||||
|     transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| /* Apple-style navigation */ | ||||
| [data-style="apple"] .sidebar-nav .nav-link { | ||||
|     color: var(--apple-text-primary); | ||||
|     text-decoration: none; | ||||
|     padding: 8px 12px; | ||||
|     border-radius: 8px; | ||||
|     transition: all 0.2s ease; | ||||
|     display: block; | ||||
|     font-weight: 500; | ||||
|     font-size: 15px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .sidebar-nav .nav-link:hover { | ||||
|     color: var(--apple-accent); | ||||
|     background: var(--apple-hover-bg); | ||||
|     transform: translateX(2px); | ||||
| } | ||||
|  | ||||
| /* Apple-style calendar list */ | ||||
| [data-style="apple"] .calendar-list h3 { | ||||
|     color: var(--apple-text-primary); | ||||
|     font-size: 17px; | ||||
|     font-weight: 600; | ||||
|     letter-spacing: -0.024em; | ||||
|     margin-bottom: 12px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .calendar-list .calendar-name { | ||||
|     color: var(--apple-text-primary); | ||||
|     font-size: 15px; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .no-calendars { | ||||
|     color: var(--apple-text-secondary); | ||||
|     font-size: 14px; | ||||
|     font-style: italic; | ||||
| } | ||||
|  | ||||
| /* Apple-style form elements */ | ||||
| [data-style="apple"] .sidebar-footer label, | ||||
| [data-style="apple"] .view-selector label, | ||||
| [data-style="apple"] .theme-selector label, | ||||
| [data-style="apple"] .style-selector label { | ||||
|     color: var(--apple-text-primary); | ||||
|     font-size: 14px; | ||||
|     font-weight: 600; | ||||
|     margin-bottom: 6px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .view-selector-dropdown, | ||||
| [data-style="apple"] .theme-selector-dropdown, | ||||
| [data-style="apple"] .style-selector-dropdown { | ||||
|     border: 1px solid var(--apple-border-primary); | ||||
|     border-radius: 8px; | ||||
|     padding: 8px 12px; | ||||
|     font-size: 15px; | ||||
|     color: var(--apple-text-primary); | ||||
|     background: var(--apple-bg-primary); | ||||
|     font-family: inherit; | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .view-selector-dropdown:focus, | ||||
| [data-style="apple"] .theme-selector-dropdown:focus, | ||||
| [data-style="apple"] .style-selector-dropdown:focus { | ||||
|     outline: none; | ||||
|     border-color: var(--apple-accent); | ||||
|     box-shadow: 0 0 0 3px var(--apple-accent-bg); | ||||
| } | ||||
|  | ||||
| /* Apple-style calendar list items */ | ||||
| [data-style="apple"] .calendar-list .calendar-item { | ||||
|     padding: 6px 8px; | ||||
|     border-radius: 8px; | ||||
|     transition: all 0.2s ease; | ||||
|     margin-bottom: 2px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .calendar-list .calendar-item:hover { | ||||
|     background-color: var(--apple-hover-bg); | ||||
|     transform: translateX(2px); | ||||
| } | ||||
|  | ||||
| /* Apple-style main content area */ | ||||
| [data-style="apple"] .app-main { | ||||
|     background: var(--apple-bg-secondary); | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     color: var(--apple-text-primary); | ||||
| } | ||||
|  | ||||
| /* Apple-style calendar header */ | ||||
| [data-style="apple"] .calendar-header { | ||||
|     background: var(--apple-bg-primary); | ||||
|     color: var(--apple-text-primary); | ||||
|     padding: 20px 24px; | ||||
|     border-radius: 16px 16px 0 0; | ||||
|     box-shadow: var(--shadow-sm); | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .calendar-header h2, | ||||
| [data-style="apple"] .calendar-header h3, | ||||
| [data-style="apple"] .month-header, | ||||
| [data-style="apple"] .week-header { | ||||
|     color: var(--apple-text-primary); | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     font-weight: 700; | ||||
|     letter-spacing: -0.04em; | ||||
| } | ||||
|  | ||||
| /* Apple-style headings */ | ||||
| [data-style="apple"] h1, | ||||
| [data-style="apple"] h2,  | ||||
| [data-style="apple"] h3, | ||||
| [data-style="apple"] .month-title, | ||||
| [data-style="apple"] .calendar-title, | ||||
| [data-style="apple"] .current-month, | ||||
| [data-style="apple"] .month-year, | ||||
| [data-style="apple"] .header-title { | ||||
|     color: var(--apple-text-primary); | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     font-weight: 700; | ||||
|     letter-spacing: -0.04em; | ||||
| } | ||||
|  | ||||
| /* Apple-style navigation buttons */ | ||||
| [data-style="apple"] button, | ||||
| [data-style="apple"] .nav-button, | ||||
| [data-style="apple"] .calendar-nav-button, | ||||
| [data-style="apple"] .prev-button, | ||||
| [data-style="apple"] .next-button, | ||||
| [data-style="apple"] .arrow-button { | ||||
|     color: var(--apple-text-primary); | ||||
|     background: var(--apple-bg-primary); | ||||
|     border: 1px solid var(--apple-border-primary); | ||||
|     border-radius: 8px; | ||||
|     padding: 8px 12px; | ||||
|     font-weight: 600; | ||||
|     transition: all 0.2s ease; | ||||
|     font-family: inherit; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] button:hover, | ||||
| [data-style="apple"] .nav-button:hover, | ||||
| [data-style="apple"] .calendar-nav-button:hover, | ||||
| [data-style="apple"] .prev-button:hover, | ||||
| [data-style="apple"] .next-button:hover, | ||||
| [data-style="apple"] .arrow-button:hover { | ||||
|     background: var(--apple-accent-bg); | ||||
|     color: var(--apple-accent); | ||||
|     border-color: var(--apple-accent); | ||||
|     transform: translateY(-1px); | ||||
|     box-shadow: var(--shadow-sm); | ||||
| } | ||||
|  | ||||
| /* Apple-style calendar controls */ | ||||
| [data-style="apple"] .calendar-controls, | ||||
| [data-style="apple"] .current-date, | ||||
| [data-style="apple"] .date-display { | ||||
|     color: var(--apple-text-primary); | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| /* Apple-style calendar grid */ | ||||
| [data-style="apple"] .calendar-grid, | ||||
| [data-style="apple"] .calendar-container { | ||||
|     border: 1px solid var(--apple-border-primary); | ||||
|     border-radius: 16px; | ||||
|     overflow: hidden; | ||||
|     background: var(--apple-bg-primary); | ||||
|     box-shadow: var(--shadow-lg); | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     margin: 16px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .month-header, | ||||
| [data-style="apple"] .week-header { | ||||
|     font-size: 28px; | ||||
|     font-weight: 700; | ||||
|     color: var(--apple-text-primary); | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     letter-spacing: -0.04em; | ||||
| } | ||||
|  | ||||
| /* Apple-style calendar cells */ | ||||
| [data-style="apple"] .calendar-day, | ||||
| [data-style="apple"] .day-cell { | ||||
|     border: 1px solid var(--apple-border-secondary); | ||||
|     background: var(--apple-bg-primary); | ||||
|     transition: all 0.3s ease; | ||||
|     padding: 12px; | ||||
|     min-height: 120px; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .calendar-day:hover, | ||||
| [data-style="apple"] .day-cell:hover { | ||||
|     background: var(--apple-hover-bg); | ||||
|     transform: scale(1.02); | ||||
|     box-shadow: var(--shadow-sm); | ||||
|     z-index: 10; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .calendar-day.today, | ||||
| [data-style="apple"] .day-cell.today { | ||||
|     background: var(--apple-today-bg); | ||||
|     border-color: var(--apple-today-accent); | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .calendar-day.today::before, | ||||
| [data-style="apple"] .day-cell.today::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     height: 3px; | ||||
|     background: var(--apple-today-accent); | ||||
|     border-radius: 2px 2px 0 0; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .calendar-day.other-month, | ||||
| [data-style="apple"] .day-cell.other-month { | ||||
|     background: var(--apple-bg-secondary); | ||||
|     color: var(--apple-text-secondary); | ||||
|     opacity: 0.6; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .day-number, | ||||
| [data-style="apple"] .date-number { | ||||
|     font-size: 16px; | ||||
|     font-weight: 600; | ||||
|     color: var(--apple-text-primary); | ||||
|     margin-bottom: 6px; | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
| } | ||||
|  | ||||
| /* Apple-style day headers */ | ||||
| [data-style="apple"] .day-header, | ||||
| [data-style="apple"] .weekday-header { | ||||
|     background: var(--apple-bg-secondary); | ||||
|     color: var(--apple-text-secondary); | ||||
|     font-size: 13px; | ||||
|     font-weight: 600; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
|     padding: 12px; | ||||
|     border-bottom: 1px solid var(--apple-border-primary); | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
| } | ||||
|  | ||||
| /* Apple Calendar-style events */ | ||||
| [data-style="apple"] .event { | ||||
|     border-radius: 6px; | ||||
|     padding: 4px 8px; | ||||
|     font-size: 12px; | ||||
|     font-weight: 500; | ||||
|     margin: 2px 0; | ||||
|     cursor: pointer; | ||||
|     border: none; | ||||
|     color: white; | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     box-shadow: var(--shadow-sm); | ||||
|     transition: all 0.2s ease; | ||||
|     display: block; | ||||
|     text-overflow: ellipsis; | ||||
|     overflow: hidden; | ||||
|     white-space: nowrap; | ||||
|     line-height: 1.3; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .event::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     left: 0; | ||||
|     top: 0; | ||||
|     bottom: 0; | ||||
|     width: 3px; | ||||
|     background: rgba(255, 255, 255, 0.8); | ||||
|     border-radius: 2px 0 0 2px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .event * { | ||||
|     color: white; | ||||
|     font-family: inherit; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .event:hover { | ||||
|     transform: translateY(-1px) scale(1.02); | ||||
|     box-shadow: var(--shadow-md); | ||||
| } | ||||
|  | ||||
| /* All-day events styling */ | ||||
| [data-style="apple"] .event.all-day { | ||||
|     border-radius: 16px; | ||||
|     padding: 6px 12px; | ||||
|     font-weight: 600; | ||||
|     margin: 3px 0; | ||||
|     font-size: 13px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .event.all-day::before { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| /* Event time display */ | ||||
| [data-style="apple"] .event-time { | ||||
|     opacity: 0.9; | ||||
|     font-size: 11px; | ||||
|     margin-right: 4px; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| /* Calendar table structure */ | ||||
| [data-style="apple"] .calendar-table, | ||||
| [data-style="apple"] table { | ||||
|     border-collapse: separate; | ||||
|     border-spacing: 0; | ||||
|     width: 100%; | ||||
|     background: var(--apple-bg-primary); | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .calendar-table td, | ||||
| [data-style="apple"] table td { | ||||
|     vertical-align: top; | ||||
|     border: 1px solid var(--apple-border-secondary); | ||||
|     background: var(--apple-bg-primary); | ||||
| } | ||||
|  | ||||
| /* Apple-style view toggle */ | ||||
| [data-style="apple"] .view-toggle { | ||||
|     display: flex; | ||||
|     gap: 0; | ||||
|     background: var(--apple-bg-primary); | ||||
|     border-radius: 10px; | ||||
|     padding: 2px; | ||||
|     box-shadow: var(--shadow-sm); | ||||
|     border: 1px solid var(--apple-border-primary); | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .view-toggle button { | ||||
|     padding: 8px 16px; | ||||
|     border: none; | ||||
|     background: transparent; | ||||
|     color: var(--apple-text-secondary); | ||||
|     border-radius: 8px; | ||||
|     font-size: 15px; | ||||
|     font-weight: 600; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     font-family: inherit; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .view-toggle button.active { | ||||
|     background: var(--apple-accent); | ||||
|     color: var(--apple-text-inverse); | ||||
|     box-shadow: var(--shadow-sm); | ||||
|     transform: scale(1.02); | ||||
| } | ||||
|  | ||||
| /* Apple-style today button */ | ||||
| [data-style="apple"] .today-button { | ||||
|     background: var(--apple-accent); | ||||
|     border: none; | ||||
|     color: var(--apple-text-inverse); | ||||
|     padding: 10px 16px; | ||||
|     border-radius: 10px; | ||||
|     font-weight: 600; | ||||
|     font-size: 15px; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     box-shadow: var(--shadow-sm); | ||||
|     font-family: inherit; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .today-button:hover { | ||||
|     transform: translateY(-1px); | ||||
|     box-shadow: var(--shadow-md); | ||||
|     filter: brightness(1.1); | ||||
| } | ||||
|  | ||||
| /* Apple-style modals */ | ||||
| [data-style="apple"] .modal-overlay { | ||||
|     background: rgba(0, 0, 0, 0.4); | ||||
|     backdrop-filter: blur(8px); | ||||
|     -webkit-backdrop-filter: blur(8px); | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .modal-content { | ||||
|     background: var(--apple-bg-primary); | ||||
|     border-radius: 16px; | ||||
|     box-shadow: var(--shadow-lg); | ||||
|     border: 1px solid var(--apple-border-primary); | ||||
|     color: var(--apple-text-primary); | ||||
|     backdrop-filter: blur(20px); | ||||
|     -webkit-backdrop-filter: blur(20px); | ||||
| } | ||||
|  | ||||
| [data-style="apple"] .modal h2 { | ||||
|     font-size: 22px; | ||||
|     font-weight: 700; | ||||
|     color: var(--apple-text-primary); | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||
|     letter-spacing: -0.04em; | ||||
| } | ||||
|  | ||||
| /* Apple-style form inputs */ | ||||
| [data-style="apple"] input[type="text"], | ||||
| [data-style="apple"] input[type="email"], | ||||
| [data-style="apple"] input[type="password"], | ||||
| [data-style="apple"] input[type="url"], | ||||
| [data-style="apple"] input[type="date"], | ||||
| [data-style="apple"] input[type="time"], | ||||
| [data-style="apple"] textarea, | ||||
| [data-style="apple"] select { | ||||
|     border: 1px solid var(--apple-border-primary); | ||||
|     border-radius: 8px; | ||||
|     padding: 10px 12px; | ||||
|     font-size: 15px; | ||||
|     color: var(--apple-text-primary); | ||||
|     background: var(--apple-bg-primary); | ||||
|     font-family: inherit; | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] input:focus, | ||||
| [data-style="apple"] textarea:focus, | ||||
| [data-style="apple"] select:focus { | ||||
|     outline: none; | ||||
|     border-color: var(--apple-accent); | ||||
|     box-shadow: 0 0 0 3px var(--apple-accent-bg); | ||||
|     transform: scale(1.02); | ||||
| } | ||||
|  | ||||
| /* Apple-style labels */ | ||||
| [data-style="apple"] label { | ||||
|     font-size: 15px; | ||||
|     font-weight: 600; | ||||
|     color: var(--apple-text-primary); | ||||
|     margin-bottom: 6px; | ||||
|     display: block; | ||||
|     letter-spacing: -0.01em; | ||||
| } | ||||
|  | ||||
| /* Smooth animations and transitions */ | ||||
| [data-style="apple"] * { | ||||
|     transition-timing-function: cubic-bezier(0.4, 0.0, 0.2, 1); | ||||
| } | ||||
|  | ||||
| /* Custom scrollbar for Apple style */ | ||||
| [data-style="apple"] ::-webkit-scrollbar { | ||||
|     width: 8px; | ||||
|     height: 8px; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] ::-webkit-scrollbar-track { | ||||
|     background: transparent; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] ::-webkit-scrollbar-thumb { | ||||
|     background: var(--apple-text-secondary); | ||||
|     border-radius: 4px; | ||||
|     opacity: 0.3; | ||||
| } | ||||
|  | ||||
| [data-style="apple"] ::-webkit-scrollbar-thumb:hover { | ||||
|     opacity: 0.6; | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user