Compare commits
	
		
			14 Commits
		
	
	
		
			5c406569af
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 933d7a8c1b | ||
|   | c938f25951 | ||
|   | c612f567b4 | ||
|   | b5b53bb23a | ||
|   | 7e058ba972 | ||
|   | 1f86ea9f71 | ||
|   | ce9914e388 | ||
|   | faf5ce2cfd | ||
|   | 2fee7a15f9 | ||
|   | 7caf3539f7 | ||
|   | 1538869f4a | ||
|   | 7ce7d4c9d9 | ||
|   | 037b733d48 | ||
|   | cb1bb23132 | 
| @@ -16,7 +16,7 @@ use crate::{ | |||||||
|     AppState, |     AppState, | ||||||
| }; | }; | ||||||
| use calendar_models::{ | 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}; | use super::auth::{extract_bearer_token, extract_password_header}; | ||||||
| @@ -456,14 +456,11 @@ pub async fn create_event( | |||||||
|         parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) |         parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) | ||||||
|             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; |             .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)))?; |         .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 |     // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events | ||||||
|     // RFC-5545 uses exclusive end dates for all-day events |     // No additional conversion needed here | ||||||
|     if request.all_day { |  | ||||||
|         end_datetime = end_datetime + chrono::Duration::days(1); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Validate that end is after start (allow equal times for all-day events) |     // Validate that end is after start (allow equal times for all-day events) | ||||||
|     if request.all_day { |     if request.all_day { | ||||||
| @@ -525,19 +522,8 @@ pub async fn create_event( | |||||||
|             .collect() |             .collect() | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Parse alarms - convert from minutes string to EventReminder structs |     // Use VAlarms directly from request (no conversion needed) | ||||||
|     let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() { |     let alarms = request.alarms; | ||||||
|         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(), |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     // Check if recurrence is already a full RRULE or just a simple type |     // Check if recurrence is already a full RRULE or just a simple type | ||||||
|     let rrule = if request.recurrence.starts_with("FREQ=") { |     let rrule = if request.recurrence.starts_with("FREQ=") { | ||||||
| @@ -648,21 +634,7 @@ pub async fn create_event( | |||||||
|     event.categories = categories; |     event.categories = categories; | ||||||
|     event.rrule = rrule; |     event.rrule = rrule; | ||||||
|     event.all_day = request.all_day; |     event.all_day = request.all_day; | ||||||
|     event.alarms = alarms |     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.calendar_path = Some(calendar_path.clone()); |     event.calendar_path = Some(calendar_path.clone()); | ||||||
|  |  | ||||||
|     // Create the event on the CalDAV server |     // Create the event on the CalDAV server | ||||||
| @@ -766,14 +738,11 @@ pub async fn update_event( | |||||||
|         parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) |         parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) | ||||||
|             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; |             .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)))?; |         .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 |     // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events | ||||||
|     // RFC-5545 uses exclusive end dates for all-day events |     // No additional conversion needed here | ||||||
|     if request.all_day { |  | ||||||
|         end_datetime = end_datetime + chrono::Duration::days(1); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Validate that end is after start (allow equal times for all-day events) |     // Validate that end is after start (allow equal times for all-day events) | ||||||
|     if request.all_day { |     if request.all_day { | ||||||
|   | |||||||
| @@ -485,7 +485,6 @@ fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<Date | |||||||
| fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||||
|     use std::collections::HashMap; |     use std::collections::HashMap; | ||||||
|      |      | ||||||
|     let original_count = events.len(); |  | ||||||
|      |      | ||||||
|     // First pass: Group by UID and prefer recurring events over single events with same UID |     // 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(); |     let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new(); | ||||||
| @@ -547,13 +546,12 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | |||||||
|      |      | ||||||
|     let mut deduplicated_recurring = Vec::new(); |     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 { |         if events_with_title.len() == 1 { | ||||||
|             // Single event with this title, keep as-is |             // Single event with this title, keep as-is | ||||||
|             deduplicated_recurring.push(events_with_title.into_iter().next().unwrap()); |             deduplicated_recurring.push(events_with_title.into_iter().next().unwrap()); | ||||||
|         } else { |         } else { | ||||||
|             // Multiple events with same title - consolidate or deduplicate |             // 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 |             // Check if these are actually different recurring patterns for the same logical event | ||||||
|             let consolidated = consolidate_same_title_events(events_with_title); |             let consolidated = consolidate_same_title_events(events_with_title); | ||||||
| @@ -580,15 +578,9 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | |||||||
|             let existing_completeness = event_completeness_score(existing_event); |             let existing_completeness = event_completeness_score(existing_event); | ||||||
|              |              | ||||||
|             if current_completeness > existing_completeness { |             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; |                 deduplicated_single[existing_index] = event; | ||||||
|             } else { |             } else { | ||||||
|                 println!("🚫 Discarding duplicate single event: Keeping existing '{}'", |                 // Discarding duplicate single event - keeping existing | ||||||
|                     existing_event.summary.as_ref().unwrap_or(&"No Title".to_string()) |  | ||||||
|                 ); |  | ||||||
|             } |             } | ||||||
|             continue; |             continue; | ||||||
|         } |         } | ||||||
| @@ -608,10 +600,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | |||||||
|         }); |         }); | ||||||
|          |          | ||||||
|         if is_rrule_generated { |         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 { |         } else { | ||||||
|             // This is a unique single event |             // This is a unique single event | ||||||
|             seen_single.insert(dedup_key, deduplicated_single.len()); |             seen_single.insert(dedup_key, deduplicated_single.len()); | ||||||
| @@ -623,11 +611,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | |||||||
|     let mut result = deduplicated_recurring; |     let mut result = deduplicated_recurring; | ||||||
|     result.extend(deduplicated_single); |     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 |     result | ||||||
| } | } | ||||||
| @@ -653,14 +636,6 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | |||||||
|     } |     } | ||||||
|      |      | ||||||
|     // Log the RRULEs we're working with |     // 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 |     // Check if all events have similar time patterns and could be consolidated | ||||||
|     let first_event = &events[0]; |     let first_event = &events[0]; | ||||||
| @@ -683,7 +658,6 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | |||||||
|     }); |     }); | ||||||
|      |      | ||||||
|     if !can_consolidate { |     if !can_consolidate { | ||||||
|         println!("🚫 Cannot consolidate events - different times or durations"); |  | ||||||
|         // Just deduplicate exact duplicates |         // Just deduplicate exact duplicates | ||||||
|         return deduplicate_exact_recurring_events(events); |         return deduplicate_exact_recurring_events(events); | ||||||
|     } |     } | ||||||
| @@ -696,13 +670,11 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | |||||||
|     if weekly_events.len() >= 2 && weekly_events.len() == events.len() { |     if weekly_events.len() >= 2 && weekly_events.len() == events.len() { | ||||||
|         // All events are weekly - try to consolidate into a single multi-day weekly pattern |         // All events are weekly - try to consolidate into a single multi-day weekly pattern | ||||||
|         if let Some(consolidated) = consolidate_weekly_patterns(&events) { |         if let Some(consolidated) = consolidate_weekly_patterns(&events) { | ||||||
|             println!("✅ Successfully consolidated {} weekly patterns into one", events.len()); |  | ||||||
|             return vec![consolidated]; |             return vec![consolidated]; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     // If we can't consolidate, just deduplicate exact matches and keep the most complete one |     // 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); |     let deduplicated = deduplicate_exact_recurring_events(events); | ||||||
|      |      | ||||||
|     // If we still have multiple events, keep only the most complete one |     // 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) |     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)))?; |         .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)))?; |         .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 |     // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events | ||||||
|     if request.all_day { |     // No additional conversion needed here | ||||||
|         end_datetime = end_datetime + chrono::Duration::days(1); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Generate a unique UID for the series |     // Generate a unique UID for the series | ||||||
|     let uid = format!("series-{}", uuid::Uuid::new_v4().to_string()); |     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 |     // Update the event on the CalDAV server using the original event's href | ||||||
|     println!("📤 Updating event on CalDAV server..."); |  | ||||||
|     let event_href = existing_event |     let event_href = existing_event | ||||||
|         .href |         .href | ||||||
|         .as_ref() |         .as_ref() | ||||||
|         .ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?; |         .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 |     match client | ||||||
|         .update_event(&calendar_path, &updated_event, event_href) |         .update_event(&calendar_path, &updated_event, event_href) | ||||||
| @@ -1028,7 +1023,7 @@ async fn update_single_occurrence( | |||||||
|  |  | ||||||
|     println!("✅ Created exception event successfully"); |     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) |     Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception) | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ use axum::{ | |||||||
|     response::{IntoResponse, Response}, |     response::{IntoResponse, Response}, | ||||||
|     Json, |     Json, | ||||||
| }; | }; | ||||||
|  | use calendar_models::VAlarm; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
|  |  | ||||||
| // API request/response types | // API request/response types | ||||||
| @@ -113,7 +114,7 @@ pub struct CreateEventRequest { | |||||||
|     pub organizer: String,             // organizer email |     pub organizer: String,             // organizer email | ||||||
|     pub attendees: String,             // comma-separated attendee emails |     pub attendees: String,             // comma-separated attendee emails | ||||||
|     pub categories: String,            // comma-separated categories |     pub categories: String,            // comma-separated categories | ||||||
|     pub reminder: String,              // reminder type |     pub alarms: Vec<VAlarm>,           // event alarms | ||||||
|     pub recurrence: String,            // recurrence type |     pub recurrence: String,            // recurrence type | ||||||
|     pub recurrence_days: Vec<bool>,    // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence |     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 |     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 organizer: String,             // organizer email | ||||||
|     pub attendees: String,             // comma-separated attendee emails |     pub attendees: String,             // comma-separated attendee emails | ||||||
|     pub categories: String,            // comma-separated categories |     pub categories: String,            // comma-separated categories | ||||||
|     pub reminder: String,              // reminder type |     pub alarms: Vec<VAlarm>,           // event alarms | ||||||
|     pub recurrence: String,            // recurrence type |     pub recurrence: String,            // recurrence type | ||||||
|     pub recurrence_days: Vec<bool>,    // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence |     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 |     pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years | ||||||
| @@ -181,7 +182,7 @@ pub struct CreateEventSeriesRequest { | |||||||
|     pub organizer: String,    // organizer email |     pub organizer: String,    // organizer email | ||||||
|     pub attendees: String,    // comma-separated attendee emails |     pub attendees: String,    // comma-separated attendee emails | ||||||
|     pub categories: String,   // comma-separated categories |     pub categories: String,   // comma-separated categories | ||||||
|     pub reminder: String,     // reminder type |     pub alarms: Vec<VAlarm>,  // event alarms | ||||||
|  |  | ||||||
|     // Series-specific fields |     // Series-specific fields | ||||||
|     pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) |     pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) | ||||||
| @@ -219,7 +220,7 @@ pub struct UpdateEventSeriesRequest { | |||||||
|     pub organizer: String,    // organizer email |     pub organizer: String,    // organizer email | ||||||
|     pub attendees: String,    // comma-separated attendee emails |     pub attendees: String,    // comma-separated attendee emails | ||||||
|     pub categories: String,   // comma-separated categories |     pub categories: String,   // comma-separated categories | ||||||
|     pub reminder: String,     // reminder type |     pub alarms: Vec<VAlarm>,  // event alarms | ||||||
|  |  | ||||||
|     // Series-specific fields |     // Series-specific fields | ||||||
|     pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) |     pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) | ||||||
|   | |||||||
| @@ -32,6 +32,23 @@ web-sys = { version = "0.3", features = [ | |||||||
|     "CssStyleDeclaration", |     "CssStyleDeclaration", | ||||||
|     "MediaQueryList", |     "MediaQueryList", | ||||||
|     "MediaQueryListEvent", |     "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" | wasm-bindgen = "0.2" | ||||||
| js-sys = "0.3" | js-sys = "0.3" | ||||||
| @@ -73,3 +90,6 @@ gloo-storage = "0.3" | |||||||
| gloo-timers = "0.3" | gloo-timers = "0.3" | ||||||
| wasm-bindgen-futures = "0.4" | 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="styles.css"> | ||||||
|     <link data-trunk rel="css" href="print-preview.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/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 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" /> |     <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> | </head> | ||||||
| <body> | <body> | ||||||
|     <script> |     <script> | ||||||
|         console.log("HTML fully loaded, waiting for WASM..."); |  | ||||||
|         window.addEventListener('TrunkApplicationStarted', () => { |         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> |     </script> | ||||||
| </body> | </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
| @@ -212,6 +212,7 @@ | |||||||
|     display: none !important; |     display: none !important; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| /* Remove today highlighting in preview */ | /* Remove today highlighting in preview */ | ||||||
| .print-preview-paper .calendar-day.today, | .print-preview-paper .calendar-day.today, | ||||||
| .print-preview-paper .week-day-header.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('/'); | ||||||
|  |         }) | ||||||
|  |     ); | ||||||
|  | }); | ||||||
| @@ -6,7 +6,7 @@ use crate::components::{ | |||||||
| use crate::components::mobile_warning_modal::is_mobile_device; | use crate::components::mobile_warning_modal::is_mobile_device; | ||||||
| use crate::components::sidebar::{Style}; | use crate::components::sidebar::{Style}; | ||||||
| use crate::models::ical::VEvent; | 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 chrono::NaiveDate; | ||||||
| use gloo_storage::{LocalStorage, Storage}; | use gloo_storage::{LocalStorage, Storage}; | ||||||
| use gloo_timers::callback::Interval; | use gloo_timers::callback::Interval; | ||||||
| @@ -148,6 +148,11 @@ pub fn App() -> Html { | |||||||
|     // Mobile warning state |     // Mobile warning state | ||||||
|     let mobile_warning_open = use_state(|| is_mobile_device()); |     let mobile_warning_open = use_state(|| is_mobile_device()); | ||||||
|     let refresh_interval = use_state(|| -> Option<Interval> { None }); |     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 |     // Calendar view state - load from localStorage if available | ||||||
|     let current_view = use_state(|| { |     let current_view = use_state(|| { | ||||||
| @@ -192,6 +197,67 @@ pub fn App() -> Html { | |||||||
|         }) |         }) | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|  |     // 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 |     // Function to refresh calendar data without full page reload | ||||||
|     let refresh_calendar_data = { |     let refresh_calendar_data = { | ||||||
|         let user_info = user_info.clone(); |         let user_info = user_info.clone(); | ||||||
| @@ -229,7 +295,21 @@ pub fn App() -> Html { | |||||||
|                     if !password.is_empty() { |                     if !password.is_empty() { | ||||||
|                         match calendar_service.fetch_user_info(&token, &password).await { |                         match calendar_service.fetch_user_info(&token, &password).await { | ||||||
|                             Ok(mut info) => { |                             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) = |                                 if let Ok(saved_colors_json) = | ||||||
|                                     LocalStorage::get::<String>("calendar_colors") |                                     LocalStorage::get::<String>("calendar_colors") | ||||||
|                                 { |                                 { | ||||||
| @@ -238,13 +318,15 @@ pub fn App() -> Html { | |||||||
|                                     { |                                     { | ||||||
|                                         for saved_cal in &saved_info.calendars { |                                         for saved_cal in &saved_info.calendars { | ||||||
|                                             for cal in &mut 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(); |                                                     cal.color = saved_cal.color.clone(); | ||||||
|                                                 } |                                                 } | ||||||
|                                             } |                                             } | ||||||
|                                         } |                                         } | ||||||
|                                     } |                                     } | ||||||
|                                 } |                                 } | ||||||
|  |                                  | ||||||
|                                 // Add timestamp to force re-render |                                 // Add timestamp to force re-render | ||||||
|                                 info.last_updated = (js_sys::Date::now() / 1000.0) as u64; |                                 info.last_updated = (js_sys::Date::now() / 1000.0) as u64; | ||||||
|                                 user_info.set(Some(info)); |                                 user_info.set(Some(info)); | ||||||
| @@ -431,6 +513,11 @@ pub fn App() -> Html { | |||||||
|             // Hot-swap stylesheet |             // Hot-swap stylesheet | ||||||
|             if let Some(window) = web_sys::window() { |             if let Some(window) = web_sys::window() { | ||||||
|                 if let Some(document) = window.document() { |                 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 |                     // Remove existing style link if it exists | ||||||
|                     if let Some(existing_link) = document.get_element_by_id("dynamic-style") { |                     if let Some(existing_link) = document.get_element_by_id("dynamic-style") { | ||||||
|                         existing_link.remove(); |                         existing_link.remove(); | ||||||
| @@ -478,6 +565,11 @@ pub fn App() -> Html { | |||||||
|             let style = (*current_style).clone(); |             let style = (*current_style).clone(); | ||||||
|             if let Some(window) = web_sys::window() { |             if let Some(window) = web_sys::window() { | ||||||
|                 if let Some(document) = window.document() { |                 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 |                     // Create and append stylesheet link for initial style only if it has a path | ||||||
|                     if let Some(stylesheet_path) = style.stylesheet_path() { |                     if let Some(stylesheet_path) = style.stylesheet_path() { | ||||||
|                         if let Ok(link) = document.create_element("link") { |                         if let Ok(link) = document.create_element("link") { | ||||||
| @@ -497,6 +589,7 @@ pub fn App() -> Html { | |||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|     // Fetch user info when token is available |     // Fetch user info when token is available | ||||||
|     { |     { | ||||||
|         let user_info = user_info.clone(); |         let user_info = user_info.clone(); | ||||||
| @@ -786,7 +879,9 @@ pub fn App() -> Html { | |||||||
|         let create_event_modal_open = create_event_modal_open.clone(); |         let create_event_modal_open = create_event_modal_open.clone(); | ||||||
|         let auth_token = auth_token.clone(); |         let auth_token = auth_token.clone(); | ||||||
|         let refresh_calendar_data = refresh_calendar_data.clone(); |         let refresh_calendar_data = refresh_calendar_data.clone(); | ||||||
|  |         let alarm_scheduler = alarm_scheduler.clone(); | ||||||
|         Callback::from(move |event_data: EventCreationData| { |         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 |             // Check if this is an update operation (has original_uid) or a create operation | ||||||
|             if let Some(original_uid) = event_data.original_uid.clone() { |             if let Some(original_uid) = event_data.original_uid.clone() { | ||||||
|                 web_sys::console::log_1(&format!("Updating event via modal: {:?}", event_data).into()); |                 web_sys::console::log_1(&format!("Updating event via modal: {:?}", event_data).into()); | ||||||
| @@ -824,13 +919,10 @@ pub fn App() -> Html { | |||||||
|                                                    crate::components::event_form::RecurrenceType::Monthly |  |                                                    crate::components::event_form::RecurrenceType::Monthly |  | ||||||
|                                                    crate::components::event_form::RecurrenceType::Yearly); |                                                    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() { |                         let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() { | ||||||
|                             // Only use series endpoint for existing recurring events being edited |                             // Only use series endpoint for existing recurring events being edited | ||||||
|                             // Singleton→series conversion should use regular update_event endpoint |                             // 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 { |                             let scope = match edit_action { | ||||||
|                                 crate::components::EditAction::EditAll => "all_in_series".to_string(), |                                 crate::components::EditAction::EditAll => "all_in_series".to_string(), | ||||||
|                                 crate::components::EditAction::EditFuture => "this_and_future".to_string(), |                                 crate::components::EditAction::EditFuture => "this_and_future".to_string(), | ||||||
| @@ -904,6 +996,45 @@ pub fn App() -> Html { | |||||||
|                         match update_result { |                         match update_result { | ||||||
|                             Ok(_) => { |                             Ok(_) => { | ||||||
|                                 web_sys::console::log_1(&"Event updated successfully via modal".into()); |                                 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 calendar data without page reload | ||||||
|                                 refresh_callback.emit(()); |                                 refresh_callback.emit(()); | ||||||
|                             } |                             } | ||||||
| @@ -961,6 +1092,12 @@ pub fn App() -> Html { | |||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
|                     let params = event_data.to_create_event_params(); |                     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 |                     let create_result = _calendar_service | ||||||
|                         .create_event( |                         .create_event( | ||||||
|                             &_token, &_password, params.0,  // title |                             &_token, &_password, params.0,  // title | ||||||
| @@ -990,6 +1127,36 @@ pub fn App() -> Html { | |||||||
|                     match create_result { |                     match create_result { | ||||||
|                         Ok(_) => { |                         Ok(_) => { | ||||||
|                             web_sys::console::log_1(&"Event created successfully".into()); |                             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 calendar data without page reload | ||||||
|                             refresh_callback.emit(()); |                             refresh_callback.emit(()); | ||||||
|                         } |                         } | ||||||
| @@ -1096,7 +1263,7 @@ pub fn App() -> Html { | |||||||
|                         }; |                         }; | ||||||
|  |  | ||||||
|                         // Convert reminders to string format |                         // 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 |                             // Convert from VAlarm to minutes before | ||||||
|                             "15".to_string() // TODO: Convert VAlarm trigger to minutes |                             "15".to_string() // TODO: Convert VAlarm trigger to minutes | ||||||
|                         } else { |                         } else { | ||||||
| @@ -1147,7 +1314,7 @@ pub fn App() -> Html { | |||||||
|                                                 .collect::<Vec<_>>() |                                                 .collect::<Vec<_>>() | ||||||
|                                                 .join(","), |                                                 .join(","), | ||||||
|                                             original_event.categories.join(","), |                                             original_event.categories.join(","), | ||||||
|                                             reminder_str.clone(), |                                             original_event.alarms.clone(), | ||||||
|                                             recurrence_str.clone(), |                                             recurrence_str.clone(), | ||||||
|                                             vec![false; 7], // recurrence_days |                                             vec![false; 7], // recurrence_days | ||||||
|                                             1, // recurrence_interval - default for drag-and-drop |                                             1, // recurrence_interval - default for drag-and-drop | ||||||
| @@ -1204,7 +1371,7 @@ pub fn App() -> Html { | |||||||
|                                         .collect::<Vec<_>>() |                                         .collect::<Vec<_>>() | ||||||
|                                         .join(","), |                                         .join(","), | ||||||
|                                     original_event.categories.join(","), |                                     original_event.categories.join(","), | ||||||
|                                     reminder_str, |                                     original_event.alarms.clone(), | ||||||
|                                     recurrence_str, |                                     recurrence_str, | ||||||
|                                     recurrence_days, |                                     recurrence_days, | ||||||
|                                     1, // recurrence_interval - default to 1 for drag-and-drop |                                     1, // recurrence_interval - default to 1 for drag-and-drop | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ use crate::components::{ | |||||||
| }; | }; | ||||||
| use crate::models::ical::VEvent; | use crate::models::ical::VEvent; | ||||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | 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 gloo_storage::{LocalStorage, Storage}; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use web_sys::MouseEvent; | 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)| { |         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 auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||||
|             let date = *date; // Clone the date to avoid lifetime issues |             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 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 |             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() |                         String::new() | ||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
|                     let current_year = date.year(); |                     // Determine which months to fetch based on view mode | ||||||
|                     let current_month = date.month(); |                     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 | ||||||
|  |                         } | ||||||
|  |                     }; | ||||||
|  |  | ||||||
|                     match calendar_service |                     // Fetch events for all required months | ||||||
|                         .fetch_events_for_month_vevent( |                     let mut all_events = Vec::new(); | ||||||
|                             &token, |                     for (year, month) in months_to_fetch { | ||||||
|                             &password, |                         match calendar_service | ||||||
|                             current_year, |                             .fetch_events_for_month_vevent( | ||||||
|                             current_month, |                                 &token, | ||||||
|                         ) |                                 &password, | ||||||
|                         .await |                                 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) => { |                         Ok(vevents) => { | ||||||
|                             // Filter CalDAV events based on calendar visibility |                             // Filter CalDAV events based on calendar visibility | ||||||
| @@ -602,3 +653,18 @@ pub fn Calendar(props: &CalendarProps) -> Html { | |||||||
|         </div> |         </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) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -257,7 +257,13 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend | |||||||
|          |          | ||||||
|         // Timing |         // Timing | ||||||
|         start_date: start_local.date(), |         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(), |         start_time: start_local.time(), | ||||||
|         end_time: end_local.time(), |         end_time: end_local.time(), | ||||||
|          |          | ||||||
| @@ -286,8 +292,8 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend | |||||||
|         // Categorization   |         // Categorization   | ||||||
|         categories: event.categories.join(","), |         categories: event.categories.join(","), | ||||||
|          |          | ||||||
|         // Reminders - TODO: Parse alarm from VEvent if needed |         // Reminders - Use VAlarms from the event | ||||||
|         reminder: ReminderType::None, |         alarms: event.alarms.clone(), | ||||||
|          |          | ||||||
|         // Recurrence - Parse RRULE if present |         // Recurrence - Parse RRULE if present | ||||||
|         recurrence: if let Some(ref rrule_str) = event.rrule { |         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 = { |     // TODO: Replace with new alarm management UI | ||||||
|         let data = data.clone(); |     // let on_reminder_change = { | ||||||
|         Callback::from(move |e: Event| { |     //     let data = data.clone(); | ||||||
|             if let Some(target) = e.target() { |     //     Callback::from(move |e: Event| { | ||||||
|                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { |     //         // Will be replaced with VAlarm management | ||||||
|                     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); |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     let on_recurrence_interval_change = { |     let on_recurrence_interval_change = { | ||||||
|         let data = data.clone(); |         let data = data.clone(); | ||||||
| @@ -321,42 +308,31 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | |||||||
|                 ></textarea> |                 ></textarea> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <div class="form-group"> |  | ||||||
|                 <label for="event-calendar">{"Calendar"}</label> |  | ||||||
|                 <select |  | ||||||
|                     id="event-calendar" |  | ||||||
|                     class="form-input" |  | ||||||
|                     onchange={on_calendar_change} |  | ||||||
|                 > |  | ||||||
|                     <option value="">{"Select Calendar"}</option> |  | ||||||
|                     { |  | ||||||
|                         props.available_calendars.iter().map(|calendar| { |  | ||||||
|                             html! { |  | ||||||
|                                 <option |  | ||||||
|                                     key={calendar.path.clone()} |  | ||||||
|                                     value={calendar.path.clone()} |  | ||||||
|                                     selected={data.selected_calendar.as_ref() == Some(&calendar.path)} |  | ||||||
|                                 > |  | ||||||
|                                     {&calendar.display_name} |  | ||||||
|                                 </option> |  | ||||||
|                             } |  | ||||||
|                         }).collect::<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-row"> | ||||||
|  |                 <div class="form-group"> | ||||||
|  |                     <label for="event-calendar">{"Calendar"}</label> | ||||||
|  |                     <select | ||||||
|  |                         id="event-calendar" | ||||||
|  |                         class="form-input" | ||||||
|  |                         onchange={on_calendar_change} | ||||||
|  |                     > | ||||||
|  |                         <option value="">{"Select Calendar"}</option> | ||||||
|  |                         { | ||||||
|  |                             props.available_calendars.iter().map(|calendar| { | ||||||
|  |                                 html! { | ||||||
|  |                                     <option | ||||||
|  |                                         key={calendar.path.clone()} | ||||||
|  |                                         value={calendar.path.clone()} | ||||||
|  |                                         selected={data.selected_calendar.as_ref() == Some(&calendar.path)} | ||||||
|  |                                     > | ||||||
|  |                                         {&calendar.display_name} | ||||||
|  |                                     </option> | ||||||
|  |                                 } | ||||||
|  |                             }).collect::<Html>() | ||||||
|  |                         } | ||||||
|  |                     </select> | ||||||
|  |                 </div> | ||||||
|  |  | ||||||
|                 <div class="form-group"> |                 <div class="form-group"> | ||||||
|                     <label for="event-recurrence-basic">{"Repeat"}</label> |                     <label for="event-recurrence-basic">{"Repeat"}</label> | ||||||
|                     <select |                     <select | ||||||
| @@ -371,21 +347,6 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | |||||||
|                         <option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option> |                         <option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option> | ||||||
|                     </select> |                     </select> | ||||||
|                 </div> |                 </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> |             </div> | ||||||
|  |  | ||||||
|             // RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder! |             // RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder! | ||||||
| @@ -659,6 +620,18 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | |||||||
|                 </div> |                 </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 |             // Date and time fields go here AFTER recurrence options | ||||||
|             <div class="form-row"> |             <div class="form-row"> | ||||||
|                 <div class="form-group"> |                 <div class="form-group"> | ||||||
|   | |||||||
| @@ -1,5 +1,7 @@ | |||||||
| // Event form components module | // Event form components module | ||||||
| pub mod types; | pub mod types; | ||||||
|  | pub mod alarm_list; | ||||||
|  | pub mod add_alarm_modal; | ||||||
| pub mod basic_details; | pub mod basic_details; | ||||||
| pub mod advanced; | pub mod advanced; | ||||||
| pub mod people; | pub mod people; | ||||||
| @@ -8,6 +10,8 @@ pub mod location; | |||||||
| pub mod reminders; | pub mod reminders; | ||||||
|  |  | ||||||
| pub use types::*; | pub use types::*; | ||||||
|  | pub use alarm_list::AlarmList; | ||||||
|  | pub use add_alarm_modal::AddAlarmModal; | ||||||
| pub use basic_details::BasicDetailsTab; | pub use basic_details::BasicDetailsTab; | ||||||
| pub use advanced::AdvancedTab; | pub use advanced::AdvancedTab; | ||||||
| pub use people::PeopleTab; | pub use people::PeopleTab; | ||||||
|   | |||||||
| @@ -1,100 +1,116 @@ | |||||||
| use super::types::*; | use super::{types::*, AlarmList, AddAlarmModal}; | ||||||
| // Types are already imported from super::types::* | use calendar_models::VAlarm; | ||||||
| use wasm_bindgen::JsCast; |  | ||||||
| use web_sys::HtmlSelectElement; |  | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[function_component(RemindersTab)] | #[function_component(RemindersTab)] | ||||||
| pub fn reminders_tab(props: &TabProps) -> Html { | pub fn reminders_tab(props: &TabProps) -> Html { | ||||||
|     let data = &props.data; |     let data = &props.data; | ||||||
|      |      | ||||||
|     let on_reminder_change = { |     // 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); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|  |      | ||||||
|  |     // 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(); |         let data = data.clone(); | ||||||
|         Callback::from(move |e: Event| { |         Callback::from(move |index: usize| { | ||||||
|             if let Some(target) = e.target() { |             let mut current_data = (*data).clone(); | ||||||
|                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { |             if index < current_data.alarms.len() { | ||||||
|                     let mut event_data = (*data).clone(); |                 current_data.alarms.remove(index); | ||||||
|                     event_data.reminder = match select.value().as_str() { |                 data.set(current_data); | ||||||
|                         "15min" => ReminderType::Minutes15, |  | ||||||
|                         "30min" => ReminderType::Minutes30, |  | ||||||
|                         "1hour" => ReminderType::Hour1, |  | ||||||
|                         "1day" => ReminderType::Day1, |  | ||||||
|                         "2days" => ReminderType::Days2, |  | ||||||
|                         "1week" => ReminderType::Week1, |  | ||||||
|                         _ => ReminderType::None, |  | ||||||
|                     }; |  | ||||||
|                     data.set(event_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! { |     html! { | ||||||
|         <div class="tab-panel"> |         <div class="tab-panel"> | ||||||
|             <div class="form-group"> |             <div class="form-group"> | ||||||
|                 <label for="event-reminder-main">{"Primary Reminder"}</label> |                 <div class="alarm-management-header"> | ||||||
|                 <select |                     <h5>{"Event Reminders"}</h5> | ||||||
|                     id="event-reminder-main" |                     <button  | ||||||
|                     class="form-input" |                         class="add-alarm-button"  | ||||||
|                     onchange={on_reminder_change} |                         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> |                         <i class="fas fa-plus"></i> | ||||||
|                     <option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option> |                         {" Add Reminder"} | ||||||
|                     <option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option> |                     </button> | ||||||
|                     <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> |  | ||||||
|             </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> |                 </div> | ||||||
|                 <p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p> |                 <p class="form-help-text">{"Configure multiple reminders with custom timing and notification types"}</p> | ||||||
|             </div> |             </div> | ||||||
|  |  | ||||||
|             <div class="reminder-info"> |             <AlarmList | ||||||
|                 <h5>{"Advanced Reminder Features"}</h5> |                 alarms={data.alarms.clone()} | ||||||
|                 <ul> |                 on_alarm_delete={on_alarm_delete} | ||||||
|                     <li>{"Multiple reminders per event with different timing"}</li> |                 on_alarm_edit={on_alarm_edit} | ||||||
|                     <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"> |             <AddAlarmModal | ||||||
|                     <h6>{"File Attachments & Documents"}</h6> |                 is_open={*is_modal_open} | ||||||
|                     <p>{"Future attachment features will include:"}</p> |                 editing_index={*editing_index} | ||||||
|                     <ul> |                 initial_alarm={initial_alarm} | ||||||
|                         <li>{"Drag-and-drop file uploads"}</li> |                 on_close={on_modal_close} | ||||||
|                         <li>{"Document preview and thumbnails"}</li> |                 on_save={on_alarm_save} | ||||||
|                         <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> |  | ||||||
|         </div> |         </div> | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| use crate::services::calendar_service::CalendarInfo; | use crate::services::calendar_service::CalendarInfo; | ||||||
| use chrono::{Local, NaiveDate, NaiveTime}; | use chrono::{Local, NaiveDate, NaiveTime}; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
|  | use calendar_models::VAlarm; | ||||||
|  |  | ||||||
| #[derive(Clone, PartialEq, Debug)] | #[derive(Clone, PartialEq, Debug)] | ||||||
| pub enum EventStatus { | 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)] | #[derive(Clone, PartialEq, Debug)] | ||||||
| pub enum RecurrenceType { | pub enum RecurrenceType { | ||||||
| @@ -104,8 +89,8 @@ pub struct EventCreationData { | |||||||
|     // Categorization |     // Categorization | ||||||
|     pub categories: String, |     pub categories: String, | ||||||
|      |      | ||||||
|     // Reminders |     // Reminders/Alarms | ||||||
|     pub reminder: ReminderType, |     pub alarms: Vec<VAlarm>, | ||||||
|      |      | ||||||
|     // Recurrence |     // Recurrence | ||||||
|     pub recurrence: RecurrenceType, |     pub recurrence: RecurrenceType, | ||||||
| @@ -145,7 +130,7 @@ impl EventCreationData { | |||||||
|         String, // organizer |         String, // organizer | ||||||
|         String, // attendees |         String, // attendees | ||||||
|         String, // categories |         String, // categories | ||||||
|         String, // reminder |         Vec<VAlarm>, // alarms | ||||||
|         String, // recurrence |         String, // recurrence | ||||||
|         Vec<bool>, // recurrence_days |         Vec<bool>, // recurrence_days | ||||||
|         u32, // recurrence_interval |         u32, // recurrence_interval | ||||||
| @@ -156,8 +141,9 @@ impl EventCreationData { | |||||||
|     ) { |     ) { | ||||||
|          |          | ||||||
|         // Use local date/times and timezone - no UTC conversion |         // 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() { |         let effective_end_date = if self.all_day { | ||||||
|             // If end time is midnight (00:00), treat it as beginning of next 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) |             self.end_date + chrono::Duration::days(1) | ||||||
|         } else { |         } else { | ||||||
|             self.end_date |             self.end_date | ||||||
| @@ -195,7 +181,7 @@ impl EventCreationData { | |||||||
|             self.organizer.clone(), |             self.organizer.clone(), | ||||||
|             self.attendees.clone(), |             self.attendees.clone(), | ||||||
|             self.categories.clone(), |             self.categories.clone(), | ||||||
|             format!("{:?}", self.reminder), |             self.alarms.clone(), | ||||||
|             format!("{:?}", self.recurrence), |             format!("{:?}", self.recurrence), | ||||||
|             self.recurrence_days.clone(), |             self.recurrence_days.clone(), | ||||||
|             self.recurrence_interval, |             self.recurrence_interval, | ||||||
| @@ -229,7 +215,7 @@ impl Default for EventCreationData { | |||||||
|             organizer: String::new(), |             organizer: String::new(), | ||||||
|             attendees: String::new(), |             attendees: String::new(), | ||||||
|             categories: String::new(), |             categories: String::new(), | ||||||
|             reminder: ReminderType::default(), |             alarms: Vec::new(), | ||||||
|             recurrence: RecurrenceType::default(), |             recurrence: RecurrenceType::default(), | ||||||
|             recurrence_interval: 1, |             recurrence_interval: 1, | ||||||
|             recurrence_until: None, |             recurrence_until: None, | ||||||
|   | |||||||
| @@ -114,8 +114,13 @@ pub fn month_view(props: &MonthViewProps) -> Html { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|      |      | ||||||
|  |     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! { |     html! { | ||||||
|         <div class="calendar-grid"> |         <div class="calendar-grid" style={dynamic_style}> | ||||||
|             // Weekday headers |             // Weekday headers | ||||||
|             <div class="weekday-header">{"Sun"}</div> |             <div class="weekday-header">{"Sun"}</div> | ||||||
|             <div class="weekday-header">{"Mon"}</div> |             <div class="weekday-header">{"Mon"}</div> | ||||||
| @@ -213,7 +218,10 @@ pub fn month_view(props: &MonthViewProps) -> Html { | |||||||
|                                                 {onclick} |                                                 {onclick} | ||||||
|                                                 {oncontextmenu} |                                                 {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> |                                             </div> | ||||||
|                                         } |                                         } | ||||||
|                                     }).collect::<Html>() |                                     }).collect::<Html>() | ||||||
| @@ -235,13 +243,27 @@ pub fn month_view(props: &MonthViewProps) -> Html { | |||||||
|                 }).collect::<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> |         </div> | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html { | fn calculate_minimum_weeks_needed(first_weekday: Weekday, days_in_month: u32) -> u32 { | ||||||
|     let total_slots = 42; // 6 rows x 7 days |     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 used_slots = prev_days_count + current_days_count as usize; | ||||||
|     let remaining_slots = if used_slots < total_slots { |     let remaining_slots = if used_slots < total_slots { | ||||||
|         total_slots - used_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::models::ical::VEvent; | ||||||
| use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | use crate::services::calendar_service::{UserInfo, ExternalCalendar}; | ||||||
| use chrono::NaiveDate; | use chrono::NaiveDate; | ||||||
| use std::collections::HashMap; | use std::collections::HashMap; | ||||||
| use wasm_bindgen::{closure::Closure, JsCast}; | use wasm_bindgen::{closure::Closure, JsCast}; | ||||||
|  | use web_sys::MouseEvent; | ||||||
| use yew::prelude::*; | use yew::prelude::*; | ||||||
|  |  | ||||||
| #[derive(Properties, PartialEq)] | #[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 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 visible_hours = (end_hour - start_hour) as f64; | ||||||
|         let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 }; |         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 header_border = 2.0;   // Week header bottom border (2px solid) | ||||||
|         let container_spacing = 8.0; // Additional container spacing/margins |         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 available_height = 720.0 - total_overhead; // Available for time content | ||||||
|         let base_unit = available_height / (visible_hours * slots_per_hour); |         let base_unit = available_height / (visible_hours * slots_per_hour); | ||||||
|         let pixels_per_hour = base_unit * 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 |                                         // Recalculate base-unit and pixels-per-hour based on actual height | ||||||
|                                         let visible_hours = (end_hour - start_hour) as f64; |                                         let visible_hours = (end_hour - start_hour) as f64; | ||||||
|                                         let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 }; |                                         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 header_border = 2.0; | ||||||
|                                         let container_spacing = 8.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 available_height = actual_height - total_overhead; | ||||||
|                                         let actual_base_unit = available_height / (visible_hours * slots_per_hour); |                                         let actual_base_unit = available_height / (visible_hours * slots_per_hour); | ||||||
|                                         let actual_pixels_per_hour = actual_base_unit * slots_per_hour; |                                         let actual_pixels_per_hour = actual_base_unit * slots_per_hour; | ||||||
| @@ -320,38 +323,50 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html { | |||||||
|                                          *start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level |                                          *start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level | ||||||
|                                      )}> |                                      )}> | ||||||
|                             <div class="print-preview-content"> |                             <div class="print-preview-content"> | ||||||
|                                 { |                                 <div class={classes!("calendar", match props.view_mode { ViewMode::Week => Some("week-view"), _ => None })}> | ||||||
|                                     match props.view_mode { |                                     <CalendarHeader | ||||||
|                                         ViewMode::Week => html! { |                                         current_date={props.current_date} | ||||||
|                                             <WeekView |                                         view_mode={props.view_mode.clone()} | ||||||
|                                                 key={format!("week-preview-{}-{}", *start_hour, *end_hour)} |                                         on_prev={Callback::from(|_: MouseEvent| {})} | ||||||
|                                                 current_date={props.current_date} |                                         on_next={Callback::from(|_: MouseEvent| {})} | ||||||
|                                                 today={props.today} |                                         on_today={Callback::from(|_: MouseEvent| {})} | ||||||
|                                                 events={props.events.clone()} |                                         time_increment={Some(props.time_increment)} | ||||||
|                                                 on_event_click={Callback::noop()} |                                         on_time_increment_toggle={None::<Callback<MouseEvent>>} | ||||||
|                                                 user_info={props.user_info.clone()} |                                         on_print={None::<Callback<MouseEvent>>} | ||||||
|                                                 external_calendars={props.external_calendars.clone()} |                                     /> | ||||||
|                                                 time_increment={props.time_increment} |                                     { | ||||||
|                                                 print_mode={true} |                                         match props.view_mode { | ||||||
|                                                 print_pixels_per_hour={Some(pixels_per_hour)} |                                             ViewMode::Week => html! { | ||||||
|                                                 print_start_hour={Some(*start_hour)} |                                                 <WeekView | ||||||
|                                             /> |                                                     key={format!("week-preview-{}-{}", *start_hour, *end_hour)} | ||||||
|                                         }, |                                                     current_date={props.current_date} | ||||||
|                                         ViewMode::Month => html! { |                                                     today={props.today} | ||||||
|                                             <MonthView |                                                     events={props.events.clone()} | ||||||
|                                                 key={format!("month-preview-{}-{}", *start_hour, *end_hour)} |                                                     on_event_click={Callback::noop()} | ||||||
|                                                 current_month={props.current_date} |                                                     user_info={props.user_info.clone()} | ||||||
|                                                 selected_date={Some(props.selected_date)} |                                                     external_calendars={props.external_calendars.clone()} | ||||||
|                                                 today={props.today} |                                                     time_increment={props.time_increment} | ||||||
|                                                 events={props.events.clone()} |                                                     print_mode={true} | ||||||
|                                                 on_day_select={None::<Callback<NaiveDate>>} |                                                     print_pixels_per_hour={Some(pixels_per_hour)} | ||||||
|                                                 on_event_click={Callback::noop()} |                                                     print_start_hour={Some(*start_hour)} | ||||||
|                                                 user_info={props.user_info.clone()} |                                                 /> | ||||||
|                                                 external_calendars={props.external_calendars.clone()} |                                             }, | ||||||
|                                             /> |                                             ViewMode::Month => html! { | ||||||
|                                         }, |                                                 <MonthView | ||||||
|  |                                                     key={format!("month-preview-{}-{}", *start_hour, *end_hour)} | ||||||
|  |                                                     current_month={props.current_date} | ||||||
|  |                                                     selected_date={Some(props.selected_date)} | ||||||
|  |                                                     today={props.today} | ||||||
|  |                                                     events={props.events.clone()} | ||||||
|  |                                                     on_day_select={None::<Callback<NaiveDate>>} | ||||||
|  |                                                     on_event_click={Callback::noop()} | ||||||
|  |                                                     user_info={props.user_info.clone()} | ||||||
|  |                                                     external_calendars={props.external_calendars.clone()} | ||||||
|  |                                                 /> | ||||||
|  |                                             }, | ||||||
|  |                                         } | ||||||
|                                     } |                                     } | ||||||
|                                 } |                                 </div> | ||||||
|                             </div> |                             </div> | ||||||
|                         </div> |                         </div> | ||||||
|                     </div> |                     </div> | ||||||
|   | |||||||
| @@ -20,12 +20,17 @@ pub enum Theme { | |||||||
|     Dark, |     Dark, | ||||||
|     Rose, |     Rose, | ||||||
|     Mint, |     Mint, | ||||||
|  |     Midnight, | ||||||
|  |     Charcoal, | ||||||
|  |     Nord, | ||||||
|  |     Dracula, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Clone, PartialEq)] | #[derive(Clone, PartialEq)] | ||||||
| pub enum Style { | pub enum Style { | ||||||
|     Default, |     Default, | ||||||
|     Google, |     Google, | ||||||
|  |     Apple, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl Theme { | impl Theme { | ||||||
| @@ -39,6 +44,10 @@ impl Theme { | |||||||
|             Theme::Dark => "dark", |             Theme::Dark => "dark", | ||||||
|             Theme::Rose => "rose", |             Theme::Rose => "rose", | ||||||
|             Theme::Mint => "mint", |             Theme::Mint => "mint", | ||||||
|  |             Theme::Midnight => "midnight", | ||||||
|  |             Theme::Charcoal => "charcoal", | ||||||
|  |             Theme::Nord => "nord", | ||||||
|  |             Theme::Dracula => "dracula", | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -51,6 +60,10 @@ impl Theme { | |||||||
|             "dark" => Theme::Dark, |             "dark" => Theme::Dark, | ||||||
|             "rose" => Theme::Rose, |             "rose" => Theme::Rose, | ||||||
|             "mint" => Theme::Mint, |             "mint" => Theme::Mint, | ||||||
|  |             "midnight" => Theme::Midnight, | ||||||
|  |             "charcoal" => Theme::Charcoal, | ||||||
|  |             "nord" => Theme::Nord, | ||||||
|  |             "dracula" => Theme::Dracula, | ||||||
|             _ => Theme::Default, |             _ => Theme::Default, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -61,12 +74,14 @@ impl Style { | |||||||
|         match self { |         match self { | ||||||
|             Style::Default => "default", |             Style::Default => "default", | ||||||
|             Style::Google => "google", |             Style::Google => "google", | ||||||
|  |             Style::Apple => "apple", | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub fn from_value(value: &str) -> Self { |     pub fn from_value(value: &str) -> Self { | ||||||
|         match value { |         match value { | ||||||
|             "google" => Style::Google, |             "google" => Style::Google, | ||||||
|  |             "apple" => Style::Apple, | ||||||
|             _ => Style::Default, |             _ => Style::Default, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -76,6 +91,7 @@ impl Style { | |||||||
|         match self { |         match self { | ||||||
|             Style::Default => None, // No additional stylesheet needed - uses base styles.css |             Style::Default => None, // No additional stylesheet needed - uses base styles.css | ||||||
|             Style::Google => Some("google.css"), // Trunk copies to root level |             Style::Google => Some("google.css"), // Trunk copies to root level | ||||||
|  |             Style::Apple => Some("apple.css"), // Trunk copies to root level | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -246,7 +262,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { | |||||||
|                                         }; |                                         }; | ||||||
|                                          |                                          | ||||||
|                                         html! { |                                         html! { | ||||||
|                                             <li class="external-calendar-item" style="position: relative;"> |                                             <li class="external-calendar-item"> | ||||||
|                                                 <div  |                                                 <div  | ||||||
|                                                     class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {  |                                                     class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {  | ||||||
|                                                         "external-calendar-info color-picker-active"  |                                                         "external-calendar-info color-picker-active"  | ||||||
| @@ -426,6 +442,10 @@ pub fn sidebar(props: &SidebarProps) -> Html { | |||||||
|                         <option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option> |                         <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="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option> | ||||||
|                         <option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</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> |                     </select> | ||||||
|                 </div> |                 </div> | ||||||
|  |  | ||||||
| @@ -433,6 +453,7 @@ pub fn sidebar(props: &SidebarProps) -> Html { | |||||||
|                     <select class="style-selector-dropdown" onchange={on_style_change}> |                     <select class="style-selector-dropdown" onchange={on_style_change}> | ||||||
|                         <option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option> |                         <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="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> |                     </select> | ||||||
|                 </div> |                 </div> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -968,7 +968,12 @@ pub fn week_view(props: &WeekViewProps) -> Html { | |||||||
|  |  | ||||||
|                                                                 // Event content |                                                                 // Event content | ||||||
|                                                                 <div class="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 { |                                                                     {if !is_all_day && duration_pixels > 30.0 { | ||||||
|                                                                         html! { <div class="event-time">{time_display}</div> } |                                                                         html! { <div class="event-time">{time_display}</div> } | ||||||
|                                                                     } else { |                                                                     } 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 wasm_bindgen_futures::JsFuture; | ||||||
| use web_sys::{Request, RequestInit, RequestMode, Response}; | use web_sys::{Request, RequestInit, RequestMode, Response}; | ||||||
|  |  | ||||||
| // Import RFC 5545 compliant VEvent from shared library | // Import RFC 5545 compliant VEvent and VAlarm from shared library | ||||||
| use calendar_models::VEvent; | use calendar_models::{VEvent, VAlarm}; | ||||||
|  |  | ||||||
| // Create type alias for backward compatibility | // Create type alias for backward compatibility | ||||||
| pub type CalendarEvent = VEvent; | pub type CalendarEvent = VEvent; | ||||||
| @@ -317,6 +317,11 @@ impl CalendarService { | |||||||
|                 event.last_modified = Some(modified_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64)); |                 event.last_modified = Some(modified_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64)); | ||||||
|                 event.last_modified_tzid = None; |                 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 |         event | ||||||
| @@ -333,8 +338,6 @@ impl CalendarService { | |||||||
|             // Convert UTC events to local time for proper display |             // Convert UTC events to local time for proper display | ||||||
|             let event = Self::convert_utc_to_local(event); |             let event = Self::convert_utc_to_local(event); | ||||||
|             if let Some(ref rrule) = event.rrule { |             if let Some(ref rrule) = event.rrule { | ||||||
|  |  | ||||||
|  |  | ||||||
|                 // Generate occurrences for recurring events using VEvent |                 // Generate occurrences for recurring events using VEvent | ||||||
|                 let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range); |                 let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range); | ||||||
|                 expanded_events.extend(occurrences); |                 expanded_events.extend(occurrences); | ||||||
| @@ -437,25 +440,14 @@ impl CalendarService { | |||||||
|  |  | ||||||
|                 // Check if this occurrence is in the exception dates (EXDATE) |                 // Check if this occurrence is in the exception dates (EXDATE) | ||||||
|                 let is_exception = base_event.exdate.iter().any(|exception_date| { |                 let is_exception = base_event.exdate.iter().any(|exception_date| { | ||||||
|                     // Compare dates ignoring sub-second precision |                     // EXDATE from server is in local time, but stored as NaiveDateTime | ||||||
|                     let exception_naive = exception_date.and_utc(); |                     // We need to compare both as local time (naive datetimes) instead of UTC | ||||||
|                     let occurrence_naive = occurrence_datetime.and_utc(); |                     let exception_naive = *exception_date; | ||||||
|  |                     let occurrence_naive = occurrence_datetime; | ||||||
|  |  | ||||||
|                     // Check if dates match (within a minute to handle minor time differences) |                     // Check if dates match (within a minute to handle minor time differences) | ||||||
|                     let diff = occurrence_naive - exception_naive; |                     let diff = occurrence_naive - exception_naive; | ||||||
|                     let matches = diff.num_seconds().abs() < 60; |                     diff.num_seconds().abs() < 60 | ||||||
|  |  | ||||||
|                     if matches { |  | ||||||
|                         web_sys::console::log_1( |  | ||||||
|                             &format!( |  | ||||||
|                                 "🚫 Excluding occurrence {} due to EXDATE {}", |  | ||||||
|                                 occurrence_naive, exception_naive |  | ||||||
|                             ) |  | ||||||
|                             .into(), |  | ||||||
|                         ); |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     matches |  | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
|                 if !is_exception { |                 if !is_exception { | ||||||
| @@ -632,22 +624,11 @@ impl CalendarService { | |||||||
|  |  | ||||||
|                 // Check if this occurrence is in the exception dates (EXDATE) |                 // Check if this occurrence is in the exception dates (EXDATE) | ||||||
|                 let is_exception = base_event.exdate.iter().any(|exception_date| { |                 let is_exception = base_event.exdate.iter().any(|exception_date| { | ||||||
|                     let exception_naive = exception_date.and_utc(); |                     // Compare as local time (naive datetimes) instead of UTC | ||||||
|                     let occurrence_naive = occurrence_datetime.and_utc(); |                     let exception_naive = *exception_date; | ||||||
|  |                     let occurrence_naive = occurrence_datetime; | ||||||
|                     let diff = occurrence_naive - exception_naive; |                     let diff = occurrence_naive - exception_naive; | ||||||
|                     let matches = diff.num_seconds().abs() < 60; |                     diff.num_seconds().abs() < 60 | ||||||
|  |  | ||||||
|                     if matches { |  | ||||||
|                         web_sys::console::log_1( |  | ||||||
|                             &format!( |  | ||||||
|                                 "🚫 Excluding occurrence {} due to EXDATE {}", |  | ||||||
|                                 occurrence_naive, exception_naive |  | ||||||
|                             ) |  | ||||||
|                             .into(), |  | ||||||
|                         ); |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     matches |  | ||||||
|                 }); |                 }); | ||||||
|  |  | ||||||
|                 if !is_exception { |                 if !is_exception { | ||||||
| @@ -1250,7 +1231,7 @@ impl CalendarService { | |||||||
|         organizer: String, |         organizer: String, | ||||||
|         attendees: String, |         attendees: String, | ||||||
|         categories: String, |         categories: String, | ||||||
|         reminder: String, |         alarms: Vec<VAlarm>, | ||||||
|         recurrence: String, |         recurrence: String, | ||||||
|         recurrence_days: Vec<bool>, |         recurrence_days: Vec<bool>, | ||||||
|         recurrence_interval: u32, |         recurrence_interval: u32, | ||||||
| @@ -1285,7 +1266,7 @@ impl CalendarService { | |||||||
|                 "organizer": organizer, |                 "organizer": organizer, | ||||||
|                 "attendees": attendees, |                 "attendees": attendees, | ||||||
|                 "categories": categories, |                 "categories": categories, | ||||||
|                 "reminder": reminder, |                 "alarms": alarms, | ||||||
|                 "recurrence": recurrence, |                 "recurrence": recurrence, | ||||||
|                 "recurrence_days": recurrence_days, |                 "recurrence_days": recurrence_days, | ||||||
|                 "recurrence_interval": recurrence_interval, |                 "recurrence_interval": recurrence_interval, | ||||||
| @@ -1313,7 +1294,7 @@ impl CalendarService { | |||||||
|                 "organizer": organizer, |                 "organizer": organizer, | ||||||
|                 "attendees": attendees, |                 "attendees": attendees, | ||||||
|                 "categories": categories, |                 "categories": categories, | ||||||
|                 "reminder": reminder, |                 "alarms": alarms, | ||||||
|                 "recurrence": recurrence, |                 "recurrence": recurrence, | ||||||
|                 "recurrence_days": recurrence_days, |                 "recurrence_days": recurrence_days, | ||||||
|                 "calendar_path": calendar_path, |                 "calendar_path": calendar_path, | ||||||
| @@ -1391,7 +1372,7 @@ impl CalendarService { | |||||||
|         organizer: String, |         organizer: String, | ||||||
|         attendees: String, |         attendees: String, | ||||||
|         categories: String, |         categories: String, | ||||||
|         reminder: String, |         alarms: Vec<VAlarm>, | ||||||
|         recurrence: String, |         recurrence: String, | ||||||
|         recurrence_days: Vec<bool>, |         recurrence_days: Vec<bool>, | ||||||
|         recurrence_interval: u32, |         recurrence_interval: u32, | ||||||
| @@ -1419,7 +1400,7 @@ impl CalendarService { | |||||||
|             organizer, |             organizer, | ||||||
|             attendees, |             attendees, | ||||||
|             categories, |             categories, | ||||||
|             reminder, |             alarms, | ||||||
|             recurrence, |             recurrence, | ||||||
|             recurrence_days, |             recurrence_days, | ||||||
|             recurrence_interval, |             recurrence_interval, | ||||||
| @@ -1450,7 +1431,7 @@ impl CalendarService { | |||||||
|         organizer: String, |         organizer: String, | ||||||
|         attendees: String, |         attendees: String, | ||||||
|         categories: String, |         categories: String, | ||||||
|         reminder: String, |         alarms: Vec<VAlarm>, | ||||||
|         recurrence: String, |         recurrence: String, | ||||||
|         recurrence_days: Vec<bool>, |         recurrence_days: Vec<bool>, | ||||||
|         recurrence_interval: u32, |         recurrence_interval: u32, | ||||||
| @@ -1482,7 +1463,7 @@ impl CalendarService { | |||||||
|             "organizer": organizer, |             "organizer": organizer, | ||||||
|             "attendees": attendees, |             "attendees": attendees, | ||||||
|             "categories": categories, |             "categories": categories, | ||||||
|             "reminder": reminder, |             "alarms": alarms, | ||||||
|             "recurrence": recurrence, |             "recurrence": recurrence, | ||||||
|             "recurrence_days": recurrence_days, |             "recurrence_days": recurrence_days, | ||||||
|             "recurrence_interval": recurrence_interval, |             "recurrence_interval": recurrence_interval, | ||||||
| @@ -1687,7 +1668,7 @@ impl CalendarService { | |||||||
|         organizer: String, |         organizer: String, | ||||||
|         attendees: String, |         attendees: String, | ||||||
|         categories: String, |         categories: String, | ||||||
|         reminder: String, |         alarms: Vec<VAlarm>, | ||||||
|         recurrence: String, |         recurrence: String, | ||||||
|         recurrence_days: Vec<bool>, |         recurrence_days: Vec<bool>, | ||||||
|         recurrence_interval: u32, |         recurrence_interval: u32, | ||||||
| @@ -1720,7 +1701,7 @@ impl CalendarService { | |||||||
|             "organizer": organizer, |             "organizer": organizer, | ||||||
|             "attendees": attendees, |             "attendees": attendees, | ||||||
|             "categories": categories, |             "categories": categories, | ||||||
|             "reminder": reminder, |             "alarms": alarms, | ||||||
|             "recurrence": recurrence, |             "recurrence": recurrence, | ||||||
|             "recurrence_days": recurrence_days, |             "recurrence_days": recurrence_days, | ||||||
|             "recurrence_interval": recurrence_interval, |             "recurrence_interval": recurrence_interval, | ||||||
|   | |||||||
| @@ -1,4 +1,8 @@ | |||||||
| pub mod calendar_service; | pub mod calendar_service; | ||||||
| pub mod preferences; | pub mod preferences; | ||||||
|  | pub mod notification_manager; | ||||||
|  | pub mod alarm_scheduler; | ||||||
|  |  | ||||||
| pub use calendar_service::CalendarService; | 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() | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										1437
									
								
								frontend/styles.css
									
									
									
									
									
								
							
							
						
						
									
										1437
									
								
								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