14 Commits

Author SHA1 Message Date
Connor Johnstone
933d7a8c1b Fix calendar visibility preservation during event updates
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m5s
Prevent hidden calendars from becoming visible when events are modified via drag-and-drop or other update operations. The refresh mechanism was overwriting frontend visibility state with fresh server data that doesn't include visibility settings.

Changes:
- Preserve existing calendar visibility and color settings during refresh_calendar_data
- Maintain smart fallback for saved colors on new calendars
- Ensure calendar visibility state persists across event modifications

This fixes the issue where users would hide calendars, then see them reappear after dragging events.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 11:20:43 -04:00
Connor Johnstone
c938f25951 Fix recurring event series modification and UI issues
Backend fixes:
- Fix "this event only" EXDATE handling - ensure proper timezone conversion for exception dates
- Remove debug logging for cleaner production output

Frontend fixes:
- Add EXDATE timezone conversion in convert_utc_to_local function
- Fix event duplication when viewing weeks across month boundaries with deduplication logic
- Update CSS theme colors for context menus, recurrence options, and recurring edit modals

These changes ensure RFC 5545 compliance for recurring event exceptions and improve the user experience across different themes and calendar views.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 10:22:02 -04:00
Connor Johnstone
c612f567b4 Fix calendar layout positioning and overflow handling
- Add position relative and height 100% to calendar component for proper layout
- Add overflow hidden to week events overlay to prevent content spillover

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-23 15:30:59 -04:00
Connor Johnstone
b5b53bb23a Fix theme-independent login styling and improve calendar responsiveness
- Remove theme reset on logout to preserve user theme preferences
- Implement hardcoded login page colors that override all theme styles
- Add comprehensive overrides for Google theme affecting login forms
- Optimize month view to show minimum required weeks (4-6) instead of fixed 6
- Implement dynamic calendar grid height calculations for better responsive fit
- Add calendar header to print preview with updated height calculations
- Update responsive breakpoints with proper header height variables

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-23 12:24:55 -04:00
Connor Johnstone
7e058ba972 Implement comprehensive responsive design improvements for sidebar and calendar views
- Add full responsive sidebar support for screens 600px+ height with three breakpoints (900px, 750px, 650px)
- Implement consistent spacing for all sidebar controls (view-selector, theme-selector, style-selector, add-calendar-button)
- Add calendar header compactness scaling based on screen height (padding, font sizes, min-heights)
- Implement width-based responsive event text sizing for better space utilization
- Fix login page theme inheritance issue by resetting theme to default on logout
- Remove problematic position:relative style from external calendar items that caused color picker interference
- Standardize external-calendar-list margin to 2px across all breakpoints
- Add proper overflow handling and minimum heights to ensure all sidebar components remain visible
- Scale event titles and times progressively smaller on narrower viewports (1200px, 900px, 600px breakpoints)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-22 18:04:02 -04:00
Connor Johnstone
1f86ea9f71 Enhance styling system with new themes and fix modal theming consistency
- Add 4 new dark themes: Midnight, Charcoal, Nord, Dracula with complete CSS variable definitions
- Create Apple Calendar style with glassmorphism effects and theme-aware design
- Fix Google Calendar style to be theme-aware instead of using hardcoded colors
- Replace hardcoded colors in modal CSS with theme variables for consistent theming
- Add data-style attribute support to document root for style-specific CSS selectors
- Update sidebar dropdowns to include all new theme and style options

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-21 23:36:47 -04:00
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
28 changed files with 4039 additions and 970 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());
@@ -467,13 +465,10 @@ pub async fn update_event_series(
};
// Update the event on the CalDAV server using the original event's href
println!("📤 Updating event on CalDAV server...");
let event_href = existing_event
.href
.as_ref()
.ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?;
println!("📤 Using event href: {}", event_href);
println!("📤 Calendar path: {}", calendar_path);
match client
.update_event(&calendar_path, &updated_event, event_href)
@@ -1028,7 +1023,7 @@ async fn update_single_occurrence(
println!("✅ Created exception event successfully");
// Return the original series (now with EXDATE) - main handler will update it on CalDAV
// Return the modified existing event with EXDATE for the main handler to update on CalDAV
Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception)
}

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,29 @@
<link data-trunk rel="css" href="styles.css">
<link data-trunk rel="css" href="print-preview.css">
<link data-trunk rel="copy-file" href="styles/google.css">
<link data-trunk rel="copy-file" href="styles/apple.css">
<link data-trunk rel="copy-file" href="service-worker.js">
<link data-trunk rel="icon" href="favicon.ico">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>
<script>
console.log("HTML fully loaded, waiting for WASM...");
window.addEventListener('TrunkApplicationStarted', () => {
console.log("Trunk application started successfully!");
// Application loaded successfully
});
// Register service worker for alarm background processing
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then((registration) => {
// Service worker registered successfully
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
</script>
</body>
</html>

View File

@@ -212,6 +212,7 @@
display: none !important;
}
/* Remove today highlighting in preview */
.print-preview-paper .calendar-day.today,
.print-preview-paper .week-day-header.today,

150
frontend/service-worker.js Normal file
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;
@@ -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();
@@ -229,7 +295,21 @@ pub fn App() -> Html {
if !password.is_empty() {
match calendar_service.fetch_user_info(&token, &password).await {
Ok(mut info) => {
// Apply saved colors
// Preserve existing calendar settings (colors and visibility) from current state
if let Some(current_info) = (*user_info).clone() {
for current_cal in &current_info.calendars {
for cal in &mut info.calendars {
if cal.path == current_cal.path {
// Preserve visibility setting
cal.is_visible = current_cal.is_visible;
// Preserve color setting
cal.color = current_cal.color.clone();
}
}
}
}
// Apply saved colors as fallback for new calendars
if let Ok(saved_colors_json) =
LocalStorage::get::<String>("calendar_colors")
{
@@ -238,13 +318,15 @@ pub fn App() -> Html {
{
for saved_cal in &saved_info.calendars {
for cal in &mut info.calendars {
if cal.path == saved_cal.path {
if cal.path == saved_cal.path && cal.color == "#3B82F6" {
// Only apply saved color if it's still the default
cal.color = saved_cal.color.clone();
}
}
}
}
}
// Add timestamp to force re-render
info.last_updated = (js_sys::Date::now() / 1000.0) as u64;
user_info.set(Some(info));
@@ -431,6 +513,11 @@ pub fn App() -> Html {
// Hot-swap stylesheet
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
// Set data-style attribute on document root
if let Some(root) = document.document_element() {
let _ = root.set_attribute("data-style", new_style.value());
}
// Remove existing style link if it exists
if let Some(existing_link) = document.get_element_by_id("dynamic-style") {
existing_link.remove();
@@ -478,6 +565,11 @@ pub fn App() -> Html {
let style = (*current_style).clone();
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
// Set data-style attribute on document root
if let Some(root) = document.document_element() {
let _ = root.set_attribute("data-style", style.value());
}
// Create and append stylesheet link for initial style only if it has a path
if let Some(stylesheet_path) = style.stylesheet_path() {
if let Ok(link) = document.create_element("link") {
@@ -497,6 +589,7 @@ pub fn App() -> Html {
});
}
// Fetch user info when token is available
{
let user_info = user_info.clone();
@@ -786,7 +879,9 @@ pub fn App() -> Html {
let create_event_modal_open = create_event_modal_open.clone();
let 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 +919,10 @@ pub fn App() -> Html {
crate::components::event_form::RecurrenceType::Monthly |
crate::components::event_form::RecurrenceType::Yearly);
web_sys::console::log_1(&format!("🐛 FRONTEND DEBUG: is_recurring={}, edit_scope={:?}, original_uid={:?}",
is_recurring, event_data_for_update.edit_scope, event_data_for_update.original_uid).into());
let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() {
// Only use series endpoint for existing recurring events being edited
// Singleton→series conversion should use regular update_event endpoint
let edit_action = event_data_for_update.edit_scope.unwrap();
let edit_action = event_data_for_update.edit_scope.as_ref().unwrap();
let scope = match edit_action {
crate::components::EditAction::EditAll => "all_in_series".to_string(),
crate::components::EditAction::EditFuture => "this_and_future".to_string(),
@@ -904,6 +996,45 @@ pub fn App() -> Html {
match update_result {
Ok(_) => {
web_sys::console::log_1(&"Event updated successfully via modal".into());
// Re-schedule alarms for the updated event
let alarm_scheduler_for_update = alarm_scheduler.clone();
let event_data_for_alarms = event_data_for_update.clone();
let original_uid_for_alarms = original_uid.clone();
wasm_bindgen_futures::spawn_local(async move {
let mut scheduler = (*alarm_scheduler_for_update).clone();
// Remove old alarms for this event
scheduler.remove_event_alarms(&original_uid_for_alarms);
// Schedule new alarms if any exist
if !event_data_for_alarms.alarms.is_empty() {
let params = event_data_for_alarms.to_create_event_params();
// Parse start date/time for alarm scheduling
if let (Ok(start_date), Ok(start_time)) = (
chrono::NaiveDate::parse_from_str(&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 +1092,12 @@ pub fn App() -> Html {
};
let params = event_data.to_create_event_params();
// Clone values we'll need for alarm scheduling
let title_for_alarms = params.0.clone();
let start_date_for_alarms = params.2.clone();
let start_time_for_alarms = params.3.clone();
let location_for_alarms = params.6.clone();
let create_result = _calendar_service
.create_event(
&_token, &_password, params.0, // title
@@ -990,6 +1127,36 @@ pub fn App() -> Html {
match create_result {
Ok(_) => {
web_sys::console::log_1(&"Event created successfully".into());
// Schedule alarms for the created event if any exist
if !event_data.alarms.is_empty() {
// Since create_event doesn't return the UID, we need to generate one
// The backend should be using the same UUID generation logic
let event_uid = uuid::Uuid::new_v4().to_string();
// Parse start date/time for alarm scheduling
if let (Ok(start_date), Ok(start_time)) = (
chrono::NaiveDate::parse_from_str(&start_date_for_alarms, "%Y-%m-%d"),
chrono::NaiveTime::parse_from_str(&start_time_for_alarms, "%H:%M")
) {
// Create a temporary VEvent for alarm scheduling
let start_datetime = start_date.and_time(start_time);
let mut temp_event = VEvent::new(event_uid.clone(), start_datetime);
temp_event.summary = Some(title_for_alarms.clone());
temp_event.location = if location_for_alarms.is_empty() { None } else { Some(location_for_alarms.clone()) };
temp_event.alarms = event_data.alarms.clone();
// Schedule alarms for the new event (synchronously)
let mut scheduler = (*alarm_scheduler).clone();
scheduler.schedule_event_alarms(&temp_event);
alarm_scheduler.set(scheduler);
web_sys::console::log_1(
&format!("🔔 Scheduled {} alarm(s) for new event", temp_event.alarms.len()).into()
);
}
}
// Refresh calendar data without page reload
refresh_callback.emit(());
}
@@ -1096,7 +1263,7 @@ pub fn App() -> Html {
};
// Convert reminders to string format
let reminder_str = if !original_event.alarms.is_empty() {
let _reminder_str = if !original_event.alarms.is_empty() {
// Convert from VAlarm to minutes before
"15".to_string() // TODO: Convert VAlarm trigger to minutes
} else {
@@ -1147,7 +1314,7 @@ pub fn App() -> Html {
.collect::<Vec<_>>()
.join(","),
original_event.categories.join(","),
reminder_str.clone(),
original_event.alarms.clone(),
recurrence_str.clone(),
vec![false; 7], // recurrence_days
1, // recurrence_interval - default for drag-and-drop
@@ -1204,7 +1371,7 @@ pub fn App() -> Html {
.collect::<Vec<_>>()
.join(","),
original_event.categories.join(","),
reminder_str,
original_event.alarms.clone(),
recurrence_str,
recurrence_days,
1, // recurrence_interval - default to 1 for drag-and-drop

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,67 @@ pub fn Calendar(props: &CalendarProps) -> Html {
String::new()
};
let current_year = date.year();
let current_month = date.month();
// Determine which months to fetch based on view mode
let months_to_fetch = match view_mode {
ViewMode::Month => {
// For month view, just fetch the current month
vec![(date.year(), date.month())]
}
ViewMode::Week => {
// For week view, calculate the week bounds and fetch all months that intersect
let start_of_week = get_start_of_week(date);
let end_of_week = start_of_week + Duration::days(6);
let mut months = vec![(start_of_week.year(), start_of_week.month())];
// If the week spans into a different month, add that month too
if end_of_week.month() != start_of_week.month() || end_of_week.year() != start_of_week.year() {
months.push((end_of_week.year(), end_of_week.month()));
}
months
}
};
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;
}
}
}
// Deduplicate events that may appear in multiple month fetches
// This happens when a recurring event spans across month boundaries
all_events.sort_by(|a, b| {
// Sort by UID first, then by start time
match a.uid.cmp(&b.uid) {
std::cmp::Ordering::Equal => a.dtstart.cmp(&b.dtstart),
other => other,
}
});
all_events.dedup_by(|a, b| {
// Remove duplicates with same UID and start time
a.uid == b.uid && a.dtstart == b.dtstart
});
// Process the combined events
match Ok(all_events) as Result<Vec<VEvent>, String>
{
Ok(vevents) => {
// Filter CalDAV events based on calendar visibility
@@ -602,3 +653,18 @@ pub fn Calendar(props: &CalendarProps) -> Html {
</div>
}
}
// Helper function to calculate the start of the week (Sunday) for a given date
fn get_start_of_week(date: NaiveDate) -> NaiveDate {
let weekday = date.weekday();
let days_from_sunday = match weekday {
Weekday::Sun => 0,
Weekday::Mon => 1,
Weekday::Tue => 2,
Weekday::Wed => 3,
Weekday::Thu => 4,
Weekday::Fri => 5,
Weekday::Sat => 6,
};
date - Duration::days(days_from_sunday)
}

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

@@ -114,8 +114,13 @@ pub fn month_view(props: &MonthViewProps) -> Html {
};
let weeks_needed = calculate_minimum_weeks_needed(first_weekday, days_in_month);
// Use calculated weeks with height-based container sizing for proper fit
let dynamic_style = format!("grid-template-rows: var(--weekday-header-height, 50px) repeat({}, 1fr);", weeks_needed);
html! {
<div class="calendar-grid">
<div class="calendar-grid" style={dynamic_style}>
// Weekday headers
<div class="weekday-header">{"Sun"}</div>
<div class="weekday-header">{"Mon"}</div>
@@ -213,7 +218,10 @@ pub fn month_view(props: &MonthViewProps) -> Html {
{onclick}
{oncontextmenu}
>
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
<span class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</span>
if !event.alarms.is_empty() {
<i class="fas fa-bell event-reminder-icon" title="Has reminders"></i>
}
</div>
}
}).collect::<Html>()
@@ -235,13 +243,27 @@ pub fn month_view(props: &MonthViewProps) -> Html {
}).collect::<Html>()
}
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
{ render_next_month_days(days_from_prev_month.len(), days_in_month, calculate_minimum_weeks_needed(first_weekday, days_in_month)) }
</div>
}
}
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
let total_slots = 42; // 6 rows x 7 days
fn calculate_minimum_weeks_needed(first_weekday: Weekday, days_in_month: u32) -> u32 {
let days_before = match first_weekday {
Weekday::Sun => 0,
Weekday::Mon => 1,
Weekday::Tue => 2,
Weekday::Wed => 3,
Weekday::Thu => 4,
Weekday::Fri => 5,
Weekday::Sat => 6,
};
let total_days_needed = days_before + days_in_month;
(total_days_needed + 6) / 7 // Round up to get number of weeks
}
fn render_next_month_days(prev_days_count: usize, current_days_count: u32, weeks_needed: u32) -> Html {
let total_slots = (weeks_needed * 7) as usize; // Dynamic based on weeks needed
let used_slots = prev_days_count + current_days_count as usize;
let remaining_slots = if used_slots < total_slots {
total_slots - used_slots

View File

@@ -1,9 +1,10 @@
use crate::components::{ViewMode, WeekView, MonthView};
use crate::components::{ViewMode, WeekView, MonthView, CalendarHeader};
use crate::models::ical::VEvent;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use chrono::NaiveDate;
use std::collections::HashMap;
use wasm_bindgen::{closure::Closure, JsCast};
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
@@ -88,10 +89,11 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
let calculate_print_dimensions = |start_hour: u32, end_hour: u32, time_increment: u32| -> (f64, f64, f64) {
let visible_hours = (end_hour - start_hour) as f64;
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
let header_height = 50.0; // Fixed week header height in print preview
let calendar_header_height = 80.0; // Calendar header height in print preview
let week_header_height = 50.0; // Fixed week header height in print preview
let header_border = 2.0; // Week header bottom border (2px solid)
let container_spacing = 8.0; // Additional container spacing/margins
let total_overhead = header_height + header_border + container_spacing;
let total_overhead = calendar_header_height + week_header_height + header_border + container_spacing;
let available_height = 720.0 - total_overhead; // Available for time content
let base_unit = available_height / (visible_hours * slots_per_hour);
let pixels_per_hour = base_unit * slots_per_hour;
@@ -151,10 +153,11 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
// Recalculate base-unit and pixels-per-hour based on actual height
let visible_hours = (end_hour - start_hour) as f64;
let slots_per_hour = if time_increment == 15 { 4.0 } else { 2.0 };
let header_height = 50.0;
let calendar_header_height = 80.0; // Calendar header height
let week_header_height = 50.0; // Week header height
let header_border = 2.0;
let container_spacing = 8.0;
let total_overhead = header_height + header_border + container_spacing;
let total_overhead = calendar_header_height + week_header_height + header_border + container_spacing;
let available_height = actual_height - total_overhead;
let actual_base_unit = available_height / (visible_hours * slots_per_hour);
let actual_pixels_per_hour = actual_base_unit * slots_per_hour;
@@ -320,38 +323,50 @@ pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
*start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level
)}>
<div class="print-preview-content">
{
match props.view_mode {
ViewMode::Week => html! {
<WeekView
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
current_date={props.current_date}
today={props.today}
events={props.events.clone()}
on_event_click={Callback::noop()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
time_increment={props.time_increment}
print_mode={true}
print_pixels_per_hour={Some(pixels_per_hour)}
print_start_hour={Some(*start_hour)}
/>
},
ViewMode::Month => html! {
<MonthView
key={format!("month-preview-{}-{}", *start_hour, *end_hour)}
current_month={props.current_date}
selected_date={Some(props.selected_date)}
today={props.today}
events={props.events.clone()}
on_day_select={None::<Callback<NaiveDate>>}
on_event_click={Callback::noop()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
/>
},
<div class={classes!("calendar", match props.view_mode { ViewMode::Week => Some("week-view"), _ => None })}>
<CalendarHeader
current_date={props.current_date}
view_mode={props.view_mode.clone()}
on_prev={Callback::from(|_: MouseEvent| {})}
on_next={Callback::from(|_: MouseEvent| {})}
on_today={Callback::from(|_: MouseEvent| {})}
time_increment={Some(props.time_increment)}
on_time_increment_toggle={None::<Callback<MouseEvent>>}
on_print={None::<Callback<MouseEvent>>}
/>
{
match props.view_mode {
ViewMode::Week => html! {
<WeekView
key={format!("week-preview-{}-{}", *start_hour, *end_hour)}
current_date={props.current_date}
today={props.today}
events={props.events.clone()}
on_event_click={Callback::noop()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
time_increment={props.time_increment}
print_mode={true}
print_pixels_per_hour={Some(pixels_per_hour)}
print_start_hour={Some(*start_hour)}
/>
},
ViewMode::Month => html! {
<MonthView
key={format!("month-preview-{}-{}", *start_hour, *end_hour)}
current_month={props.current_date}
selected_date={Some(props.selected_date)}
today={props.today}
events={props.events.clone()}
on_day_select={None::<Callback<NaiveDate>>}
on_event_click={Callback::noop()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
/>
},
}
}
}
</div>
</div>
</div>
</div>

View File

@@ -20,12 +20,17 @@ pub enum Theme {
Dark,
Rose,
Mint,
Midnight,
Charcoal,
Nord,
Dracula,
}
#[derive(Clone, PartialEq)]
pub enum Style {
Default,
Google,
Apple,
}
impl Theme {
@@ -39,6 +44,10 @@ impl Theme {
Theme::Dark => "dark",
Theme::Rose => "rose",
Theme::Mint => "mint",
Theme::Midnight => "midnight",
Theme::Charcoal => "charcoal",
Theme::Nord => "nord",
Theme::Dracula => "dracula",
}
}
@@ -51,6 +60,10 @@ impl Theme {
"dark" => Theme::Dark,
"rose" => Theme::Rose,
"mint" => Theme::Mint,
"midnight" => Theme::Midnight,
"charcoal" => Theme::Charcoal,
"nord" => Theme::Nord,
"dracula" => Theme::Dracula,
_ => Theme::Default,
}
}
@@ -61,12 +74,14 @@ impl Style {
match self {
Style::Default => "default",
Style::Google => "google",
Style::Apple => "apple",
}
}
pub fn from_value(value: &str) -> Self {
match value {
"google" => Style::Google,
"apple" => Style::Apple,
_ => Style::Default,
}
}
@@ -76,6 +91,7 @@ impl Style {
match self {
Style::Default => None, // No additional stylesheet needed - uses base styles.css
Style::Google => Some("google.css"), // Trunk copies to root level
Style::Apple => Some("apple.css"), // Trunk copies to root level
}
}
}
@@ -246,7 +262,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
};
html! {
<li class="external-calendar-item" style="position: relative;">
<li class="external-calendar-item">
<div
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
"external-calendar-info color-picker-active"
@@ -426,6 +442,10 @@ pub fn sidebar(props: &SidebarProps) -> Html {
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option>
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option>
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
<option value="midnight" selected={matches!(props.current_theme, Theme::Midnight)}>{"Midnight"}</option>
<option value="charcoal" selected={matches!(props.current_theme, Theme::Charcoal)}>{"Charcoal"}</option>
<option value="nord" selected={matches!(props.current_theme, Theme::Nord)}>{"Nord"}</option>
<option value="dracula" selected={matches!(props.current_theme, Theme::Dracula)}>{"Dracula"}</option>
</select>
</div>
@@ -433,6 +453,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
<select class="style-selector-dropdown" onchange={on_style_change}>
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>
<option value="apple" selected={matches!(props.current_style, Style::Apple)}>{"Apple Calendar"}</option>
</select>
</div>

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;
@@ -317,6 +317,11 @@ impl CalendarService {
event.last_modified = Some(modified_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64));
event.last_modified_tzid = None;
}
// Convert EXDATE entries from UTC to local time
event.exdate = event.exdate.into_iter()
.map(|exdate| exdate + chrono::Duration::minutes(-timezone_offset_minutes as i64))
.collect();
}
event
@@ -333,8 +338,6 @@ impl CalendarService {
// Convert UTC events to local time for proper display
let event = Self::convert_utc_to_local(event);
if let Some(ref rrule) = event.rrule {
// Generate occurrences for recurring events using VEvent
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
expanded_events.extend(occurrences);
@@ -437,25 +440,14 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| {
// Compare dates ignoring sub-second precision
let exception_naive = exception_date.and_utc();
let occurrence_naive = occurrence_datetime.and_utc();
// EXDATE from server is in local time, but stored as NaiveDateTime
// We need to compare both as local time (naive datetimes) instead of UTC
let exception_naive = *exception_date;
let occurrence_naive = occurrence_datetime;
// Check if dates match (within a minute to handle minor time differences)
let diff = occurrence_naive - exception_naive;
let matches = diff.num_seconds().abs() < 60;
if matches {
web_sys::console::log_1(
&format!(
"🚫 Excluding occurrence {} due to EXDATE {}",
occurrence_naive, exception_naive
)
.into(),
);
}
matches
diff.num_seconds().abs() < 60
});
if !is_exception {
@@ -632,22 +624,11 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.and_utc();
let occurrence_naive = occurrence_datetime.and_utc();
// Compare as local time (naive datetimes) instead of UTC
let exception_naive = *exception_date;
let occurrence_naive = occurrence_datetime;
let diff = occurrence_naive - exception_naive;
let matches = diff.num_seconds().abs() < 60;
if matches {
web_sys::console::log_1(
&format!(
"🚫 Excluding occurrence {} due to EXDATE {}",
occurrence_naive, exception_naive
)
.into(),
);
}
matches
diff.num_seconds().abs() < 60
});
if !is_exception {
@@ -1250,7 +1231,7 @@ impl CalendarService {
organizer: String,
attendees: String,
categories: String,
reminder: String,
alarms: Vec<VAlarm>,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
@@ -1285,7 +1266,7 @@ impl CalendarService {
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"alarms": alarms,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": recurrence_interval,
@@ -1313,7 +1294,7 @@ impl CalendarService {
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"alarms": alarms,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"calendar_path": calendar_path,
@@ -1391,7 +1372,7 @@ impl CalendarService {
organizer: String,
attendees: String,
categories: String,
reminder: String,
alarms: Vec<VAlarm>,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
@@ -1419,7 +1400,7 @@ impl CalendarService {
organizer,
attendees,
categories,
reminder,
alarms,
recurrence,
recurrence_days,
recurrence_interval,
@@ -1450,7 +1431,7 @@ impl CalendarService {
organizer: String,
attendees: String,
categories: String,
reminder: String,
alarms: Vec<VAlarm>,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
@@ -1482,7 +1463,7 @@ impl CalendarService {
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"alarms": alarms,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": recurrence_interval,
@@ -1687,7 +1668,7 @@ impl CalendarService {
organizer: String,
attendees: String,
categories: String,
reminder: String,
alarms: Vec<VAlarm>,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
@@ -1720,7 +1701,7 @@ impl CalendarService {
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"alarms": alarms,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": recurrence_interval,

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

File diff suppressed because it is too large Load Diff

691
frontend/styles/apple.css Normal file
View File

@@ -0,0 +1,691 @@
/* Apple Calendar-inspired styles */
/* Override CSS Variables for Apple Calendar Style */
:root {
/* Apple-style spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
/* Apple-style borders and radius */
--border-radius-small: 6px;
--border-radius-medium: 10px;
--border-radius-large: 16px;
/* Apple-style shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
--shadow-md: 0 3px 6px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.12);
--shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.15), 0 3px 6px rgba(0, 0, 0, 0.10);
}
/* Theme-aware Apple style colors - use theme colors but with Apple aesthetic */
[data-style="apple"] {
/* Use theme background and text colors */
--apple-bg-primary: var(--background-secondary);
--apple-bg-secondary: var(--background-primary);
--apple-text-primary: var(--text-primary);
--apple-text-secondary: var(--text-secondary);
--apple-text-tertiary: var(--text-secondary);
--apple-text-inverse: var(--text-inverse);
--apple-border-primary: var(--border-primary);
--apple-border-secondary: var(--border-secondary);
--apple-accent: var(--primary-color);
--apple-hover-bg: var(--background-tertiary);
--apple-today-accent: var(--primary-color);
/* Apple font family */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
/* Theme-specific Apple style adjustments */
[data-style="apple"][data-theme="default"] {
--apple-bg-tertiary: rgba(248, 249, 250, 0.8);
--apple-bg-sidebar: rgba(246, 246, 246, 0.7);
--apple-accent-bg: rgba(102, 126, 234, 0.1);
--apple-today-bg: rgba(102, 126, 234, 0.15);
}
[data-style="apple"][data-theme="ocean"] {
--apple-bg-tertiary: rgba(224, 247, 250, 0.8);
--apple-bg-sidebar: rgba(224, 247, 250, 0.7);
--apple-accent-bg: rgba(0, 105, 148, 0.1);
--apple-today-bg: rgba(0, 105, 148, 0.15);
}
[data-style="apple"][data-theme="forest"] {
--apple-bg-tertiary: rgba(232, 245, 232, 0.8);
--apple-bg-sidebar: rgba(232, 245, 232, 0.7);
--apple-accent-bg: rgba(6, 95, 70, 0.1);
--apple-today-bg: rgba(6, 95, 70, 0.15);
}
[data-style="apple"][data-theme="sunset"] {
--apple-bg-tertiary: rgba(255, 243, 224, 0.8);
--apple-bg-sidebar: rgba(255, 243, 224, 0.7);
--apple-accent-bg: rgba(234, 88, 12, 0.1);
--apple-today-bg: rgba(234, 88, 12, 0.15);
}
[data-style="apple"][data-theme="purple"] {
--apple-bg-tertiary: rgba(243, 229, 245, 0.8);
--apple-bg-sidebar: rgba(243, 229, 245, 0.7);
--apple-accent-bg: rgba(124, 58, 237, 0.1);
--apple-today-bg: rgba(124, 58, 237, 0.15);
}
[data-style="apple"][data-theme="dark"] {
--apple-bg-tertiary: rgba(31, 41, 55, 0.9);
--apple-bg-sidebar: rgba(44, 44, 46, 0.8);
--apple-accent-bg: rgba(55, 65, 81, 0.3);
--apple-today-bg: rgba(55, 65, 81, 0.4);
}
[data-style="apple"][data-theme="rose"] {
--apple-bg-tertiary: rgba(252, 228, 236, 0.8);
--apple-bg-sidebar: rgba(252, 228, 236, 0.7);
--apple-accent-bg: rgba(225, 29, 72, 0.1);
--apple-today-bg: rgba(225, 29, 72, 0.15);
}
[data-style="apple"][data-theme="mint"] {
--apple-bg-tertiary: rgba(224, 242, 241, 0.8);
--apple-bg-sidebar: rgba(224, 242, 241, 0.7);
--apple-accent-bg: rgba(16, 185, 129, 0.1);
--apple-today-bg: rgba(16, 185, 129, 0.15);
}
[data-style="apple"][data-theme="midnight"] {
--apple-bg-tertiary: rgba(21, 27, 38, 0.9);
--apple-bg-sidebar: rgba(21, 27, 38, 0.8);
--apple-accent-bg: rgba(76, 154, 255, 0.15);
--apple-today-bg: rgba(76, 154, 255, 0.2);
}
[data-style="apple"][data-theme="charcoal"] {
--apple-bg-tertiary: rgba(26, 26, 26, 0.9);
--apple-bg-sidebar: rgba(26, 26, 26, 0.8);
--apple-accent-bg: rgba(74, 222, 128, 0.15);
--apple-today-bg: rgba(74, 222, 128, 0.2);
}
[data-style="apple"][data-theme="nord"] {
--apple-bg-tertiary: rgba(59, 66, 82, 0.9);
--apple-bg-sidebar: rgba(59, 66, 82, 0.8);
--apple-accent-bg: rgba(136, 192, 208, 0.15);
--apple-today-bg: rgba(136, 192, 208, 0.2);
}
[data-style="apple"][data-theme="dracula"] {
--apple-bg-tertiary: rgba(68, 71, 90, 0.9);
--apple-bg-sidebar: rgba(68, 71, 90, 0.8);
--apple-accent-bg: rgba(189, 147, 249, 0.15);
--apple-today-bg: rgba(189, 147, 249, 0.2);
}
/* Apple-style body and base styles */
[data-style="apple"] body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--apple-bg-secondary);
color: var(--apple-text-primary);
font-weight: 400;
line-height: 1.47;
letter-spacing: -0.022em;
}
/* Apple-style sidebar with glassmorphism */
[data-style="apple"] .app-sidebar {
background: var(--apple-bg-sidebar);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--apple-border-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
box-shadow: none;
}
[data-style="apple"] .sidebar-header {
background: transparent;
border-bottom: 1px solid var(--apple-border-primary);
padding: 20px 16px 16px 16px;
}
[data-style="apple"] .sidebar-header h1 {
font-size: 24px;
font-weight: 700;
color: var(--apple-text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
letter-spacing: -0.04em;
margin-bottom: 4px;
}
[data-style="apple"] .user-info {
color: var(--apple-text-primary);
font-size: 15px;
line-height: 1.4;
}
[data-style="apple"] .user-info .username {
font-weight: 600;
color: var(--apple-text-primary);
font-size: 16px;
}
[data-style="apple"] .user-info .server-url {
color: var(--apple-text-secondary);
font-size: 13px;
font-weight: 400;
}
/* Apple-style buttons */
[data-style="apple"] .create-calendar-button {
background: var(--apple-accent);
color: var(--apple-text-inverse);
border: none;
border-radius: 8px;
padding: 10px 16px;
font-weight: 600;
font-size: 15px;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
font-family: inherit;
}
[data-style="apple"] .create-calendar-button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
background: var(--apple-accent);
filter: brightness(1.1);
}
[data-style="apple"] .logout-button {
background: var(--apple-bg-primary);
color: var(--apple-accent);
border: 1px solid var(--apple-border-primary);
border-radius: 8px;
padding: 8px 16px;
font-weight: 500;
font-size: 15px;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
[data-style="apple"] .logout-button:hover {
background: var(--apple-hover-bg);
transform: translateY(-1px);
}
/* Apple-style navigation */
[data-style="apple"] .sidebar-nav .nav-link {
color: var(--apple-text-primary);
text-decoration: none;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.2s ease;
display: block;
font-weight: 500;
font-size: 15px;
}
[data-style="apple"] .sidebar-nav .nav-link:hover {
color: var(--apple-accent);
background: var(--apple-hover-bg);
transform: translateX(2px);
}
/* Apple-style calendar list */
[data-style="apple"] .calendar-list h3 {
color: var(--apple-text-primary);
font-size: 17px;
font-weight: 600;
letter-spacing: -0.024em;
margin-bottom: 12px;
}
[data-style="apple"] .calendar-list .calendar-name {
color: var(--apple-text-primary);
font-size: 15px;
font-weight: 500;
}
[data-style="apple"] .no-calendars {
color: var(--apple-text-secondary);
font-size: 14px;
font-style: italic;
}
/* Apple-style form elements */
[data-style="apple"] .sidebar-footer label,
[data-style="apple"] .view-selector label,
[data-style="apple"] .theme-selector label,
[data-style="apple"] .style-selector label {
color: var(--apple-text-primary);
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
}
[data-style="apple"] .view-selector-dropdown,
[data-style="apple"] .theme-selector-dropdown,
[data-style="apple"] .style-selector-dropdown {
border: 1px solid var(--apple-border-primary);
border-radius: 8px;
padding: 8px 12px;
font-size: 15px;
color: var(--apple-text-primary);
background: var(--apple-bg-primary);
font-family: inherit;
font-weight: 500;
transition: all 0.2s ease;
}
[data-style="apple"] .view-selector-dropdown:focus,
[data-style="apple"] .theme-selector-dropdown:focus,
[data-style="apple"] .style-selector-dropdown:focus {
outline: none;
border-color: var(--apple-accent);
box-shadow: 0 0 0 3px var(--apple-accent-bg);
}
/* Apple-style calendar list items */
[data-style="apple"] .calendar-list .calendar-item {
padding: 6px 8px;
border-radius: 8px;
transition: all 0.2s ease;
margin-bottom: 2px;
}
[data-style="apple"] .calendar-list .calendar-item:hover {
background-color: var(--apple-hover-bg);
transform: translateX(2px);
}
/* Apple-style main content area */
[data-style="apple"] .app-main {
background: var(--apple-bg-secondary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
color: var(--apple-text-primary);
}
/* Apple-style calendar header */
[data-style="apple"] .calendar-header {
background: var(--apple-bg-primary);
color: var(--apple-text-primary);
padding: 20px 24px;
border-radius: 16px 16px 0 0;
box-shadow: var(--shadow-sm);
}
[data-style="apple"] .calendar-header h2,
[data-style="apple"] .calendar-header h3,
[data-style="apple"] .month-header,
[data-style="apple"] .week-header {
color: var(--apple-text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.04em;
}
/* Apple-style headings */
[data-style="apple"] h1,
[data-style="apple"] h2,
[data-style="apple"] h3,
[data-style="apple"] .month-title,
[data-style="apple"] .calendar-title,
[data-style="apple"] .current-month,
[data-style="apple"] .month-year,
[data-style="apple"] .header-title {
color: var(--apple-text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.04em;
}
/* Apple-style navigation buttons */
[data-style="apple"] button,
[data-style="apple"] .nav-button,
[data-style="apple"] .calendar-nav-button,
[data-style="apple"] .prev-button,
[data-style="apple"] .next-button,
[data-style="apple"] .arrow-button {
color: var(--apple-text-primary);
background: var(--apple-bg-primary);
border: 1px solid var(--apple-border-primary);
border-radius: 8px;
padding: 8px 12px;
font-weight: 600;
transition: all 0.2s ease;
font-family: inherit;
}
[data-style="apple"] button:hover,
[data-style="apple"] .nav-button:hover,
[data-style="apple"] .calendar-nav-button:hover,
[data-style="apple"] .prev-button:hover,
[data-style="apple"] .next-button:hover,
[data-style="apple"] .arrow-button:hover {
background: var(--apple-accent-bg);
color: var(--apple-accent);
border-color: var(--apple-accent);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
/* Apple-style calendar controls */
[data-style="apple"] .calendar-controls,
[data-style="apple"] .current-date,
[data-style="apple"] .date-display {
color: var(--apple-text-primary);
font-weight: 600;
}
/* Apple-style calendar grid */
[data-style="apple"] .calendar-grid,
[data-style="apple"] .calendar-container {
border: 1px solid var(--apple-border-primary);
border-radius: 16px;
overflow: hidden;
background: var(--apple-bg-primary);
box-shadow: var(--shadow-lg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
margin: 16px;
}
[data-style="apple"] .month-header,
[data-style="apple"] .week-header {
font-size: 28px;
font-weight: 700;
color: var(--apple-text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
letter-spacing: -0.04em;
}
/* Apple-style calendar cells */
[data-style="apple"] .calendar-day,
[data-style="apple"] .day-cell {
border: 1px solid var(--apple-border-secondary);
background: var(--apple-bg-primary);
transition: all 0.3s ease;
padding: 12px;
min-height: 120px;
position: relative;
}
[data-style="apple"] .calendar-day:hover,
[data-style="apple"] .day-cell:hover {
background: var(--apple-hover-bg);
transform: scale(1.02);
box-shadow: var(--shadow-sm);
z-index: 10;
}
[data-style="apple"] .calendar-day.today,
[data-style="apple"] .day-cell.today {
background: var(--apple-today-bg);
border-color: var(--apple-today-accent);
position: relative;
}
[data-style="apple"] .calendar-day.today::before,
[data-style="apple"] .day-cell.today::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--apple-today-accent);
border-radius: 2px 2px 0 0;
}
[data-style="apple"] .calendar-day.other-month,
[data-style="apple"] .day-cell.other-month {
background: var(--apple-bg-secondary);
color: var(--apple-text-secondary);
opacity: 0.6;
}
[data-style="apple"] .day-number,
[data-style="apple"] .date-number {
font-size: 16px;
font-weight: 600;
color: var(--apple-text-primary);
margin-bottom: 6px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
/* Apple-style day headers */
[data-style="apple"] .day-header,
[data-style="apple"] .weekday-header {
background: var(--apple-bg-secondary);
color: var(--apple-text-secondary);
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 12px;
border-bottom: 1px solid var(--apple-border-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
/* Apple Calendar-style events */
[data-style="apple"] .event {
border-radius: 6px;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
margin: 2px 0;
cursor: pointer;
border: none;
color: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
display: block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
line-height: 1.3;
position: relative;
}
[data-style="apple"] .event::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: rgba(255, 255, 255, 0.8);
border-radius: 2px 0 0 2px;
}
[data-style="apple"] .event * {
color: white;
font-family: inherit;
}
[data-style="apple"] .event:hover {
transform: translateY(-1px) scale(1.02);
box-shadow: var(--shadow-md);
}
/* All-day events styling */
[data-style="apple"] .event.all-day {
border-radius: 16px;
padding: 6px 12px;
font-weight: 600;
margin: 3px 0;
font-size: 13px;
}
[data-style="apple"] .event.all-day::before {
display: none;
}
/* Event time display */
[data-style="apple"] .event-time {
opacity: 0.9;
font-size: 11px;
margin-right: 4px;
font-weight: 600;
}
/* Calendar table structure */
[data-style="apple"] .calendar-table,
[data-style="apple"] table {
border-collapse: separate;
border-spacing: 0;
width: 100%;
background: var(--apple-bg-primary);
}
[data-style="apple"] .calendar-table td,
[data-style="apple"] table td {
vertical-align: top;
border: 1px solid var(--apple-border-secondary);
background: var(--apple-bg-primary);
}
/* Apple-style view toggle */
[data-style="apple"] .view-toggle {
display: flex;
gap: 0;
background: var(--apple-bg-primary);
border-radius: 10px;
padding: 2px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--apple-border-primary);
}
[data-style="apple"] .view-toggle button {
padding: 8px 16px;
border: none;
background: transparent;
color: var(--apple-text-secondary);
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
[data-style="apple"] .view-toggle button.active {
background: var(--apple-accent);
color: var(--apple-text-inverse);
box-shadow: var(--shadow-sm);
transform: scale(1.02);
}
/* Apple-style today button */
[data-style="apple"] .today-button {
background: var(--apple-accent);
border: none;
color: var(--apple-text-inverse);
padding: 10px 16px;
border-radius: 10px;
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
font-family: inherit;
}
[data-style="apple"] .today-button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
filter: brightness(1.1);
}
/* Apple-style modals */
[data-style="apple"] .modal-overlay {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
[data-style="apple"] .modal-content {
background: var(--apple-bg-primary);
border-radius: 16px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--apple-border-primary);
color: var(--apple-text-primary);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
[data-style="apple"] .modal h2 {
font-size: 22px;
font-weight: 700;
color: var(--apple-text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
letter-spacing: -0.04em;
}
/* Apple-style form inputs */
[data-style="apple"] input[type="text"],
[data-style="apple"] input[type="email"],
[data-style="apple"] input[type="password"],
[data-style="apple"] input[type="url"],
[data-style="apple"] input[type="date"],
[data-style="apple"] input[type="time"],
[data-style="apple"] textarea,
[data-style="apple"] select {
border: 1px solid var(--apple-border-primary);
border-radius: 8px;
padding: 10px 12px;
font-size: 15px;
color: var(--apple-text-primary);
background: var(--apple-bg-primary);
font-family: inherit;
font-weight: 500;
transition: all 0.2s ease;
}
[data-style="apple"] input:focus,
[data-style="apple"] textarea:focus,
[data-style="apple"] select:focus {
outline: none;
border-color: var(--apple-accent);
box-shadow: 0 0 0 3px var(--apple-accent-bg);
transform: scale(1.02);
}
/* Apple-style labels */
[data-style="apple"] label {
font-size: 15px;
font-weight: 600;
color: var(--apple-text-primary);
margin-bottom: 6px;
display: block;
letter-spacing: -0.01em;
}
/* Smooth animations and transitions */
[data-style="apple"] * {
transition-timing-function: cubic-bezier(0.4, 0.0, 0.2, 1);
}
/* Custom scrollbar for Apple style */
[data-style="apple"] ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
[data-style="apple"] ::-webkit-scrollbar-track {
background: transparent;
}
[data-style="apple"] ::-webkit-scrollbar-thumb {
background: var(--apple-text-secondary);
border-radius: 4px;
opacity: 0.3;
}
[data-style="apple"] ::-webkit-scrollbar-thumb:hover {
opacity: 0.6;
}

File diff suppressed because it is too large Load Diff