8 Commits

Author SHA1 Message Date
Connor Johnstone
ce9914e388 Clean up code to resolve all compiler warnings
All checks were successful
Build and Push Docker Image / docker (push) Successful in 3m48s
- Remove unused AlarmStorage module and all references
- Remove unused imports (AlarmAction, AlarmTrigger, VAlarm from backend)
- Remove unused ReminderType enum from event form types
- Remove unused methods from AlarmScheduler and NotificationManager
- Fix unnecessary mut on NotificationOptions
- Simplify alarm system initialization in app.rs
- Remove unused variable assignments

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 21:51:14 -04:00
Connor Johnstone
faf5ce2cfd Remove remaining frontend console logs for production
- Remove HTML loading and WASM initialization logs
- Clean up service worker registration/activation logs
- Remove alarm system initialization messages
- Remove notification permission logging
- Keep essential error logging for troubleshooting
- Clean production console output

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 21:35:06 -04:00
Connor Johnstone
2fee7a15f9 Clean up verbose debug logging from backend server
- Remove emoji debug logs from event deduplication process
- Remove verbose RRULE consolidation logging
- Remove "found X events with title Y" spam logs
- Keep essential functionality intact
- Maintain clean production server logs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 21:29:26 -04:00
Connor Johnstone
7caf3539f7 Clean up debug logging from notification system
- Remove verbose debug console logs from alarm scheduler
- Remove debug logs from notification manager
- Keep essential error logging for troubleshooting
- Maintain clean, production-ready code
- System functionality unchanged

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 21:22:19 -04:00
Connor Johnstone
1538869f4a Implement browser notification system for calendar event alarms
- Add comprehensive alarm scheduling and notification system
- Support for VAlarm data structures from calendar events
- Browser notification API integration with permission handling
- localStorage persistence for alarms across sessions
- Background service worker for alarm checking when app inactive
- Real-time alarm detection with 30-second intervals
- Debug logging for troubleshooting notification issues
- Automatic permission requests when events with alarms are created
- Support for Display action alarms with Duration and DateTime triggers
- Clean alarm management (create, update, remove, expire)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 21:09:14 -04:00
Connor Johnstone
7ce7d4c9d9 Fix week view cross-month event fetching bug
When viewing a week that spans two months, events from the second month
were not appearing in the week view. The issue was that the calendar
component only fetched events for the month of the current date, but
week view needs events from all months that appear in the visible week.

- Modified event fetching logic to detect when week view spans multiple months
- Added cross-month support by fetching events from all relevant months
- Added get_start_of_week helper function to calculate week boundaries
- Enhanced ViewMode handling to distinguish between month and week fetching strategies

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 14:17:33 -04:00
Connor Johnstone
037b733d48 Implement custom reminders with multiple VAlarms per event
Major Features:
- Replace single ReminderType enum with Vec<VAlarm> throughout stack
- Add comprehensive alarm management UI with AlarmList and AddAlarmModal components
- Support relative (15min before, 2hrs after) and absolute (specific date/time) triggers
- Display reminder icons in both month and week calendar views
- RFC 5545 compliant VALARM implementation using calendar-models library

Frontend Changes:
- Create AlarmList component for displaying configured reminders
- Create AddAlarmModal with full alarm configuration (trigger, timing, description)
- Update RemindersTab to use new alarm management interface
- Replace old ReminderType dropdown with modern multi-alarm system
- Add reminder icons to event displays in month/week views
- Fix event title ellipsis behavior in week view with proper CSS constraints

Backend Changes:
- Update all request/response models to use Vec<VAlarm> instead of String
- Remove EventReminder conversion logic, pass VAlarms directly through
- Maintain RFC 5545 compliance for CalDAV server compatibility

UI/UX Improvements:
- Improved basic details tab layout (calendar/repeat side-by-side, All Day checkbox repositioned)
- Simplified reminder system to single notification type for user clarity
- Font Awesome icons throughout instead of emojis for consistency
- Clean modal styling with proper button padding and hover states
- Removed non-standard custom message fields for maximum CalDAV compatibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 14:08:31 -04:00
Connor Johnstone
cb1bb23132 Fix all-day event end date handling by removing double conversion
Root cause: Both frontend and backend were adding a day for all-day events:
- Frontend: Converts inclusive UI dates (9/22-9/25) to exclusive (9/22-9/26)
- Backend: Was incorrectly adding another day (9/22-9/27) causing display issues

Fixed by:
- Remove duplicate day addition in backend handlers (events.rs, series.rs)
- Keep frontend conversion for proper RFC 5545 compliance
- Add reverse conversion when loading events for editing
- Maintain user-friendly inclusive dates in UI while storing exclusive dates

Now properly handles: UI 9/22-9/25 ↔ Storage 9/22-9/26 (exclusive per spec)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 12:34:09 -04:00
23 changed files with 1787 additions and 301 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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());

View File

@@ -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)

View File

@@ -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"

View File

@@ -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
View 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('/');
})
);
});

View File

