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;
|
||||
@@ -148,6 +148,11 @@ pub fn App() -> Html {
|
||||
// Mobile warning state
|
||||
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(|| {
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
match calendar_service
|
||||
.fetch_events_for_month_vevent(
|
||||
&token,
|
||||
&password,
|
||||
current_year,
|
||||
current_month,
|
||||
)
|
||||
.await
|
||||
// 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,
|
||||
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,42 +308,31 @@ pub fn basic_details_tab(props: &TabProps) -> Html {
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="event-calendar">{"Calendar"}</label>
|
||||
<select
|
||||
id="event-calendar"
|
||||
class="form-input"
|
||||
onchange={on_calendar_change}
|
||||
>
|
||||
<option value="">{"Select Calendar"}</option>
|
||||
{
|
||||
props.available_calendars.iter().map(|calendar| {
|
||||
html! {
|
||||
<option
|
||||
key={calendar.path.clone()}
|
||||
value={calendar.path.clone()}
|
||||
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
|
||||
>
|
||||
{&calendar.display_name}
|
||||
</option>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.all_day}
|
||||
onchange={on_all_day_change}
|
||||
/>
|
||||
{" All Day"}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="event-calendar">{"Calendar"}</label>
|
||||
<select
|
||||
id="event-calendar"
|
||||
class="form-input"
|
||||
onchange={on_calendar_change}
|
||||
>
|
||||
<option value="">{"Select Calendar"}</option>
|
||||
{
|
||||
props.available_calendars.iter().map(|calendar| {
|
||||
html! {
|
||||
<option
|
||||
key={calendar.path.clone()}
|
||||
value={calendar.path.clone()}
|
||||
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
|
||||
>
|
||||
{&calendar.display_name}
|
||||
</option>
|
||||
}
|
||||
}).collect::<Html>()
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label 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 = {
|
||||
// Modal state
|
||||
let is_modal_open = use_state(|| false);
|
||||
let editing_index = use_state(|| None::<usize>);
|
||||
|
||||
// Add alarm callback
|
||||
let on_add_alarm = {
|
||||
let is_modal_open = is_modal_open.clone();
|
||||
let editing_index = editing_index.clone();
|
||||
Callback::from(move |_| {
|
||||
editing_index.set(None);
|
||||
is_modal_open.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
// Edit alarm callback
|
||||
let on_alarm_edit = {
|
||||
let is_modal_open = is_modal_open.clone();
|
||||
let editing_index = editing_index.clone();
|
||||
Callback::from(move |index: usize| {
|
||||
editing_index.set(Some(index));
|
||||
is_modal_open.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
// Delete alarm callback
|
||||
let on_alarm_delete = {
|
||||
let data = data.clone();
|
||||
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);
|
||||
}
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
</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 class="alarm-management-header">
|
||||
<h5>{"Event Reminders"}</h5>
|
||||
<button
|
||||
class="add-alarm-button"
|
||||
onclick={on_add_alarm}
|
||||
type="button"
|
||||
>
|
||||
<i class="fas fa-plus"></i>
|
||||
{" Add Reminder"}
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p>
|
||||
<p class="form-help-text">{"Configure multiple reminders with custom timing and notification types"}</p>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<AlarmList
|
||||
alarms={data.alarms.clone()}
|
||||
on_alarm_delete={on_alarm_delete}
|
||||
on_alarm_edit={on_alarm_edit}
|
||||
/>
|
||||
|
||||
<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