Compare commits
	
		
			8 Commits
		
	
	
		
			5c406569af
			...
			ce9914e388
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ce9914e388 | ||
|   | faf5ce2cfd | ||
|   | 2fee7a15f9 | ||
|   | 7caf3539f7 | ||
|   | 1538869f4a | ||
|   | 7ce7d4c9d9 | ||
|   | 037b733d48 | ||
|   | cb1bb23132 | 
| @@ -16,7 +16,7 @@ use crate::{ | ||||
|     AppState, | ||||
| }; | ||||
| use calendar_models::{ | ||||
|     AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent, | ||||
|     Attendee, CalendarUser, EventClass, EventStatus, VEvent, | ||||
| }; | ||||
|  | ||||
| use super::auth::{extract_bearer_token, extract_password_header}; | ||||
| @@ -456,14 +456,11 @@ pub async fn create_event( | ||||
|         parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) | ||||
|             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||
|  | ||||
|     let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|     let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; | ||||
|  | ||||
|     // For all-day events, add one day to end date for RFC-5545 compliance | ||||
|     // RFC-5545 uses exclusive end dates for all-day events | ||||
|     if request.all_day { | ||||
|         end_datetime = end_datetime + chrono::Duration::days(1); | ||||
|     } | ||||
|     // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events | ||||
|     // No additional conversion needed here | ||||
|  | ||||
|     // Validate that end is after start (allow equal times for all-day events) | ||||
|     if request.all_day { | ||||
| @@ -525,19 +522,8 @@ pub async fn create_event( | ||||
|             .collect() | ||||
|     }; | ||||
|  | ||||
|     // Parse alarms - convert from minutes string to EventReminder structs | ||||
|     let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() { | ||||
|         Vec::new() | ||||
|     } else { | ||||
|         match request.reminder.parse::<i32>() { | ||||
|             Ok(minutes) => vec![crate::calendar::EventReminder { | ||||
|                 minutes_before: minutes, | ||||
|                 action: crate::calendar::ReminderAction::Display, | ||||
|                 description: None, | ||||
|             }], | ||||
|             Err(_) => Vec::new(), | ||||
|         } | ||||
|     }; | ||||
|     // Use VAlarms directly from request (no conversion needed) | ||||
|     let alarms = request.alarms; | ||||
|  | ||||
|     // Check if recurrence is already a full RRULE or just a simple type | ||||
|     let rrule = if request.recurrence.starts_with("FREQ=") { | ||||
| @@ -648,21 +634,7 @@ pub async fn create_event( | ||||
|     event.categories = categories; | ||||
|     event.rrule = rrule; | ||||
|     event.all_day = request.all_day; | ||||
|     event.alarms = alarms | ||||
|         .into_iter() | ||||
|         .map(|reminder| VAlarm { | ||||
|             action: AlarmAction::Display, | ||||
|             trigger: AlarmTrigger::Duration(chrono::Duration::minutes( | ||||
|                 -reminder.minutes_before as i64, | ||||
|             )), | ||||
|             duration: None, | ||||
|             repeat: None, | ||||
|             description: reminder.description, | ||||
|             summary: None, | ||||
|             attendees: Vec::new(), | ||||
|             attach: Vec::new(), | ||||
|         }) | ||||
|         .collect(); | ||||
|     event.alarms = alarms; | ||||
|     event.calendar_path = Some(calendar_path.clone()); | ||||
|  | ||||
|     // Create the event on the CalDAV server | ||||
| @@ -766,14 +738,11 @@ pub async fn update_event( | ||||
|         parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) | ||||
|             .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||
|  | ||||
|     let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|     let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; | ||||
|  | ||||
|     // For all-day events, add one day to end date for RFC-5545 compliance | ||||
|     // RFC-5545 uses exclusive end dates for all-day events | ||||
|     if request.all_day { | ||||
|         end_datetime = end_datetime + chrono::Duration::days(1); | ||||
|     } | ||||
|     // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events | ||||
|     // No additional conversion needed here | ||||
|  | ||||
|     // Validate that end is after start (allow equal times for all-day events) | ||||
|     if request.all_day { | ||||
|   | ||||
| @@ -485,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> { | ||||
|     use std::collections::HashMap; | ||||
|      | ||||
|     let original_count = events.len(); | ||||
|      | ||||
|     // First pass: Group by UID and prefer recurring events over single events with same UID | ||||
|     let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new(); | ||||
| @@ -547,13 +546,12 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|      | ||||
|     let mut deduplicated_recurring = Vec::new(); | ||||
|      | ||||
|     for (title, events_with_title) in title_groups.drain() { | ||||
|     for (_title, events_with_title) in title_groups.drain() { | ||||
|         if events_with_title.len() == 1 { | ||||
|             // Single event with this title, keep as-is | ||||
|             deduplicated_recurring.push(events_with_title.into_iter().next().unwrap()); | ||||
|         } else { | ||||
|             // Multiple events with same title - consolidate or deduplicate | ||||
|             println!("🔍 Found {} events with title '{}'", events_with_title.len(), title); | ||||
|              | ||||
|             // Check if these are actually different recurring patterns for the same logical event | ||||
|             let consolidated = consolidate_same_title_events(events_with_title); | ||||
| @@ -580,15 +578,9 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|             let existing_completeness = event_completeness_score(existing_event); | ||||
|              | ||||
|             if current_completeness > existing_completeness { | ||||
|                 println!("🔄 Replacing single event: Keeping '{}' over '{}'", | ||||
|                     event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||
|                     existing_event.summary.as_ref().unwrap_or(&"No Title".to_string()) | ||||
|                 ); | ||||
|                 deduplicated_single[existing_index] = event; | ||||
|             } else { | ||||
|                 println!("🚫 Discarding duplicate single event: Keeping existing '{}'", | ||||
|                     existing_event.summary.as_ref().unwrap_or(&"No Title".to_string()) | ||||
|                 ); | ||||
|                 // Discarding duplicate single event - keeping existing | ||||
|             } | ||||
|             continue; | ||||
|         } | ||||
| @@ -608,10 +600,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|         }); | ||||
|          | ||||
|         if is_rrule_generated { | ||||
|             println!("🚫 Discarding RRULE-generated instance: '{}' at {} would be generated by recurring event", | ||||
|                 event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||
|                 event.dtstart.format("%Y-%m-%d %H:%M") | ||||
|             ); | ||||
|         } else { | ||||
|             // This is a unique single event | ||||
|             seen_single.insert(dedup_key, deduplicated_single.len()); | ||||
| @@ -623,11 +611,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     let mut result = deduplicated_recurring; | ||||
|     result.extend(deduplicated_single); | ||||
|      | ||||
|     println!("📊 Deduplication complete: {} -> {} events ({} recurring, {} single)",  | ||||
|         original_count, result.len(),  | ||||
|         result.iter().filter(|e| e.rrule.is_some()).count(), | ||||
|         result.iter().filter(|e| e.rrule.is_none()).count() | ||||
|     ); | ||||
|      | ||||
|     result | ||||
| } | ||||
| @@ -653,14 +636,6 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     } | ||||
|      | ||||
|     // Log the RRULEs we're working with | ||||
|     for event in &events { | ||||
|         if let Some(rrule) = &event.rrule { | ||||
|             println!("🔍 RRULE for '{}': {}",  | ||||
|                 event.summary.as_ref().unwrap_or(&"No Title".to_string()), | ||||
|                 rrule | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Check if all events have similar time patterns and could be consolidated | ||||
|     let first_event = &events[0]; | ||||
| @@ -683,7 +658,6 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     }); | ||||
|      | ||||
|     if !can_consolidate { | ||||
|         println!("🚫 Cannot consolidate events - different times or durations"); | ||||
|         // Just deduplicate exact duplicates | ||||
|         return deduplicate_exact_recurring_events(events); | ||||
|     } | ||||
| @@ -696,13 +670,11 @@ fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> { | ||||
|     if weekly_events.len() >= 2 && weekly_events.len() == events.len() { | ||||
|         // All events are weekly - try to consolidate into a single multi-day weekly pattern | ||||
|         if let Some(consolidated) = consolidate_weekly_patterns(&events) { | ||||
|             println!("✅ Successfully consolidated {} weekly patterns into one", events.len()); | ||||
|             return vec![consolidated]; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // If we can't consolidate, just deduplicate exact matches and keep the most complete one | ||||
|     println!("🚫 Cannot consolidate - keeping most complete event"); | ||||
|     let deduplicated = deduplicate_exact_recurring_events(events); | ||||
|      | ||||
|     // If we still have multiple events, keep only the most complete one | ||||
|   | ||||
| @@ -137,13 +137,11 @@ pub async fn create_event_series( | ||||
|     let start_datetime = parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; | ||||
|  | ||||
|     let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|     let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day) | ||||
|         .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; | ||||
|  | ||||
|     // For all-day events, add one day to end date for RFC-5545 compliance | ||||
|     if request.all_day { | ||||
|         end_datetime = end_datetime + chrono::Duration::days(1); | ||||
|     } | ||||
|     // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events | ||||
|     // No additional conversion needed here | ||||
|  | ||||
|     // Generate a unique UID for the series | ||||
|     let uid = format!("series-{}", uuid::Uuid::new_v4().to_string()); | ||||
|   | ||||
| @@ -3,6 +3,7 @@ use axum::{ | ||||
|     response::{IntoResponse, Response}, | ||||
|     Json, | ||||
| }; | ||||
| use calendar_models::VAlarm; | ||||
| use serde::{Deserialize, Serialize}; | ||||
|  | ||||
| // API request/response types | ||||
| @@ -113,7 +114,7 @@ pub struct CreateEventRequest { | ||||
|     pub organizer: String,             // organizer email | ||||
|     pub attendees: String,             // comma-separated attendee emails | ||||
|     pub categories: String,            // comma-separated categories | ||||
|     pub reminder: String,              // reminder type | ||||
|     pub alarms: Vec<VAlarm>,           // event alarms | ||||
|     pub recurrence: String,            // recurrence type | ||||
|     pub recurrence_days: Vec<bool>,    // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence | ||||
|     pub calendar_path: Option<String>, // Optional - use first calendar if not specified | ||||
| @@ -144,7 +145,7 @@ pub struct UpdateEventRequest { | ||||
|     pub organizer: String,             // organizer email | ||||
|     pub attendees: String,             // comma-separated attendee emails | ||||
|     pub categories: String,            // comma-separated categories | ||||
|     pub reminder: String,              // reminder type | ||||
|     pub alarms: Vec<VAlarm>,           // event alarms | ||||
|     pub recurrence: String,            // recurrence type | ||||
|     pub recurrence_days: Vec<bool>,    // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence | ||||
|     pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years | ||||
| @@ -181,7 +182,7 @@ pub struct CreateEventSeriesRequest { | ||||
|     pub organizer: String,    // organizer email | ||||
|     pub attendees: String,    // comma-separated attendee emails | ||||
|     pub categories: String,   // comma-separated categories | ||||
|     pub reminder: String,     // reminder type | ||||
|     pub alarms: Vec<VAlarm>,  // event alarms | ||||
|  | ||||
|     // Series-specific fields | ||||
|     pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) | ||||
| @@ -219,7 +220,7 @@ pub struct UpdateEventSeriesRequest { | ||||
|     pub organizer: String,    // organizer email | ||||
|     pub attendees: String,    // comma-separated attendee emails | ||||
|     pub categories: String,   // comma-separated categories | ||||
|     pub reminder: String,     // reminder type | ||||
|     pub alarms: Vec<VAlarm>,  // event alarms | ||||
|  | ||||
|     // Series-specific fields | ||||
|     pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) | ||||
|   | ||||
| @@ -32,6 +32,23 @@ web-sys = { version = "0.3", features = [ | ||||
|     "CssStyleDeclaration", | ||||
|     "MediaQueryList", | ||||
|     "MediaQueryListEvent", | ||||
|     # Notification API for browser notifications | ||||
|     "Notification", | ||||
|     "NotificationOptions", | ||||
|     "NotificationPermission", | ||||
|     # Service Worker API for background processing | ||||
|     "ServiceWorkerContainer", | ||||
|     "ServiceWorkerRegistration", | ||||
|     "MessageEvent", | ||||
|     # IndexedDB API for persistent alarm storage | ||||
|     "IdbDatabase", | ||||
|     "IdbObjectStore", | ||||
|     "IdbTransaction", | ||||
|     "IdbRequest", | ||||
|     "IdbKeyRange", | ||||
|     "IdbFactory", | ||||
|     "IdbOpenDbRequest", | ||||
|     "IdbVersionChangeEvent", | ||||
| ] } | ||||
| wasm-bindgen = "0.2" | ||||
| js-sys = "0.3" | ||||
| @@ -73,3 +90,6 @@ gloo-storage = "0.3" | ||||
| gloo-timers = "0.3" | ||||
| wasm-bindgen-futures = "0.4" | ||||
|  | ||||
| # IndexedDB for persistent alarm storage | ||||
| indexed_db_futures = "0.4" | ||||
|  | ||||
|   | ||||
| @@ -8,15 +8,28 @@ | ||||
|     <link data-trunk rel="css" href="styles.css"> | ||||
|     <link data-trunk rel="css" href="print-preview.css"> | ||||
|     <link data-trunk rel="copy-file" href="styles/google.css"> | ||||
|     <link data-trunk rel="copy-file" href="service-worker.js"> | ||||
|     <link data-trunk rel="icon" href="favicon.ico"> | ||||
|     <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" /> | ||||
| </head> | ||||
| <body> | ||||
|     <script> | ||||
|         console.log("HTML fully loaded, waiting for WASM..."); | ||||
|         window.addEventListener('TrunkApplicationStarted', () => { | ||||
|             console.log("Trunk application started successfully!"); | ||||
|             // Application loaded successfully | ||||
|         }); | ||||
|  | ||||
|         // Register service worker for alarm background processing | ||||
|         if ('serviceWorker' in navigator) { | ||||
|             window.addEventListener('load', () => { | ||||
|                 navigator.serviceWorker.register('/service-worker.js') | ||||
|                     .then((registration) => { | ||||
|                         // Service worker registered successfully | ||||
|                     }) | ||||
|                     .catch((registrationError) => { | ||||
|                         console.log('SW registration failed: ', registrationError); | ||||
|                     }); | ||||
|             }); | ||||
|         } | ||||
|     </script> | ||||
| </body> | ||||
| </html> | ||||
|   | ||||
							
								
								
									
										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::sidebar::{Style}; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService, AlarmScheduler}; | ||||
| use chrono::NaiveDate; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use gloo_timers::callback::Interval; | ||||
| @@ -149,6 +149,11 @@ pub fn App() -> Html { | ||||
|     let mobile_warning_open = use_state(|| is_mobile_device()); | ||||
|     let refresh_interval = use_state(|| -> Option<Interval> { None }); | ||||
|      | ||||
|     // Alarm system state | ||||
|     let alarm_scheduler = use_state(|| AlarmScheduler::new()); | ||||
|     let alarm_check_interval = use_state(|| -> Option<Interval> { None }); | ||||
|     let alarm_system_initialized = use_state(|| false); | ||||
|  | ||||
|     // Calendar view state - load from localStorage if available | ||||
|     let current_view = use_state(|| { | ||||
|         // Try to load saved view mode from localStorage | ||||
| @@ -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 | ||||
|     let refresh_calendar_data = { | ||||
|         let user_info = user_info.clone(); | ||||
| @@ -786,7 +852,9 @@ pub fn App() -> Html { | ||||
|         let create_event_modal_open = create_event_modal_open.clone(); | ||||
|         let auth_token = auth_token.clone(); | ||||
|         let refresh_calendar_data = refresh_calendar_data.clone(); | ||||
|         let alarm_scheduler = alarm_scheduler.clone(); | ||||
|         Callback::from(move |event_data: EventCreationData| { | ||||
|             let alarm_scheduler = alarm_scheduler.clone(); | ||||
|             // Check if this is an update operation (has original_uid) or a create operation | ||||
|             if let Some(original_uid) = event_data.original_uid.clone() { | ||||
|                 web_sys::console::log_1(&format!("Updating event via modal: {:?}", event_data).into()); | ||||
| @@ -824,13 +892,10 @@ pub fn App() -> Html { | ||||
|                                                    crate::components::event_form::RecurrenceType::Monthly |  | ||||
|                                                    crate::components::event_form::RecurrenceType::Yearly); | ||||
|                          | ||||
|                         web_sys::console::log_1(&format!("🐛 FRONTEND DEBUG: is_recurring={}, edit_scope={:?}, original_uid={:?}",  | ||||
|                             is_recurring, event_data_for_update.edit_scope, event_data_for_update.original_uid).into()); | ||||
|                          | ||||
|                         let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() { | ||||
|                             // Only use series endpoint for existing recurring events being edited | ||||
|                             // Singleton→series conversion should use regular update_event endpoint | ||||
|                             let edit_action = event_data_for_update.edit_scope.unwrap(); | ||||
|                             let edit_action = event_data_for_update.edit_scope.as_ref().unwrap(); | ||||
|                             let scope = match edit_action { | ||||
|                                 crate::components::EditAction::EditAll => "all_in_series".to_string(), | ||||
|                                 crate::components::EditAction::EditFuture => "this_and_future".to_string(), | ||||
| @@ -904,6 +969,45 @@ pub fn App() -> Html { | ||||
|                         match update_result { | ||||
|                             Ok(_) => { | ||||
|                                 web_sys::console::log_1(&"Event updated successfully via modal".into()); | ||||
|                                  | ||||
|                                 // Re-schedule alarms for the updated event | ||||
|                                 let alarm_scheduler_for_update = alarm_scheduler.clone(); | ||||
|                                 let event_data_for_alarms = event_data_for_update.clone(); | ||||
|                                 let original_uid_for_alarms = original_uid.clone(); | ||||
|                                  | ||||
|                                 wasm_bindgen_futures::spawn_local(async move { | ||||
|                                     let mut scheduler = (*alarm_scheduler_for_update).clone(); | ||||
|                                      | ||||
|                                     // Remove old alarms for this event | ||||
|                                     scheduler.remove_event_alarms(&original_uid_for_alarms); | ||||
|                                      | ||||
|                                     // Schedule new alarms if any exist | ||||
|                                     if !event_data_for_alarms.alarms.is_empty() { | ||||
|                                         let params = event_data_for_alarms.to_create_event_params(); | ||||
|                                          | ||||
|                                         // Parse start date/time for alarm scheduling | ||||
|                                         if let (Ok(start_date), Ok(start_time)) = ( | ||||
|                                             chrono::NaiveDate::parse_from_str(¶ms.2, "%Y-%m-%d"), | ||||
|                                             chrono::NaiveTime::parse_from_str(¶ms.3, "%H:%M") | ||||
|                                         ) { | ||||
|                                             // Create a temporary VEvent for alarm scheduling | ||||
|                                             let start_datetime = start_date.and_time(start_time); | ||||
|                                             let mut temp_event = VEvent::new(original_uid_for_alarms.clone(), start_datetime); | ||||
|                                             temp_event.summary = Some(params.0.clone()); // title | ||||
|                                             temp_event.location = if params.6.is_empty() { None } else { Some(params.6.clone()) }; | ||||
|                                             temp_event.alarms = event_data_for_alarms.alarms.clone(); | ||||
|                                              | ||||
|                                             scheduler.schedule_event_alarms(&temp_event); | ||||
|                                              | ||||
|                                             web_sys::console::log_1( | ||||
|                                                 &format!("🔔 Re-scheduled {} alarm(s) for updated event", temp_event.alarms.len()).into() | ||||
|                                             ); | ||||
|                                         } | ||||
|                                     } | ||||
|                                      | ||||
|                                     alarm_scheduler_for_update.set(scheduler); | ||||
|                                 }); | ||||
|                                  | ||||
|                                 // Refresh calendar data without page reload | ||||
|                                 refresh_callback.emit(()); | ||||
|                             } | ||||
| @@ -961,6 +1065,12 @@ pub fn App() -> Html { | ||||
|                     }; | ||||
|  | ||||
|                     let params = event_data.to_create_event_params(); | ||||
|                     // Clone values we'll need for alarm scheduling | ||||
|                     let title_for_alarms = params.0.clone(); | ||||
|                     let start_date_for_alarms = params.2.clone(); | ||||
|                     let start_time_for_alarms = params.3.clone(); | ||||
|                     let location_for_alarms = params.6.clone(); | ||||
|                      | ||||
|                     let create_result = _calendar_service | ||||
|                         .create_event( | ||||
|                             &_token, &_password, params.0,  // title | ||||
| @@ -990,6 +1100,36 @@ pub fn App() -> Html { | ||||
|                     match create_result { | ||||
|                         Ok(_) => { | ||||
|                             web_sys::console::log_1(&"Event created successfully".into()); | ||||
|                              | ||||
|                             // Schedule alarms for the created event if any exist | ||||
|                             if !event_data.alarms.is_empty() { | ||||
|                                 // Since create_event doesn't return the UID, we need to generate one | ||||
|                                 // The backend should be using the same UUID generation logic | ||||
|                                 let event_uid = uuid::Uuid::new_v4().to_string(); | ||||
|                                  | ||||
|                                 // Parse start date/time for alarm scheduling | ||||
|                                 if let (Ok(start_date), Ok(start_time)) = ( | ||||
|                                     chrono::NaiveDate::parse_from_str(&start_date_for_alarms, "%Y-%m-%d"), | ||||
|                                     chrono::NaiveTime::parse_from_str(&start_time_for_alarms, "%H:%M") | ||||
|                                 ) { | ||||
|                                     // Create a temporary VEvent for alarm scheduling | ||||
|                                     let start_datetime = start_date.and_time(start_time); | ||||
|                                     let mut temp_event = VEvent::new(event_uid.clone(), start_datetime); | ||||
|                                     temp_event.summary = Some(title_for_alarms.clone()); | ||||
|                                     temp_event.location = if location_for_alarms.is_empty() { None } else { Some(location_for_alarms.clone()) }; | ||||
|                                     temp_event.alarms = event_data.alarms.clone(); | ||||
|                                      | ||||
|                                     // Schedule alarms for the new event (synchronously) | ||||
|                                     let mut scheduler = (*alarm_scheduler).clone(); | ||||
|                                     scheduler.schedule_event_alarms(&temp_event); | ||||
|                                     alarm_scheduler.set(scheduler); | ||||
|                                      | ||||
|                                     web_sys::console::log_1( | ||||
|                                         &format!("🔔 Scheduled {} alarm(s) for new event", temp_event.alarms.len()).into() | ||||
|                                     ); | ||||
|                                 } | ||||
|                             } | ||||
|                              | ||||
|                             // Refresh calendar data without page reload | ||||
|                             refresh_callback.emit(()); | ||||
|                         } | ||||
| @@ -1096,7 +1236,7 @@ pub fn App() -> Html { | ||||
|                         }; | ||||
|  | ||||
|                         // Convert reminders to string format | ||||
|                         let reminder_str = if !original_event.alarms.is_empty() { | ||||
|                         let _reminder_str = if !original_event.alarms.is_empty() { | ||||
|                             // Convert from VAlarm to minutes before | ||||
|                             "15".to_string() // TODO: Convert VAlarm trigger to minutes | ||||
|                         } else { | ||||
| @@ -1147,7 +1287,7 @@ pub fn App() -> Html { | ||||
|                                                 .collect::<Vec<_>>() | ||||
|                                                 .join(","), | ||||
|                                             original_event.categories.join(","), | ||||
|                                             reminder_str.clone(), | ||||
|                                             original_event.alarms.clone(), | ||||
|                                             recurrence_str.clone(), | ||||
|                                             vec![false; 7], // recurrence_days | ||||
|                                             1, // recurrence_interval - default for drag-and-drop | ||||
| @@ -1204,7 +1344,7 @@ pub fn App() -> Html { | ||||
|                                         .collect::<Vec<_>>() | ||||
|                                         .join(","), | ||||
|                                     original_event.categories.join(","), | ||||
|                                     reminder_str, | ||||
|                                     original_event.alarms.clone(), | ||||
|                                     recurrence_str, | ||||
|                                     recurrence_days, | ||||
|                                     1, // recurrence_interval - default to 1 for drag-and-drop | ||||
|   | ||||
| @@ -3,7 +3,7 @@ use crate::components::{ | ||||
| }; | ||||
| use crate::models::ical::VEvent; | ||||
| use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; | ||||
| use chrono::{Datelike, Duration, Local, NaiveDate}; | ||||
| use chrono::{Datelike, Duration, Local, NaiveDate, Weekday}; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
| use std::collections::HashMap; | ||||
| use web_sys::MouseEvent; | ||||
| @@ -111,6 +111,7 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|         use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| { | ||||
|             let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); | ||||
|             let date = *date; // Clone the date to avoid lifetime issues | ||||
|             let view_mode = _view.clone(); // Clone the view mode to avoid lifetime issues | ||||
|             let external_events = external_events.clone(); // Clone external events to avoid lifetime issues | ||||
|             let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues | ||||
|              | ||||
| @@ -136,17 +137,53 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|                         String::new() | ||||
|                     }; | ||||
|  | ||||
|                     let current_year = date.year(); | ||||
|                     let current_month = date.month(); | ||||
|                     // Determine which months to fetch based on view mode | ||||
|                     let months_to_fetch = match view_mode { | ||||
|                         ViewMode::Month => { | ||||
|                             // For month view, just fetch the current month | ||||
|                             vec![(date.year(), date.month())] | ||||
|                         } | ||||
|                         ViewMode::Week => { | ||||
|                             // For week view, calculate the week bounds and fetch all months that intersect | ||||
|                             let start_of_week = get_start_of_week(date); | ||||
|                             let end_of_week = start_of_week + Duration::days(6); | ||||
|                              | ||||
|                             let mut months = vec![(start_of_week.year(), start_of_week.month())]; | ||||
|                              | ||||
|                             // If the week spans into a different month, add that month too | ||||
|                             if end_of_week.month() != start_of_week.month() || end_of_week.year() != start_of_week.year() { | ||||
|                                 months.push((end_of_week.year(), end_of_week.month())); | ||||
|                             } | ||||
|                              | ||||
|                             months | ||||
|                         } | ||||
|                     }; | ||||
|  | ||||
|                     // Fetch events for all required months | ||||
|                     let mut all_events = Vec::new(); | ||||
|                     for (year, month) in months_to_fetch { | ||||
|                         match calendar_service | ||||
|                             .fetch_events_for_month_vevent( | ||||
|                                 &token, | ||||
|                                 &password, | ||||
|                             current_year, | ||||
|                             current_month, | ||||
|                                 year, | ||||
|                                 month, | ||||
|                             ) | ||||
|                             .await | ||||
|                         { | ||||
|                             Ok(mut month_events) => { | ||||
|                                 all_events.append(&mut month_events); | ||||
|                             } | ||||
|                             Err(err) => { | ||||
|                                 error.set(Some(format!("Failed to load events for {}-{}: {}", year, month, err))); | ||||
|                                 loading.set(false); | ||||
|                                 return; | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     // Process the combined events | ||||
|                     match Ok(all_events) as Result<Vec<VEvent>, String> | ||||
|                     { | ||||
|                         Ok(vevents) => { | ||||
|                             // Filter CalDAV events based on calendar visibility | ||||
| @@ -602,3 +639,18 @@ pub fn Calendar(props: &CalendarProps) -> Html { | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Helper function to calculate the start of the week (Sunday) for a given date | ||||
| fn get_start_of_week(date: NaiveDate) -> NaiveDate { | ||||
|     let weekday = date.weekday(); | ||||
|     let days_from_sunday = match weekday { | ||||
|         Weekday::Sun => 0, | ||||
|         Weekday::Mon => 1, | ||||
|         Weekday::Tue => 2, | ||||
|         Weekday::Wed => 3, | ||||
|         Weekday::Thu => 4, | ||||
|         Weekday::Fri => 5, | ||||
|         Weekday::Sat => 6, | ||||
|     }; | ||||
|     date - Duration::days(days_from_sunday) | ||||
| } | ||||
|   | ||||
| @@ -257,7 +257,13 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend | ||||
|          | ||||
|         // Timing | ||||
|         start_date: start_local.date(), | ||||
|         end_date: end_local.date(), | ||||
|         end_date: if event.all_day { | ||||
|             // For all-day events, subtract one day to convert from exclusive to inclusive end date | ||||
|             // (UI expects inclusive dates, but iCalendar stores exclusive end dates) | ||||
|             end_local.date() - chrono::Duration::days(1) | ||||
|         } else { | ||||
|             end_local.date() | ||||
|         }, | ||||
|         start_time: start_local.time(), | ||||
|         end_time: end_local.time(), | ||||
|          | ||||
| @@ -286,8 +292,8 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend | ||||
|         // Categorization   | ||||
|         categories: event.categories.join(","), | ||||
|          | ||||
|         // Reminders - TODO: Parse alarm from VEvent if needed | ||||
|         reminder: ReminderType::None, | ||||
|         // Reminders - Use VAlarms from the event | ||||
|         alarms: event.alarms.clone(), | ||||
|          | ||||
|         // Recurrence - Parse RRULE if present | ||||
|         recurrence: if let Some(ref rrule_str) = event.rrule { | ||||
|   | ||||
							
								
								
									
										296
									
								
								frontend/src/components/event_form/add_alarm_modal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										296
									
								
								frontend/src/components/event_form/add_alarm_modal.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,296 @@ | ||||
| use calendar_models::{VAlarm, AlarmAction, AlarmTrigger}; | ||||
| use chrono::{Duration, DateTime, Utc, NaiveTime}; | ||||
| use wasm_bindgen::JsCast; | ||||
| use web_sys::{HtmlSelectElement, HtmlInputElement}; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum TriggerType { | ||||
|     Relative, // Duration before/after event | ||||
|     Absolute, // Specific date/time | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum RelativeTo { | ||||
|     Start, | ||||
|     End, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq)] | ||||
| pub enum TimeUnit { | ||||
|     Minutes, | ||||
|     Hours, | ||||
|     Days, | ||||
|     Weeks, | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct AddAlarmModalProps { | ||||
|     pub is_open: bool, | ||||
|     pub editing_index: Option<usize>, // If editing an existing alarm | ||||
|     pub initial_alarm: Option<VAlarm>, // For editing mode | ||||
|     pub on_close: Callback<()>, | ||||
|     pub on_save: Callback<VAlarm>, | ||||
| } | ||||
|  | ||||
| #[function_component(AddAlarmModal)] | ||||
| pub fn add_alarm_modal(props: &AddAlarmModalProps) -> Html { | ||||
|     // Form state | ||||
|     let trigger_type = use_state(|| TriggerType::Relative); | ||||
|     let relative_to = use_state(|| RelativeTo::Start); | ||||
|     let time_unit = use_state(|| TimeUnit::Minutes); | ||||
|     let time_value = use_state(|| 15i32); | ||||
|     let before_after = use_state(|| true); // true = before, false = after | ||||
|     let absolute_date = use_state(|| chrono::Local::now().date_naive()); | ||||
|     let absolute_time = use_state(|| NaiveTime::from_hms_opt(9, 0, 0).unwrap()); | ||||
|  | ||||
|     // Initialize form with existing alarm data if editing | ||||
|     { | ||||
|         let trigger_type = trigger_type.clone(); | ||||
|         let time_value = time_value.clone(); | ||||
|          | ||||
|         use_effect_with(props.initial_alarm.clone(), move |initial_alarm| { | ||||
|             if let Some(alarm) = initial_alarm { | ||||
|                 match &alarm.trigger { | ||||
|                     AlarmTrigger::Duration(duration) => { | ||||
|                         trigger_type.set(TriggerType::Relative); | ||||
|                         let minutes = duration.num_minutes().abs(); | ||||
|                         time_value.set(minutes as i32); | ||||
|                     } | ||||
|                     AlarmTrigger::DateTime(_) => { | ||||
|                         trigger_type.set(TriggerType::Absolute); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     let on_trigger_type_change = { | ||||
|         let trigger_type = trigger_type.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let new_type = match target.value().as_str() { | ||||
|                     "absolute" => TriggerType::Absolute, | ||||
|                     _ => TriggerType::Relative, | ||||
|                 }; | ||||
|                 trigger_type.set(new_type); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|  | ||||
|     let on_relative_to_change = { | ||||
|         let relative_to = relative_to.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let new_relative = match target.value().as_str() { | ||||
|                     "end" => RelativeTo::End, | ||||
|                     _ => RelativeTo::Start, | ||||
|                 }; | ||||
|                 relative_to.set(new_relative); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_time_unit_change = { | ||||
|         let time_unit = time_unit.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let new_unit = match target.value().as_str() { | ||||
|                     "hours" => TimeUnit::Hours, | ||||
|                     "days" => TimeUnit::Days, | ||||
|                     "weeks" => TimeUnit::Weeks, | ||||
|                     _ => TimeUnit::Minutes, | ||||
|                 }; | ||||
|                 time_unit.set(new_unit); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_time_value_change = { | ||||
|         let time_value = time_value.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlInputElement>() { | ||||
|                 if let Ok(value) = target.value().parse::<i32>() { | ||||
|                     time_value.set(value.max(1)); // Minimum 1 | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_before_after_change = { | ||||
|         let before_after = before_after.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target_dyn_into::<HtmlSelectElement>() { | ||||
|                 let is_before = target.value() == "before"; | ||||
|                 before_after.set(is_before); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|  | ||||
|  | ||||
|     let on_save_click = { | ||||
|         let trigger_type = trigger_type.clone(); | ||||
|         let time_unit = time_unit.clone(); | ||||
|         let time_value = time_value.clone(); | ||||
|         let before_after = before_after.clone(); | ||||
|         let absolute_date = absolute_date.clone(); | ||||
|         let absolute_time = absolute_time.clone(); | ||||
|         let on_save = props.on_save.clone(); | ||||
|          | ||||
|         Callback::from(move |_| { | ||||
|             // Create the alarm trigger | ||||
|             let trigger = match *trigger_type { | ||||
|                 TriggerType::Relative => { | ||||
|                     let minutes = match *time_unit { | ||||
|                         TimeUnit::Minutes => *time_value, | ||||
|                         TimeUnit::Hours => *time_value * 60, | ||||
|                         TimeUnit::Days => *time_value * 60 * 24, | ||||
|                         TimeUnit::Weeks => *time_value * 60 * 24 * 7, | ||||
|                     }; | ||||
|                      | ||||
|                     let signed_minutes = if *before_after { -minutes } else { minutes } as i64; | ||||
|                     AlarmTrigger::Duration(Duration::minutes(signed_minutes)) | ||||
|                 } | ||||
|                 TriggerType::Absolute => { | ||||
|                     // Combine date and time to create a DateTime<Utc> | ||||
|                     let naive_datetime = absolute_date.and_time(*absolute_time); | ||||
|                     let utc_datetime = DateTime::from_naive_utc_and_offset(naive_datetime, Utc); | ||||
|                     AlarmTrigger::DateTime(utc_datetime) | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             // Create the VAlarm - always use Display action, no custom description | ||||
|             let alarm = VAlarm { | ||||
|                 action: AlarmAction::Display, | ||||
|                 trigger, | ||||
|                 duration: None, | ||||
|                 repeat: None, | ||||
|                 description: None, | ||||
|                 summary: None, | ||||
|                 attendees: Vec::new(), | ||||
|                 attach: Vec::new(), | ||||
|             }; | ||||
|  | ||||
|             on_save.emit(alarm); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_backdrop_click = { | ||||
|         let on_close = props.on_close.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             if let Some(target) = e.target() { | ||||
|                 if let Some(element) = target.dyn_ref::<web_sys::Element>() { | ||||
|                     if element.class_list().contains("add-alarm-backdrop") { | ||||
|                         on_close.emit(()); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     if !props.is_open { | ||||
|         return html! {}; | ||||
|     } | ||||
|  | ||||
|     html! { | ||||
|         <div class="add-alarm-backdrop" onclick={on_backdrop_click}> | ||||
|             <div class="add-alarm-modal"> | ||||
|                 <div class="add-alarm-header"> | ||||
|                     <h3>{ | ||||
|                         if props.editing_index.is_some() {  | ||||
|                             "Edit Reminder"  | ||||
|                         } else {  | ||||
|                             "Add Reminder"  | ||||
|                         } | ||||
|                     }</h3> | ||||
|                     <button class="close-button" onclick={Callback::from({ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         move |_| on_close.emit(()) | ||||
|                     })}> | ||||
|                         {"×"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="add-alarm-content"> | ||||
|                     // Trigger Type Selection | ||||
|                     <div class="form-group"> | ||||
|                         <label for="trigger-type">{"Trigger Type"}</label> | ||||
|                         <select id="trigger-type" class="form-input" onchange={on_trigger_type_change}> | ||||
|                             <option value="relative" selected={matches!(*trigger_type, TriggerType::Relative)}> | ||||
|                                 {"Relative to event time"} | ||||
|                             </option> | ||||
|                             <option value="absolute" selected={matches!(*trigger_type, TriggerType::Absolute)}> | ||||
|                                 {"Specific date and time"} | ||||
|                             </option> | ||||
|                         </select> | ||||
|                     </div> | ||||
|  | ||||
|                     // Relative Trigger Configuration | ||||
|                     if matches!(*trigger_type, TriggerType::Relative) { | ||||
|                         <div class="form-group"> | ||||
|                             <label>{"When"}</label> | ||||
|                             <div class="relative-time-inputs"> | ||||
|                                 <input  | ||||
|                                     type="number"  | ||||
|                                     class="form-input time-value-input" | ||||
|                                     value={time_value.to_string()} | ||||
|                                     min="1" | ||||
|                                     onchange={on_time_value_change} | ||||
|                                 /> | ||||
|                                 <select class="form-input time-unit-select" onchange={on_time_unit_change}> | ||||
|                                     <option value="minutes" selected={matches!(*time_unit, TimeUnit::Minutes)}>{"minute(s)"}</option> | ||||
|                                     <option value="hours" selected={matches!(*time_unit, TimeUnit::Hours)}>{"hour(s)"}</option> | ||||
|                                     <option value="days" selected={matches!(*time_unit, TimeUnit::Days)}>{"day(s)"}</option> | ||||
|                                     <option value="weeks" selected={matches!(*time_unit, TimeUnit::Weeks)}>{"week(s)"}</option> | ||||
|                                 </select> | ||||
|                                 <select class="form-input before-after-select" onchange={on_before_after_change}> | ||||
|                                     <option value="before" selected={*before_after}>{"before"}</option> | ||||
|                                     <option value="after" selected={!*before_after}>{"after"}</option> | ||||
|                                 </select> | ||||
|                                 <select class="form-input relative-to-select" onchange={on_relative_to_change}> | ||||
|                                     <option value="start" selected={matches!(*relative_to, RelativeTo::Start)}>{"event start"}</option> | ||||
|                                     <option value="end" selected={matches!(*relative_to, RelativeTo::End)}>{"event end"}</option> | ||||
|                                 </select> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|  | ||||
|                     // Absolute Trigger Configuration   | ||||
|                     if matches!(*trigger_type, TriggerType::Absolute) { | ||||
|                         <div class="form-group"> | ||||
|                             <label>{"Date and Time"}</label> | ||||
|                             <div class="absolute-time-inputs"> | ||||
|                                 <input  | ||||
|                                     type="date"  | ||||
|                                     class="form-input" | ||||
|                                     value={absolute_date.format("%Y-%m-%d").to_string()} | ||||
|                                 /> | ||||
|                                 <input  | ||||
|                                     type="time"  | ||||
|                                     class="form-input" | ||||
|                                     value={absolute_time.format("%H:%M").to_string()} | ||||
|                                 /> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|  | ||||
|  | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="add-alarm-footer"> | ||||
|                     <button class="cancel-button" onclick={Callback::from({ | ||||
|                         let on_close = props.on_close.clone(); | ||||
|                         move |_| on_close.emit(()) | ||||
|                     })}> | ||||
|                         {"Cancel"} | ||||
|                     </button> | ||||
|                     <button class="save-button" onclick={on_save_click}> | ||||
|                         {if props.editing_index.is_some() { "Update" } else { "Add Reminder" }} | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										133
									
								
								frontend/src/components/event_form/alarm_list.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								frontend/src/components/event_form/alarm_list.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| use calendar_models::{VAlarm, AlarmAction, AlarmTrigger}; | ||||
| use chrono::Duration; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct AlarmListProps { | ||||
|     pub alarms: Vec<VAlarm>, | ||||
|     pub on_alarm_delete: Callback<usize>, // Index of alarm to delete | ||||
|     pub on_alarm_edit: Callback<usize>,   // Index of alarm to edit | ||||
| } | ||||
|  | ||||
| #[function_component(AlarmList)] | ||||
| pub fn alarm_list(props: &AlarmListProps) -> Html { | ||||
|     if props.alarms.is_empty() { | ||||
|         return html! { | ||||
|             <div class="alarm-list-empty"> | ||||
|                 <p class="text-muted">{"No reminders set"}</p> | ||||
|                 <p class="text-small">{"Click 'Add Reminder' to create your first reminder"}</p> | ||||
|             </div> | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     html! { | ||||
|         <div class="alarm-list"> | ||||
|             <h6>{"Configured Reminders"}</h6> | ||||
|             <div class="alarm-items"> | ||||
|                 { | ||||
|                     props.alarms.iter().enumerate().map(|(index, alarm)| { | ||||
|                         let alarm_description = format_alarm_description(alarm); | ||||
|                         let action_icon = get_action_icon(&alarm.action); | ||||
|                          | ||||
|                         let on_delete = { | ||||
|                             let on_alarm_delete = props.on_alarm_delete.clone(); | ||||
|                             Callback::from(move |_| { | ||||
|                                 on_alarm_delete.emit(index); | ||||
|                             }) | ||||
|                         }; | ||||
|                          | ||||
|                         let on_edit = { | ||||
|                             let on_alarm_edit = props.on_alarm_edit.clone(); | ||||
|                             Callback::from(move |_| { | ||||
|                                 on_alarm_edit.emit(index); | ||||
|                             }) | ||||
|                         }; | ||||
|  | ||||
|                         html! { | ||||
|                             <div key={index} class="alarm-item"> | ||||
|                                 <div class="alarm-content"> | ||||
|                                     <span class="alarm-icon">{action_icon}</span> | ||||
|                                     <span class="alarm-description">{alarm_description}</span> | ||||
|                                 </div> | ||||
|                                 <div class="alarm-actions"> | ||||
|                                     <button  | ||||
|                                         class="alarm-action-btn edit-btn"  | ||||
|                                         title="Edit reminder" | ||||
|                                         onclick={on_edit} | ||||
|                                     > | ||||
|                                         <i class="fas fa-edit"></i> | ||||
|                                     </button> | ||||
|                                     <button  | ||||
|                                         class="alarm-action-btn delete-btn"  | ||||
|                                         title="Delete reminder" | ||||
|                                         onclick={on_delete} | ||||
|                                     > | ||||
|                                         <i class="fas fa-trash"></i> | ||||
|                                     </button> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         } | ||||
|                     }).collect::<Html>() | ||||
|                 } | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Format alarm description for display | ||||
| fn format_alarm_description(alarm: &VAlarm) -> String { | ||||
|     match &alarm.trigger { | ||||
|         AlarmTrigger::Duration(duration) => { | ||||
|             format_duration_description(duration) | ||||
|         } | ||||
|         AlarmTrigger::DateTime(datetime) => { | ||||
|             format!("At {}", datetime.format("%Y-%m-%d %H:%M UTC")) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Get icon for alarm action - always use bell for consistent notification type | ||||
| fn get_action_icon(_action: &AlarmAction) -> Html { | ||||
|     html! { <i class="fas fa-bell"></i> } | ||||
| } | ||||
|  | ||||
| /// Format duration for human-readable description | ||||
| fn format_duration_description(duration: &Duration) -> String { | ||||
|     let minutes = duration.num_minutes(); | ||||
|      | ||||
|     if minutes == 0 { | ||||
|         return "At event time".to_string(); | ||||
|     } | ||||
|      | ||||
|     let abs_minutes = minutes.abs(); | ||||
|     let before_or_after = if minutes < 0 { "before" } else { "after" }; | ||||
|      | ||||
|     // Convert to human-readable format | ||||
|     if abs_minutes >= 60 * 24 * 7 { | ||||
|         let weeks = abs_minutes / (60 * 24 * 7); | ||||
|         let remainder = abs_minutes % (60 * 24 * 7); | ||||
|         if remainder == 0 { | ||||
|             format!("{} week{} {}", weeks, if weeks == 1 { "" } else { "s" }, before_or_after) | ||||
|         } else { | ||||
|             format!("{} minutes {}", abs_minutes, before_or_after) | ||||
|         } | ||||
|     } else if abs_minutes >= 60 * 24 { | ||||
|         let days = abs_minutes / (60 * 24); | ||||
|         let remainder = abs_minutes % (60 * 24); | ||||
|         if remainder == 0 { | ||||
|             format!("{} day{} {}", days, if days == 1 { "" } else { "s" }, before_or_after) | ||||
|         } else { | ||||
|             format!("{} minutes {}", abs_minutes, before_or_after) | ||||
|         } | ||||
|     } else if abs_minutes >= 60 { | ||||
|         let hours = abs_minutes / 60; | ||||
|         let remainder = abs_minutes % 60; | ||||
|         if remainder == 0 { | ||||
|             format!("{} hour{} {}", hours, if hours == 1 { "" } else { "s" }, before_or_after) | ||||
|         } else { | ||||
|             format!("{} minutes {}", abs_minutes, before_or_after) | ||||
|         } | ||||
|     } else { | ||||
|         format!("{} minute{} {}", abs_minutes, if abs_minutes == 1 { "" } else { "s" }, before_or_after) | ||||
|     } | ||||
| } | ||||
| @@ -99,26 +99,13 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_reminder_change = { | ||||
|         let data = data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target() { | ||||
|                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||
|                     let mut event_data = (*data).clone(); | ||||
|                     event_data.reminder = match select.value().as_str() { | ||||
|                         "15min" => ReminderType::Minutes15, | ||||
|                         "30min" => ReminderType::Minutes30, | ||||
|                         "1hour" => ReminderType::Hour1, | ||||
|                         "1day" => ReminderType::Day1, | ||||
|                         "2days" => ReminderType::Days2, | ||||
|                         "1week" => ReminderType::Week1, | ||||
|                         _ => ReminderType::None, | ||||
|                     }; | ||||
|                     data.set(event_data); | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|     // TODO: Replace with new alarm management UI | ||||
|     // let on_reminder_change = { | ||||
|     //     let data = data.clone(); | ||||
|     //     Callback::from(move |e: Event| { | ||||
|     //         // Will be replaced with VAlarm management | ||||
|     //     }) | ||||
|     // }; | ||||
|  | ||||
|     let on_recurrence_interval_change = { | ||||
|         let data = data.clone(); | ||||
| @@ -321,6 +308,7 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|                 ></textarea> | ||||
|             </div> | ||||
|  | ||||
|             <div class="form-row"> | ||||
|                 <div class="form-group"> | ||||
|                     <label for="event-calendar">{"Calendar"}</label> | ||||
|                     <select | ||||
| @@ -345,18 +333,6 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|                     </select> | ||||
|                 </div> | ||||
|  | ||||
|             <div class="form-group"> | ||||
|                 <label class="checkbox-label"> | ||||
|                     <input | ||||
|                         type="checkbox" | ||||
|                         checked={data.all_day} | ||||
|                         onchange={on_all_day_change} | ||||
|                     /> | ||||
|                     {" All Day"} | ||||
|                 </label> | ||||
|             </div> | ||||
|  | ||||
|             <div class="form-row"> | ||||
|                 <div class="form-group"> | ||||
|                     <label for="event-recurrence-basic">{"Repeat"}</label> | ||||
|                     <select | ||||
| @@ -371,21 +347,6 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|                         <option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|  | ||||
|                 <div class="form-group"> | ||||
|                     <label for="event-reminder-basic">{"Reminder"}</label> | ||||
|                     <select | ||||
|                         id="event-reminder-basic" | ||||
|                         class="form-input" | ||||
|                         onchange={on_reminder_change} | ||||
|                     > | ||||
|                         <option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option> | ||||
|                         <option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option> | ||||
|                         <option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option> | ||||
|                         <option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option> | ||||
|                         <option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option> | ||||
|                     </select> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             // RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder! | ||||
| @@ -659,6 +620,18 @@ pub fn basic_details_tab(props: &TabProps) -> Html { | ||||
|                 </div> | ||||
|             } | ||||
|  | ||||
|             // All Day checkbox above date/time fields | ||||
|             <div class="form-group"> | ||||
|                 <label class="checkbox-label"> | ||||
|                     <input | ||||
|                         type="checkbox" | ||||
|                         checked={data.all_day} | ||||
|                         onchange={on_all_day_change} | ||||
|                     /> | ||||
|                     {" All Day"} | ||||
|                 </label> | ||||
|             </div> | ||||
|  | ||||
|             // Date and time fields go here AFTER recurrence options | ||||
|             <div class="form-row"> | ||||
|                 <div class="form-group"> | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| // Event form components module | ||||
| pub mod types; | ||||
| pub mod alarm_list; | ||||
| pub mod add_alarm_modal; | ||||
| pub mod basic_details; | ||||
| pub mod advanced; | ||||
| pub mod people; | ||||
| @@ -8,6 +10,8 @@ pub mod location; | ||||
| pub mod reminders; | ||||
|  | ||||
| pub use types::*; | ||||
| pub use alarm_list::AlarmList; | ||||
| pub use add_alarm_modal::AddAlarmModal; | ||||
| pub use basic_details::BasicDetailsTab; | ||||
| pub use advanced::AdvancedTab; | ||||
| pub use people::PeopleTab; | ||||
|   | ||||
| @@ -1,100 +1,116 @@ | ||||
| use super::types::*; | ||||
| // Types are already imported from super::types::* | ||||
| use wasm_bindgen::JsCast; | ||||
| use web_sys::HtmlSelectElement; | ||||
| use super::{types::*, AlarmList, AddAlarmModal}; | ||||
| use calendar_models::VAlarm; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[function_component(RemindersTab)] | ||||
| pub fn reminders_tab(props: &TabProps) -> Html { | ||||
|     let data = &props.data; | ||||
|      | ||||
|     let on_reminder_change = { | ||||
|         let data = data.clone(); | ||||
|         Callback::from(move |e: Event| { | ||||
|             if let Some(target) = e.target() { | ||||
|                 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { | ||||
|                     let mut event_data = (*data).clone(); | ||||
|                     event_data.reminder = match select.value().as_str() { | ||||
|                         "15min" => ReminderType::Minutes15, | ||||
|                         "30min" => ReminderType::Minutes30, | ||||
|                         "1hour" => ReminderType::Hour1, | ||||
|                         "1day" => ReminderType::Day1, | ||||
|                         "2days" => ReminderType::Days2, | ||||
|                         "1week" => ReminderType::Week1, | ||||
|                         _ => ReminderType::None, | ||||
|     // Modal state | ||||
|     let is_modal_open = use_state(|| false); | ||||
|     let editing_index = use_state(|| None::<usize>); | ||||
|      | ||||
|     // Add alarm callback | ||||
|     let on_add_alarm = { | ||||
|         let is_modal_open = is_modal_open.clone(); | ||||
|         let editing_index = editing_index.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             editing_index.set(None); | ||||
|             is_modal_open.set(true); | ||||
|         }) | ||||
|     }; | ||||
|                     data.set(event_data); | ||||
|                 } | ||||
|      | ||||
|     // Edit alarm callback | ||||
|     let on_alarm_edit = { | ||||
|         let is_modal_open = is_modal_open.clone(); | ||||
|         let editing_index = editing_index.clone(); | ||||
|         Callback::from(move |index: usize| { | ||||
|             editing_index.set(Some(index)); | ||||
|             is_modal_open.set(true); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Delete alarm callback | ||||
|     let on_alarm_delete = { | ||||
|         let data = data.clone(); | ||||
|         Callback::from(move |index: usize| { | ||||
|             let mut current_data = (*data).clone(); | ||||
|             if index < current_data.alarms.len() { | ||||
|                 current_data.alarms.remove(index); | ||||
|                 data.set(current_data); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Close modal callback | ||||
|     let on_modal_close = { | ||||
|         let is_modal_open = is_modal_open.clone(); | ||||
|         let editing_index = editing_index.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             is_modal_open.set(false); | ||||
|             editing_index.set(None); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Save alarm callback | ||||
|     let on_alarm_save = { | ||||
|         let data = data.clone(); | ||||
|         let is_modal_open = is_modal_open.clone(); | ||||
|         let editing_index = editing_index.clone(); | ||||
|         Callback::from(move |alarm: VAlarm| { | ||||
|             let mut current_data = (*data).clone(); | ||||
|              | ||||
|             if let Some(index) = *editing_index { | ||||
|                 // Edit existing alarm | ||||
|                 if index < current_data.alarms.len() { | ||||
|                     current_data.alarms[index] = alarm; | ||||
|                 } | ||||
|             } else { | ||||
|                 // Add new alarm | ||||
|                 current_data.alarms.push(alarm); | ||||
|             } | ||||
|              | ||||
|             data.set(current_data); | ||||
|             is_modal_open.set(false); | ||||
|             editing_index.set(None); | ||||
|         }) | ||||
|     }; | ||||
|      | ||||
|     // Get initial alarm for editing | ||||
|     let initial_alarm = (*editing_index).and_then(|index| { | ||||
|         data.alarms.get(index).cloned() | ||||
|     }); | ||||
|  | ||||
|     html! { | ||||
|         <div class="tab-panel"> | ||||
|             <div class="form-group"> | ||||
|                 <label for="event-reminder-main">{"Primary Reminder"}</label> | ||||
|                 <select | ||||
|                     id="event-reminder-main" | ||||
|                     class="form-input" | ||||
|                     onchange={on_reminder_change} | ||||
|                 <div class="alarm-management-header"> | ||||
|                     <h5>{"Event Reminders"}</h5> | ||||
|                     <button  | ||||
|                         class="add-alarm-button"  | ||||
|                         onclick={on_add_alarm} | ||||
|                         type="button" | ||||
|                     > | ||||
|                     <option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"No reminder"}</option> | ||||
|                     <option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option> | ||||
|                     <option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option> | ||||
|                     <option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option> | ||||
|                     <option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option> | ||||
|                     <option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option> | ||||
|                     <option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option> | ||||
|                 </select> | ||||
|                 <p class="form-help-text">{"Choose when you'd like to be reminded about this event"}</p> | ||||
|                         <i class="fas fa-plus"></i> | ||||
|                         {" Add Reminder"} | ||||
|                     </button> | ||||
|                 </div> | ||||
|                 <p class="form-help-text">{"Configure multiple reminders with custom timing and notification types"}</p> | ||||
|             </div> | ||||
|  | ||||
|             <div class="reminder-types"> | ||||
|                 <h5>{"Reminder & Alarm Types"}</h5> | ||||
|                 <div class="alarm-examples"> | ||||
|                     <div class="alarm-type"> | ||||
|                         <strong>{"Display Alarm"}</strong> | ||||
|                         <p>{"Pop-up notification on your device"}</p> | ||||
|                     </div> | ||||
|                     <div class="alarm-type"> | ||||
|                         <strong>{"Email Reminder"}</strong> | ||||
|                         <p>{"Email notification sent to your address"}</p> | ||||
|                     </div> | ||||
|                     <div class="alarm-type"> | ||||
|                         <strong>{"Audio Alert"}</strong> | ||||
|                         <p>{"Sound notification with custom audio"}</p> | ||||
|                     </div> | ||||
|                     <div class="alarm-type"> | ||||
|                         <strong>{"SMS/Text"}</strong> | ||||
|                         <p>{"Text message reminder (enterprise feature)"}</p> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p> | ||||
|             </div> | ||||
|             <AlarmList | ||||
|                 alarms={data.alarms.clone()} | ||||
|                 on_alarm_delete={on_alarm_delete} | ||||
|                 on_alarm_edit={on_alarm_edit} | ||||
|             /> | ||||
|  | ||||
|             <div class="reminder-info"> | ||||
|                 <h5>{"Advanced Reminder Features"}</h5> | ||||
|                 <ul> | ||||
|                     <li>{"Multiple reminders per event with different timing"}</li> | ||||
|                     <li>{"Custom reminder messages and descriptions"}</li> | ||||
|                     <li>{"Recurring reminders for recurring events"}</li> | ||||
|                     <li>{"Snooze and dismiss functionality"}</li> | ||||
|                     <li>{"Integration with system notifications"}</li> | ||||
|                 </ul> | ||||
|  | ||||
|                 <div class="attachments-section"> | ||||
|                     <h6>{"File Attachments & Documents"}</h6> | ||||
|                     <p>{"Future attachment features will include:"}</p> | ||||
|                     <ul> | ||||
|                         <li>{"Drag-and-drop file uploads"}</li> | ||||
|                         <li>{"Document preview and thumbnails"}</li> | ||||
|                         <li>{"Cloud storage integration (Google Drive, OneDrive)"}</li> | ||||
|                         <li>{"Version control for updated documents"}</li> | ||||
|                         <li>{"Shared access permissions for attendees"}</li> | ||||
|                     </ul> | ||||
|                     <p class="form-help-text">{"Attachment functionality will be implemented in a future release."}</p> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <AddAlarmModal | ||||
|                 is_open={*is_modal_open} | ||||
|                 editing_index={*editing_index} | ||||
|                 initial_alarm={initial_alarm} | ||||
|                 on_close={on_modal_close} | ||||
|                 on_save={on_alarm_save} | ||||
|             /> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| use crate::services::calendar_service::CalendarInfo; | ||||
| use chrono::{Local, NaiveDate, NaiveTime}; | ||||
| use yew::prelude::*; | ||||
| use calendar_models::VAlarm; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum EventStatus { | ||||
| @@ -28,22 +29,6 @@ impl Default for EventClass { | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum ReminderType { | ||||
|     None, | ||||
|     Minutes15, | ||||
|     Minutes30, | ||||
|     Hour1, | ||||
|     Day1, | ||||
|     Days2, | ||||
|     Week1, | ||||
| } | ||||
|  | ||||
| impl Default for ReminderType { | ||||
|     fn default() -> Self { | ||||
|         ReminderType::None | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum RecurrenceType { | ||||
| @@ -104,8 +89,8 @@ pub struct EventCreationData { | ||||
|     // Categorization | ||||
|     pub categories: String, | ||||
|      | ||||
|     // Reminders | ||||
|     pub reminder: ReminderType, | ||||
|     // Reminders/Alarms | ||||
|     pub alarms: Vec<VAlarm>, | ||||
|      | ||||
|     // Recurrence | ||||
|     pub recurrence: RecurrenceType, | ||||
| @@ -145,7 +130,7 @@ impl EventCreationData { | ||||
|         String, // organizer | ||||
|         String, // attendees | ||||
|         String, // categories | ||||
|         String, // reminder | ||||
|         Vec<VAlarm>, // alarms | ||||
|         String, // recurrence | ||||
|         Vec<bool>, // recurrence_days | ||||
|         u32, // recurrence_interval | ||||
| @@ -156,8 +141,9 @@ impl EventCreationData { | ||||
|     ) { | ||||
|          | ||||
|         // Use local date/times and timezone - no UTC conversion | ||||
|         let effective_end_date = if self.end_time == NaiveTime::from_hms_opt(0, 0, 0).unwrap() { | ||||
|             // If end time is midnight (00:00), treat it as beginning of next day | ||||
|         let effective_end_date = if self.all_day { | ||||
|             // For all-day events, add one day to convert from inclusive to exclusive end date | ||||
|             // (iCalendar spec requires exclusive end dates for all-day events) | ||||
|             self.end_date + chrono::Duration::days(1) | ||||
|         } else { | ||||
|             self.end_date | ||||
| @@ -195,7 +181,7 @@ impl EventCreationData { | ||||
|             self.organizer.clone(), | ||||
|             self.attendees.clone(), | ||||
|             self.categories.clone(), | ||||
|             format!("{:?}", self.reminder), | ||||
|             self.alarms.clone(), | ||||
|             format!("{:?}", self.recurrence), | ||||
|             self.recurrence_days.clone(), | ||||
|             self.recurrence_interval, | ||||
| @@ -229,7 +215,7 @@ impl Default for EventCreationData { | ||||
|             organizer: String::new(), | ||||
|             attendees: String::new(), | ||||
|             categories: String::new(), | ||||
|             reminder: ReminderType::default(), | ||||
|             alarms: Vec::new(), | ||||
|             recurrence: RecurrenceType::default(), | ||||
|             recurrence_interval: 1, | ||||
|             recurrence_until: None, | ||||
|   | ||||
| @@ -213,7 +213,10 @@ pub fn month_view(props: &MonthViewProps) -> Html { | ||||
|                                                 {onclick} | ||||
|                                                 {oncontextmenu} | ||||
|                                             > | ||||
|                                                 {event.summary.as_ref().unwrap_or(&"Untitled".to_string())} | ||||
|                                                 <span class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</span> | ||||
|                                                 if !event.alarms.is_empty() { | ||||
|                                                     <i class="fas fa-bell event-reminder-icon" title="Has reminders"></i> | ||||
|                                                 } | ||||
|                                             </div> | ||||
|                                         } | ||||
|                                     }).collect::<Html>() | ||||
|   | ||||
| @@ -968,7 +968,12 @@ pub fn week_view(props: &WeekViewProps) -> Html { | ||||
|  | ||||
|                                                                 // Event content | ||||
|                                                                 <div class="event-content"> | ||||
|                                                                     <div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> | ||||
|                                                                     <div class="event-title-row"> | ||||
|                                                                         <span class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</span> | ||||
|                                                                         if !event.alarms.is_empty() { | ||||
|                                                                             <i class="fas fa-bell event-reminder-icon" title="Has reminders"></i> | ||||
|                                                                         } | ||||
|                                                                     </div> | ||||
|                                                                     {if !is_all_day && duration_pixels > 30.0 { | ||||
|                                                                         html! { <div class="event-time">{time_display}</div> } | ||||
|                                                                     } else { | ||||
|   | ||||
							
								
								
									
										299
									
								
								frontend/src/services/alarm_scheduler.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								frontend/src/services/alarm_scheduler.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,299 @@ | ||||
| use calendar_models::{VAlarm, AlarmAction, AlarmTrigger, VEvent}; | ||||
| use chrono::{Duration, Local, NaiveDateTime}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::collections::HashMap; | ||||
| use crate::services::{NotificationManager, AlarmNotification}; | ||||
| use gloo_storage::{LocalStorage, Storage}; | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub struct ScheduledAlarm { | ||||
|     pub id: String,                    // Unique alarm ID | ||||
|     pub event_uid: String,             // Event this alarm belongs to | ||||
|     pub event_summary: String,         // Event title for notification | ||||
|     pub event_location: Option<String>, // Event location for notification | ||||
|     pub event_start: NaiveDateTime,    // Event start time (local) | ||||
|     pub trigger_time: NaiveDateTime,   // When alarm should trigger (local) | ||||
|     pub alarm_action: AlarmAction,     // Type of alarm | ||||
|     pub status: AlarmStatus,           // Current status | ||||
|     pub created_at: NaiveDateTime,     // When alarm was scheduled | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | ||||
| pub enum AlarmStatus { | ||||
|     Pending,    // Waiting to trigger | ||||
|     Triggered,  // Has been triggered | ||||
|     Dismissed,  // User dismissed | ||||
|     Expired,    // Past due (event ended) | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct AlarmScheduler { | ||||
|     scheduled_alarms: HashMap<String, ScheduledAlarm>, | ||||
|     notification_manager: NotificationManager, | ||||
| } | ||||
|  | ||||
| const ALARMS_STORAGE_KEY: &str = "scheduled_alarms"; | ||||
|  | ||||
| impl AlarmScheduler { | ||||
|     pub fn new() -> Self { | ||||
|         let mut scheduler = Self { | ||||
|             scheduled_alarms: HashMap::new(), | ||||
|             notification_manager: NotificationManager::new(), | ||||
|         }; | ||||
|          | ||||
|         // Load alarms from localStorage | ||||
|         scheduler.load_alarms_from_storage(); | ||||
|         scheduler | ||||
|     } | ||||
|      | ||||
|     /// Load alarms from localStorage | ||||
|     fn load_alarms_from_storage(&mut self) { | ||||
|         if let Ok(alarms) = LocalStorage::get::<HashMap<String, ScheduledAlarm>>(ALARMS_STORAGE_KEY) { | ||||
|             self.scheduled_alarms = alarms; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Save alarms to localStorage | ||||
|     fn save_alarms_to_storage(&self) { | ||||
|         if let Err(e) = LocalStorage::set(ALARMS_STORAGE_KEY, &self.scheduled_alarms) { | ||||
|             web_sys::console::error_1( | ||||
|                 &format!("Failed to save alarms to localStorage: {:?}", e).into() | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Schedule alarms for an event | ||||
|     pub fn schedule_event_alarms(&mut self, event: &VEvent) { | ||||
|         // Check notification permission before scheduling | ||||
|         let permission = NotificationManager::get_permission(); | ||||
|         if permission != web_sys::NotificationPermission::Granted && !event.alarms.is_empty() { | ||||
|             // Try to force request permission asynchronously | ||||
|             wasm_bindgen_futures::spawn_local(async move { | ||||
|                 let _ = NotificationManager::force_request_permission().await; | ||||
|             }); | ||||
|         } | ||||
|          | ||||
|         // Remove any existing alarms for this event | ||||
|         self.remove_event_alarms(&event.uid); | ||||
|  | ||||
|         // Get event details | ||||
|         let event_summary = event.summary.as_ref().unwrap_or(&"Untitled Event".to_string()).clone(); | ||||
|         let event_location = event.location.clone(); | ||||
|         let event_start = event.dtstart; | ||||
|  | ||||
|         // Schedule each alarm | ||||
|         for alarm in &event.alarms { | ||||
|             if let Some(scheduled_alarm) = self.create_scheduled_alarm( | ||||
|                 event, | ||||
|                 alarm, | ||||
|                 &event_summary, | ||||
|                 &event_location, | ||||
|                 event_start, | ||||
|             ) { | ||||
|                 self.scheduled_alarms.insert(scheduled_alarm.id.clone(), scheduled_alarm); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Save to localStorage | ||||
|         self.save_alarms_to_storage(); | ||||
|     } | ||||
|  | ||||
|     /// Create a scheduled alarm from a VAlarm | ||||
|     fn create_scheduled_alarm( | ||||
|         &self, | ||||
|         event: &VEvent, | ||||
|         valarm: &VAlarm, | ||||
|         event_summary: &str, | ||||
|         event_location: &Option<String>, | ||||
|         event_start: NaiveDateTime, | ||||
|     ) -> Option<ScheduledAlarm> { | ||||
|         // Only handle Display alarms for now | ||||
|         if valarm.action != AlarmAction::Display { | ||||
|             return None; | ||||
|         } | ||||
|  | ||||
|         // Calculate trigger time | ||||
|         let trigger_time = match &valarm.trigger { | ||||
|             AlarmTrigger::Duration(duration) => { | ||||
|                 // Duration relative to event start | ||||
|                 let trigger_time = event_start + *duration; | ||||
|                  | ||||
|                 // Ensure trigger time is not in the past (with 30 second tolerance) | ||||
|                 let now = Local::now().naive_local(); | ||||
|                 if trigger_time < now - Duration::seconds(30) { | ||||
|                     web_sys::console::warn_1( | ||||
|                         &format!("Skipping past alarm for event: {} (trigger: {})",  | ||||
|                             event_summary,  | ||||
|                             trigger_time.format("%Y-%m-%d %H:%M:%S") | ||||
|                         ).into() | ||||
|                     ); | ||||
|                     return None; | ||||
|                 } | ||||
|                  | ||||
|                 trigger_time | ||||
|             } | ||||
|             AlarmTrigger::DateTime(datetime) => { | ||||
|                 // Absolute datetime - convert to local time | ||||
|                 let local_trigger = datetime.with_timezone(&Local).naive_local(); | ||||
|                  | ||||
|                 // Ensure trigger time is not in the past | ||||
|                 let now = Local::now().naive_local(); | ||||
|                 if local_trigger < now - Duration::seconds(30) { | ||||
|                     web_sys::console::warn_1( | ||||
|                         &format!("Skipping past absolute alarm for event: {} (trigger: {})",  | ||||
|                             event_summary,  | ||||
|                             local_trigger.format("%Y-%m-%d %H:%M:%S") | ||||
|                         ).into() | ||||
|                     ); | ||||
|                     return None; | ||||
|                 } | ||||
|                  | ||||
|                 local_trigger | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         // Generate unique alarm ID | ||||
|         let alarm_id = format!("{}_{}", event.uid, trigger_time.and_utc().timestamp()); | ||||
|  | ||||
|         Some(ScheduledAlarm { | ||||
|             id: alarm_id, | ||||
|             event_uid: event.uid.clone(), | ||||
|             event_summary: event_summary.to_string(), | ||||
|             event_location: event_location.clone(), | ||||
|             event_start, | ||||
|             trigger_time, | ||||
|             alarm_action: valarm.action.clone(), | ||||
|             status: AlarmStatus::Pending, | ||||
|             created_at: Local::now().naive_local(), | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /// Remove all alarms for an event | ||||
|     pub fn remove_event_alarms(&mut self, event_uid: &str) { | ||||
|         let alarm_ids: Vec<String> = self.scheduled_alarms | ||||
|             .iter() | ||||
|             .filter(|(_, alarm)| alarm.event_uid == event_uid) | ||||
|             .map(|(id, _)| id.clone()) | ||||
|             .collect(); | ||||
|  | ||||
|         for alarm_id in alarm_ids { | ||||
|             self.scheduled_alarms.remove(&alarm_id); | ||||
|         } | ||||
|  | ||||
|         // Also close any active notifications for this event | ||||
|         self.notification_manager.close_notification(event_uid); | ||||
|          | ||||
|         // Save to localStorage | ||||
|         self.save_alarms_to_storage(); | ||||
|     } | ||||
|  | ||||
|     /// Check for alarms that should trigger now and trigger them | ||||
|     pub fn check_and_trigger_alarms(&mut self) -> usize { | ||||
|         // Reload alarms from localStorage to ensure we have the latest data | ||||
|         self.load_alarms_from_storage(); | ||||
|          | ||||
|         let now = Local::now().naive_local(); | ||||
|         let mut triggered_count = 0; | ||||
|  | ||||
|         // Find alarms that should trigger (within 30 seconds tolerance) | ||||
|         let alarms_to_trigger: Vec<ScheduledAlarm> = self.scheduled_alarms | ||||
|             .values() | ||||
|             .filter(|alarm| { | ||||
|                 alarm.status == AlarmStatus::Pending &&  | ||||
|                 alarm.trigger_time <= now + Duration::seconds(30) && | ||||
|                 alarm.trigger_time >= now - Duration::seconds(30) | ||||
|             }) | ||||
|             .cloned() | ||||
|             .collect(); | ||||
|  | ||||
|         for alarm in alarms_to_trigger { | ||||
|             if self.trigger_alarm(&alarm) { | ||||
|                 // Mark alarm as triggered | ||||
|                 if let Some(scheduled_alarm) = self.scheduled_alarms.get_mut(&alarm.id) { | ||||
|                     scheduled_alarm.status = AlarmStatus::Triggered; | ||||
|                 } | ||||
|                 triggered_count += 1; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Clean up expired alarms (events that ended more than 1 hour ago) | ||||
|         self.cleanup_expired_alarms(); | ||||
|          | ||||
|         // Save to localStorage if any changes were made | ||||
|         if triggered_count > 0 { | ||||
|             self.save_alarms_to_storage(); | ||||
|         } | ||||
|  | ||||
|         triggered_count | ||||
|     } | ||||
|  | ||||
|     /// Trigger a specific alarm | ||||
|     fn trigger_alarm(&mut self, alarm: &ScheduledAlarm) -> bool { | ||||
|         // Don't trigger if already showing notification for this event | ||||
|         if self.notification_manager.has_notification(&alarm.event_uid) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         let alarm_notification = AlarmNotification { | ||||
|             event_uid: alarm.event_uid.clone(), | ||||
|             event_summary: alarm.event_summary.clone(), | ||||
|             event_location: alarm.event_location.clone(), | ||||
|             alarm_time: alarm.event_start, | ||||
|         }; | ||||
|  | ||||
|         match self.notification_manager.show_alarm_notification(alarm_notification) { | ||||
|             Ok(()) => true, | ||||
|             Err(err) => { | ||||
|                 web_sys::console::error_1( | ||||
|                     &format!("Failed to trigger alarm: {:?}", err).into() | ||||
|                 ); | ||||
|                 false | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Clean up expired alarms | ||||
|     fn cleanup_expired_alarms(&mut self) { | ||||
|         let now = Local::now().naive_local(); | ||||
|         let cutoff_time = now - Duration::hours(1); | ||||
|  | ||||
|         let expired_alarm_ids: Vec<String> = self.scheduled_alarms | ||||
|             .iter() | ||||
|             .filter(|(_, alarm)| { | ||||
|                 // Mark as expired if event ended more than 1 hour ago | ||||
|                 alarm.event_start < cutoff_time | ||||
|             }) | ||||
|             .map(|(id, _)| id.clone()) | ||||
|             .collect(); | ||||
|  | ||||
|         for alarm_id in &expired_alarm_ids { | ||||
|             if let Some(alarm) = self.scheduled_alarms.get_mut(alarm_id) { | ||||
|                 alarm.status = AlarmStatus::Expired; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Remove expired alarms from memory | ||||
|         let had_expired = !expired_alarm_ids.is_empty(); | ||||
|         for alarm_id in expired_alarm_ids { | ||||
|             self.scheduled_alarms.remove(&alarm_id); | ||||
|         } | ||||
|          | ||||
|         // Save to localStorage if any expired alarms were removed | ||||
|         if had_expired { | ||||
|             self.save_alarms_to_storage(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /// Request notification permission | ||||
|     pub async fn request_notification_permission(&self) -> Result<web_sys::NotificationPermission, wasm_bindgen::JsValue> { | ||||
|         NotificationManager::request_permission().await | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| impl Default for AlarmScheduler { | ||||
|     fn default() -> Self { | ||||
|         Self::new() | ||||
|     } | ||||
| } | ||||
| @@ -6,8 +6,8 @@ use wasm_bindgen::JsCast; | ||||
| use wasm_bindgen_futures::JsFuture; | ||||
| use web_sys::{Request, RequestInit, RequestMode, Response}; | ||||
|  | ||||
| // Import RFC 5545 compliant VEvent from shared library | ||||
| use calendar_models::VEvent; | ||||
| // Import RFC 5545 compliant VEvent and VAlarm from shared library | ||||
| use calendar_models::{VEvent, VAlarm}; | ||||
|  | ||||
| // Create type alias for backward compatibility | ||||
| pub type CalendarEvent = VEvent; | ||||
| @@ -1250,7 +1250,7 @@ impl CalendarService { | ||||
|         organizer: String, | ||||
|         attendees: String, | ||||
|         categories: String, | ||||
|         reminder: String, | ||||
|         alarms: Vec<VAlarm>, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_interval: u32, | ||||
| @@ -1285,7 +1285,7 @@ impl CalendarService { | ||||
|                 "organizer": organizer, | ||||
|                 "attendees": attendees, | ||||
|                 "categories": categories, | ||||
|                 "reminder": reminder, | ||||
|                 "alarms": alarms, | ||||
|                 "recurrence": recurrence, | ||||
|                 "recurrence_days": recurrence_days, | ||||
|                 "recurrence_interval": recurrence_interval, | ||||
| @@ -1313,7 +1313,7 @@ impl CalendarService { | ||||
|                 "organizer": organizer, | ||||
|                 "attendees": attendees, | ||||
|                 "categories": categories, | ||||
|                 "reminder": reminder, | ||||
|                 "alarms": alarms, | ||||
|                 "recurrence": recurrence, | ||||
|                 "recurrence_days": recurrence_days, | ||||
|                 "calendar_path": calendar_path, | ||||
| @@ -1391,7 +1391,7 @@ impl CalendarService { | ||||
|         organizer: String, | ||||
|         attendees: String, | ||||
|         categories: String, | ||||
|         reminder: String, | ||||
|         alarms: Vec<VAlarm>, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_interval: u32, | ||||
| @@ -1419,7 +1419,7 @@ impl CalendarService { | ||||
|             organizer, | ||||
|             attendees, | ||||
|             categories, | ||||
|             reminder, | ||||
|             alarms, | ||||
|             recurrence, | ||||
|             recurrence_days, | ||||
|             recurrence_interval, | ||||
| @@ -1450,7 +1450,7 @@ impl CalendarService { | ||||
|         organizer: String, | ||||
|         attendees: String, | ||||
|         categories: String, | ||||
|         reminder: String, | ||||
|         alarms: Vec<VAlarm>, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_interval: u32, | ||||
| @@ -1482,7 +1482,7 @@ impl CalendarService { | ||||
|             "organizer": organizer, | ||||
|             "attendees": attendees, | ||||
|             "categories": categories, | ||||
|             "reminder": reminder, | ||||
|             "alarms": alarms, | ||||
|             "recurrence": recurrence, | ||||
|             "recurrence_days": recurrence_days, | ||||
|             "recurrence_interval": recurrence_interval, | ||||
| @@ -1687,7 +1687,7 @@ impl CalendarService { | ||||
|         organizer: String, | ||||
|         attendees: String, | ||||
|         categories: String, | ||||
|         reminder: String, | ||||
|         alarms: Vec<VAlarm>, | ||||
|         recurrence: String, | ||||
|         recurrence_days: Vec<bool>, | ||||
|         recurrence_interval: u32, | ||||
| @@ -1720,7 +1720,7 @@ impl CalendarService { | ||||
|             "organizer": organizer, | ||||
|             "attendees": attendees, | ||||
|             "categories": categories, | ||||
|             "reminder": reminder, | ||||
|             "alarms": alarms, | ||||
|             "recurrence": recurrence, | ||||
|             "recurrence_days": recurrence_days, | ||||
|             "recurrence_interval": recurrence_interval, | ||||
|   | ||||
| @@ -1,4 +1,8 @@ | ||||
| pub mod calendar_service; | ||||
| pub mod preferences; | ||||
| pub mod notification_manager; | ||||
| pub mod alarm_scheduler; | ||||
|  | ||||
| pub use calendar_service::CalendarService; | ||||
| pub use notification_manager::{NotificationManager, AlarmNotification}; | ||||
| pub use alarm_scheduler::AlarmScheduler; | ||||
|   | ||||
							
								
								
									
										189
									
								
								frontend/src/services/notification_manager.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								frontend/src/services/notification_manager.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,189 @@ | ||||
| use web_sys::{window, Notification, NotificationOptions, NotificationPermission}; | ||||
| use wasm_bindgen::prelude::*; | ||||
| use wasm_bindgen_futures::JsFuture; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct NotificationManager { | ||||
|     // Track displayed notifications to prevent duplicates | ||||
|     active_notifications: HashMap<String, Notification>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct AlarmNotification { | ||||
|     pub event_uid: String, | ||||
|     pub event_summary: String, | ||||
|     pub event_location: Option<String>, | ||||
|     pub alarm_time: chrono::NaiveDateTime, | ||||
| } | ||||
|  | ||||
| impl NotificationManager { | ||||
|     pub fn new() -> Self { | ||||
|         Self { | ||||
|             active_notifications: HashMap::new(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Check if the browser supports notifications | ||||
|     pub fn is_supported() -> bool { | ||||
|         // Check if the Notification constructor exists on the window | ||||
|         if let Some(window) = window() { | ||||
|             let has_notification = js_sys::Reflect::has(&window, &"Notification".into()).unwrap_or(false); | ||||
|              | ||||
|             // Additional check - try to access Notification directly via JsValue | ||||
|             let window_js: &wasm_bindgen::JsValue = window.as_ref(); | ||||
|             let direct_check = js_sys::Reflect::get(window_js, &"Notification".into()).unwrap_or(wasm_bindgen::JsValue::UNDEFINED); | ||||
|             let has_direct = !direct_check.is_undefined(); | ||||
|              | ||||
|             // Use either check | ||||
|             has_notification || has_direct | ||||
|         } else { | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Get current notification permission status | ||||
|     pub fn get_permission() -> NotificationPermission { | ||||
|         if Self::is_supported() { | ||||
|             Notification::permission() | ||||
|         } else { | ||||
|             NotificationPermission::Denied | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Force request notification permission (even if previously denied) | ||||
|     pub async fn force_request_permission() -> Result<NotificationPermission, JsValue> { | ||||
|         if !Self::is_supported() { | ||||
|             return Ok(NotificationPermission::Denied); | ||||
|         } | ||||
|  | ||||
|         // Always request permission, regardless of current status | ||||
|         let promise = Notification::request_permission()?; | ||||
|         let js_value = JsFuture::from(promise).await?; | ||||
|          | ||||
|         // Convert JS string back to NotificationPermission | ||||
|         if let Some(permission_str) = js_value.as_string() { | ||||
|             match permission_str.as_str() { | ||||
|                 "granted" => Ok(NotificationPermission::Granted), | ||||
|                 "denied" => Ok(NotificationPermission::Denied), | ||||
|                 _ => Ok(NotificationPermission::Default), | ||||
|             } | ||||
|         } else { | ||||
|             Ok(NotificationPermission::Denied) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Request notification permission from the user | ||||
|     pub async fn request_permission() -> Result<NotificationPermission, JsValue> { | ||||
|         if !Self::is_supported() { | ||||
|             return Ok(NotificationPermission::Denied); | ||||
|         } | ||||
|  | ||||
|         // Check current permission status | ||||
|         let current_permission = Notification::permission(); | ||||
|         if current_permission != NotificationPermission::Default { | ||||
|             return Ok(current_permission); | ||||
|         } | ||||
|  | ||||
|         // Request permission | ||||
|         let promise = Notification::request_permission()?; | ||||
|         let js_value = JsFuture::from(promise).await?; | ||||
|          | ||||
|         // Convert JS string back to NotificationPermission | ||||
|         if let Some(permission_str) = js_value.as_string() { | ||||
|             match permission_str.as_str() { | ||||
|                 "granted" => Ok(NotificationPermission::Granted), | ||||
|                 "denied" => Ok(NotificationPermission::Denied), | ||||
|                 _ => Ok(NotificationPermission::Default), | ||||
|             } | ||||
|         } else { | ||||
|             Ok(NotificationPermission::Denied) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Display a notification for an alarm | ||||
|     pub fn show_alarm_notification(&mut self, alarm: AlarmNotification) -> Result<(), JsValue> { | ||||
|         // Check permission | ||||
|         if Self::get_permission() != NotificationPermission::Granted { | ||||
|             return Ok(()); // Don't error, just skip | ||||
|         } | ||||
|  | ||||
|         // Check if notification already exists for this event | ||||
|         if self.active_notifications.contains_key(&alarm.event_uid) { | ||||
|             return Ok(()); // Already showing notification for this event | ||||
|         } | ||||
|  | ||||
|         // Create notification options | ||||
|         let options = NotificationOptions::new(); | ||||
|          | ||||
|         // Set notification body with time and location | ||||
|         let body = if let Some(location) = &alarm.event_location { | ||||
|             format!("📅 {}\n📍 {}",  | ||||
|                 alarm.alarm_time.format("%H:%M"),  | ||||
|                 location | ||||
|             ) | ||||
|         } else { | ||||
|             format!("📅 {}", alarm.alarm_time.format("%H:%M")) | ||||
|         }; | ||||
|         options.set_body(&body); | ||||
|  | ||||
|         // Set icon | ||||
|         options.set_icon("/favicon.ico"); | ||||
|          | ||||
|         // Set tag to prevent duplicates | ||||
|         options.set_tag(&alarm.event_uid); | ||||
|          | ||||
|         // Set require interaction to keep notification visible | ||||
|         options.set_require_interaction(true); | ||||
|  | ||||
|         // Create and show notification | ||||
|         let notification = Notification::new_with_options(&alarm.event_summary, &options)?; | ||||
|          | ||||
|         // Store reference to track active notifications | ||||
|         self.active_notifications.insert(alarm.event_uid.clone(), notification.clone()); | ||||
|  | ||||
|         // Set up click handler to focus the calendar app | ||||
|         let _event_uid = alarm.event_uid.clone(); | ||||
|         let onclick_closure = Closure::wrap(Box::new(move |_event: web_sys::Event| { | ||||
|             // Focus the window when notification is clicked | ||||
|             if let Some(window) = window() { | ||||
|                 let _ = window.focus(); | ||||
|             } | ||||
|              | ||||
|         }) as Box<dyn FnMut(_)>); | ||||
|          | ||||
|         notification.set_onclick(Some(onclick_closure.as_ref().unchecked_ref())); | ||||
|         onclick_closure.forget(); // Keep closure alive | ||||
|  | ||||
|         // Set up close handler to clean up tracking | ||||
|         let event_uid_close = alarm.event_uid.clone(); | ||||
|         let mut active_notifications_close = self.active_notifications.clone(); | ||||
|         let onclose_closure = Closure::wrap(Box::new(move |_event: web_sys::Event| { | ||||
|             active_notifications_close.remove(&event_uid_close); | ||||
|         }) as Box<dyn FnMut(_)>); | ||||
|          | ||||
|         notification.set_onclose(Some(onclose_closure.as_ref().unchecked_ref())); | ||||
|         onclose_closure.forget(); // Keep closure alive | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Close notification for a specific event | ||||
|     pub fn close_notification(&mut self, event_uid: &str) { | ||||
|         if let Some(notification) = self.active_notifications.remove(event_uid) { | ||||
|             notification.close(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /// Check if notification exists for event | ||||
|     pub fn has_notification(&self, event_uid: &str) -> bool { | ||||
|         self.active_notifications.contains_key(event_uid) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for NotificationManager { | ||||
|     fn default() -> Self { | ||||
|         Self::new() | ||||
|     } | ||||
| } | ||||
| @@ -1029,8 +1029,6 @@ body { | ||||
|     font-weight: 500; | ||||
|     box-shadow: 0 1px 3px rgba(0,0,0,0.1); | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| /* Disable pointer events on existing events when creating a new event */ | ||||
| @@ -1150,11 +1148,41 @@ body { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: center; | ||||
|     align-items: center; /* Center the content horizontally */ | ||||
|     text-align: center; /* Center text within elements */ | ||||
|     pointer-events: auto; | ||||
|     z-index: 5; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .week-event .event-title-row { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; /* Center the title and icon */ | ||||
|     gap: 4px; | ||||
|     width: 100%; | ||||
|     max-width: 100%; | ||||
| } | ||||
|  | ||||
| .week-event .event-title { | ||||
|     flex: 1; | ||||
|     min-width: 0 !important; | ||||
|     max-width: calc(100% - 16px) !important; /* This was needed for ellipsis */ | ||||
|     white-space: nowrap !important; | ||||
|     overflow: hidden !important; | ||||
|     text-overflow: ellipsis !important; | ||||
|     font-weight: 600; | ||||
|     margin-bottom: 2px; | ||||
|     display: block !important; /* This was also needed */ | ||||
|     text-align: center; /* Center the text within the title element */ | ||||
| } | ||||
|  | ||||
| .week-event .event-reminder-icon { | ||||
|     font-size: 0.6rem; | ||||
|     color: rgba(255, 255, 255, 0.8); | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| /* Left-click drag handles */ | ||||
| .resize-handle { | ||||
|     position: absolute; | ||||
| @@ -1195,13 +1223,7 @@ body { | ||||
| } | ||||
|  | ||||
|  | ||||
| .week-event .event-title { | ||||
|     font-weight: 600; | ||||
|     margin-bottom: 2px; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
| /* Moved to .week-event .event-header .event-title for better specificity */ | ||||
|  | ||||
| .week-event .event-time { | ||||
|     font-size: 0.65rem; | ||||
| @@ -1570,9 +1592,6 @@ body { | ||||
|     border-radius: 3px; | ||||
|     font-size: 0.7rem; | ||||
|     line-height: 1.2; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
|     cursor: pointer; | ||||
|     transition: var(--standard-transition); | ||||
|     border: 1px solid rgba(255,255,255,0.2); | ||||
| @@ -1580,6 +1599,10 @@ body { | ||||
|     font-weight: 500; | ||||
|     box-shadow: 0 1px 2px rgba(0,0,0,0.1); | ||||
|     position: relative; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-xs); | ||||
|     min-width: 0; | ||||
| } | ||||
|  | ||||
| .event-box:hover { | ||||
| @@ -4429,3 +4452,237 @@ body { | ||||
|     border-color: #138496; | ||||
| } | ||||
|  | ||||
| /* Alarm List Component */ | ||||
| .alarm-list { | ||||
|     margin-bottom: var(--spacing-lg); | ||||
| } | ||||
|  | ||||
| .alarm-list h6 { | ||||
|     margin: 0 0 var(--spacing-sm) 0; | ||||
|     font-size: 0.9rem; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .alarm-list-empty { | ||||
|     text-align: center; | ||||
|     padding: var(--spacing-lg); | ||||
|     background: var(--background-tertiary); | ||||
|     border: 1px dashed var(--border-secondary); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .alarm-list-empty p { | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| .alarm-list-empty .text-small { | ||||
|     font-size: 0.8rem; | ||||
|     margin-top: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .alarm-items { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .alarm-item { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     padding: var(--spacing-sm) var(--spacing-md); | ||||
|     background: var(--background-secondary); | ||||
|     border: 1px solid var(--border-secondary); | ||||
|     border-radius: var(--border-radius-small); | ||||
|     transition: var(--transition-fast); | ||||
| } | ||||
|  | ||||
| .alarm-item:hover { | ||||
|     background: var(--background-tertiary); | ||||
|     border-color: var(--border-primary); | ||||
| } | ||||
|  | ||||
| .alarm-content { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-sm); | ||||
|     flex: 1; | ||||
| } | ||||
|  | ||||
| .alarm-icon { | ||||
|     font-size: 1.1rem; | ||||
|     min-width: 24px; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .alarm-description { | ||||
|     font-size: 0.9rem; | ||||
|     color: var(--text-primary); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .alarm-actions { | ||||
|     display: flex; | ||||
|     gap: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .alarm-action-btn { | ||||
|     background: none; | ||||
|     border: none; | ||||
|     padding: var(--spacing-xs); | ||||
|     border-radius: var(--border-radius-small); | ||||
|     cursor: pointer; | ||||
|     color: var(--text-secondary); | ||||
|     transition: var(--transition-fast); | ||||
|     width: 28px; | ||||
|     height: 28px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
| } | ||||
|  | ||||
| .alarm-action-btn:hover { | ||||
|     background: var(--background-tertiary); | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .alarm-action-btn.edit-btn:hover { | ||||
|     background: var(--info-color); | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .alarm-action-btn.delete-btn:hover { | ||||
|     background: var(--error-color); | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .alarm-action-btn i { | ||||
|     font-size: 0.8rem; | ||||
| } | ||||
|  | ||||
| /* Alarm Management Header */ | ||||
| .alarm-management-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: var(--spacing-sm); | ||||
| } | ||||
|  | ||||
| .alarm-management-header h5 { | ||||
|     margin: 0; | ||||
|     font-size: 1rem; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .add-alarm-button { | ||||
|     background: var(--primary-color); | ||||
|     color: white; | ||||
|     border: none; | ||||
|     padding: var(--spacing-sm) var(--spacing-md); | ||||
|     border-radius: var(--border-radius-small); | ||||
|     font-size: 0.85rem; | ||||
|     font-weight: 500; | ||||
|     cursor: pointer; | ||||
|     transition: var(--transition-fast); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .add-alarm-button:hover { | ||||
|     background: #1d4ed8; | ||||
|     transform: translateY(-1px); | ||||
|     box-shadow: 0 2px 8px rgba(29, 78, 216, 0.25); | ||||
| } | ||||
|  | ||||
| .add-alarm-button i { | ||||
|     font-size: 0.8rem; | ||||
| } | ||||
|  | ||||
| /* Alarm Types Info */ | ||||
| .alarm-types-info { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | ||||
|     gap: var(--spacing-sm); | ||||
|     margin: var(--spacing-md) 0; | ||||
| } | ||||
|  | ||||
| .alarm-type-info { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-sm); | ||||
|     padding: var(--spacing-sm); | ||||
|     background: var(--background-secondary); | ||||
|     border: 1px solid var(--border-secondary); | ||||
|     border-radius: var(--border-radius-small); | ||||
|     transition: var(--transition-fast); | ||||
| } | ||||
|  | ||||
| .alarm-type-info:hover { | ||||
|     background: var(--background-tertiary); | ||||
|     border-color: var(--border-primary); | ||||
| } | ||||
|  | ||||
| .alarm-type-info .alarm-icon { | ||||
|     font-size: 1.2rem; | ||||
|     min-width: 24px; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .alarm-type-info div { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 2px; | ||||
| } | ||||
|  | ||||
| .alarm-type-info strong { | ||||
|     font-size: 0.85rem; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .alarm-type-info span:not(.alarm-icon) { | ||||
|     font-size: 0.75rem; | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| /* Event Reminder Icon */ | ||||
| .event-reminder-icon { | ||||
|     font-size: 0.7rem; | ||||
|     color: rgba(255, 255, 255, 0.8); | ||||
|     margin-left: auto; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .event-box .event-title { | ||||
|     flex: 1; | ||||
|     min-width: 0; | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
| } | ||||
|  | ||||
| .event-content { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-xs); | ||||
|     width: 100%; | ||||
|     min-width: 0; | ||||
| } | ||||
|  | ||||
| .event-content .event-title { | ||||
|     flex: 1; | ||||
|     min-width: 0; | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
| } | ||||
|  | ||||
| .event-content .event-reminder-icon { | ||||
|     margin-left: auto; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user