@@ -6,7 +6,7 @@ use crate::components::{
use crate::components::mobile_warning_modal::is_mobile_device;
use crate::components::sidebar::{Style};
use crate::models::ical::VEvent;
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService, AlarmScheduler};
use chrono::NaiveDate;
use gloo_storage::{LocalStorage, Storage};
use gloo_timers::callback::Interval;
@@ -149,6 +149,11 @@ pub fn App() -> Html {
let mobile_warning_open = use_state(|| is_mobile_device());
let refresh_interval = use_state(|| -> Option<Interval> { None });
// Alarm system state
let alarm_scheduler = use_state(|| AlarmScheduler::new());
let alarm_check_interval = use_state(|| -> Option<Interval> { None });
let alarm_system_initialized = use_state(|| false);
// Calendar view state - load from localStorage if available
let current_view = use_state(|| {
// Try to load saved view mode from localStorage
@@ -192,6 +197,67 @@ pub fn App() -> Html {
})
};
// Initialize alarm system after user login
{
let auth_token = auth_token.clone();
let alarm_scheduler = alarm_scheduler.clone();
let alarm_system_initialized = alarm_system_initialized.clone();
let alarm_check_interval = alarm_check_interval.clone();
use_effect_with((*auth_token).clone(), move |token| {
if token.is_some() && !*alarm_system_initialized {
let alarm_scheduler = alarm_scheduler.clone();
let alarm_system_initialized = alarm_system_initialized.clone();
let alarm_check_interval = alarm_check_interval.clone();
wasm_bindgen_futures::spawn_local(async move {
// Request notification permission
let scheduler = (*alarm_scheduler).clone();
match scheduler.request_notification_permission().await {
Ok(_permission) => {
}
Err(e) => {
web_sys::console::warn_1(
&format!("⚠️ Failed to request notification permission: {:?}", e).into()
);
}
}
alarm_scheduler.set(scheduler);
alarm_system_initialized.set(true);
// Set up alarm checking interval (every 30 seconds)
let interval = {
let alarm_scheduler_ref = alarm_scheduler.clone();
Interval::new(30_000, move || {
// Get a fresh copy of the current scheduler state each time
let mut scheduler = (*alarm_scheduler_ref).clone();
let triggered_count = scheduler.check_and_trigger_alarms();
if triggered_count > 0 {
web_sys::console::log_1(
&format!("🔔 Triggered {} alarm(s)", triggered_count).into()
);
}
// Update the scheduler state with any changes (like alarm status updates)
alarm_scheduler_ref.set(scheduler);
})
};
alarm_check_interval.set(Some(interval));
});
} else if token.is_none() {
// Clean up alarm system on logout
alarm_check_interval.set(None); // This will drop and cancel the interval
alarm_system_initialized.set(false);
}
|| ()
});
}
// Function to refresh calendar data without full page reload
let refresh_calendar_data = {
let user_info = user_info.clone();
@@ -786,7 +852,9 @@ pub fn App() -> Html {
let create_event_modal_open = create_event_modal_open.clone();
let auth_token = auth_token.clone();
let refresh_calendar_data = refresh_calendar_data.clone();
let alarm_scheduler = alarm_scheduler.clone();
Callback::from(move |event_data: EventCreationData| {
let alarm_scheduler = alarm_scheduler.clone();
// Check if this is an update operation (has original_uid) or a create operation
if let Some(original_uid) = event_data.original_uid.clone() {
web_sys::console::log_1(&format!("Updating event via modal: {:?}", event_data).into());
@@ -824,13 +892,10 @@ pub fn App() -> Html {
crate::components::event_form::RecurrenceType::Monthly |
crate::components::event_form::RecurrenceType::Yearly);
web_sys::console::log_1(&format!("🐛 FRONTEND DEBUG: is_recurring={}, edit_scope={:?}, original_uid={:?}",
is_recurring, event_data_for_update.edit_scope, event_data_for_update.original_uid).into());
let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() {
// Only use series endpoint for existing recurring events being edited
// Singleton→series conversion should use regular update_event endpoint
let edit_action = event_data_for_update.edit_scope.unwrap();
let edit_action = event_data_for_update.edit_scope.as_ref().unwrap();
let scope = match edit_action {
crate::components::EditAction::EditAll => "all_in_series".to_string(),
crate::components::EditAction::EditFuture => "this_and_future".to_string(),
@@ -904,6 +969,45 @@ pub fn App() -> Html {
match update_result {
Ok(_) => {
web_sys::console::log_1(&"Event updated successfully via modal".into());
// Re-schedule alarms for the updated event
let alarm_scheduler_for_update = alarm_scheduler.clone();
let event_data_for_alarms = event_data_for_update.clone();
let original_uid_for_alarms = original_uid.clone();
wasm_bindgen_futures::spawn_local(async move {
let mut scheduler = (*alarm_scheduler_for_update).clone();
// Remove old alarms for this event
scheduler.remove_event_alarms(&original_uid_for_alarms);
// Schedule new alarms if any exist
if !event_data_for_alarms.alarms.is_empty() {
let params = event_data_for_alarms.to_create_event_params();
// Parse start date/time for alarm scheduling
if let (Ok(start_date), Ok(start_time)) = (
chrono::NaiveDate::parse_from_str(&params.2, "%Y-%m-%d"),
chrono::NaiveTime::parse_from_str(&params.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

View File

@@ -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);
match calendar_service
.fetch_events_for_month_vevent(
&token,
&password,
current_year,
current_month,
)
.await
let mut months = vec![(start_of_week.year(), start_of_week.month())];
// If the week spans into a different month, add that month too
if end_of_week.month() != start_of_week.month() || end_of_week.year() != start_of_week.year() {
months.push((end_of_week.year(), end_of_week.month()));
}
months
}
};
// Fetch events for all required months
let mut all_events = Vec::new();
for (year, month) in months_to_fetch {
match calendar_service
.fetch_events_for_month_vevent(
&token,
&password,
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)
}

View File

@@ -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 {

View 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>
}
}

View 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)
}
}

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>
}
}

View File

@@ -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,

View File

@@ -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>()

View File

@@ -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 {

View 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()
}
}

View File

@@ -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,

View File

@@ -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;

View 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()
}
}

View File

@@ -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;
}