56 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
Connor Johnstone
5c406569af Add reset buttons to color editor modal for individual and bulk color resets
All checks were successful
Build and Push Docker Image / docker (push) Successful in 31s
- Add "Reset This Color" button in color preview section for individual resets
- Add "Reset All Colors" button in modal footer for bulk palette reset
- Implement reset callbacks with database persistence via preferences API
- Reorganize color preview layout with flex column for better button placement
- Style reset buttons with appropriate warning colors and hover states
- Support both granular and comprehensive color customization workflows

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 12:03:25 -04:00
Connor Johnstone
4aca6c7fae Implement right-click color editor modal for customizable calendar colors
- Add ColorEditorModal component with full color picker interface
- Replace theme-dependent colors with unified color palette
- Store custom colors in database via preferences API for cross-device sync
- Add right-click handlers on color dots to open editor modal
- Fix event bubbling to prevent calendar context menu conflicts
- Add comprehensive CSS styling for modal with proper positioning

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 11:56:16 -04:00
Connor Johnstone
fd80624429 Fix frontend compilation warnings by removing dead code
All checks were successful
Build and Push Docker Image / docker (push) Successful in 4m5s
- Remove unused Route enum and yew_router import from sidebar.rs
- Remove unused last_fetched field from ExternalCalendarEventsResponse
- Prefix unused variables with underscore (_preserve_rrule, _until_date, etc.)

Frontend now compiles cleanly with no warnings.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 16:28:44 -04:00
Connor Johnstone
b530dcaa69 Remove unnecessary frontend console logs
- Remove app rendering auth_token debug logs
- Remove auth token validation success message
- Remove recurring event occurrence generation logs
- Remove exception dates logging for VEvents

Frontend now runs with minimal essential logging.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 16:20:55 -04:00
Connor Johnstone
0821573041 Remove remaining verbose CalDAV discovery and authentication logs
- Remove discovery response XML dumps that flood console
- Remove calendar collection checking logs
- Remove authentication success messages
- Remove API call password length logging
- Fix unused variable warning

Backend now runs with minimal essential logging only.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 16:15:30 -04:00
Connor Johnstone
703c9ee2f5 Clean up debug logging and fix compiler warnings
- Remove non-critical debug logs from backend CalDAV parsing
- Remove all-day event debug logs from frontend components
- Fix unused variable warnings with underscore prefixes
- Keep essential logging while reducing noise

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 16:02:41 -04:00
Connor Johnstone
5854ad291d Fix all-day event parsing with VALUE=DATE format support
- Add special handling for date-only format (%Y%m%d) in parse_datetime_with_tz
- Convert NaiveDate to midnight NaiveDateTime for all-day events
- Reorder all-day detection to occur before datetime parsing
- All-day events now properly display in calendar views

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 15:51:00 -04:00
Connor Johnstone
ac1164fd81 Fix singleton to series conversion with complete RRULE parameter support
All checks were successful
Build and Push Docker Image / docker (push) Successful in 2m16s
- Add new context menu callback for singleton events to avoid series pipeline
- Implement complete RRULE construction with INTERVAL, COUNT, and UNTIL parameters
- Update frontend service methods to pass recurrence parameters correctly
- Add missing recurrence fields to backend UpdateEventRequest model
- Fix parameter ordering in frontend method calls
- Ensure singleton→series conversion uses single event pipeline initially

This resolves issues where converting singleton events to recurring series
would not respect recurrence interval (every N days), count (N occurrences),
or until date parameters.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 22:28:52 -04:00
Connor Johnstone
a6092d13ce Complete calendar model refactor to use NaiveDateTime for local time handling
- Refactor VEvent to use NaiveDateTime for all date/time fields (dtstart, dtend, created, etc.)
- Add separate timezone ID fields (_tzid) for proper RFC 5545 compliance
- Update all handlers and services to work with naive local times
- Fix external calendar event conversion to handle new model structure
- Remove UTC conversions from frontend - backend now handles timezone conversion
- Update series operations to work with local time throughout the system
- Maintain compatibility with existing CalDAV servers and RFC 5545 specification

This major refactor simplifies timezone handling by treating all event times as local
until the final CalDAV conversion step, eliminating multiple conversion points that
caused timing inconsistencies.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:57:35 -04:00
Connor Johnstone
acc5ced551 Fix timezone handling for drag-and-drop and recurring event updates
- Fix double timezone conversion in drag-and-drop that caused 4-hour time shifts
- Frontend now sends local times instead of UTC to backend for proper conversion
- Add missing timezone parameter to update_series method to fix recurring event updates
- Update both event modal and drag-and-drop paths to include timezone information
- Maintain RFC 5545 compliance with proper timezone conversion in backend

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 20:56:18 -04:00
Connor Johnstone
890940fe31 Update README with project status and usage notes
All checks were successful
Build and Push Docker Image / docker (push) Successful in 32s
2025-09-13 18:28:14 -04:00
Connor Johnstone
fdea5cd646 Fix timezone conversion bug for events displaying on wrong day
- Preserve original local dates when converting times to UTC for storage
- Prevent events created late in day from appearing on next day due to timezone offset
- Remove hacky bandaid logic in week view that tried to handle timezone shifts
- Use stored event date directly instead of calculating from UTC conversion
- Ensure events always display on intended day regardless of timezone

Fixes issue where events created within last 4 hours of day would show on wrong day
after UTC conversion caused date component to shift to next day.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:27:09 -04:00
Connor Johnstone
b307be7eb1 Add password visibility toggle to login form
- Implement show/hide password functionality with eye icon toggle button
- Add dynamic input type switching between password and text
- Position toggle button inside password input field with proper styling
- Include hover, focus states and accessibility features (tabindex, title)
- Use FontAwesome eye/eye-slash icons for visual feedback
- Maintain secure default (password hidden) with optional visibility
- Integrate proper tab order with existing form elements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:21:56 -04:00
Connor Johnstone
9d84c380d1 Fix server and username pre-population on login page
- Save credentials to LocalStorage on successful login when remember checkboxes are checked
- Save credentials immediately when input values change and remember is enabled
- Fix closure ownership issues with checkbox state in submit handler
- Ensure remembered values persist and pre-populate correctly on subsequent visits
- Address issue where values weren't saved if checkboxes defaulted to checked state

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:19:16 -04:00
Connor Johnstone
fad03f94f9 Improve login form layout and accessibility
- Move remember checkboxes inline with inputs for better space utilization
- Implement proper tab order: server → username → password → checkboxes
- Increase form width from 400px to 500px to accommodate horizontal layout
- Make checkbox labels more concise ("Remember" instead of full text)
- Enhance checkbox styling with vertical label placement and larger size
- Reduce form label bottom margin for tighter spacing
- Ensure consistent input widths across all form fields

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 18:15:47 -04:00
a4476dcfae Merge pull request 'Full Printing Now Available (with some bugs)' (#20) from print-preview-feature into main
All checks were successful
Build and Push Docker Image / docker (push) Successful in 35s
Reviewed-on: #20
2025-09-12 14:56:41 -04:00
Connor Johnstone
ca1ca0c3b1 Implement comprehensive theme system with FontAwesome icons
- Add comprehensive CSS custom properties for all theme colors
- Include modal, button, input, text, and background color variables
- Enhance dark theme with complete variable overrides for proper contrast
- Replace hardcoded colors in print-preview.css with theme variables
- Add FontAwesome CDN integration and replace emoji icons
- Create minimalistic glass-effect checkbox styling with transparency
- Fix white-on-white text issue in dark theme across all modals

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 14:55:07 -04:00
Connor Johnstone
64dbf65beb Fix event positioning in print copy with dynamic base-unit recalculation
- Measure actual print copy div height after aspect-ratio scaling
- Recalculate base-unit based on measured height vs original 720px assumption
- Apply position scaling to .week-event elements in print copy only
- Parse and recalculate top/height pixel values using scale factor
- Add landscape orientation and fit-to-page CSS hints for better printing
- Preserve hour filtering with proper data attributes and CSS variables

This ensures events display correctly when print copy is scaled to fit page
while maintaining proper aspect ratio and hour range filtering.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 14:10:43 -04:00
Connor Johnstone
96585440d1 Implement hybrid print preview with CSS-based approach
- Add hidden print-preview-copy div at app level for clean print isolation
- Use @media print rules to show only print copy (960x720) in landscape
- Auto-sync print copy with preview content on modal render via use_effect
- Copy CSS variables and data attributes for proper hour filtering
- Set explicit dimensions matching print-preview-paper content area
- Force landscape orientation with @page rule for better calendar printing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 13:17:21 -04:00
Connor Johnstone
a297d38276 Implement selective print preview content printing with dynamic restoration
- Replace page body with only .print-preview-content div during printing
- Use visibilitychange and focus events to restore original content when print dialog closes
- Add 100ms delay before print dialog to show content replacement briefly
- Remove debug logging for clean production code
- Ensure print output matches preview exactly by sending only preview content to system print dialog

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 10:59:42 -04:00
Connor Johnstone
4fdaa9931d Fix print preview event positioning to respond to hour range changes
- Add print_start_hour parameter to calculate_event_position function
- Implement proper hour offset calculation for events in print mode
- Remove CSS transform hacks for event positioning
- Use dynamic pixels_per_hour for proper scaling with hour ranges
- Increase modal max-width to 1600px for better visibility
- Events now correctly reposition when start/end hours change

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 20:38:56 -04:00
Connor Johnstone
c6c7b38bef Implement dynamic base unit calculation for print preview scaling
- Add dynamic height calculation system based on selected hour range and time increment
- Replace hardcoded CSS heights with CSS variables (--print-base-unit, --print-pixels-per-hour)
- Update WeekView component with print mode support and dynamic event positioning
- Optimize week header for print: reduced to 50px height with smaller fonts
- Account for all borders and spacing in calculation (660px available content height)
- Remove debug styling (blue borders, yellow backgrounds)
- Ensure time slots, time labels, and events scale together proportionally
- Perfect fit within 720px content area regardless of hour selection

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 19:26:53 -04:00
Connor Johnstone
78db2cc00f Fix print preview paper dimensions and cleanup redundant CSS files
- Change print preview to landscape orientation (11" x 8.5")
- Fix paper div to render at exact 1056x816 pixels
- Add 48px padding (0.5 inches) directly to paper div
- Remove CSS file redundancy: deleted styles.css.backup, styles/base.css, styles/default.css
- Improve modal sizing to accommodate paper dimensions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 18:53:40 -04:00
Connor Johnstone
73d191c5ca Merge branch 'main' of git.rcjohnstone.com:connor/calendar into print-preview-feature 2025-09-11 18:01:56 -04:00
d930468748 Merge pull request 'Small bugfixes on the external calendar handling' (#19) from bugfix/external_cal_misc into main
All checks were successful
Build and Push Docker Image / docker (push) Successful in 4m3s
Reviewed-on: #19
2025-09-11 18:01:00 -04:00
Connor Johnstone
91be4436a9 Fix external calendar creation and Outlook compatibility issues
- Fix external calendar form validation by replacing node refs with controlled state inputs
- Add multiple user-agent fallback approach for better external calendar compatibility
- Enhance HTTP client configuration with proper redirect handling and timeouts
- Add detailed error logging and Outlook-specific error detection
- Improve request headers for better calendar server compatibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 17:58:53 -04:00
Connor Johnstone
4cbc495c48 Add print preview modal with hour range selection
Implements comprehensive print functionality for calendar views:
- Print preview modal with live preview and zoom controls
- Hour range selection for week view (start/end hours)
- Print-specific CSS to hide UI elements and optimize layout
- Event repositioning to align with visible time labels
- Support for both 30-minute and 15-minute time increments
- Mouse wheel zoom functionality in preview

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 14:55:18 -04:00
Connor Johnstone
927cd7d2bb Add color picker functionality to external calendars
- Enable clicking external calendar color icons to open color picker dropdown
- Implement backend API integration for updating external calendar colors
- Add conditional hover effects to prevent interference with color picker
- Use extremely high z-index (999999) to ensure dropdown appears above all elements
- Match existing CalDAV calendar color picker behavior and styling
- Support real-time color updates with immediate visual feedback
- Maintain color consistency across sidebar and calendar events

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 12:17:09 -04:00
Connor Johnstone
38b22287c7 Unify hover behavior across all sidebar selectors
All checks were successful
Build and Push Docker Image / docker (push) Successful in 31s
- Update view-selector-dropdown hover to match theme/style selectors
- Change from var(--glass-bg-light) to rgba(255, 255, 255, 0.15)
- Ensures consistent glassmorphism hover effects throughout sidebar
- Provides cohesive user experience across Week/Month, Theme, and Style dropdowns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 12:04:11 -04:00
Connor Johnstone
0de2eee626 Fix calendar management modal color picker issues
- Fix z-index issue by creating separate CSS classes for inline vs dropdown color pickers
- Unify CalDAV and external calendar color pickers to use same grid interface
- Improve color picker styling with 4x4 grid layout for 16 colors
- Enhance color option appearance with proper border centering and sizing
- Replace native HTML color input with consistent predefined color grid
- Add visual improvements: larger swatches, better hover effects, checkmark selection

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 11:58:35 -04:00
Connor Johnstone
aa7a15e6fa Implement tabbed calendar management modal with improved styling
- Replace separate Create Calendar and External Calendar modals with unified tabbed interface
- Redesign modal styling with less rounded corners and cleaner appearance
- Significantly increase padding throughout modal components for better spacing
- Fix CSS variable self-references (control-padding, standard-transition)
- Improve button styling with better padding (0.875rem 2rem) and colors
- Enhance form elements with generous padding (1rem) and improved focus states
- Redesign tab bar with segmented control appearance and proper active states
- Update context menus with modern glassmorphism styling and smooth animations
- Consolidate calendar management functionality into single reusable component

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 11:46:21 -04:00
Connor Johnstone
b0a8ef09a8 Major CSS cleanup and mobile detection system
CSS Improvements:
- Remove all mobile responsive CSS (@media queries) - 348+ lines removed
- Add comprehensive CSS variables for glass effects, control dimensions, transitions
- Consolidate duplicate patterns (43+ transition, 37+ border-radius, 61+ padding instances)
- Remove legacy week grid CSS section
- Reduce total CSS from 4,197 to 3,828 lines (8.8% reduction)

Sidebar Enhancements:
- Remove unused sidebar-nav div and navigation link
- Standardize all dropdown controls to consistent 40px height and styling
- Reduce calendar item padding from 0.75rem to 0.5rem for more compact display
- Unify theme-selector and style-selector styling with view-selector

Mobile Detection:
- Add MobileWarningModal component with device detection
- Show helpful popup directing mobile users to native CalDAV apps
- Add Navigator and DomTokenList web-sys features
- Desktop-focused experience with appropriate mobile guidance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 10:58:47 -04:00
Connor Johnstone
efbaea5ac1 Add current time indicator to week view
- Add real-time current time indicator that updates every 5 seconds
- Display horizontal line with dot and time label on current day only
- Position indicator accurately based on time increment mode (15/30 min)
- Use theme-aware colors with subdued gray styling for dark mode
- Include subtle shadows and proper z-indexing for visibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 10:33:07 -04:00
Connor Johnstone
bbad327ea2 Replace page reloads with dynamic calendar refresh functionality
All checks were successful
Build and Push Docker Image / docker (push) Successful in 29s
- Add refresh_calendar_data function to replace window.location.reload()
- Implement dynamic event re-fetching without full page refresh
- Add last_updated timestamp to UserInfo to force component re-renders
- Fix WASM compatibility by using js_sys::Date::now() instead of SystemTime
- Remove debug logging from refresh operations
- Maintain same user experience with improved performance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 16:53:58 -04:00
Connor Johnstone
72273a3f1c Fix event creation timezone handling to prevent time offset issues
- Convert local datetime to UTC before sending to backend for non-all-day events
- Keep all-day events unchanged (no timezone conversion needed)
- Add proper timezone conversion using chrono::Local and chrono::Utc
- Include fallback handling if timezone conversion fails
- Add debug logging for timezone conversion issues

This resolves the issue where events appeared 4 hours earlier than expected
due to frontend sending local time but backend treating it as UTC time.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 16:26:05 -04:00
Connor Johnstone
8329244c69 Fix authentication validation to properly reject invalid CalDAV servers
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m9s
- Backend: Enhance CalDAV discovery to require at least one valid 207 response
- Backend: Fail authentication if no valid CalDAV endpoints are found
- Frontend: Add token verification on app startup to validate stored tokens
- Frontend: Clear invalid tokens when login fails or token verification fails
- Frontend: Prevent users with invalid tokens from accessing calendar page

This resolves the issue where invalid servers (like google.com) were incorrectly
accepted as valid CalDAV servers, and ensures proper authentication flow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 16:06:18 -04:00
Connor Johnstone
b16603b50b Implement comprehensive external calendar event deduplication and fixes
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m11s
- Add UID-based deduplication to prefer recurring events over single events with same UID
- Implement RRULE-generated instance detection to filter duplicate occurrences
- Add title normalization for case-insensitive matching and consolidation
- Fix external calendar refresh button with proper error handling and loading states
- Update context menu for external events to show only "View Event Details" option
- Add comprehensive multi-pass deduplication: UID → title consolidation → RRULE filtering

This resolves issues where Outlook calendars showed duplicate events with same UID
but different RRULE states (e.g., "Dragster Stand Up" appearing both as recurring
and single events).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 15:35:42 -04:00
Connor Johnstone
c6eea88002 Fix drag-and-drop timezone bug between dev and production environments
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m12s
The root cause was that drag operations sent naive local time to the backend,
which the backend interpreted using the SERVER's local timezone rather than
the USER's timezone. This caused different behavior between development and
production servers in different timezones.

**Frontend Changes:**
- Convert naive datetime from drag operations to UTC before sending to backend
- Use client-side Local timezone to properly convert user's intended times
- Handle DST transition edge cases with fallback logic

**Backend Changes:**
- Update parse_event_datetime to treat incoming times as UTC (no server timezone conversion)
- Update series handlers to expect UTC times from frontend
- Remove server-side Local timezone dependency for event parsing

**Result:**
- Consistent behavior across all server environments regardless of server timezone
- Drag operations now correctly preserve user's intended local times
- Fixes "4 hours too early" issue in production drag-and-drop operations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 14:07:33 -04:00
Connor Johnstone
5876553515 Manual update to frontend deploy script
All checks were successful
Build and Push Docker Image / docker (push) Successful in 33s
2025-09-04 13:39:06 -04:00
Connor Johnstone
d73bc78af5 Add comprehensive timezone support to CalDAV client parsing
Some checks failed
Build and Push Docker Image / docker (push) Has been cancelled
Enhanced CalDAV datetime parsing to match the full timezone capabilities
of external calendar parsing, now supporting:

- Standard IANA timezone identifiers (America/Denver, Europe/London, etc.)
- Mozilla/Thunderbird timezone format (/mozilla.org/20070129_1/Europe/London)
- Windows timezone names (60+ global mappings from "Mountain Standard Time" to IANA)
- Timezone abbreviations (EST, PST, MST, CST)
- Timezone offset parsing (20231225T120000-0500, 2023-12-25T12:00:00-05:00)
- ISO datetime formats with UTC and offset notation
- Comprehensive global timezone coverage (North America, Europe, Asia, Australia, Africa, South America)

This ensures consistent timezone handling across both CalDAV client events
and external calendar imports, providing robust support for international users.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 13:37:45 -04:00
Connor Johnstone
393bfecff2 Fix CalDAV timezone parsing for external client events
Events created in external CalDAV clients (like AgendaV) with timezone information
were showing incorrect times due to improper timezone handling. Fixed by:

- Enhanced datetime parser to extract TZID parameters from iCal properties
- Added proper timezone conversion from source timezone to UTC using chrono-tz
- Preserved full property strings with parameters during parsing
- Maintained backward compatibility with existing UTC format events

This resolves the issue where events created at 9 AM Mountain Time were
displaying as 5 AM instead of the correct 11 AM Eastern Time.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-04 13:33:59 -04:00
aab478202b Merge pull request 'Added support for external calendars' (#14) from feature/external-calendars into main
All checks were successful
Build and Push Docker Image / docker (push) Successful in 2m14s
Reviewed-on: #14
2025-09-03 22:34:35 -04:00
49 changed files with 9515 additions and 9233 deletions

View File

@@ -4,7 +4,7 @@
![Runway Screenshot](sample.png) ![Runway Screenshot](sample.png)
>[!WARNING] >[!WARNING]
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty solid. >This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty decent. There are still a lot of places where the AI has implemented some really poor solutions to the problems that I didn't catch, but I've begun using it for my own general use.
A modern CalDAV web client built with Rust WebAssembly. A modern CalDAV web client built with Rust WebAssembly.

View File

@@ -39,19 +39,13 @@ impl AuthService {
request.username.clone(), request.username.clone(),
request.password.clone(), request.password.clone(),
); );
println!("📝 Created CalDAV config");
// Test authentication against CalDAV server // Test authentication against CalDAV server
let caldav_client = CalDAVClient::new(caldav_config.clone()); let caldav_client = CalDAVClient::new(caldav_config.clone());
println!("🔗 Created CalDAV client, attempting to discover calendars...");
// Try to discover calendars as an authentication test // Try to discover calendars as an authentication test
match caldav_client.discover_calendars().await { match caldav_client.discover_calendars().await {
Ok(calendars) => { Ok(_calendars) => {
println!(
"✅ Authentication successful! Found {} calendars",
calendars.len()
);
// Find or create user in database // Find or create user in database
let user_repo = UserRepository::new(&self.db); let user_repo = UserRepository::new(&self.db);

View File

@@ -167,8 +167,6 @@ impl CalDAVClient {
}; };
let basic_auth = self.config.get_basic_auth(); let basic_auth = self.config.get_basic_auth();
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
println!("🌐 REPORT URL: {}", url);
let response = self let response = self
.http_client .http_client
@@ -330,13 +328,28 @@ impl CalDAVClient {
event: ical::parser::ical::component::IcalEvent, event: ical::parser::ical::component::IcalEvent,
) -> Result<CalendarEvent, CalDAVError> { ) -> Result<CalendarEvent, CalDAVError> {
let mut properties: HashMap<String, String> = HashMap::new(); let mut properties: HashMap<String, String> = HashMap::new();
let mut full_properties: HashMap<String, String> = HashMap::new();
// Extract all properties from the event // Extract all properties from the event
for property in &event.properties { for property in &event.properties {
properties.insert( let prop_name = property.name.to_uppercase();
property.name.to_uppercase(), let prop_value = property.value.clone().unwrap_or_default();
property.value.clone().unwrap_or_default(),
); properties.insert(prop_name.clone(), prop_value.clone());
// Build full property string with parameters for timezone parsing
let mut full_prop = format!("{}", prop_name);
if let Some(params) = &property.params {
for (param_name, param_values) in params {
if !param_values.is_empty() {
full_prop.push_str(&format!(";{}={}", param_name, param_values.join(",")));
}
}
}
full_prop.push_str(&format!(":{}", prop_value));
full_properties.insert(prop_name, full_prop);
} }
// Required UID field // Required UID field
@@ -345,27 +358,30 @@ impl CalDAVClient {
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))? .ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
.clone(); .clone();
// Determine if it's an all-day event FIRST by checking for VALUE=DATE parameter per RFC 5545
let empty_string = String::new();
let dtstart_raw = full_properties.get("DTSTART").unwrap_or(&empty_string);
let dtstart_value = properties.get("DTSTART").unwrap_or(&empty_string);
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_value.contains("T") && dtstart_value.len() == 8);
// Parse start time (required) // Parse start time (required)
let start = properties let start_prop = properties
.get("DTSTART") .get("DTSTART")
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?; .ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
let start = self.parse_datetime(start, properties.get("DTSTART"))?; let (start_naive, start_tzid) = self.parse_datetime_with_tz(start_prop, full_properties.get("DTSTART"))?;
// Parse end time (optional - use start time if not present) // Parse end time (optional - use start time if not present)
let end = if let Some(dtend) = properties.get("DTEND") { let (end_naive, end_tzid) = if let Some(dtend) = properties.get("DTEND") {
Some(self.parse_datetime(dtend, properties.get("DTEND"))?) let (end_dt, end_tz) = self.parse_datetime_with_tz(dtend, full_properties.get("DTEND"))?;
(Some(end_dt), end_tz)
} else if let Some(_duration) = properties.get("DURATION") { } else if let Some(_duration) = properties.get("DURATION") {
// TODO: Parse duration and add to start time // TODO: Parse duration and add to start time
Some(start) (Some(start_naive), start_tzid.clone())
} else { } else {
None (None, None)
}; };
// Determine if it's an all-day event by checking for VALUE=DATE parameter
let empty_string = String::new();
let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string);
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8);
// Parse status // Parse status
let status = properties let status = properties
.get("STATUS") .get("STATUS")
@@ -398,23 +414,35 @@ impl CalDAVClient {
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect()) .map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
.unwrap_or_default(); .unwrap_or_default();
// Parse dates // Parse dates with timezone information
let created = properties let (created_naive, created_tzid) = if let Some(created_str) = properties.get("CREATED") {
.get("CREATED") match self.parse_datetime_with_tz(created_str, None) {
.and_then(|s| self.parse_datetime(s, None).ok()); Ok((dt, tz)) => (Some(dt), tz),
Err(_) => (None, None)
let last_modified = properties }
.get("LAST-MODIFIED") } else {
.and_then(|s| self.parse_datetime(s, None).ok()); (None, None)
};
let (last_modified_naive, last_modified_tzid) = if let Some(modified_str) = properties.get("LAST-MODIFIED") {
match self.parse_datetime_with_tz(modified_str, None) {
Ok((dt, tz)) => (Some(dt), tz),
Err(_) => (None, None)
}
} else {
(None, None)
};
// Parse exception dates (EXDATE) // Parse exception dates (EXDATE)
let exdate = self.parse_exdate(&event); let exdate = self.parse_exdate(&event);
// Create VEvent with required fields // Create VEvent with parsed naive datetime and timezone info
let mut vevent = VEvent::new(uid, start); let mut vevent = VEvent::new(uid, start_naive);
// Set optional fields // Set optional fields with timezone information
vevent.dtend = end; vevent.dtend = end_naive;
vevent.dtstart_tzid = start_tzid;
vevent.dtend_tzid = end_tzid;
vevent.summary = properties.get("SUMMARY").cloned(); vevent.summary = properties.get("SUMMARY").cloned();
vevent.description = properties.get("DESCRIPTION").cloned(); vevent.description = properties.get("DESCRIPTION").cloned();
vevent.location = properties.get("LOCATION").cloned(); vevent.location = properties.get("LOCATION").cloned();
@@ -437,10 +465,13 @@ impl CalDAVClient {
vevent.attendees = Vec::new(); vevent.attendees = Vec::new();
vevent.categories = categories; vevent.categories = categories;
vevent.created = created; vevent.created = created_naive;
vevent.last_modified = last_modified; vevent.created_tzid = created_tzid;
vevent.last_modified = last_modified_naive;
vevent.last_modified_tzid = last_modified_tzid;
vevent.rrule = properties.get("RRULE").cloned(); vevent.rrule = properties.get("RRULE").cloned();
vevent.exdate = exdate; vevent.exdate = exdate.into_iter().map(|dt| dt.naive_utc()).collect();
vevent.exdate_tzid = None; // TODO: Parse timezone info for EXDATE
vevent.all_day = all_day; vevent.all_day = all_day;
// Parse alarms // Parse alarms
@@ -553,11 +584,9 @@ impl CalDAVClient {
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> { pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
// First, try to discover user calendars if we have a calendar path in config // First, try to discover user calendars if we have a calendar path in config
if let Some(calendar_path) = &self.config.calendar_path { if let Some(calendar_path) = &self.config.calendar_path {
println!("Using configured calendar path: {}", calendar_path);
return Ok(vec![calendar_path.clone()]); return Ok(vec![calendar_path.clone()]);
} }
println!("No calendar path configured, discovering calendars...");
// Try different common CalDAV discovery paths // Try different common CalDAV discovery paths
// Note: paths should be relative to the server URL base // Note: paths should be relative to the server URL base
@@ -567,14 +596,30 @@ impl CalDAVClient {
let mut all_calendars = Vec::new(); let mut all_calendars = Vec::new();
let mut has_valid_caldav_response = false;
for path in discovery_paths { for path in discovery_paths {
println!("Trying discovery path: {}", path); match self.discover_calendars_at_path(&path).await {
if let Ok(calendars) = self.discover_calendars_at_path(&path).await { Ok(calendars) => {
println!("Found {} calendar(s) at {}", calendars.len(), path); has_valid_caldav_response = true;
all_calendars.extend(calendars); all_calendars.extend(calendars);
}
Err(CalDAVError::ServerError(_status)) => {
// HTTP error - this might be expected for some paths, continue trying
}
Err(e) => {
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
return Err(e);
}
} }
} }
// If we never got a valid CalDAV response (e.g., all requests failed),
// this is likely not a CalDAV server
if !has_valid_caldav_response {
return Err(CalDAVError::ServerError(404));
}
// Remove duplicates // Remove duplicates
all_calendars.sort(); all_calendars.sort();
all_calendars.dedup(); all_calendars.dedup();
@@ -620,7 +665,6 @@ impl CalDAVClient {
} }
let body = response.text().await.map_err(CalDAVError::RequestError)?; let body = response.text().await.map_err(CalDAVError::RequestError)?;
println!("Discovery response for {}: {}", path, body);
let mut calendar_paths = Vec::new(); let mut calendar_paths = Vec::new();
@@ -631,7 +675,6 @@ impl CalDAVClient {
// Extract href first // Extract href first
if let Some(href) = self.extract_xml_content(response_content, "href") { if let Some(href) = self.extract_xml_content(response_content, "href") {
println!("🔍 Checking resource: {}", href);
// Check if this is a calendar collection by looking for supported-calendar-component-set // Check if this is a calendar collection by looking for supported-calendar-component-set
// This indicates it's an actual calendar that can contain events // This indicates it's an actual calendar that can contain events
@@ -655,14 +698,10 @@ impl CalDAVClient {
&& !href.ends_with("/calendars/") && !href.ends_with("/calendars/")
&& href.ends_with('/') && href.ends_with('/')
{ {
println!("📅 Found calendar collection: {}", href);
calendar_paths.push(href); calendar_paths.push(href);
} else { } else {
println!("❌ Skipping system/root directory: {}", href);
} }
} else { } else {
println!(" Not a calendar collection: {} (is_calendar: {}, has_collection: {})",
href, is_calendar, has_collection);
} }
} }
} }
@@ -671,16 +710,37 @@ impl CalDAVClient {
Ok(calendar_paths) Ok(calendar_paths)
} }
/// Parse iCal datetime format /// Parse iCal datetime format and return NaiveDateTime + timezone info
fn parse_datetime( /// According to RFC 5545: if no TZID parameter is provided, treat as UTC
fn parse_datetime_with_tz(
&self, &self,
datetime_str: &str, datetime_str: &str,
_original_property: Option<&String>, original_property: Option<&String>,
) -> Result<DateTime<Utc>, CalDAVError> { ) -> Result<(chrono::NaiveDateTime, Option<String>), CalDAVError> {
use chrono::TimeZone; // Extract timezone information from the original property if available
let mut timezone_id: Option<String> = None;
if let Some(prop) = original_property {
// Look for TZID parameter in the property
// Format: DTSTART;TZID=America/Denver:20231225T090000
if let Some(tzid_start) = prop.find("TZID=") {
let tzid_part = &prop[tzid_start + 5..];
if let Some(tzid_end) = tzid_part.find(':') {
timezone_id = Some(tzid_part[..tzid_end].to_string());
} else if let Some(tzid_end) = tzid_part.find(';') {
timezone_id = Some(tzid_part[..tzid_end].to_string());
}
}
}
// Handle different iCal datetime formats // Clean the datetime string - remove any TZID prefix if present
let cleaned = datetime_str.replace("TZID=", "").trim().to_string(); let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
// Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000"
let datetime_part = if let Some(colon_pos) = cleaned.find(':') {
&cleaned[colon_pos + 1..]
} else {
&cleaned
};
// Try different parsing formats // Try different parsing formats
let formats = [ let formats = [
@@ -690,17 +750,240 @@ impl CalDAVClient {
]; ];
for format in &formats { for format in &formats {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) { // Try parsing as UTC format (with Z suffix)
return Ok(Utc.from_utc_datetime(&dt)); if datetime_part.ends_with('Z') {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") {
// Z suffix means UTC, ignore any TZID parameter
return Ok((dt, Some("UTC".to_string())));
}
} }
if let Ok(date) = chrono::NaiveDate::parse_from_str(&cleaned, format) {
// Try parsing with timezone offset (e.g., 20231225T120000-0500)
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") {
// Convert to naive UTC time and return UTC timezone
return Ok((dt.naive_utc(), Some("UTC".to_string())));
}
// Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00)
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") {
// Convert to naive UTC time and return UTC timezone
return Ok((dt.naive_utc(), Some("UTC".to_string())));
}
// Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z)
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") {
// Z suffix means UTC
return Ok((dt.naive_utc(), Some("UTC".to_string())));
}
// Special handling for date-only format (all-day events)
if *format == "%Y%m%d" {
if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) {
// Convert date to midnight datetime for all-day events
let naive_dt = date.and_hms_opt(0, 0, 0).unwrap();
let tz = timezone_id.unwrap_or_else(|| "UTC".to_string());
return Ok((naive_dt, Some(tz)));
}
} else {
// Try parsing as naive datetime for time-based formats
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) {
// Per RFC 5545: if no TZID parameter is provided, treat as UTC
let tz = timezone_id.unwrap_or_else(|| "UTC".to_string());
// If it's UTC, the naive time is already correct
// If it's a local timezone, we store the naive time and the timezone ID
return Ok((naive_dt, Some(tz)));
}
}
}
Err(CalDAVError::ParseError(format!(
"Could not parse datetime: {}",
datetime_str
)))
}
/// Parse iCal datetime format with timezone support
fn parse_datetime(
&self,
datetime_str: &str,
original_property: Option<&String>,
) -> Result<DateTime<Utc>, CalDAVError> {
use chrono::TimeZone;
use chrono_tz::Tz;
// Extract timezone information from the original property if available
let mut timezone_id: Option<&str> = None;
if let Some(prop) = original_property {
// Look for TZID parameter in the property
// Format: DTSTART;TZID=America/Denver:20231225T090000
if let Some(tzid_start) = prop.find("TZID=") {
let tzid_part = &prop[tzid_start + 5..];
if let Some(tzid_end) = tzid_part.find(':') {
timezone_id = Some(&tzid_part[..tzid_end]);
} else if let Some(tzid_end) = tzid_part.find(';') {
timezone_id = Some(&tzid_part[..tzid_end]);
}
}
}
// Clean the datetime string - remove any TZID prefix if present
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
// Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000"
let datetime_part = if let Some(colon_pos) = cleaned.find(':') {
&cleaned[colon_pos + 1..]
} else {
&cleaned
};
// Try different parsing formats
let formats = [
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
"%Y%m%d", // Date only: 20231225
];
for format in &formats {
// Try parsing as UTC first (if it has Z suffix)
if datetime_part.ends_with('Z') {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") {
return Ok(dt.and_utc());
}
}
// Try parsing with timezone offset (e.g., 20231225T120000-0500)
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") {
return Ok(dt.with_timezone(&Utc));
}
// Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00)
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") {
return Ok(dt.with_timezone(&Utc));
}
// Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z)
if let Ok(dt) = DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") {
return Ok(dt.with_timezone(&Utc));
}
// Try parsing as naive datetime
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) {
// If we have timezone information, convert accordingly
if let Some(tz_id) = timezone_id {
let tz_result = if tz_id.starts_with("/mozilla.org/") {
// Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London
tz_id.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok())
} else if tz_id.contains('/') {
// Standard timezone format: America/New_York, Europe/London
tz_id.parse::<Tz>().ok()
} else {
// Try common abbreviations and Windows timezone names
match tz_id {
// Standard abbreviations
"EST" => Some(Tz::America__New_York),
"PST" => Some(Tz::America__Los_Angeles),
"MST" => Some(Tz::America__Denver),
"CST" => Some(Tz::America__Chicago),
// North America - Windows timezone names to IANA mapping
"Mountain Standard Time" => Some(Tz::America__Denver),
"Eastern Standard Time" => Some(Tz::America__New_York),
"Central Standard Time" => Some(Tz::America__Chicago),
"Pacific Standard Time" => Some(Tz::America__Los_Angeles),
"Mountain Daylight Time" => Some(Tz::America__Denver),
"Eastern Daylight Time" => Some(Tz::America__New_York),
"Central Daylight Time" => Some(Tz::America__Chicago),
"Pacific Daylight Time" => Some(Tz::America__Los_Angeles),
"Hawaiian Standard Time" => Some(Tz::Pacific__Honolulu),
"Alaskan Standard Time" => Some(Tz::America__Anchorage),
"Alaskan Daylight Time" => Some(Tz::America__Anchorage),
"Atlantic Standard Time" => Some(Tz::America__Halifax),
"Newfoundland Standard Time" => Some(Tz::America__St_Johns),
// Europe
"GMT Standard Time" => Some(Tz::Europe__London),
"Greenwich Standard Time" => Some(Tz::UTC),
"W. Europe Standard Time" => Some(Tz::Europe__Berlin),
"Central Europe Standard Time" => Some(Tz::Europe__Warsaw),
"Romance Standard Time" => Some(Tz::Europe__Paris),
"Central European Standard Time" => Some(Tz::Europe__Belgrade),
"E. Europe Standard Time" => Some(Tz::Europe__Bucharest),
"FLE Standard Time" => Some(Tz::Europe__Helsinki),
"GTB Standard Time" => Some(Tz::Europe__Athens),
"Russian Standard Time" => Some(Tz::Europe__Moscow),
"Turkey Standard Time" => Some(Tz::Europe__Istanbul),
// Asia
"China Standard Time" => Some(Tz::Asia__Shanghai),
"Tokyo Standard Time" => Some(Tz::Asia__Tokyo),
"Korea Standard Time" => Some(Tz::Asia__Seoul),
"Singapore Standard Time" => Some(Tz::Asia__Singapore),
"India Standard Time" => Some(Tz::Asia__Kolkata),
"Pakistan Standard Time" => Some(Tz::Asia__Karachi),
"Bangladesh Standard Time" => Some(Tz::Asia__Dhaka),
"Thailand Standard Time" => Some(Tz::Asia__Bangkok),
"SE Asia Standard Time" => Some(Tz::Asia__Bangkok),
"Myanmar Standard Time" => Some(Tz::Asia__Yangon),
"Sri Lanka Standard Time" => Some(Tz::Asia__Colombo),
"Nepal Standard Time" => Some(Tz::Asia__Kathmandu),
"Central Asia Standard Time" => Some(Tz::Asia__Almaty),
"West Asia Standard Time" => Some(Tz::Asia__Tashkent),
"N. Central Asia Standard Time" => Some(Tz::Asia__Novosibirsk),
"North Asia Standard Time" => Some(Tz::Asia__Krasnoyarsk),
"North Asia East Standard Time" => Some(Tz::Asia__Irkutsk),
"Yakutsk Standard Time" => Some(Tz::Asia__Yakutsk),
"Vladivostok Standard Time" => Some(Tz::Asia__Vladivostok),
"Magadan Standard Time" => Some(Tz::Asia__Magadan),
// Australia & Pacific
"AUS Eastern Standard Time" => Some(Tz::Australia__Sydney),
"AUS Central Standard Time" => Some(Tz::Australia__Adelaide),
"W. Australia Standard Time" => Some(Tz::Australia__Perth),
"Tasmania Standard Time" => Some(Tz::Australia__Hobart),
"New Zealand Standard Time" => Some(Tz::Pacific__Auckland),
"Fiji Standard Time" => Some(Tz::Pacific__Fiji),
"Tonga Standard Time" => Some(Tz::Pacific__Tongatapu),
// Africa & Middle East
"South Africa Standard Time" => Some(Tz::Africa__Johannesburg),
"Egypt Standard Time" => Some(Tz::Africa__Cairo),
"Israel Standard Time" => Some(Tz::Asia__Jerusalem),
"Iran Standard Time" => Some(Tz::Asia__Tehran),
"Arabic Standard Time" => Some(Tz::Asia__Baghdad),
"Arab Standard Time" => Some(Tz::Asia__Riyadh),
// South America
"SA Eastern Standard Time" => Some(Tz::America__Sao_Paulo),
"Argentina Standard Time" => Some(Tz::America__Buenos_Aires),
"SA Western Standard Time" => Some(Tz::America__La_Paz),
"SA Pacific Standard Time" => Some(Tz::America__Bogota),
_ => None,
}
};
if let Some(tz) = tz_result {
// Convert from the specified timezone to UTC
if let Some(local_dt) = tz.from_local_datetime(&naive_dt).single() {
return Ok(local_dt.with_timezone(&Utc));
}
}
// If timezone parsing fails, fall back to UTC
}
// No timezone info or parsing failed - treat as UTC
return Ok(Utc.from_utc_datetime(&naive_dt));
}
// Try parsing as date only
if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) {
return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap())); return Ok(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap()));
} }
} }
Err(CalDAVError::ParseError(format!( Err(CalDAVError::ParseError(format!(
"Unable to parse datetime: {}", "Unable to parse datetime: {} (cleaned: {}, timezone: {:?})",
datetime_str datetime_str, datetime_part, timezone_id
))) )))
} }
@@ -1023,8 +1306,19 @@ impl CalDAVClient {
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format) // Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
let format_datetime = let format_datetime =
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() }; |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
let format_datetime_naive =
|dt: &chrono::NaiveDateTime| -> String { dt.format("%Y%m%dT%H%M%S").to_string() };
let format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() }; let _format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
// Format NaiveDateTime for iCal (local time without Z suffix)
let format_naive_datetime = |dt: &chrono::NaiveDateTime| -> String {
dt.format("%Y%m%dT%H%M%S").to_string()
};
let format_naive_date = |dt: &chrono::NaiveDateTime| -> String {
dt.format("%Y%m%d").to_string()
};
// Start building the iCal event // Start building the iCal event
let mut ical = String::new(); let mut ical = String::new();
@@ -1041,15 +1335,77 @@ impl CalDAVClient {
if event.all_day { if event.all_day {
ical.push_str(&format!( ical.push_str(&format!(
"DTSTART;VALUE=DATE:{}\r\n", "DTSTART;VALUE=DATE:{}\r\n",
format_date(&event.dtstart) format_naive_date(&event.dtstart)
)); ));
if let Some(end) = &event.dtend { if let Some(end) = &event.dtend {
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end))); ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_naive_date(end)));
} }
} else { } else {
ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.dtstart))); // Include timezone information for non-all-day events per RFC 5545
if let Some(ref start_tzid) = event.dtstart_tzid {
if start_tzid == "UTC" {
// UTC events should use Z suffix format
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&event.dtstart)));
} else if start_tzid.starts_with('+') || start_tzid.starts_with('-') {
// Timezone offset format (e.g., "+05:00", "-04:00")
// Convert local time to UTC using the offset and use Z format
if let Ok(offset_hours) = start_tzid[1..3].parse::<i32>() {
let offset_minutes = start_tzid[4..6].parse::<i32>().unwrap_or(0);
let total_offset_minutes = if start_tzid.starts_with('+') {
offset_hours * 60 + offset_minutes
} else {
-(offset_hours * 60 + offset_minutes)
};
// Convert local time to UTC by applying the inverse offset
// If timezone is +04:00 (local ahead of UTC), subtract to get UTC
// If timezone is -04:00 (local behind UTC), add to get UTC
let utc_time = event.dtstart - chrono::Duration::minutes(total_offset_minutes as i64);
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&utc_time)));
} else {
// Fallback to floating time if offset parsing fails
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
}
} else {
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", start_tzid, format_naive_datetime(&event.dtstart)));
}
} else {
// No timezone info - treat as floating local time per RFC 5545
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
}
if let Some(end) = &event.dtend { if let Some(end) = &event.dtend {
ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end))); if let Some(ref end_tzid) = event.dtend_tzid {
if end_tzid == "UTC" {
// UTC events should use Z suffix format
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(end)));
} else if end_tzid.starts_with('+') || end_tzid.starts_with('-') {
// Timezone offset format (e.g., "+05:00", "-04:00")
// Convert local time to UTC using the offset and use Z format
if let Ok(offset_hours) = end_tzid[1..3].parse::<i32>() {
let offset_minutes = end_tzid[4..6].parse::<i32>().unwrap_or(0);
let total_offset_minutes = if end_tzid.starts_with('+') {
offset_hours * 60 + offset_minutes
} else {
-(offset_hours * 60 + offset_minutes)
};
// Convert local time to UTC by subtracting the offset
let utc_time = *end - chrono::Duration::minutes(total_offset_minutes as i64);
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(&utc_time)));
} else {
// Fallback to floating time if offset parsing fails
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
}
} else {
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
ical.push_str(&format!("DTEND;TZID={}:{}\r\n", end_tzid, format_naive_datetime(end)));
}
} else {
// No timezone info - treat as floating local time per RFC 5545
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
}
} }
} }
@@ -1105,7 +1461,18 @@ impl CalDAVClient {
// Creation and modification times // Creation and modification times
if let Some(created) = &event.created { if let Some(created) = &event.created {
ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created))); if let Some(ref created_tzid) = event.created_tzid {
if created_tzid == "UTC" {
ical.push_str(&format!("CREATED:{}Z\r\n", format_datetime_naive(created)));
} else {
// Per RFC 5545, CREATED typically should be in UTC or floating time
// Treat non-UTC as floating time
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
}
} else {
// No timezone info - output as floating time per RFC 5545
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
}
} }
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now))); ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now)));
@@ -1162,10 +1529,10 @@ impl CalDAVClient {
if event.all_day { if event.all_day {
ical.push_str(&format!( ical.push_str(&format!(
"EXDATE;VALUE=DATE:{}\r\n", "EXDATE;VALUE=DATE:{}\r\n",
format_date(exception_date) format_naive_date(exception_date)
)); ));
} else { } else {
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date))); ical.push_str(&format!("EXDATE:{}\r\n", format_naive_datetime(exception_date)));
} }
} }

View File

@@ -82,10 +82,6 @@ pub async fn get_user_info(
.await .await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?; .map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
println!(
"✅ Authentication successful! Found {} calendars",
calendar_paths.len()
);
let calendars: Vec<CalendarInfo> = calendar_paths let calendars: Vec<CalendarInfo> = calendar_paths
.iter() .iter()

View File

@@ -16,7 +16,7 @@ use crate::{
AppState, AppState,
}; };
use calendar_models::{ use calendar_models::{
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent, Attendee, CalendarUser, EventClass, EventStatus, VEvent,
}; };
use super::auth::{extract_bearer_token, extract_password_header}; use super::auth::{extract_bearer_token, extract_password_header};
@@ -35,7 +35,6 @@ pub async fn get_calendar_events(
// Extract and verify token // Extract and verify token
let token = extract_bearer_token(&headers)?; let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?; let password = extract_password_header(&headers)?;
println!("🔑 API call with password length: {}", password.len());
// Create CalDAV config from token and password // Create CalDAV config from token and password
let config = state let config = state
@@ -85,7 +84,7 @@ pub async fn get_calendar_events(
} - chrono::Duration::days(1); } - chrono::Duration::days(1);
all_events.retain(|event| { all_events.retain(|event| {
let event_date = event.dtstart.date_naive(); let event_date = event.dtstart.date();
// For non-recurring events, check if the event date is within the month // For non-recurring events, check if the event date is within the month
if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() { if event.rrule.is_none() || event.rrule.as_ref().unwrap().is_empty() {
@@ -127,7 +126,6 @@ pub async fn get_calendar_events(
}); });
} }
println!("📅 Returning {} events", all_events.len());
Ok(Json(all_events)) Ok(Json(all_events))
} }
@@ -234,26 +232,26 @@ pub async fn delete_event(
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() { if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
// Recurring event - add EXDATE for this occurrence // Recurring event - add EXDATE for this occurrence
if let Some(occurrence_date) = &request.occurrence_date { if let Some(occurrence_date) = &request.occurrence_date {
let exception_utc = if let Ok(date) = let exception_datetime = if let Ok(date) =
chrono::DateTime::parse_from_rfc3339(occurrence_date) chrono::DateTime::parse_from_rfc3339(occurrence_date)
{ {
// RFC3339 format (with time and timezone) // RFC3339 format (with time and timezone) - convert to naive
date.with_timezone(&chrono::Utc) date.naive_utc()
} else if let Ok(naive_date) = } else if let Ok(naive_date) =
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
{ {
// Simple date format (YYYY-MM-DD) // Simple date format (YYYY-MM-DD)
naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() naive_date.and_hms_opt(0, 0, 0).unwrap()
} else { } else {
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date))); return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
}; };
let mut updated_event = event; let mut updated_event = event;
updated_event.exdate.push(exception_utc); updated_event.exdate.push(exception_datetime);
println!( println!(
"🔄 Adding EXDATE {} to recurring event {}", "🔄 Adding EXDATE {} to recurring event {}",
exception_utc.format("%Y%m%dT%H%M%SZ"), exception_datetime.format("%Y%m%dT%H%M%S"),
updated_event.uid updated_event.uid
); );
@@ -453,19 +451,16 @@ pub async fn create_event(
calendar_paths[0].clone() calendar_paths[0].clone()
}; };
// Parse dates and times // Parse dates and times as local times (no UTC conversion)
let start_datetime = let start_datetime =
parse_event_datetime(&request.start_date, &request.start_time, request.all_day) parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// For all-day events, add one day to end date for RFC-5545 compliance // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events
// RFC-5545 uses exclusive end dates for all-day events // No additional conversion needed here
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// Validate that end is after start (allow equal times for all-day events) // Validate that end is after start (allow equal times for all-day events)
if request.all_day { if request.all_day {
@@ -527,19 +522,8 @@ pub async fn create_event(
.collect() .collect()
}; };
// Parse alarms - convert from minutes string to EventReminder structs // Use VAlarms directly from request (no conversion needed)
let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() { let alarms = request.alarms;
Vec::new()
} else {
match request.reminder.parse::<i32>() {
Ok(minutes) => vec![crate::calendar::EventReminder {
minutes_before: minutes,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
Err(_) => Vec::new(),
}
};
// Check if recurrence is already a full RRULE or just a simple type // Check if recurrence is already a full RRULE or just a simple type
let rrule = if request.recurrence.starts_with("FREQ=") { let rrule = if request.recurrence.starts_with("FREQ=") {
@@ -594,9 +578,13 @@ pub async fn create_event(
} }
}; };
// Create the VEvent struct (RFC 5545 compliant) // Create the VEvent struct (RFC 5545 compliant) with local times
let mut event = VEvent::new(uid, start_datetime); let mut event = VEvent::new(uid, start_datetime);
event.dtend = Some(end_datetime); event.dtend = Some(end_datetime);
// Set timezone information from client
event.dtstart_tzid = Some(request.timezone.clone());
event.dtend_tzid = Some(request.timezone.clone());
event.summary = if request.title.trim().is_empty() { event.summary = if request.title.trim().is_empty() {
None None
} else { } else {
@@ -646,21 +634,7 @@ pub async fn create_event(
event.categories = categories; event.categories = categories;
event.rrule = rrule; event.rrule = rrule;
event.all_day = request.all_day; event.all_day = request.all_day;
event.alarms = alarms event.alarms = alarms;
.into_iter()
.map(|reminder| VAlarm {
action: AlarmAction::Display,
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
-reminder.minutes_before as i64,
)),
duration: None,
repeat: None,
description: reminder.description,
summary: None,
attendees: Vec::new(),
attach: Vec::new(),
})
.collect();
event.calendar_path = Some(calendar_path.clone()); event.calendar_path = Some(calendar_path.clone());
// Create the event on the CalDAV server // Create the event on the CalDAV server
@@ -757,19 +731,18 @@ pub async fn update_event(
let (mut event, calendar_path, event_href) = found_event let (mut event, calendar_path, event_href) = found_event
.ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?; .ok_or_else(|| ApiError::NotFound(format!("Event with UID '{}' not found", request.uid)))?;
// Parse dates and times // Parse dates and times as local times (no UTC conversion)
println!("🕐 UPDATE: Received start_date: '{}', start_time: '{}', timezone: '{}'",
request.start_date, request.start_time, request.timezone);
let start_datetime = let start_datetime =
parse_event_datetime(&request.start_date, &request.start_time, request.all_day) parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?; .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day) let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?; .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// For all-day events, add one day to end date for RFC-5545 compliance // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events
// RFC-5545 uses exclusive end dates for all-day events // No additional conversion needed here
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// Validate that end is after start (allow equal times for all-day events) // Validate that end is after start (allow equal times for all-day events)
if request.all_day { if request.all_day {
@@ -786,9 +759,11 @@ pub async fn update_event(
} }
} }
// Update event properties // Update event properties with local times and timezone info
event.dtstart = start_datetime; event.dtstart = start_datetime;
event.dtend = Some(end_datetime); event.dtend = Some(end_datetime);
event.dtstart_tzid = Some(request.timezone.clone());
event.dtend_tzid = Some(request.timezone.clone());
event.summary = if request.title.trim().is_empty() { event.summary = if request.title.trim().is_empty() {
None None
} else { } else {
@@ -822,6 +797,99 @@ pub async fn update_event(
event.priority = request.priority; event.priority = request.priority;
// Process recurrence information to set RRULE
println!("🔄 Processing recurrence: '{}'", request.recurrence);
println!("🔄 Recurrence days: {:?}", request.recurrence_days);
println!("🔄 Recurrence interval: {:?}", request.recurrence_interval);
println!("🔄 Recurrence count: {:?}", request.recurrence_count);
println!("🔄 Recurrence end date: {:?}", request.recurrence_end_date);
let rrule = if request.recurrence.starts_with("FREQ=") {
// Frontend sent a complete RRULE string, use it directly
if request.recurrence.is_empty() {
None
} else {
Some(request.recurrence.clone())
}
} else {
// Parse recurrence type and build RRULE with all parameters
let base_rrule = match request.recurrence.to_uppercase().as_str() {
"DAILY" => Some("FREQ=DAILY".to_string()),
"WEEKLY" => {
// Handle weekly recurrence with optional BYDAY parameter
let mut rrule = "FREQ=WEEKLY".to_string();
// Check if specific days are selected (recurrence_days has 7 elements: [Sun, Mon, Tue, Wed, Thu, Fri, Sat])
if request.recurrence_days.len() == 7 {
let selected_days: Vec<&str> = request
.recurrence_days
.iter()
.enumerate()
.filter_map(|(i, &selected)| {
if selected {
Some(match i {
0 => "SU", // Sunday
1 => "MO", // Monday
2 => "TU", // Tuesday
3 => "WE", // Wednesday
4 => "TH", // Thursday
5 => "FR", // Friday
6 => "SA", // Saturday
_ => return None,
})
} else {
None
}
})
.collect();
if !selected_days.is_empty() {
rrule = format!("{};BYDAY={}", rrule, selected_days.join(","));
}
}
Some(rrule)
}
"MONTHLY" => Some("FREQ=MONTHLY".to_string()),
"YEARLY" => Some("FREQ=YEARLY".to_string()),
"NONE" | "" => None, // Clear any existing recurrence
_ => None,
};
// Add INTERVAL, COUNT, and UNTIL parameters if specified
if let Some(mut rrule_string) = base_rrule {
// Add INTERVAL parameter (every N days/weeks/months/years)
if let Some(interval) = request.recurrence_interval {
if interval > 1 {
rrule_string = format!("{};INTERVAL={}", rrule_string, interval);
}
}
// Add COUNT or UNTIL parameter (but not both - COUNT takes precedence)
if let Some(count) = request.recurrence_count {
rrule_string = format!("{};COUNT={}", rrule_string, count);
} else if let Some(end_date) = &request.recurrence_end_date {
// Convert YYYY-MM-DD to YYYYMMDD format for UNTIL
if let Ok(date) = chrono::NaiveDate::parse_from_str(end_date, "%Y-%m-%d") {
rrule_string = format!("{};UNTIL={}", rrule_string, date.format("%Y%m%d"));
}
}
Some(rrule_string)
} else {
None
}
};
event.rrule = rrule.clone();
println!("🔄 Set event RRULE to: {:?}", rrule);
if rrule.is_some() {
println!("✨ Converting singleton event to recurring series with RRULE: {}", rrule.as_ref().unwrap());
} else {
println!("📝 Event remains non-recurring (no RRULE set)");
}
// Update the event on the CalDAV server // Update the event on the CalDAV server
println!( println!(
"📝 Updating event {} at calendar_path: {}, event_href: {}", "📝 Updating event {} at calendar_path: {}, event_href: {}",
@@ -840,37 +908,29 @@ pub async fn update_event(
})) }))
} }
fn parse_event_datetime( fn parse_event_datetime_local(
date_str: &str, date_str: &str,
time_str: &str, time_str: &str,
all_day: bool, all_day: bool,
) -> Result<chrono::DateTime<chrono::Utc>, String> { ) -> Result<chrono::NaiveDateTime, String> {
use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
// Parse the date // Parse the date
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?; .map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
if all_day { if all_day {
// For all-day events, use noon UTC to avoid timezone boundary issues // For all-day events, use start of day
// This ensures the date remains correct when converted to any local timezone
let datetime = date let datetime = date
.and_hms_opt(12, 0, 0) .and_hms_opt(0, 0, 0)
.ok_or_else(|| "Failed to create noon datetime".to_string())?; .ok_or_else(|| "Failed to create start-of-day datetime".to_string())?;
Ok(Utc.from_utc_datetime(&datetime)) Ok(datetime)
} else { } else {
// Parse the time // Parse the time
let time = NaiveTime::parse_from_str(time_str, "%H:%M") let time = NaiveTime::parse_from_str(time_str, "%H:%M")
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?; .map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
// Combine date and time // Combine date and time - now keeping as local time
let datetime = NaiveDateTime::new(date, time); Ok(NaiveDateTime::new(date, time))
// Treat the datetime as local time and convert to UTC
let local_datetime = Local.from_local_datetime(&datetime)
.single()
.ok_or_else(|| "Ambiguous local datetime".to_string())?;
Ok(local_datetime.with_timezone(&Utc))
} }
} }

View File

@@ -2,7 +2,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Json, response::Json,
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc, Datelike};
use ical::parser::ical::component::IcalEvent; use ical::parser::ical::component::IcalEvent;
use reqwest::Client; use reqwest::Client;
use serde::Serialize; use serde::Serialize;
@@ -78,17 +78,75 @@ pub async fn fetch_external_calendar_events(
// If not fetched from cache, get from external URL // If not fetched from cache, get from external URL
if !fetched_from_cache { if !fetched_from_cache {
let client = Client::new(); // Log the URL being fetched for debugging
let response = client println!("🌍 Fetching calendar URL: {}", calendar.url);
.get(&calendar.url)
.send() let user_agents = vec![
.await "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
.map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?; "Mozilla/5.0 (compatible; Runway Calendar/1.0)",
"Outlook-iOS/709.2226530.prod.iphone (3.24.1)"
if !response.status().is_success() { ];
return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status())));
let mut response = None;
let mut last_error = None;
// Try different user agents
for (i, ua) in user_agents.iter().enumerate() {
println!("🔄 Attempt {} with User-Agent: {}", i + 1, ua);
let client = Client::builder()
.redirect(reqwest::redirect::Policy::limited(10))
.timeout(std::time::Duration::from_secs(30))
.user_agent(*ua)
.build()
.map_err(|e| ApiError::Internal(format!("Failed to create HTTP client: {}", e)))?;
let result = client
.get(&calendar.url)
.header("Accept", "text/calendar,application/calendar+xml,text/plain,*/*")
.header("Accept-Charset", "utf-8")
.header("Cache-Control", "no-cache")
.send()
.await;
match result {
Ok(resp) => {
let status = resp.status();
println!("📡 Response status: {}", status);
if status.is_success() {
response = Some(resp);
break;
} else if status == 400 {
// Check if this is an Outlook auth error
let error_body = resp.text().await.unwrap_or_default();
if error_body.contains("OwaPage") || error_body.contains("Outlook") {
println!("🚫 Outlook authentication error detected, trying next approach...");
last_error = Some(format!("Outlook auth error: {}", error_body.chars().take(100).collect::<String>()));
continue;
}
last_error = Some(format!("Bad Request: {}", error_body.chars().take(100).collect::<String>()));
} else {
last_error = Some(format!("HTTP {}", status));
}
}
Err(e) => {
println!("❌ Request failed: {}", e);
last_error = Some(format!("Request error: {}", e));
}
}
} }
let response = response.ok_or_else(|| {
ApiError::Internal(format!(
"Failed to fetch calendar after trying {} different approaches. Last error: {}",
user_agents.len(),
last_error.unwrap_or("Unknown error".to_string())
))
})?;
// Response is guaranteed to be successful here since we checked in the loop
println!("✅ Successfully fetched calendar data");
ics_content = response ics_content = response
.text() .text()
.await .await
@@ -138,6 +196,9 @@ fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::erro
} }
} }
// Deduplicate events based on UID, start time, and summary
// Outlook sometimes includes duplicate events (recurring exceptions may appear multiple times)
events = deduplicate_events(events);
Ok(events) Ok(events)
} }
@@ -224,17 +285,25 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
let vevent = VEvent { let vevent = VEvent {
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()), uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
dtstart, dtstart: dtstart.naive_utc(),
dtend, dtstart_tzid: None, // TODO: Parse timezone from ICS
dtend: dtend.map(|dt| dt.naive_utc()),
dtend_tzid: None, // TODO: Parse timezone from ICS
summary, summary,
description, description,
location, location,
all_day, all_day,
rrule, rrule,
rdate: Vec::new(),
rdate_tzid: None,
exdate: Vec::new(), // External calendars don't need exception handling exdate: Vec::new(), // External calendars don't need exception handling
exdate_tzid: None,
recurrence_id: None, recurrence_id: None,
recurrence_id_tzid: None,
created: None, created: None,
created_tzid: None,
last_modified: None, last_modified: None,
last_modified_tzid: None,
dtstamp: Utc::now(), dtstamp: Utc::now(),
sequence: Some(0), sequence: Some(0),
status: None, status: None,
@@ -252,7 +321,6 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
class: None, class: None,
contact: None, contact: None,
comment: None, comment: None,
rdate: Vec::new(),
alarms: Vec::new(), alarms: Vec::new(),
etag: None, etag: None,
href: None, href: None,
@@ -407,4 +475,399 @@ fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<Date
} }
None None
}
/// Deduplicate events based on UID, start time, and summary
/// Some calendar systems (like Outlook) may include duplicate events in ICS feeds
/// This includes both exact duplicates and recurring event instances that would be
/// generated by existing RRULE patterns, and events with same title but different
/// RRULE patterns that should be consolidated
fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
use std::collections::HashMap;
// 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();
for event in events.drain(..) {
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
}
let mut uid_deduplicated_events = Vec::new();
for (_uid, mut events_with_uid) in uid_groups.drain() {
if events_with_uid.len() == 1 {
// Only one event with this UID, keep it
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
} else {
// Multiple events with same UID - prefer recurring over non-recurring
// Sort by preference: recurring events first, then by completeness
events_with_uid.sort_by(|a, b| {
let a_has_rrule = a.rrule.is_some();
let b_has_rrule = b.rrule.is_some();
match (a_has_rrule, b_has_rrule) {
(true, false) => std::cmp::Ordering::Less, // a (recurring) comes first
(false, true) => std::cmp::Ordering::Greater, // b (recurring) comes first
_ => {
// Both same type (both recurring or both single) - compare by completeness
event_completeness_score(b).cmp(&event_completeness_score(a))
}
}
});
// Keep the first (preferred) event
let preferred_event = events_with_uid.into_iter().next().unwrap();
uid_deduplicated_events.push(preferred_event);
}
}
// Second pass: separate recurring and single events from UID-deduplicated set
let mut recurring_events = Vec::new();
let mut single_events = Vec::new();
for event in uid_deduplicated_events.drain(..) {
if event.rrule.is_some() {
recurring_events.push(event);
} else {
single_events.push(event);
}
}
// Third pass: Group recurring events by normalized title and consolidate different RRULE patterns
let mut title_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
for event in recurring_events.drain(..) {
let title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
title_groups.entry(title).or_insert_with(Vec::new).push(event);
}
let mut deduplicated_recurring = Vec::new();
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
// Check if these are actually different recurring patterns for the same logical event
let consolidated = consolidate_same_title_events(events_with_title);
deduplicated_recurring.extend(consolidated);
}
}
// Fourth pass: filter single events, removing those that would be generated by recurring events
let mut deduplicated_single = Vec::new();
let mut seen_single: HashMap<String, usize> = HashMap::new();
for event in single_events.drain(..) {
let normalized_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
let dedup_key = format!(
"{}|{}",
event.dtstart.format("%Y%m%dT%H%M%S"),
normalized_title
);
// First check for exact duplicates among single events
if let Some(&existing_index) = seen_single.get(&dedup_key) {
let existing_event: &VEvent = &deduplicated_single[existing_index];
let current_completeness = event_completeness_score(&event);
let existing_completeness = event_completeness_score(existing_event);
if current_completeness > existing_completeness {
deduplicated_single[existing_index] = event;
} else {
// Discarding duplicate single event - keeping existing
}
continue;
}
// Check if this single event would be generated by any recurring event
let is_rrule_generated = deduplicated_recurring.iter().any(|recurring_event| {
// Check if this single event matches the recurring event's pattern (use normalized titles)
let single_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new()));
let recurring_title = normalize_title(recurring_event.summary.as_ref().unwrap_or(&String::new()));
if single_title != recurring_title {
return false; // Different events
}
// Check if this single event would be generated by the recurring event
would_event_be_generated_by_rrule(recurring_event, &event)
});
if is_rrule_generated {
} else {
// This is a unique single event
seen_single.insert(dedup_key, deduplicated_single.len());
deduplicated_single.push(event);
}
}
// Combine recurring and single events
let mut result = deduplicated_recurring;
result.extend(deduplicated_single);
result
}
/// Normalize title for grouping similar events
fn normalize_title(title: &str) -> String {
title.trim()
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
}
/// Consolidate events with the same title but potentially different RRULE patterns
/// This handles cases where calendar systems provide multiple recurring definitions
/// for the same logical meeting (e.g., one RRULE for Tuesdays, another for Thursdays)
fn consolidate_same_title_events(events: Vec<VEvent>) -> Vec<VEvent> {
if events.is_empty() {
return events;
}
// Log the RRULEs we're working with
// Check if all events have similar time patterns and could be consolidated
let first_event = &events[0];
let base_time = first_event.dtstart.time();
let base_duration = if let Some(end) = first_event.dtend {
Some(end.signed_duration_since(first_event.dtstart))
} else {
None
};
// Check if all events have the same time and duration
let can_consolidate = events.iter().all(|event| {
let same_time = event.dtstart.time() == base_time;
let same_duration = match (event.dtend, base_duration) {
(Some(end), Some(base_dur)) => end.signed_duration_since(event.dtstart) == base_dur,
(None, None) => true,
_ => false,
};
same_time && same_duration
});
if !can_consolidate {
// Just deduplicate exact duplicates
return deduplicate_exact_recurring_events(events);
}
// Try to detect if these are complementary weekly patterns
let weekly_events: Vec<_> = events.iter()
.filter(|e| e.rrule.as_ref().map_or(false, |r| r.contains("FREQ=WEEKLY")))
.collect();
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) {
return vec![consolidated];
}
}
// If we can't consolidate, just deduplicate exact matches and keep the most complete one
let deduplicated = deduplicate_exact_recurring_events(events);
// If we still have multiple events, keep only the most complete one
if deduplicated.len() > 1 {
let best_event = deduplicated.into_iter()
.max_by_key(|e| event_completeness_score(e))
.unwrap();
println!("🎯 Kept most complete event: '{}'",
best_event.summary.as_ref().unwrap_or(&"No Title".to_string())
);
vec![best_event]
} else {
deduplicated
}
}
/// Deduplicate exact recurring event matches
fn deduplicate_exact_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
use std::collections::HashMap;
let mut seen: HashMap<String, usize> = HashMap::new();
let mut deduplicated = Vec::new();
for event in events {
let dedup_key = format!(
"{}|{}|{}",
event.dtstart.format("%Y%m%dT%H%M%S"),
event.summary.as_ref().unwrap_or(&String::new()),
event.rrule.as_ref().unwrap_or(&String::new())
);
if let Some(&existing_index) = seen.get(&dedup_key) {
let existing_event: &VEvent = &deduplicated[existing_index];
let current_completeness = event_completeness_score(&event);
let existing_completeness = event_completeness_score(existing_event);
if current_completeness > existing_completeness {
println!("🔄 Replacing exact duplicate: Keeping more complete event");
deduplicated[existing_index] = event;
}
} else {
seen.insert(dedup_key, deduplicated.len());
deduplicated.push(event);
}
}
deduplicated
}
/// Attempt to consolidate multiple weekly RRULE patterns into a single pattern
fn consolidate_weekly_patterns(events: &[VEvent]) -> Option<VEvent> {
use std::collections::HashSet;
let mut all_days = HashSet::new();
let mut base_event = None;
for event in events {
let Some(rrule) = &event.rrule else { continue; };
if !rrule.contains("FREQ=WEEKLY") {
continue;
}
// Extract BYDAY if present
if let Some(byday_part) = rrule.split(';').find(|part| part.starts_with("BYDAY=")) {
let days_str = byday_part.strip_prefix("BYDAY=").unwrap_or("");
for day in days_str.split(',') {
all_days.insert(day.trim().to_string());
}
} else {
// If no BYDAY specified, use the weekday from the start date
let weekday = match event.dtstart.weekday() {
chrono::Weekday::Mon => "MO",
chrono::Weekday::Tue => "TU",
chrono::Weekday::Wed => "WE",
chrono::Weekday::Thu => "TH",
chrono::Weekday::Fri => "FR",
chrono::Weekday::Sat => "SA",
chrono::Weekday::Sun => "SU",
};
all_days.insert(weekday.to_string());
}
// Use the first event as the base (we already know they have same time/duration)
if base_event.is_none() {
base_event = Some(event.clone());
}
}
if all_days.is_empty() || base_event.is_none() {
return None;
}
// Create consolidated RRULE
let mut base = base_event.unwrap();
let days_list: Vec<_> = all_days.into_iter().collect();
let byday_str = days_list.join(",");
// Build new RRULE with consolidated BYDAY
let new_rrule = if let Some(existing_rrule) = &base.rrule {
// Remove existing BYDAY and add our consolidated one
let parts: Vec<_> = existing_rrule.split(';')
.filter(|part| !part.starts_with("BYDAY="))
.collect();
format!("{};BYDAY={}", parts.join(";"), byday_str)
} else {
format!("FREQ=WEEKLY;BYDAY={}", byday_str)
};
base.rrule = Some(new_rrule);
println!("🔗 Consolidated weekly pattern: BYDAY={}", byday_str);
Some(base)
}
/// Check if a single event would be generated by a recurring event's RRULE
fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VEvent) -> bool {
let Some(rrule) = &recurring_event.rrule else {
return false; // No RRULE to check against
};
// Parse basic RRULE patterns
if rrule.contains("FREQ=DAILY") {
// Daily recurrence
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
if days_diff >= 0 && days_diff % interval as i64 == 0 {
// Check if times match (allowing for timezone differences within same day)
let recurring_time = recurring_event.dtstart.time();
let single_time = single_event.dtstart.time();
return recurring_time == single_time;
}
} else if rrule.contains("FREQ=WEEKLY") {
// Weekly recurrence
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
// First check if it's the same day of week and time
let recurring_weekday = recurring_event.dtstart.weekday();
let single_weekday = single_event.dtstart.weekday();
let recurring_time = recurring_event.dtstart.time();
let single_time = single_event.dtstart.time();
if recurring_weekday == single_weekday && recurring_time == single_time && days_diff >= 0 {
// Calculate how many weeks apart they are
let weeks_diff = days_diff / 7;
// Check if this falls on an interval boundary
return weeks_diff % interval as i64 == 0;
}
} else if rrule.contains("FREQ=MONTHLY") {
// Monthly recurrence - simplified check
let months_diff = (single_event.dtstart.year() - recurring_event.dtstart.year()) * 12
+ (single_event.dtstart.month() as i32 - recurring_event.dtstart.month() as i32);
if months_diff >= 0 {
let interval = extract_interval_from_rrule(rrule).unwrap_or(1) as i32;
if months_diff % interval == 0 {
// Same day of month and time
return recurring_event.dtstart.day() == single_event.dtstart.day()
&& recurring_event.dtstart.time() == single_event.dtstart.time();
}
}
}
false
}
/// Extract INTERVAL value from RRULE string, defaulting to 1 if not found
fn extract_interval_from_rrule(rrule: &str) -> Option<u32> {
for part in rrule.split(';') {
if part.starts_with("INTERVAL=") {
return part.strip_prefix("INTERVAL=")
.and_then(|s| s.parse().ok());
}
}
Some(1) // Default interval is 1 if not specified
}
/// Calculate a completeness score for an event based on how many optional fields are filled
fn event_completeness_score(event: &VEvent) -> u32 {
let mut score = 0;
if event.summary.is_some() { score += 1; }
if event.description.is_some() { score += 1; }
if event.location.is_some() { score += 1; }
if event.dtend.is_some() { score += 1; }
if event.rrule.is_some() { score += 1; }
if !event.categories.is_empty() { score += 1; }
if !event.alarms.is_empty() { score += 1; }
if event.organizer.is_some() { score += 1; }
if !event.attendees.is_empty() { score += 1; }
score
} }

View File

@@ -14,6 +14,33 @@ use calendar_models::{EventClass, EventStatus, VEvent};
use super::auth::{extract_bearer_token, extract_password_header}; use super::auth::{extract_bearer_token, extract_password_header};
fn parse_event_datetime_local(
date_str: &str,
time_str: &str,
all_day: bool,
) -> Result<chrono::NaiveDateTime, String> {
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
// Parse the date
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
if all_day {
// For all-day events, use start of day
let datetime = date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| "Failed to create start-of-day datetime".to_string())?;
Ok(datetime)
} else {
// Parse the time
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
// Combine date and time - now keeping as local time
Ok(NaiveDateTime::new(date, time))
}
}
/// Create a new recurring event series /// Create a new recurring event series
pub async fn create_event_series( pub async fn create_event_series(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@@ -106,92 +133,27 @@ pub async fn create_event_series(
println!("📅 Using calendar path: {}", calendar_path); println!("📅 Using calendar path: {}", calendar_path);
// Parse datetime components // Parse dates and times as local times (no UTC conversion)
let start_date = let start_datetime = parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| { .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
})?;
let (start_datetime, end_datetime) = if request.all_day { let end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
// For all-day events, use the dates as-is .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
let start_dt = start_date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
let end_date = if !request.end_date.is_empty() { // Note: Frontend already converts inclusive UI dates to exclusive dates for all-day events
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| { // No additional conversion needed here
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
})?
} else {
start_date
};
let end_dt = end_date
.and_hms_opt(23, 59, 59)
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
// Convert from local time to UTC
let start_local = chrono::Local.from_local_datetime(&start_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
let end_local = chrono::Local.from_local_datetime(&end_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
(
start_local.with_timezone(&chrono::Utc),
end_local.with_timezone(&chrono::Utc),
)
} else {
// Parse times for timed events
let start_time = if !request.start_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
})?
} else {
chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM
};
let end_time = if !request.end_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
})?
} else {
chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration
};
let start_dt = start_date.and_time(start_time);
let end_dt = if !request.end_date.is_empty() {
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
.map_err(|_| {
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
})?;
end_date.and_time(end_time)
} else {
start_date.and_time(end_time)
};
// Convert from local time to UTC
let start_local = chrono::Local.from_local_datetime(&start_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
let end_local = chrono::Local.from_local_datetime(&end_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
(
start_local.with_timezone(&chrono::Utc),
end_local.with_timezone(&chrono::Utc),
)
};
// Generate a unique UID for the series // Generate a unique UID for the series
let uid = format!("series-{}", uuid::Uuid::new_v4().to_string()); let uid = format!("series-{}", uuid::Uuid::new_v4().to_string());
// Create the VEvent for the series // Create the VEvent for the series with local times
let mut event = VEvent::new(uid.clone(), start_datetime); let mut event = VEvent::new(uid.clone(), start_datetime);
event.dtend = Some(end_datetime); event.dtend = Some(end_datetime);
event.all_day = request.all_day; // Set the all_day flag properly event.all_day = request.all_day; // Set the all_day flag properly
// Set timezone information from client
event.dtstart_tzid = Some(request.timezone.clone());
event.dtend_tzid = Some(request.timezone.clone());
event.summary = if request.title.trim().is_empty() { event.summary = if request.title.trim().is_empty() {
None None
} else { } else {
@@ -265,6 +227,8 @@ pub async fn update_event_series(
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}", "🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date
); );
println!("🕐 SERIES: Received start_date: '{}', start_time: '{}', timezone: '{}'",
request.start_date, request.start_time, request.timezone);
// Extract and verify token // Extract and verify token
let token = extract_bearer_token(&headers)?; let token = extract_bearer_token(&headers)?;
@@ -380,7 +344,7 @@ pub async fn update_event_series(
); );
// Parse datetime components for the update // Parse datetime components for the update
let original_start_date = existing_event.dtstart.date_naive(); let original_start_date = existing_event.dtstart.date();
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event // For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
// For "all_in_series" updates, preserve the original series start date // For "all_in_series" updates, preserve the original series start date
@@ -407,7 +371,7 @@ pub async fn update_event_series(
// Calculate the duration from the original event // Calculate the duration from the original event
let original_duration_days = existing_event let original_duration_days = existing_event
.dtend .dtend
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days()) .map(|end| (end.date() - existing_event.dtstart.date()).num_days())
.unwrap_or(0); .unwrap_or(0);
start_date + chrono::Duration::days(original_duration_days) start_date + chrono::Duration::days(original_duration_days)
} else { } else {
@@ -418,11 +382,8 @@ pub async fn update_event_series(
.and_hms_opt(12, 0, 0) .and_hms_opt(12, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
// For all-day events, use UTC directly (no local conversion needed) // For all-day events, use local times directly
( (start_dt, end_dt)
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
)
} else { } else {
let start_time = if !request.start_time.is_empty() { let start_time = if !request.start_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| { chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
@@ -453,21 +414,11 @@ pub async fn update_event_series(
.dtend .dtend
.map(|end| end - existing_event.dtstart) .map(|end| end - existing_event.dtstart)
.unwrap_or_else(|| chrono::Duration::hours(1)); .unwrap_or_else(|| chrono::Duration::hours(1));
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc() start_dt + original_duration
}; };
// Convert from local time to UTC // Frontend now sends local times, so use them directly
let start_local = chrono::Local.from_local_datetime(&start_dt) (start_dt, end_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
let end_local = chrono::Local.from_local_datetime(&end_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
(
start_local.with_timezone(&chrono::Utc),
end_local.with_timezone(&chrono::Utc),
)
}; };
// Handle different update scopes // Handle different update scopes
@@ -514,13 +465,10 @@ pub async fn update_event_series(
}; };
// Update the event on the CalDAV server using the original event's href // Update the event on the CalDAV server using the original event's href
println!("📤 Updating event on CalDAV server...");
let event_href = existing_event let event_href = existing_event
.href .href
.as_ref() .as_ref()
.ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?; .ok_or_else(|| ApiError::Internal("Event missing href for update".to_string()))?;
println!("📤 Using event href: {}", event_href);
println!("📤 Calendar path: {}", calendar_path);
match client match client
.update_event(&calendar_path, &updated_event, event_href) .update_event(&calendar_path, &updated_event, event_href)
@@ -714,8 +662,8 @@ fn build_series_rrule_with_freq(
fn update_entire_series( fn update_entire_series(
existing_event: &mut VEvent, existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest, request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>, start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::DateTime<chrono::Utc>, end_datetime: chrono::NaiveDateTime,
) -> Result<(VEvent, u32), ApiError> { ) -> Result<(VEvent, u32), ApiError> {
// Clone the existing event to preserve all metadata // Clone the existing event to preserve all metadata
let mut updated_event = existing_event.clone(); let mut updated_event = existing_event.clone();
@@ -723,6 +671,8 @@ fn update_entire_series(
// Update only the modified properties from the request // Update only the modified properties from the request
updated_event.dtstart = start_datetime; updated_event.dtstart = start_datetime;
updated_event.dtend = Some(end_datetime); updated_event.dtend = Some(end_datetime);
updated_event.dtstart_tzid = Some(request.timezone.clone());
updated_event.dtend_tzid = Some(request.timezone.clone());
updated_event.summary = if request.title.trim().is_empty() { updated_event.summary = if request.title.trim().is_empty() {
existing_event.summary.clone() // Keep original if empty existing_event.summary.clone() // Keep original if empty
} else { } else {
@@ -755,8 +705,9 @@ fn update_entire_series(
// Update timestamps // Update timestamps
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let now_naive = now.naive_utc();
updated_event.dtstamp = now; updated_event.dtstamp = now;
updated_event.last_modified = Some(now); updated_event.last_modified = Some(now_naive);
// Keep original created timestamp to preserve event history // Keep original created timestamp to preserve event history
// Update RRULE if recurrence parameters are provided // Update RRULE if recurrence parameters are provided
@@ -844,8 +795,8 @@ fn update_entire_series(
async fn update_this_and_future( async fn update_this_and_future(
existing_event: &mut VEvent, existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest, request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>, start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::DateTime<chrono::Utc>, end_datetime: chrono::NaiveDateTime,
client: &CalDAVClient, client: &CalDAVClient,
calendar_path: &str, calendar_path: &str,
) -> Result<(VEvent, u32), ApiError> { ) -> Result<(VEvent, u32), ApiError> {
@@ -893,6 +844,8 @@ async fn update_this_and_future(
new_series.uid = new_series_uid.clone(); new_series.uid = new_series_uid.clone();
new_series.dtstart = start_datetime; new_series.dtstart = start_datetime;
new_series.dtend = Some(end_datetime); new_series.dtend = Some(end_datetime);
new_series.dtstart_tzid = Some(request.timezone.clone());
new_series.dtend_tzid = Some(request.timezone.clone());
new_series.summary = if request.title.trim().is_empty() { new_series.summary = if request.title.trim().is_empty() {
None None
} else { } else {
@@ -925,9 +878,10 @@ async fn update_this_and_future(
// Update timestamps // Update timestamps
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let now_naive = now.naive_utc();
new_series.dtstamp = now; new_series.dtstamp = now;
new_series.created = Some(now); new_series.created = Some(now_naive);
new_series.last_modified = Some(now); new_series.last_modified = Some(now_naive);
new_series.href = None; // Will be set when created new_series.href = None; // Will be set when created
println!( println!(
@@ -955,8 +909,8 @@ async fn update_this_and_future(
async fn update_single_occurrence( async fn update_single_occurrence(
existing_event: &mut VEvent, existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest, request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>, start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::DateTime<chrono::Utc>, end_datetime: chrono::NaiveDateTime,
client: &CalDAVClient, client: &CalDAVClient,
calendar_path: &str, calendar_path: &str,
_original_event_href: &str, _original_event_href: &str,
@@ -981,21 +935,20 @@ async fn update_single_occurrence(
// Create the EXDATE datetime using the original event's time // Create the EXDATE datetime using the original event's time
let original_time = existing_event.dtstart.time(); let original_time = existing_event.dtstart.time();
let exception_datetime = exception_date.and_time(original_time); let exception_datetime = exception_date.and_time(original_time);
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
// Add the exception date to the original series // Add the exception date to the original series
println!( println!(
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}", "📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
existing_event.exdate existing_event.exdate
); );
existing_event.exdate.push(exception_utc); existing_event.exdate.push(exception_datetime);
println!( println!(
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}", "📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
existing_event.exdate existing_event.exdate
); );
println!( println!(
"🚫 Added EXDATE for single occurrence modification: {}", "🚫 Added EXDATE for single occurrence modification: {}",
exception_utc.format("%Y-%m-%d %H:%M:%S") exception_datetime.format("%Y-%m-%d %H:%M:%S")
); );
// Create exception event by cloning the existing event to preserve all metadata // Create exception event by cloning the existing event to preserve all metadata
@@ -1007,6 +960,8 @@ async fn update_single_occurrence(
// Update the modified properties from the request // Update the modified properties from the request
exception_event.dtstart = start_datetime; exception_event.dtstart = start_datetime;
exception_event.dtend = Some(end_datetime); exception_event.dtend = Some(end_datetime);
exception_event.dtstart_tzid = Some(request.timezone.clone());
exception_event.dtend_tzid = Some(request.timezone.clone());
exception_event.summary = if request.title.trim().is_empty() { exception_event.summary = if request.title.trim().is_empty() {
existing_event.summary.clone() // Keep original if empty existing_event.summary.clone() // Keep original if empty
} else { } else {
@@ -1039,8 +994,9 @@ async fn update_single_occurrence(
// Update timestamps for the exception event // Update timestamps for the exception event
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let now_naive = now.naive_utc();
exception_event.dtstamp = now; exception_event.dtstamp = now;
exception_event.last_modified = Some(now); exception_event.last_modified = Some(now_naive);
// Keep original created timestamp to preserve event history // Keep original created timestamp to preserve event history
// Set RECURRENCE-ID to point to the original occurrence // Set RECURRENCE-ID to point to the original occurrence
@@ -1056,7 +1012,7 @@ async fn update_single_occurrence(
println!( println!(
"✨ Created exception event with RECURRENCE-ID: {}", "✨ Created exception event with RECURRENCE-ID: {}",
exception_utc.format("%Y-%m-%d %H:%M:%S") exception_datetime.format("%Y-%m-%d %H:%M:%S")
); );
// Create the exception event as a new event (original series will be updated by main handler) // Create the exception event as a new event (original series will be updated by main handler)
@@ -1067,7 +1023,7 @@ async fn update_single_occurrence(
println!("✅ Created exception event successfully"); println!("✅ Created exception event successfully");
// Return the original series (now with EXDATE) - main handler will update it on CalDAV // Return the modified existing event with EXDATE for the main handler to update on CalDAV
Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception) Ok((existing_event.clone(), 1)) // 1 occurrence modified (via exception)
} }
@@ -1184,15 +1140,14 @@ async fn delete_single_occurrence(
// Create the EXDATE datetime (use the same time as the original event) // Create the EXDATE datetime (use the same time as the original event)
let original_time = existing_event.dtstart.time(); let original_time = existing_event.dtstart.time();
let exception_datetime = exception_date.and_time(original_time); let exception_datetime = exception_date.and_time(original_time);
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
// Add the exception date to the event's EXDATE list // Add the exception date to the event's EXDATE list
let mut updated_event = existing_event; let mut updated_event = existing_event;
updated_event.exdate.push(exception_utc); updated_event.exdate.push(exception_datetime);
println!( println!(
"🗑️ Added EXDATE for single occurrence deletion: {}", "🗑️ Added EXDATE for single occurrence deletion: {}",
exception_utc.format("%Y%m%dT%H%M%SZ") exception_datetime.format("%Y%m%dT%H%M%S")
); );
// Update the event on the CalDAV server // Update the event on the CalDAV server

View File

@@ -3,6 +3,7 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
}; };
use calendar_models::VAlarm;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// API request/response types // API request/response types
@@ -113,10 +114,11 @@ pub struct CreateEventRequest {
pub organizer: String, // organizer email pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories pub categories: String, // comma-separated categories
pub reminder: String, // reminder type pub alarms: Vec<VAlarm>, // event alarms
pub recurrence: String, // recurrence type pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub calendar_path: Option<String>, // Optional - use first calendar if not specified pub calendar_path: Option<String>, // Optional - use first calendar if not specified
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -143,11 +145,15 @@ pub struct UpdateEventRequest {
pub organizer: String, // organizer email pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories pub categories: String, // comma-separated categories
pub reminder: String, // reminder type pub alarms: Vec<VAlarm>, // event alarms
pub recurrence: String, // recurrence type pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
pub recurrence_count: Option<u32>, // Number of occurrences
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
pub calendar_path: Option<String>, // Optional - search all calendars if not specified pub calendar_path: Option<String>, // Optional - search all calendars if not specified
pub update_action: Option<String>, // "update_series" for recurring events pub update_action: Option<String>, // "update_series" for recurring events
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
} }
@@ -176,7 +182,7 @@ pub struct CreateEventSeriesRequest {
pub organizer: String, // organizer email pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories pub categories: String, // comma-separated categories
pub reminder: String, // reminder type pub alarms: Vec<VAlarm>, // event alarms
// Series-specific fields // Series-specific fields
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
@@ -185,6 +191,7 @@ pub struct CreateEventSeriesRequest {
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD) pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
pub recurrence_count: Option<u32>, // Number of occurrences pub recurrence_count: Option<u32>, // Number of occurrences
pub calendar_path: Option<String>, // Optional - search all calendars if not specified pub calendar_path: Option<String>, // Optional - search all calendars if not specified
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -213,7 +220,7 @@ pub struct UpdateEventSeriesRequest {
pub organizer: String, // organizer email pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories pub categories: String, // comma-separated categories
pub reminder: String, // reminder type pub alarms: Vec<VAlarm>, // event alarms
// Series-specific fields // Series-specific fields
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly) pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
@@ -227,6 +234,7 @@ pub struct UpdateEventSeriesRequest {
pub update_scope: String, // "this_only", "this_and_future", "all_in_series" pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization) pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]

View File

@@ -1,7 +1,7 @@
//! VEvent - RFC 5545 compliant calendar event structure //! VEvent - RFC 5545 compliant calendar event structure
use crate::common::*; use crate::common::*;
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, NaiveDateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ==================== VEVENT COMPONENT ==================== // ==================== VEVENT COMPONENT ====================
@@ -9,12 +9,14 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VEvent { pub struct VEvent {
// Required properties // Required properties
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED (always UTC)
pub uid: String, // Unique identifier (UID) - REQUIRED pub uid: String, // Unique identifier (UID) - REQUIRED
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED pub dtstart: NaiveDateTime, // Start date-time (DTSTART) - REQUIRED (local time)
pub dtstart_tzid: Option<String>, // Timezone ID for DTSTART (TZID parameter)
// Optional properties (commonly used) // Optional properties (commonly used)
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND) pub dtend: Option<NaiveDateTime>, // End date-time (DTEND) (local time)
pub dtend_tzid: Option<String>, // Timezone ID for DTEND (TZID parameter)
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
pub summary: Option<String>, // Summary/title (SUMMARY) pub summary: Option<String>, // Summary/title (SUMMARY)
pub description: Option<String>, // Description (DESCRIPTION) pub description: Option<String>, // Description (DESCRIPTION)
@@ -43,14 +45,19 @@ pub struct VEvent {
// Versioning and modification // Versioning and modification
pub sequence: Option<u32>, // Sequence number (SEQUENCE) pub sequence: Option<u32>, // Sequence number (SEQUENCE)
pub created: Option<DateTime<Utc>>, // Creation time (CREATED) pub created: Option<NaiveDateTime>, // Creation time (CREATED) (local time)
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED) pub created_tzid: Option<String>, // Timezone ID for CREATED
pub last_modified: Option<NaiveDateTime>, // Last modified (LAST-MODIFIED) (local time)
pub last_modified_tzid: Option<String>, // Timezone ID for LAST-MODIFIED
// Recurrence // Recurrence
pub rrule: Option<String>, // Recurrence rule (RRULE) pub rrule: Option<String>, // Recurrence rule (RRULE)
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE) pub rdate: Vec<NaiveDateTime>, // Recurrence dates (RDATE) (local time)
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE) pub rdate_tzid: Option<String>, // Timezone ID for RDATE
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID) pub exdate: Vec<NaiveDateTime>, // Exception dates (EXDATE) (local time)
pub exdate_tzid: Option<String>, // Timezone ID for EXDATE
pub recurrence_id: Option<NaiveDateTime>, // Recurrence ID (RECURRENCE-ID) (local time)
pub recurrence_id_tzid: Option<String>, // Timezone ID for RECURRENCE-ID
// Alarms and attachments // Alarms and attachments
pub alarms: Vec<VAlarm>, // VALARM components pub alarms: Vec<VAlarm>, // VALARM components
@@ -64,13 +71,15 @@ pub struct VEvent {
} }
impl VEvent { impl VEvent {
/// Create a new VEvent with required fields /// Create a new VEvent with required fields (local time)
pub fn new(uid: String, dtstart: DateTime<Utc>) -> Self { pub fn new(uid: String, dtstart: NaiveDateTime) -> Self {
Self { Self {
dtstamp: Utc::now(), dtstamp: Utc::now(),
uid, uid,
dtstart, dtstart,
dtstart_tzid: None,
dtend: None, dtend: None,
dtend_tzid: None,
duration: None, duration: None,
summary: None, summary: None,
description: None, description: None,
@@ -89,12 +98,17 @@ impl VEvent {
url: None, url: None,
geo: None, geo: None,
sequence: None, sequence: None,
created: Some(Utc::now()), created: Some(chrono::Local::now().naive_local()),
last_modified: Some(Utc::now()), created_tzid: None,
last_modified: Some(chrono::Local::now().naive_local()),
last_modified_tzid: None,
rrule: None, rrule: None,
rdate: Vec::new(), rdate: Vec::new(),
rdate_tzid: None,
exdate: Vec::new(), exdate: Vec::new(),
exdate_tzid: None,
recurrence_id: None, recurrence_id: None,
recurrence_id_tzid: None,
alarms: Vec::new(), alarms: Vec::new(),
attachments: Vec::new(), attachments: Vec::new(),
etag: None, etag: None,
@@ -105,7 +119,7 @@ impl VEvent {
} }
/// Helper method to get effective end time (dtend or dtstart + duration) /// Helper method to get effective end time (dtend or dtstart + duration)
pub fn get_end_time(&self) -> DateTime<Utc> { pub fn get_end_time(&self) -> NaiveDateTime {
if let Some(dtend) = self.dtend { if let Some(dtend) = self.dtend {
dtend dtend
} else if let Some(duration) = self.duration { } else if let Some(duration) = self.duration {
@@ -136,7 +150,7 @@ impl VEvent {
/// Helper method to get start date for UI compatibility /// Helper method to get start date for UI compatibility
pub fn get_date(&self) -> chrono::NaiveDate { pub fn get_date(&self) -> chrono::NaiveDate {
self.dtstart.date_naive() self.dtstart.date()
} }
/// Check if event is recurring /// Check if event is recurring

View File

@@ -1,4 +1,6 @@
#!/bin/sh #!/bin/sh
export BACKEND_API_URL="https://runway.rcjohnstone.com/api"
trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml trunk build --release --config /home/connor/docs/projects/calendar/frontend/Trunk.toml
sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/ sudo rsync -azX --delete --info=progress2 -e 'ssh -T -q' --rsync-path='sudo rsync' /home/connor/docs/projects/calendar/frontend/dist connor@server.rcjohnstone.com:/home/connor/data/runway/
unset BACKEND_API_URL

View File

@@ -22,14 +22,36 @@ web-sys = { version = "0.3", features = [
"Document", "Document",
"Window", "Window",
"Location", "Location",
"Navigator",
"DomTokenList",
"Headers", "Headers",
"Request", "Request",
"RequestInit", "RequestInit",
"RequestMode", "RequestMode",
"Response", "Response",
"CssStyleDeclaration", "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" wasm-bindgen = "0.2"
js-sys = "0.3"
# HTTP client for CalDAV requests # HTTP client for CalDAV requests
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
@@ -68,3 +90,6 @@ gloo-storage = "0.3"
gloo-timers = "0.3" gloo-timers = "0.3"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"
# IndexedDB for persistent alarm storage
indexed_db_futures = "0.4"

View File

@@ -6,7 +6,7 @@ dist = "dist"
BACKEND_API_URL = "http://localhost:3000/api" BACKEND_API_URL = "http://localhost:3000/api"
[watch] [watch]
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "index.html"] watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "print-preview.css", "index.html"]
ignore = ["../backend/", "../target/"] ignore = ["../backend/", "../target/"]
[serve] [serve]

View File

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

1216
frontend/print-preview.css Normal file

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,50 @@ impl AuthService {
self.post_json("/auth/login", &request).await self.post_json("/auth/login", &request).await
} }
pub async fn verify_token(&self, token: &str) -> Result<bool, String> {
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("GET");
opts.set_mode(RequestMode::Cors);
let url = format!("{}/auth/verify", self.base_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request
.headers()
.set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Network request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response cast failed: {:?}", e))?;
if resp.ok() {
let text = JsFuture::from(
resp.text()
.map_err(|e| format!("Text extraction failed: {:?}", e))?,
)
.await
.map_err(|e| format!("Text promise failed: {:?}", e))?;
let text_string = text.as_string().ok_or("Response text is not a string")?;
// Parse the response to get the "valid" field
let response: serde_json::Value = serde_json::from_str(&text_string)
.map_err(|e| format!("JSON parsing failed: {}", e))?;
Ok(response.get("valid").and_then(|v| v.as_bool()).unwrap_or(false))
} else {
Ok(false) // Invalid token
}
}
// Helper method for POST requests with JSON body // Helper method for POST requests with JSON body
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>( async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
&self, &self,

View File

@@ -1,9 +1,9 @@
use crate::components::{ use crate::components::{
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView, CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView,
}; };
use crate::models::ical::VEvent; use crate::models::ical::VEvent;
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
use chrono::{Datelike, Duration, Local, NaiveDate}; use chrono::{Datelike, Duration, Local, NaiveDate, Weekday};
use gloo_storage::{LocalStorage, Storage}; use gloo_storage::{LocalStorage, Storage};
use std::collections::HashMap; use std::collections::HashMap;
use web_sys::MouseEvent; use web_sys::MouseEvent;
@@ -32,7 +32,7 @@ pub struct CalendarProps {
chrono::NaiveDateTime, chrono::NaiveDateTime,
chrono::NaiveDateTime, chrono::NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)>, )>,
@@ -111,6 +111,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| { use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| {
let auth_token: Option<String> = LocalStorage::get("auth_token").ok(); let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
let date = *date; // Clone the date to avoid lifetime issues let date = *date; // Clone the date to avoid lifetime issues
let view_mode = _view.clone(); // Clone the view mode to avoid lifetime issues
let external_events = external_events.clone(); // Clone external events to avoid lifetime issues let external_events = external_events.clone(); // Clone external events to avoid lifetime issues
let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues
@@ -136,17 +137,67 @@ pub fn Calendar(props: &CalendarProps) -> Html {
String::new() String::new()
}; };
let current_year = date.year(); // Determine which months to fetch based on view mode
let current_month = date.month(); let months_to_fetch = match view_mode {
ViewMode::Month => {
// For month view, just fetch the current month
vec![(date.year(), date.month())]
}
ViewMode::Week => {
// For week view, calculate the week bounds and fetch all months that intersect
let start_of_week = get_start_of_week(date);
let end_of_week = start_of_week + Duration::days(6);
let mut months = vec![(start_of_week.year(), start_of_week.month())];
// If the week spans into a different month, add that month too
if end_of_week.month() != start_of_week.month() || end_of_week.year() != start_of_week.year() {
months.push((end_of_week.year(), end_of_week.month()));
}
months
}
};
match calendar_service // Fetch events for all required months
.fetch_events_for_month_vevent( let mut all_events = Vec::new();
&token, for (year, month) in months_to_fetch {
&password, match calendar_service
current_year, .fetch_events_for_month_vevent(
current_month, &token,
) &password,
.await year,
month,
)
.await
{
Ok(mut month_events) => {
all_events.append(&mut month_events);
}
Err(err) => {
error.set(Some(format!("Failed to load events for {}-{}: {}", year, month, err)));
loading.set(false);
return;
}
}
}
// Deduplicate events that may appear in multiple month fetches
// This happens when a recurring event spans across month boundaries
all_events.sort_by(|a, b| {
// Sort by UID first, then by start time
match a.uid.cmp(&b.uid) {
std::cmp::Ordering::Equal => a.dtstart.cmp(&b.dtstart),
other => other,
}
});
all_events.dedup_by(|a, b| {
// Remove duplicates with same UID and start time
a.uid == b.uid && a.dtstart == b.dtstart
});
// Process the combined events
match Ok(all_events) as Result<Vec<VEvent>, String>
{ {
Ok(vevents) => { Ok(vevents) => {
// Filter CalDAV events based on calendar visibility // Filter CalDAV events based on calendar visibility
@@ -389,6 +440,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
}) })
}; };
// Handle print calendar preview
let show_print_preview = use_state(|| false);
let on_print = {
let show_print_preview = show_print_preview.clone();
Callback::from(move |_: MouseEvent| {
show_print_preview.set(true);
})
};
// Handle drag-to-create event // Handle drag-to-create event
let on_create_event = { let on_create_event = {
let show_create_modal = show_create_modal.clone(); let show_create_modal = show_create_modal.clone();
@@ -428,7 +488,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
chrono::NaiveDateTime, chrono::NaiveDateTime,
chrono::NaiveDateTime, chrono::NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)| { )| {
@@ -457,6 +517,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_today={on_today} on_today={on_today}
time_increment={Some(*time_increment)} time_increment={Some(*time_increment)}
on_time_increment_toggle={Some(on_time_increment_toggle)} on_time_increment_toggle={Some(on_time_increment_toggle)}
on_print={Some(on_print)}
/> />
{ {
@@ -563,6 +624,47 @@ pub fn Calendar(props: &CalendarProps) -> Html {
}) })
}} }}
/> />
// Print preview modal
{
if *show_print_preview {
html! {
<PrintPreviewModal
on_close={{
let show_print_preview = show_print_preview.clone();
Callback::from(move |_| {
show_print_preview.set(false);
})
}}
view_mode={props.view.clone()}
current_date={*current_date}
selected_date={*selected_date}
events={(*events).clone()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
time_increment={*time_increment}
today={today}
/>
}
} else {
html! {}
}
}
</div> </div>
} }
} }
// Helper function to calculate the start of the week (Sunday) for a given date
fn get_start_of_week(date: NaiveDate) -> NaiveDate {
let weekday = date.weekday();
let days_from_sunday = match weekday {
Weekday::Sun => 0,
Weekday::Mon => 1,
Weekday::Tue => 2,
Weekday::Wed => 3,
Weekday::Thu => 4,
Weekday::Fri => 5,
Weekday::Sat => 6,
};
date - Duration::days(days_from_sunday)
}

View File

@@ -14,6 +14,8 @@ pub struct CalendarHeaderProps {
pub time_increment: Option<u32>, pub time_increment: Option<u32>,
#[prop_or_default] #[prop_or_default]
pub on_time_increment_toggle: Option<Callback<MouseEvent>>, pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
#[prop_or_default]
pub on_print: Option<Callback<MouseEvent>>,
} }
#[function_component(CalendarHeader)] #[function_component(CalendarHeader)]
@@ -39,6 +41,17 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
html! {} html! {}
} }
} }
{
if let Some(print_callback) = &props.on_print {
html! {
<button class="print-button" onclick={print_callback.clone()} title="Print Calendar">
<i class="fas fa-print"></i>
</button>
}
} else {
html! {}
}
}
</div> </div>
<h2 class="month-year">{title}</h2> <h2 class="month-year">{title}</h2>
<div class="header-right"> <div class="header-right">

View File

@@ -9,6 +9,7 @@ pub struct CalendarListItemProps {
pub on_color_change: Callback<(String, String)>, // (calendar_path, color) pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
pub on_color_picker_toggle: Callback<String>, // calendar_path pub on_color_picker_toggle: Callback<String>, // calendar_path
pub available_colors: Vec<String>, pub available_colors: Vec<String>,
pub on_color_editor_open: Callback<(usize, String)>, // (index, current_color)
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path) pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
pub on_visibility_toggle: Callback<String>, // calendar_path pub on_visibility_toggle: Callback<String>, // calendar_path
} }
@@ -55,7 +56,7 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
{ {
if props.color_picker_open { if props.color_picker_open {
html! { html! {
<div class="color-picker"> <div class="color-picker-dropdown">
{ {
props.available_colors.iter().map(|color| { props.available_colors.iter().map(|color| {
let color_str = color.clone(); let color_str = color.clone();
@@ -66,13 +67,25 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
on_color_change.emit((cal_path.clone(), color_str.clone())); on_color_change.emit((cal_path.clone(), color_str.clone()));
}); });
let on_color_right_click = {
let on_color_editor_open = props.on_color_editor_open.clone();
let color_index = props.available_colors.iter().position(|c| c == color).unwrap_or(0);
let color_str = color.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
e.stop_propagation();
on_color_editor_open.emit((color_index, color_str.clone()));
})
};
let is_selected = props.calendar.color == *color; let is_selected = props.calendar.color == *color;
let class_name = if is_selected { "color-option selected" } else { "color-option" }; let class_name = if is_selected { "color-option selected" } else { "color-option" };
html! { html! {
<div class={class_name} <div class={class_name}
style={format!("background-color: {}", color)} style={format!("background-color: {}", color)}
onclick={on_color_select}> onclick={on_color_select}
oncontextmenu={on_color_right_click}>
</div> </div>
} }
}).collect::<Html>() }).collect::<Html>()

View File

@@ -0,0 +1,449 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use wasm_bindgen::JsCast;
use crate::services::calendar_service::CalendarService;
#[derive(Clone, PartialEq)]
pub enum CalendarTab {
Create,
External,
}
#[derive(Properties, PartialEq)]
pub struct CalendarManagementModalProps {
pub is_open: bool,
pub on_close: Callback<()>,
pub on_create_calendar: Callback<(String, Option<String>, Option<String>)>, // name, description, color
pub on_external_success: Callback<i32>, // Pass the newly created external calendar ID
pub available_colors: Vec<String>,
}
#[function_component(CalendarManagementModal)]
pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
let active_tab = use_state(|| CalendarTab::Create);
// Create Calendar state
let calendar_name = use_state(|| String::new());
let description = use_state(|| String::new());
let selected_color = use_state(|| None::<String>);
let create_error_message = use_state(|| None::<String>);
let is_creating = use_state(|| false);
// External Calendar state
let external_name = use_state(|| String::new());
let external_url = use_state(|| String::new());
let external_selected_color = use_state(|| Some("#4285f4".to_string()));
let external_is_loading = use_state(|| false);
let external_error_message = use_state(|| None::<String>);
// Reset state when modal opens
use_effect_with(props.is_open, {
let calendar_name = calendar_name.clone();
let description = description.clone();
let selected_color = selected_color.clone();
let create_error_message = create_error_message.clone();
let is_creating = is_creating.clone();
let external_name = external_name.clone();
let external_url = external_url.clone();
let external_is_loading = external_is_loading.clone();
let external_error_message = external_error_message.clone();
let external_selected_color = external_selected_color.clone();
let active_tab = active_tab.clone();
move |is_open| {
if *is_open {
// Reset all state when modal opens
calendar_name.set(String::new());
description.set(String::new());
selected_color.set(None);
create_error_message.set(None);
is_creating.set(false);
external_name.set(String::new());
external_url.set(String::new());
external_is_loading.set(false);
external_error_message.set(None);
external_selected_color.set(Some("#4285f4".to_string()));
active_tab.set(CalendarTab::Create);
}
}
});
let on_tab_click = {
let active_tab = active_tab.clone();
Callback::from(move |tab: CalendarTab| {
active_tab.set(tab);
})
};
let on_backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
if let Some(target) = e.target() {
let element = target.dyn_into::<web_sys::Element>().unwrap();
if element.class_list().contains("modal-backdrop") {
on_close.emit(());
}
}
})
};
// Create Calendar handlers
let on_name_change = {
let calendar_name = calendar_name.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
calendar_name.set(input.value());
})
};
let on_description_change = {
let description = description.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
description.set(input.value());
})
};
let on_color_select = {
let selected_color = selected_color.clone();
Callback::from(move |color: String| {
selected_color.set(Some(color));
})
};
let on_external_color_select = {
let external_selected_color = external_selected_color.clone();
Callback::from(move |color: String| {
external_selected_color.set(Some(color));
})
};
let on_create_submit = {
let calendar_name = calendar_name.clone();
let description = description.clone();
let selected_color = selected_color.clone();
let create_error_message = create_error_message.clone();
let is_creating = is_creating.clone();
let on_create_calendar = props.on_create_calendar.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let name = (*calendar_name).trim();
if name.is_empty() {
create_error_message.set(Some("Calendar name is required".to_string()));
return;
}
is_creating.set(true);
create_error_message.set(None);
let desc = if description.is_empty() {
None
} else {
Some((*description).clone())
};
on_create_calendar.emit((name.to_string(), desc, (*selected_color).clone()));
})
};
// External Calendar handlers
let on_external_submit = {
let external_name = external_name.clone();
let external_url = external_url.clone();
let external_selected_color = external_selected_color.clone();
let external_is_loading = external_is_loading.clone();
let external_error_message = external_error_message.clone();
let on_close = props.on_close.clone();
let on_external_success = props.on_external_success.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let name = (*external_name).trim().to_string();
let url = (*external_url).trim().to_string();
let color = (*external_selected_color).clone().unwrap_or_else(|| "#4285f4".to_string());
// Debug logging to understand the issue
web_sys::console::log_1(&format!("External calendar form submission - Name: '{}', URL: '{}'", name, url).into());
if name.is_empty() {
external_error_message.set(Some("Calendar name is required".to_string()));
web_sys::console::log_1(&"Validation failed: empty name".into());
return;
}
if url.is_empty() {
external_error_message.set(Some("Calendar URL is required".to_string()));
web_sys::console::log_1(&"Validation failed: empty URL".into());
return;
}
// Basic URL validation
if !url.starts_with("http://") && !url.starts_with("https://") {
external_error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
return;
}
external_is_loading.set(true);
external_error_message.set(None);
let external_is_loading = external_is_loading.clone();
let external_error_message = external_error_message.clone();
let on_close = on_close.clone();
let on_external_success = on_external_success.clone();
wasm_bindgen_futures::spawn_local(async move {
let _calendar_service = CalendarService::new();
match CalendarService::create_external_calendar(&name, &url, &color).await {
Ok(calendar) => {
external_is_loading.set(false);
on_close.emit(());
on_external_success.emit(calendar.id);
}
Err(e) => {
external_is_loading.set(false);
external_error_message.set(Some(format!("Failed to add calendar: {}", e)));
}
}
});
})
};
// External input change handlers
let on_external_name_change = {
let external_name = external_name.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
external_name.set(input.value());
}
})
};
let on_external_url_change = {
let external_url = external_url.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
external_url.set(input.value());
}
})
};
if !props.is_open {
return html! {};
}
html! {
<div class="modal-backdrop" onclick={on_backdrop_click}>
<div class="modal-content calendar-management-modal">
<div class="modal-header">
<h2>{"Add Calendar"}</h2>
<button class="modal-close" onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}>{"×"}</button>
</div>
<div class="calendar-management-tabs">
<button
class={if *active_tab == CalendarTab::Create { "tab-button active" } else { "tab-button" }}
onclick={
let on_tab_click = on_tab_click.clone();
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::Create))
}
>
{"Create Calendar"}
</button>
<button
class={if *active_tab == CalendarTab::External { "tab-button active" } else { "tab-button" }}
onclick={
let on_tab_click = on_tab_click.clone();
Callback::from(move |_: MouseEvent| on_tab_click.emit(CalendarTab::External))
}
>
{"Add External"}
</button>
</div>
<div class="modal-body">
{
match *active_tab {
CalendarTab::Create => html! {
<form onsubmit={on_create_submit}>
<div class="form-group">
<label for="calendar-name">{"Calendar Name"}</label>
<input
type="text"
id="calendar-name"
value={(*calendar_name).clone()}
oninput={on_name_change}
placeholder="Enter calendar name"
disabled={*is_creating}
/>
</div>
<div class="form-group">
<label for="calendar-description">{"Description (optional)"}</label>
<textarea
id="calendar-description"
value={(*description).clone()}
oninput={on_description_change}
placeholder="Enter calendar description"
disabled={*is_creating}
/>
</div>
<div class="form-group">
<label>{"Calendar Color"}</label>
<div class="color-picker">
{
props.available_colors.iter().map(|color| {
let is_selected = selected_color.as_ref() == Some(color);
let color_clone = color.clone();
let on_color_select = on_color_select.clone();
html! {
<div
key={color.clone()}
class={if is_selected { "color-option selected" } else { "color-option" }}
style={format!("background-color: {}", color)}
onclick={Callback::from(move |_: MouseEvent| {
on_color_select.emit(color_clone.clone());
})}
/>
}
}).collect::<Html>()
}
</div>
</div>
{
if let Some(ref error) = *create_error_message {
html! {
<div class="error-message">
{error}
</div>
}
} else {
html! {}
}
}
<div class="modal-footer">
<button
type="button"
class="cancel-button"
onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}
disabled={*is_creating}
>
{"Cancel"}
</button>
<button
type="submit"
class="create-button"
disabled={*is_creating}
>
{if *is_creating { "Creating..." } else { "Create Calendar" }}
</button>
</div>
</form>
},
CalendarTab::External => html! {
<form onsubmit={on_external_submit}>
<div class="form-group">
<label for="external-name">{"Calendar Name"}</label>
<input
type="text"
id="external-name"
value={(*external_name).clone()}
onchange={on_external_name_change}
placeholder="Enter calendar name"
disabled={*external_is_loading}
/>
</div>
<div class="form-group">
<label for="external-url">{"Calendar URL"}</label>
<input
type="url"
id="external-url"
value={(*external_url).clone()}
onchange={on_external_url_change}
placeholder="https://example.com/calendar.ics"
disabled={*external_is_loading}
/>
<small class="help-text">
{"Enter the ICS/CalDAV URL for your external calendar"}
</small>
</div>
<div class="form-group">
<label>{"Calendar Color"}</label>
<div class="color-picker">
{
props.available_colors.iter().map(|color| {
let is_selected = external_selected_color.as_ref() == Some(color);
let color_clone = color.clone();
let on_external_color_select = on_external_color_select.clone();
html! {
<div
key={color.clone()}
class={if is_selected { "color-option selected" } else { "color-option" }}
style={format!("background-color: {}", color)}
onclick={Callback::from(move |_: MouseEvent| {
on_external_color_select.emit(color_clone.clone());
})}
/>
}
}).collect::<Html>()
}
</div>
</div>
{
if let Some(ref error) = *external_error_message {
html! {
<div class="error-message">
{error}
</div>
}
} else {
html! {}
}
}
<div class="modal-footer">
<button
type="button"
class="cancel-button"
onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}
disabled={*external_is_loading}
>
{"Cancel"}
</button>
<button
type="submit"
class="create-button"
disabled={*external_is_loading}
>
{if *external_is_loading { "Adding..." } else { "Add Calendar" }}
</button>
</div>
</form>
}
}
}
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,176 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use wasm_bindgen::JsCast;
#[derive(Properties, PartialEq)]
pub struct ColorEditorModalProps {
pub is_open: bool,
pub current_color: String,
pub color_index: usize,
pub default_color: String, // Default color for this index
pub on_close: Callback<()>,
pub on_save: Callback<(usize, String)>, // (index, new_color)
pub on_reset_all: Callback<()>, // Reset all colors to defaults
}
#[function_component(ColorEditorModal)]
pub fn color_editor_modal(props: &ColorEditorModalProps) -> Html {
let selected_color = use_state(|| props.current_color.clone());
// Reset selected color when modal opens with new color
{
let selected_color = selected_color.clone();
use_effect_with(props.current_color.clone(), move |current_color| {
selected_color.set(current_color.clone());
});
}
let on_color_input = {
let selected_color = selected_color.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
selected_color.set(input.value());
}
})
};
let on_save_click = {
let selected_color = selected_color.clone();
let on_save = props.on_save.clone();
let color_index = props.color_index;
Callback::from(move |_| {
on_save.emit((color_index, (*selected_color).clone()));
})
};
let on_backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
// Only close if clicking the backdrop, not the modal content
if let Some(target) = e.target() {
if let Some(element) = target.dyn_ref::<web_sys::Element>() {
if element.class_list().contains("color-editor-backdrop") {
on_close.emit(());
}
}
}
})
};
if !props.is_open {
return html! {};
}
// Predefined color suggestions
let suggested_colors = vec![
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4",
"#84CC16", "#F97316", "#EC4899", "#6366F1", "#14B8A6", "#F3B806",
"#8B5A2B", "#6B7280", "#DC2626", "#7C3AED", "#F87171", "#34D399",
"#FBBF24", "#A78BFA", "#60A5FA", "#2DD4BF", "#FB7185", "#FDBA74",
];
html! {
<div class="color-editor-backdrop" onclick={on_backdrop_click}>
<div class="color-editor-modal">
<div class="color-editor-header">
<h3>{"Edit Color"}</h3>
<button class="close-button" onclick={Callback::from({
let on_close = props.on_close.clone();
move |_| on_close.emit(())
})}>
{"×"}
</button>
</div>
<div class="color-editor-content">
<div class="current-color-preview">
<div
class="color-preview-large"
style={format!("background-color: {}", *selected_color)}
></div>
<div class="color-preview-info">
<span class="color-value">{&*selected_color}</span>
<button class="reset-this-color-button" onclick={{
let selected_color = selected_color.clone();
let default_color = props.default_color.clone();
Callback::from(move |_| {
selected_color.set(default_color.clone());
})
}}>
{"Reset This Color"}
</button>
</div>
</div>
<div class="color-input-section">
<label for="color-picker">{"Custom Color:"}</label>
<div class="color-input-group">
<input
type="color"
id="color-picker"
value={(*selected_color).clone()}
oninput={on_color_input.clone()}
/>
<input
type="text"
class="color-text-input"
value={(*selected_color).clone()}
oninput={on_color_input}
placeholder="#000000"
/>
</div>
</div>
<div class="suggested-colors-section">
<label>{"Suggested Colors:"}</label>
<div class="suggested-colors-grid">
{
suggested_colors.iter().map(|color| {
let color = color.to_string();
let selected_color = selected_color.clone();
let onclick = {
let color = color.clone();
Callback::from(move |_| {
selected_color.set(color.clone());
})
};
html! {
<div
class="suggested-color"
style={format!("background-color: {}", color)}
onclick={onclick}
title={color.clone()}
></div>
}
}).collect::<Html>()
}
</div>
</div>
</div>
<div class="color-editor-footer">
<button class="cancel-button" onclick={Callback::from({
let on_close = props.on_close.clone();
move |_| on_close.emit(())
})}>
{"Cancel"}
</button>
<button class="reset-all-button" onclick={Callback::from({
let on_reset_all = props.on_reset_all.clone();
let on_close = props.on_close.clone();
move |_| {
on_reset_all.emit(());
on_close.emit(());
}
})}>
{"Reset All Colors"}
</button>
<button class="save-button" onclick={on_save_click}>
{"Save"}
</button>
</div>
</div>
</div>
}
}

View File

@@ -238,12 +238,11 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
// Convert VEvent to EventCreationData for editing // Convert VEvent to EventCreationData for editing
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData { fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
use chrono::Local;
// Convert start datetime from UTC to local // VEvent fields are already local time (NaiveDateTime)
let start_local = event.dtstart.with_timezone(&Local).naive_local(); let start_local = event.dtstart;
let end_local = if let Some(dtend) = event.dtend { let end_local = if let Some(dtend) = event.dtend {
dtend.with_timezone(&Local).naive_local() dtend
} else { } else {
// Default to 1 hour after start if no end time // Default to 1 hour after start if no end time
start_local + chrono::Duration::hours(1) start_local + chrono::Duration::hours(1)
@@ -258,7 +257,13 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
// Timing // Timing
start_date: start_local.date(), start_date: start_local.date(),
end_date: end_local.date(), end_date: if event.all_day {
// For all-day events, subtract one day to convert from exclusive to inclusive end date
// (UI expects inclusive dates, but iCalendar stores exclusive end dates)
end_local.date() - chrono::Duration::days(1)
} else {
end_local.date()
},
start_time: start_local.time(), start_time: start_local.time(),
end_time: end_local.time(), end_time: end_local.time(),
@@ -287,13 +292,15 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
// Categorization // Categorization
categories: event.categories.join(","), categories: event.categories.join(","),
// Reminders - TODO: Parse alarm from VEvent if needed // Reminders - Use VAlarms from the event
reminder: ReminderType::None, alarms: event.alarms.clone(),
// Recurrence - Parse RRULE if present // Recurrence - Parse RRULE if present
recurrence: if let Some(ref rrule_str) = event.rrule { recurrence: if let Some(ref rrule_str) = event.rrule {
web_sys::console::log_1(&format!("🐛 MODAL DEBUG: Event has RRULE: {}", rrule_str).into());
parse_rrule_frequency(rrule_str) parse_rrule_frequency(rrule_str)
} else { } else {
web_sys::console::log_1(&"🐛 MODAL DEBUG: Event has no RRULE (singleton)".into());
RecurrenceType::None RecurrenceType::None
}, },
recurrence_interval: if let Some(ref rrule_str) = event.rrule { recurrence_interval: if let Some(ref rrule_str) = event.rrule {
@@ -338,7 +345,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
}, },
// Edit tracking // Edit tracking
edit_scope: None, // Will be set by the modal after creation edit_scope: {
web_sys::console::log_1(&"🐛 MODAL DEBUG: Setting edit_scope to None for vevent_to_creation_data".into());
None // Will be set by the modal after creation
},
changed_fields: vec![], changed_fields: vec![],
original_uid: Some(event.uid.clone()), // Preserve original UID for editing original_uid: Some(event.uid.clone()), // Preserve original UID for editing
occurrence_date: Some(start_local.date()), // The occurrence date being edited occurrence_date: Some(start_local.date()), // The occurrence date being edited

View File

@@ -24,7 +24,9 @@ pub struct EventContextMenuProps {
pub event: Option<VEvent>, pub event: Option<VEvent>,
pub on_edit: Callback<EditAction>, pub on_edit: Callback<EditAction>,
pub on_delete: Callback<DeleteAction>, pub on_delete: Callback<DeleteAction>,
pub on_view_details: Callback<VEvent>,
pub on_close: Callback<()>, pub on_close: Callback<()>,
pub on_edit_singleton: Callback<VEvent>, // New callback for editing singleton events without scope
} }
#[function_component(EventContextMenu)] #[function_component(EventContextMenu)]
@@ -90,6 +92,14 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
.as_ref() .as_ref()
.map(|event| event.rrule.is_some()) .map(|event| event.rrule.is_some())
.unwrap_or(false); .unwrap_or(false);
// Check if the event is from an external calendar (read-only)
let is_external = props
.event
.as_ref()
.and_then(|event| event.calendar_path.as_ref())
.map(|path| path.starts_with("external_"))
.unwrap_or(false);
let create_edit_callback = |action: EditAction| { let create_edit_callback = |action: EditAction| {
let on_edit = props.on_edit.clone(); let on_edit = props.on_edit.clone();
@@ -100,6 +110,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
}) })
}; };
let create_singleton_edit_callback = {
let on_edit_singleton = props.on_edit_singleton.clone();
let on_close = props.on_close.clone();
let event = props.event.clone();
Callback::from(move |_: MouseEvent| {
if let Some(event) = &event {
on_edit_singleton.emit(event.clone());
}
on_close.emit(());
})
};
let create_delete_callback = |action: DeleteAction| { let create_delete_callback = |action: DeleteAction| {
let on_delete = props.on_delete.clone(); let on_delete = props.on_delete.clone();
let on_close = props.on_close.clone(); let on_close = props.on_close.clone();
@@ -109,6 +131,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
}) })
}; };
let create_view_details_callback = {
let on_view_details = props.on_view_details.clone();
let on_close = props.on_close.clone();
let event = props.event.clone();
Callback::from(move |_: MouseEvent| {
if let Some(event) = &event {
on_view_details.emit(event.clone());
}
on_close.emit(());
})
};
html! { html! {
<div <div
ref={menu_ref} ref={menu_ref}
@@ -116,7 +150,15 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
style={style} style={style}
> >
{ {
if is_recurring { if is_external {
// External calendar events are read-only - only show "View Details"
html! {
<div class="context-menu-item" onclick={create_view_details_callback}>
{"View Event Details"}
</div>
}
} else if is_recurring {
// Regular recurring events - show edit options
html! { html! {
<> <>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}> <div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
@@ -131,34 +173,41 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
</> </>
} }
} else { } else {
// Regular single events - show edit option without setting edit scope
html! { html! {
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}> <div class="context-menu-item" onclick={create_singleton_edit_callback}>
{"Edit Event"} {"Edit Event"}
</div> </div>
} }
} }
} }
{ {
if is_recurring { if !is_external {
html! { // Only show delete options for non-external events
<> if is_recurring {
html! {
<>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
{"Delete This Event"}
</div>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
{"Delete Following Events"}
</div>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
{"Delete Entire Series"}
</div>
</>
}
} else {
html! {
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}> <div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
{"Delete This Event"} {"Delete Event"}
</div> </div>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}> }
{"Delete Following Events"}
</div>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
{"Delete Entire Series"}
</div>
</>
} }
} else { } else {
html! { // No delete options for external events
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}> html! {}
{"Delete Event"}
</div>
}
} }
} }
</div> </div>

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 = { // TODO: Replace with new alarm management UI
let data = data.clone(); // let on_reminder_change = {
Callback::from(move |e: Event| { // let data = data.clone();
if let Some(target) = e.target() { // Callback::from(move |e: Event| {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { // // Will be replaced with VAlarm management
let mut event_data = (*data).clone(); // })
event_data.reminder = match select.value().as_str() { // };
"15min" => ReminderType::Minutes15,
"30min" => ReminderType::Minutes30,
"1hour" => ReminderType::Hour1,
"1day" => ReminderType::Day1,
"2days" => ReminderType::Days2,
"1week" => ReminderType::Week1,
_ => ReminderType::None,
};
data.set(event_data);
}
}
})
};
let on_recurrence_interval_change = { let on_recurrence_interval_change = {
let data = data.clone(); let data = data.clone();
@@ -321,42 +308,31 @@ pub fn basic_details_tab(props: &TabProps) -> Html {
></textarea> ></textarea>
</div> </div>
<div class="form-group">
<label for="event-calendar">{"Calendar"}</label>
<select
id="event-calendar"
class="form-input"
onchange={on_calendar_change}
>
<option value="">{"Select Calendar"}</option>
{
props.available_calendars.iter().map(|calendar| {
html! {
<option
key={calendar.path.clone()}
value={calendar.path.clone()}
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
>
{&calendar.display_name}
</option>
}
}).collect::<Html>()
}
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
checked={data.all_day}
onchange={on_all_day_change}
/>
{" All Day"}
</label>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group">
<label for="event-calendar">{"Calendar"}</label>
<select
id="event-calendar"
class="form-input"
onchange={on_calendar_change}
>
<option value="">{"Select Calendar"}</option>
{
props.available_calendars.iter().map(|calendar| {
html! {
<option
key={calendar.path.clone()}
value={calendar.path.clone()}
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
>
{&calendar.display_name}
</option>
}
}).collect::<Html>()
}
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="event-recurrence-basic">{"Repeat"}</label> <label for="event-recurrence-basic">{"Repeat"}</label>
<select <select
@@ -371,21 +347,6 @@ pub fn basic_details_tab(props: &TabProps) -> Html {
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option> <option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
</select> </select>
</div> </div>
<div class="form-group">
<label for="event-reminder-basic">{"Reminder"}</label>
<select
id="event-reminder-basic"
class="form-input"
onchange={on_reminder_change}
>
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option>
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
</select>
</div>
</div> </div>
// RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder! // RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder!
@@ -659,6 +620,18 @@ pub fn basic_details_tab(props: &TabProps) -> Html {
</div> </div>
} }
// All Day checkbox above date/time fields
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
checked={data.all_day}
onchange={on_all_day_change}
/>
{" All Day"}
</label>
</div>
// Date and time fields go here AFTER recurrence options // Date and time fields go here AFTER recurrence options
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">

View File

@@ -1,5 +1,7 @@
// Event form components module // Event form components module
pub mod types; pub mod types;
pub mod alarm_list;
pub mod add_alarm_modal;
pub mod basic_details; pub mod basic_details;
pub mod advanced; pub mod advanced;
pub mod people; pub mod people;
@@ -8,6 +10,8 @@ pub mod location;
pub mod reminders; pub mod reminders;
pub use types::*; pub use types::*;
pub use alarm_list::AlarmList;
pub use add_alarm_modal::AddAlarmModal;
pub use basic_details::BasicDetailsTab; pub use basic_details::BasicDetailsTab;
pub use advanced::AdvancedTab; pub use advanced::AdvancedTab;
pub use people::PeopleTab; pub use people::PeopleTab;

View File

@@ -1,100 +1,116 @@
use super::types::*; use super::{types::*, AlarmList, AddAlarmModal};
// Types are already imported from super::types::* use calendar_models::VAlarm;
use wasm_bindgen::JsCast;
use web_sys::HtmlSelectElement;
use yew::prelude::*; use yew::prelude::*;
#[function_component(RemindersTab)] #[function_component(RemindersTab)]
pub fn reminders_tab(props: &TabProps) -> Html { pub fn reminders_tab(props: &TabProps) -> Html {
let data = &props.data; let data = &props.data;
let on_reminder_change = { // Modal state
let is_modal_open = use_state(|| false);
let editing_index = use_state(|| None::<usize>);
// Add alarm callback
let on_add_alarm = {
let is_modal_open = is_modal_open.clone();
let editing_index = editing_index.clone();
Callback::from(move |_| {
editing_index.set(None);
is_modal_open.set(true);
})
};
// Edit alarm callback
let on_alarm_edit = {
let is_modal_open = is_modal_open.clone();
let editing_index = editing_index.clone();
Callback::from(move |index: usize| {
editing_index.set(Some(index));
is_modal_open.set(true);
})
};
// Delete alarm callback
let on_alarm_delete = {
let data = data.clone(); let data = data.clone();
Callback::from(move |e: Event| { Callback::from(move |index: usize| {
if let Some(target) = e.target() { let mut current_data = (*data).clone();
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() { if index < current_data.alarms.len() {
let mut event_data = (*data).clone(); current_data.alarms.remove(index);
event_data.reminder = match select.value().as_str() { data.set(current_data);
"15min" => ReminderType::Minutes15,
"30min" => ReminderType::Minutes30,
"1hour" => ReminderType::Hour1,
"1day" => ReminderType::Day1,
"2days" => ReminderType::Days2,
"1week" => ReminderType::Week1,
_ => ReminderType::None,
};
data.set(event_data);
}
} }
}) })
}; };
// Close modal callback
let on_modal_close = {
let is_modal_open = is_modal_open.clone();
let editing_index = editing_index.clone();
Callback::from(move |_| {
is_modal_open.set(false);
editing_index.set(None);
})
};
// Save alarm callback
let on_alarm_save = {
let data = data.clone();
let is_modal_open = is_modal_open.clone();
let editing_index = editing_index.clone();
Callback::from(move |alarm: VAlarm| {
let mut current_data = (*data).clone();
if let Some(index) = *editing_index {
// Edit existing alarm
if index < current_data.alarms.len() {
current_data.alarms[index] = alarm;
}
} else {
// Add new alarm
current_data.alarms.push(alarm);
}
data.set(current_data);
is_modal_open.set(false);
editing_index.set(None);
})
};
// Get initial alarm for editing
let initial_alarm = (*editing_index).and_then(|index| {
data.alarms.get(index).cloned()
});
html! { html! {
<div class="tab-panel"> <div class="tab-panel">
<div class="form-group"> <div class="form-group">
<label for="event-reminder-main">{"Primary Reminder"}</label> <div class="alarm-management-header">
<select <h5>{"Event Reminders"}</h5>
id="event-reminder-main" <button
class="form-input" class="add-alarm-button"
onchange={on_reminder_change} onclick={on_add_alarm}
> type="button"
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"No reminder"}</option> >
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option> <i class="fas fa-plus"></i>
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option> {" Add Reminder"}
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option> </button>
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option>
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option>
</select>
<p class="form-help-text">{"Choose when you'd like to be reminded about this event"}</p>
</div>
<div class="reminder-types">
<h5>{"Reminder & Alarm Types"}</h5>
<div class="alarm-examples">
<div class="alarm-type">
<strong>{"Display Alarm"}</strong>
<p>{"Pop-up notification on your device"}</p>
</div>
<div class="alarm-type">
<strong>{"Email Reminder"}</strong>
<p>{"Email notification sent to your address"}</p>
</div>
<div class="alarm-type">
<strong>{"Audio Alert"}</strong>
<p>{"Sound notification with custom audio"}</p>
</div>
<div class="alarm-type">
<strong>{"SMS/Text"}</strong>
<p>{"Text message reminder (enterprise feature)"}</p>
</div>
</div> </div>
<p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p> <p class="form-help-text">{"Configure multiple reminders with custom timing and notification types"}</p>
</div> </div>
<div class="reminder-info"> <AlarmList
<h5>{"Advanced Reminder Features"}</h5> alarms={data.alarms.clone()}
<ul> on_alarm_delete={on_alarm_delete}
<li>{"Multiple reminders per event with different timing"}</li> on_alarm_edit={on_alarm_edit}
<li>{"Custom reminder messages and descriptions"}</li> />
<li>{"Recurring reminders for recurring events"}</li>
<li>{"Snooze and dismiss functionality"}</li>
<li>{"Integration with system notifications"}</li>
</ul>
<div class="attachments-section"> <AddAlarmModal
<h6>{"File Attachments & Documents"}</h6> is_open={*is_modal_open}
<p>{"Future attachment features will include:"}</p> editing_index={*editing_index}
<ul> initial_alarm={initial_alarm}
<li>{"Drag-and-drop file uploads"}</li> on_close={on_modal_close}
<li>{"Document preview and thumbnails"}</li> on_save={on_alarm_save}
<li>{"Cloud storage integration (Google Drive, OneDrive)"}</li> />
<li>{"Version control for updated documents"}</li>
<li>{"Shared access permissions for attendees"}</li>
</ul>
<p class="form-help-text">{"Attachment functionality will be implemented in a future release."}</p>
</div>
</div>
</div> </div>
} }
} }

View File

@@ -1,6 +1,7 @@
use crate::services::calendar_service::CalendarInfo; use crate::services::calendar_service::CalendarInfo;
use chrono::{Local, NaiveDate, NaiveTime}; use chrono::{Local, NaiveDate, NaiveTime};
use yew::prelude::*; use yew::prelude::*;
use calendar_models::VAlarm;
#[derive(Clone, PartialEq, Debug)] #[derive(Clone, PartialEq, Debug)]
pub enum EventStatus { pub enum EventStatus {
@@ -28,22 +29,6 @@ impl Default for EventClass {
} }
} }
#[derive(Clone, PartialEq, Debug)]
pub enum ReminderType {
None,
Minutes15,
Minutes30,
Hour1,
Day1,
Days2,
Week1,
}
impl Default for ReminderType {
fn default() -> Self {
ReminderType::None
}
}
#[derive(Clone, PartialEq, Debug)] #[derive(Clone, PartialEq, Debug)]
pub enum RecurrenceType { pub enum RecurrenceType {
@@ -104,8 +89,8 @@ pub struct EventCreationData {
// Categorization // Categorization
pub categories: String, pub categories: String,
// Reminders // Reminders/Alarms
pub reminder: ReminderType, pub alarms: Vec<VAlarm>,
// Recurrence // Recurrence
pub recurrence: RecurrenceType, pub recurrence: RecurrenceType,
@@ -145,20 +130,49 @@ impl EventCreationData {
String, // organizer String, // organizer
String, // attendees String, // attendees
String, // categories String, // categories
String, // reminder Vec<VAlarm>, // alarms
String, // recurrence String, // recurrence
Vec<bool>, // recurrence_days Vec<bool>, // recurrence_days
Option<String>, // calendar_path u32, // recurrence_interval
Option<u32>, // recurrence_count Option<u32>, // recurrence_count
Option<String>, // recurrence_until Option<String>, // recurrence_until
Option<String>, // calendar_path
String, // timezone
) { ) {
// Use local date/times and timezone - no UTC conversion
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
};
// Get the local timezone
let timezone = {
use js_sys::Date;
let date = Date::new_0();
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
let minutes = (timezone_offset as i32).abs() % 60;
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
};
let (start_date, start_time, end_date, end_time) = (
self.start_date.format("%Y-%m-%d").to_string(),
self.start_time.format("%H:%M").to_string(),
effective_end_date.format("%Y-%m-%d").to_string(),
self.end_time.format("%H:%M").to_string(),
);
( (
self.title.clone(), self.title.clone(),
self.description.clone(), self.description.clone(),
self.start_date.format("%Y-%m-%d").to_string(), start_date,
self.start_time.format("%H:%M").to_string(), start_time,
self.end_date.format("%Y-%m-%d").to_string(), end_date,
self.end_time.format("%H:%M").to_string(), end_time,
self.location.clone(), self.location.clone(),
self.all_day, self.all_day,
format!("{:?}", self.status).to_uppercase(), format!("{:?}", self.status).to_uppercase(),
@@ -167,12 +181,14 @@ impl EventCreationData {
self.organizer.clone(), self.organizer.clone(),
self.attendees.clone(), self.attendees.clone(),
self.categories.clone(), self.categories.clone(),
format!("{:?}", self.reminder), self.alarms.clone(),
format!("{:?}", self.recurrence), format!("{:?}", self.recurrence),
self.recurrence_days.clone(), self.recurrence_days.clone(),
self.selected_calendar.clone(), self.recurrence_interval,
self.recurrence_count, self.recurrence_count,
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()), self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
self.selected_calendar.clone(),
timezone,
) )
} }
} }
@@ -199,7 +215,7 @@ impl Default for EventCreationData {
organizer: String::new(), organizer: String::new(),
attendees: String::new(), attendees: String::new(),
categories: String::new(), categories: String::new(),
reminder: ReminderType::default(), alarms: Vec::new(),
recurrence: RecurrenceType::default(), recurrence: RecurrenceType::default(),
recurrence_interval: 1, recurrence_interval: 1,
recurrence_until: None, recurrence_until: None,

View File

@@ -1,5 +1,4 @@
use crate::models::ical::VEvent; use crate::models::ical::VEvent;
use chrono::{DateTime, Utc};
use yew::prelude::*; use yew::prelude::*;
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
@@ -213,7 +212,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
} }
} }
fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String { fn format_datetime(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
if all_day { if all_day {
dt.format("%B %d, %Y").to_string() dt.format("%B %d, %Y").to_string()
} else { } else {
@@ -221,7 +220,7 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
} }
} }
fn format_datetime_end(dt: &DateTime<Utc>, all_day: bool) -> String { fn format_datetime_end(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
if all_day { if all_day {
// For all-day events, subtract one day from end date for display // For all-day events, subtract one day from end date for display
// RFC-5545 uses exclusive end dates, but users expect inclusive display // RFC-5545 uses exclusive end dates, but users expect inclusive display

View File

@@ -23,6 +23,9 @@ pub fn Login(props: &LoginProps) -> Html {
// Remember checkboxes state - default to checked // Remember checkboxes state - default to checked
let remember_server = use_state(|| true); let remember_server = use_state(|| true);
let remember_username = use_state(|| true); let remember_username = use_state(|| true);
// Password visibility toggle
let show_password = use_state(|| false);
let server_url_ref = use_node_ref(); let server_url_ref = use_node_ref();
let username_ref = use_node_ref(); let username_ref = use_node_ref();
@@ -30,17 +33,31 @@ pub fn Login(props: &LoginProps) -> Html {
let on_server_url_change = { let on_server_url_change = {
let server_url = server_url.clone(); let server_url = server_url.clone();
let remember_server = remember_server.clone();
Callback::from(move |e: Event| { Callback::from(move |e: Event| {
let target = e.target_unchecked_into::<HtmlInputElement>(); let target = e.target_unchecked_into::<HtmlInputElement>();
server_url.set(target.value()); let new_value = target.value();
server_url.set(new_value.clone());
// Save to localStorage immediately if remember is checked
if *remember_server {
let _ = LocalStorage::set("remembered_server_url", new_value);
}
}) })
}; };
let on_username_change = { let on_username_change = {
let username = username.clone(); let username = username.clone();
let remember_username = remember_username.clone();
Callback::from(move |e: Event| { Callback::from(move |e: Event| {
let target = e.target_unchecked_into::<HtmlInputElement>(); let target = e.target_unchecked_into::<HtmlInputElement>();
username.set(target.value()); let new_value = target.value();
username.set(new_value.clone());
// Save to localStorage immediately if remember is checked
if *remember_username {
let _ = LocalStorage::set("remembered_username", new_value);
}
}) })
}; };
@@ -83,6 +100,13 @@ pub fn Login(props: &LoginProps) -> Html {
} }
}) })
}; };
let on_toggle_password_visibility = {
let show_password = show_password.clone();
Callback::from(move |_| {
show_password.set(!*show_password);
})
};
let on_submit = { let on_submit = {
let server_url = server_url.clone(); let server_url = server_url.clone();
@@ -90,6 +114,8 @@ pub fn Login(props: &LoginProps) -> Html {
let password = password.clone(); let password = password.clone();
let error_message = error_message.clone(); let error_message = error_message.clone();
let is_loading = is_loading.clone(); let is_loading = is_loading.clone();
let remember_server = remember_server.clone();
let remember_username = remember_username.clone();
let on_login = props.on_login.clone(); let on_login = props.on_login.clone();
Callback::from(move |e: SubmitEvent| { Callback::from(move |e: SubmitEvent| {
@@ -100,6 +126,8 @@ pub fn Login(props: &LoginProps) -> Html {
let password = (*password).clone(); let password = (*password).clone();
let error_message = error_message.clone(); let error_message = error_message.clone();
let is_loading = is_loading.clone(); let is_loading = is_loading.clone();
let remember_server_value = *remember_server;
let remember_username_value = *remember_username;
let on_login = on_login.clone(); let on_login = on_login.clone();
// Basic client-side validation // Basic client-side validation
@@ -140,11 +168,23 @@ pub fn Login(props: &LoginProps) -> Html {
let _ = LocalStorage::set("user_preferences", &prefs_json); let _ = LocalStorage::set("user_preferences", &prefs_json);
} }
// Save server URL and username to LocalStorage if remember checkboxes are checked
if remember_server_value {
let _ = LocalStorage::set("remembered_server_url", server_url.clone());
}
if remember_username_value {
let _ = LocalStorage::set("remembered_username", username.clone());
}
is_loading.set(false); is_loading.set(false);
on_login.emit(token); on_login.emit(token);
} }
Err(err) => { Err(err) => {
web_sys::console::log_1(&format!("❌ Login failed: {}", err).into()); web_sys::console::log_1(&format!("❌ Login failed: {}", err).into());
// Clear any existing invalid tokens
let _ = LocalStorage::delete("auth_token");
let _ = LocalStorage::delete("session_token");
let _ = LocalStorage::delete("caldav_credentials");
error_message.set(Some(err)); error_message.set(Some(err));
is_loading.set(false); is_loading.set(false);
} }
@@ -160,59 +200,79 @@ pub fn Login(props: &LoginProps) -> Html {
<form onsubmit={on_submit}> <form onsubmit={on_submit}>
<div class="form-group"> <div class="form-group">
<label for="server_url">{"CalDAV Server URL"}</label> <label for="server_url">{"CalDAV Server URL"}</label>
<input <div class="input-with-checkbox">
ref={server_url_ref}
type="text"
id="server_url"
placeholder="https://your-caldav-server.com/dav/"
value={(*server_url).clone()}
onchange={on_server_url_change}
disabled={*is_loading}
/>
<div class="remember-checkbox">
<input <input
type="checkbox" ref={server_url_ref}
id="remember_server" type="text"
checked={*remember_server} id="server_url"
onchange={on_remember_server_change} placeholder="https://your-caldav-server.com/dav/"
value={(*server_url).clone()}
onchange={on_server_url_change}
disabled={*is_loading}
tabindex="1"
/> />
<label for="remember_server">{"Remember server"}</label> <div class="remember-checkbox">
<label for="remember_server">{"Remember"}</label>
<input
type="checkbox"
id="remember_server"
checked={*remember_server}
onchange={on_remember_server_change}
tabindex="4"
/>
</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="username">{"Username"}</label> <label for="username">{"Username"}</label>
<input <div class="input-with-checkbox">
ref={username_ref}
type="text"
id="username"
placeholder="Enter your username"
value={(*username).clone()}
onchange={on_username_change}
disabled={*is_loading}
/>
<div class="remember-checkbox">
<input <input
type="checkbox" ref={username_ref}
id="remember_username" type="text"
checked={*remember_username} id="username"
onchange={on_remember_username_change} placeholder="Enter your username"
value={(*username).clone()}
onchange={on_username_change}
disabled={*is_loading}
tabindex="2"
/> />
<label for="remember_username">{"Remember username"}</label> <div class="remember-checkbox">
<label for="remember_username">{"Remember"}</label>
<input
type="checkbox"
id="remember_username"
checked={*remember_username}
onchange={on_remember_username_change}
tabindex="5"
/>
</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">{"Password"}</label> <label for="password">{"Password"}</label>
<input <div class="password-input-container">
ref={password_ref} <input
type="password" ref={password_ref}
id="password" type={if *show_password { "text" } else { "password" }}
placeholder="Enter your password" id="password"
value={(*password).clone()} placeholder="Enter your password"
onchange={on_password_change} value={(*password).clone()}
disabled={*is_loading} onchange={on_password_change}
/> disabled={*is_loading}
tabindex="3"
/>
<button
type="button"
class="password-toggle-btn"
onclick={on_toggle_password_visibility}
tabindex="6"
title={if *show_password { "Hide password" } else { "Show password" }}
>
<i class={if *show_password { "fas fa-eye-slash" } else { "fas fa-eye" }}></i>
</button>
</div>
</div> </div>
{ {

View File

@@ -0,0 +1,96 @@
use yew::prelude::*;
use web_sys::window;
use wasm_bindgen::JsCast;
#[derive(Properties, PartialEq)]
pub struct MobileWarningModalProps {
pub is_open: bool,
pub on_close: Callback<()>,
}
#[function_component(MobileWarningModal)]
pub fn mobile_warning_modal(props: &MobileWarningModalProps) -> Html {
let on_backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
if let Some(target) = e.target() {
let element = target.dyn_into::<web_sys::Element>().unwrap();
if element.class_list().contains("modal-overlay") {
on_close.emit(());
}
}
})
};
if !props.is_open {
return html! {};
}
html! {
<div class="modal-overlay mobile-warning-overlay" onclick={on_backdrop_click}>
<div class="modal-content mobile-warning-modal">
<div class="modal-header">
<h2>{"Desktop Application"}</h2>
<button class="modal-close" onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}>{"×"}</button>
</div>
<div class="modal-body">
<div class="mobile-warning-icon">
{"💻"}
</div>
<p class="mobile-warning-title">
{"This calendar application is designed for desktop usage"}
</p>
<p class="mobile-warning-description">
{"For the best mobile calendar experience, we recommend using dedicated CalDAV apps available on your device's app store:"}
</p>
<ul class="mobile-warning-apps">
<li>
<strong>{"iOS:"}</strong>
{" Calendar (built-in), Calendars 5, Fantastical"}
</li>
<li>
<strong>{"Android:"}</strong>
{" Google Calendar, DAVx5, CalDAV Sync"}
</li>
</ul>
<p class="mobile-warning-note">
{"These apps can sync with the same CalDAV server you're using here."}
</p>
</div>
<div class="modal-footer">
<button class="continue-anyway-button" onclick={
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
}>
{"Continue Anyway"}
</button>
</div>
</div>
</div>
}
}
// Helper function to detect mobile devices
pub fn is_mobile_device() -> bool {
if let Some(window) = window() {
let navigator = window.navigator();
let user_agent = navigator.user_agent().unwrap_or_default();
let user_agent = user_agent.to_lowercase();
// Check for mobile device indicators
user_agent.contains("mobile")
|| user_agent.contains("android")
|| user_agent.contains("iphone")
|| user_agent.contains("ipad")
|| user_agent.contains("ipod")
|| user_agent.contains("blackberry")
|| user_agent.contains("webos")
|| user_agent.contains("opera mini")
|| user_agent.contains("windows phone")
} else {
false
}
}

View File

@@ -1,7 +1,9 @@
pub mod calendar; pub mod calendar;
pub mod calendar_context_menu; pub mod calendar_context_menu;
pub mod calendar_management_modal;
pub mod calendar_header; pub mod calendar_header;
pub mod calendar_list_item; pub mod calendar_list_item;
pub mod color_editor_modal;
pub mod context_menu; pub mod context_menu;
pub mod create_calendar_modal; pub mod create_calendar_modal;
pub mod create_event_modal; pub mod create_event_modal;
@@ -10,7 +12,9 @@ pub mod event_form;
pub mod event_modal; pub mod event_modal;
pub mod external_calendar_modal; pub mod external_calendar_modal;
pub mod login; pub mod login;
pub mod mobile_warning_modal;
pub mod month_view; pub mod month_view;
pub mod print_preview_modal;
pub mod recurring_edit_modal; pub mod recurring_edit_modal;
pub mod route_handler; pub mod route_handler;
pub mod sidebar; pub mod sidebar;
@@ -18,18 +22,20 @@ pub mod week_view;
pub use calendar::Calendar; pub use calendar::Calendar;
pub use calendar_context_menu::CalendarContextMenu; pub use calendar_context_menu::CalendarContextMenu;
pub use calendar_management_modal::CalendarManagementModal;
pub use calendar_header::CalendarHeader; pub use calendar_header::CalendarHeader;
pub use calendar_list_item::CalendarListItem; pub use calendar_list_item::CalendarListItem;
pub use color_editor_modal::ColorEditorModal;
pub use context_menu::ContextMenu; pub use context_menu::ContextMenu;
pub use create_calendar_modal::CreateCalendarModal;
pub use create_event_modal::CreateEventModal; pub use create_event_modal::CreateEventModal;
// Re-export event form types for backwards compatibility // Re-export event form types for backwards compatibility
pub use event_form::EventCreationData; pub use event_form::EventCreationData;
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu}; pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
pub use event_modal::EventModal; pub use event_modal::EventModal;
pub use external_calendar_modal::ExternalCalendarModal;
pub use login::Login; pub use login::Login;
pub use mobile_warning_modal::MobileWarningModal;
pub use month_view::MonthView; pub use month_view::MonthView;
pub use print_preview_modal::PrintPreviewModal;
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal}; pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
pub use route_handler::RouteHandler; pub use route_handler::RouteHandler;
pub use sidebar::{Sidebar, Theme, ViewMode}; pub use sidebar::{Sidebar, Theme, ViewMode};

View File

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

View File

@@ -0,0 +1,377 @@
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)]
pub struct PrintPreviewModalProps {
pub on_close: Callback<()>,
pub view_mode: ViewMode,
pub current_date: NaiveDate,
pub selected_date: NaiveDate,
pub events: HashMap<NaiveDate, Vec<VEvent>>,
pub user_info: Option<UserInfo>,
pub external_calendars: Vec<ExternalCalendar>,
pub time_increment: u32,
pub today: NaiveDate,
}
#[function_component]
pub fn PrintPreviewModal(props: &PrintPreviewModalProps) -> Html {
let start_hour = use_state(|| 6u32);
let end_hour = use_state(|| 22u32);
let zoom_level = use_state(|| 0.4f64); // Default 40% zoom
let close_modal = {
let on_close = props.on_close.clone();
Callback::from(move |_| {
on_close.emit(());
})
};
let backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
if e.target() == e.current_target() {
on_close.emit(());
}
})
};
let on_start_hour_change = {
let start_hour = start_hour.clone();
let end_hour = end_hour.clone();
Callback::from(move |e: Event| {
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
if let Some(select) = target {
if let Ok(hour) = select.value().parse::<u32>() {
if hour < *end_hour {
start_hour.set(hour);
}
}
}
})
};
let on_end_hour_change = {
let start_hour = start_hour.clone();
let end_hour = end_hour.clone();
Callback::from(move |e: Event| {
let target = e.target_dyn_into::<web_sys::HtmlSelectElement>();
if let Some(select) = target {
if let Ok(hour) = select.value().parse::<u32>() {
if hour > *start_hour && hour <= 24 {
end_hour.set(hour);
}
}
}
})
};
let format_hour = |hour: u32| -> String {
if hour == 0 {
"12 AM".to_string()
} else if hour < 12 {
format!("{} AM", hour)
} else if hour == 12 {
"12 PM".to_string()
} else {
format!("{} PM", hour - 12)
}
};
// Calculate dynamic base unit for print preview
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 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 = 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;
(base_unit, pixels_per_hour, available_height)
};
// Calculate print dimensions for the current hour range
let (base_unit, pixels_per_hour, _available_height) = calculate_print_dimensions(*start_hour, *end_hour, props.time_increment);
// Effect to update print copy whenever modal renders or content changes
{
let start_hour = *start_hour;
let end_hour = *end_hour;
let time_increment = props.time_increment;
let original_base_unit = base_unit;
use_effect(move || {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
// Set CSS variables on document root
if let Some(document_element) = document.document_element() {
if let Some(html_element) = document_element.dyn_ref::<web_sys::HtmlElement>() {
let style = html_element.style();
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
}
}
// Copy content from print-preview-content to the hidden print-preview-copy div
let copy_content = move || {
if let Some(preview_content) = document.query_selector(".print-preview-content").ok().flatten() {
if let Some(print_copy) = document.get_element_by_id("print-preview-copy") {
// Clone the preview content
if let Some(content_clone) = preview_content.clone_node_with_deep(true).ok() {
// Clear the print copy div and add the cloned content
print_copy.set_inner_html("");
let _ = print_copy.append_child(&content_clone);
// Get the actual rendered height of the print copy div and recalculate base-unit
if let Some(print_copy_html) = print_copy.dyn_ref::<web_sys::HtmlElement>() {
// Temporarily make visible to measure height, then hide again
let original_display = print_copy_html.style().get_property_value("display").unwrap_or_default();
let _ = print_copy_html.style().set_property("display", "block");
let _ = print_copy_html.style().set_property("visibility", "hidden");
let _ = print_copy_html.style().set_property("position", "absolute");
let _ = print_copy_html.style().set_property("top", "-9999px");
// Now measure the height
let actual_height = print_copy_html.client_height() as f64;
// Restore original display
let _ = print_copy_html.style().set_property("display", &original_display);
let _ = print_copy_html.style().remove_property("visibility");
let _ = print_copy_html.style().remove_property("position");
let _ = print_copy_html.style().remove_property("top");
// 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 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 = 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;
// Set CSS variables with recalculated values
let style = print_copy_html.style();
let _ = style.set_property("--print-base-unit", &format!("{:.2}", actual_base_unit));
let _ = style.set_property("--print-pixels-per-hour", &format!("{:.2}", actual_pixels_per_hour));
let _ = style.set_property("--print-start-hour", &start_hour.to_string());
let _ = style.set_property("--print-end-hour", &end_hour.to_string());
// Copy data attributes
let _ = print_copy.set_attribute("data-start-hour", &start_hour.to_string());
let _ = print_copy.set_attribute("data-end-hour", &end_hour.to_string());
// Recalculate event positions using the new base-unit
let events = print_copy.query_selector_all(".week-event").unwrap();
let scale_factor = actual_base_unit / original_base_unit;
for i in 0..events.length() {
if let Some(event_element) = events.get(i) {
if let Some(event_html) = event_element.dyn_ref::<web_sys::HtmlElement>() {
let event_style = event_html.style();
// Get current positioning values and recalculate
if let Ok(current_top) = event_style.get_property_value("top") {
if current_top.ends_with("px") {
if let Ok(top_px) = current_top[..current_top.len()-2].parse::<f64>() {
let new_top = top_px * scale_factor;
let _ = event_style.set_property("top", &format!("{:.2}px", new_top));
}
}
}
if let Ok(current_height) = event_style.get_property_value("height") {
if current_height.ends_with("px") {
if let Ok(height_px) = current_height[..current_height.len()-2].parse::<f64>() {
let new_height = height_px * scale_factor;
let _ = event_style.set_property("height", &format!("{:.2}px", new_height));
}
}
}
}
}
}
web_sys::console::log_1(&format!("Height: {:.2}, Original base-unit: {:.2}, New base-unit: {:.2}, Scale factor: {:.2}",
actual_height, original_base_unit, actual_base_unit, scale_factor).into());
}
}
}
}
};
// Copy content immediately
copy_content();
// Also set up a small delay to catch any async rendering
let copy_callback = Closure::wrap(Box::new(copy_content) as Box<dyn FnMut()>);
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
copy_callback.as_ref().unchecked_ref(),
100
);
copy_callback.forget();
}
}
|| ()
});
}
let on_print = {
Callback::from(move |_: MouseEvent| {
if let Some(window) = web_sys::window() {
// Print copy is already updated by the use_effect, just trigger print
let _ = window.print();
}
})
};
html! {
<div class="modal-backdrop print-preview-modal-backdrop" onclick={backdrop_click}>
<div class="modal-content print-preview-modal">
<div class="modal-header">
<h3>{"Print Preview"}</h3>
<button class="modal-close" onclick={close_modal.clone()}>{"×"}</button>
</div>
<div class="modal-body print-preview-body">
<div class="print-preview-controls">
{
if props.view_mode == ViewMode::Week {
html! {
<>
<div class="control-group">
<label for="start-hour">{"Start Hour:"}</label>
<select id="start-hour" onchange={on_start_hour_change}>
{
(0..24).map(|hour| {
html! {
<option value={hour.to_string()} selected={hour == *start_hour}>
{format_hour(hour)}
</option>
}
}).collect::<Html>()
}
</select>
</div>
<div class="control-group">
<label for="end-hour">{"End Hour:"}</label>
<select id="end-hour" onchange={on_end_hour_change}>
{
(1..=24).map(|hour| {
html! {
<option value={hour.to_string()} selected={hour == *end_hour}>
{if hour == 24 { "12 AM".to_string() } else { format_hour(hour) }}
</option>
}
}).collect::<Html>()
}
</select>
</div>
<div class="hour-range-info">
{format!("Will print from {} to {}",
format_hour(*start_hour),
if *end_hour == 24 { "12 AM".to_string() } else { format_hour(*end_hour) }
)}
</div>
</>
}
} else {
html! {
<div class="month-info">
{"Will print entire month view"}
</div>
}
}
}
<div class="zoom-display-info">
<label>{"Zoom: "}</label>
<span>{format!("{}%", (*zoom_level * 100.0) as i32)}</span>
<span class="zoom-hint">{"(scroll to zoom)"}</span>
</div>
<div class="preview-actions">
<button class="btn-primary" onclick={on_print}>{"Print"}</button>
<button class="btn-secondary" onclick={close_modal}>{"Cancel"}</button>
</div>
</div>
<div class="print-preview-display" onwheel={{
let zoom_level = zoom_level.clone();
Callback::from(move |e: WheelEvent| {
e.prevent_default(); // Prevent page scroll
let delta_y = e.delta_y();
let zoom_change = if delta_y < 0.0 { 1.1 } else { 1.0 / 1.1 };
let new_zoom = (*zoom_level * zoom_change).clamp(0.2, 1.5);
zoom_level.set(new_zoom);
})
}}>
<div class="print-preview-paper"
data-start-hour={start_hour.to_string()}
data-end-hour={end_hour.to_string()}
style={format!(
"--print-start-hour: {}; --print-end-hour: {}; --print-base-unit: {:.2}; --print-pixels-per-hour: {:.2}; transform: scale({}); transform-origin: top center;",
*start_hour, *end_hour, base_unit, pixels_per_hour, *zoom_level
)}>
<div class="print-preview-content">
<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>
</div>
</div>
</div>
}
}

View File

@@ -38,7 +38,7 @@ pub struct RouteHandlerProps {
chrono::NaiveDateTime, chrono::NaiveDateTime,
chrono::NaiveDateTime, chrono::NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)>, )>,
@@ -136,7 +136,7 @@ pub struct CalendarViewProps {
chrono::NaiveDateTime, chrono::NaiveDateTime,
chrono::NaiveDateTime, chrono::NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)>, )>,

View File

@@ -2,17 +2,7 @@ use crate::components::CalendarListItem;
use crate::services::calendar_service::{UserInfo, ExternalCalendar}; use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use web_sys::HtmlSelectElement; use web_sys::HtmlSelectElement;
use yew::prelude::*; use yew::prelude::*;
use yew_router::prelude::*;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Home,
#[at("/login")]
Login,
#[at("/calendar")]
Calendar,
}
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub enum ViewMode { pub enum ViewMode {
@@ -30,12 +20,17 @@ pub enum Theme {
Dark, Dark,
Rose, Rose,
Mint, Mint,
Midnight,
Charcoal,
Nord,
Dracula,
} }
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
pub enum Style { pub enum Style {
Default, Default,
Google, Google,
Apple,
} }
impl Theme { impl Theme {
@@ -49,6 +44,10 @@ impl Theme {
Theme::Dark => "dark", Theme::Dark => "dark",
Theme::Rose => "rose", Theme::Rose => "rose",
Theme::Mint => "mint", Theme::Mint => "mint",
Theme::Midnight => "midnight",
Theme::Charcoal => "charcoal",
Theme::Nord => "nord",
Theme::Dracula => "dracula",
} }
} }
@@ -61,6 +60,10 @@ impl Theme {
"dark" => Theme::Dark, "dark" => Theme::Dark,
"rose" => Theme::Rose, "rose" => Theme::Rose,
"mint" => Theme::Mint, "mint" => Theme::Mint,
"midnight" => Theme::Midnight,
"charcoal" => Theme::Charcoal,
"nord" => Theme::Nord,
"dracula" => Theme::Dracula,
_ => Theme::Default, _ => Theme::Default,
} }
} }
@@ -71,12 +74,14 @@ impl Style {
match self { match self {
Style::Default => "default", Style::Default => "default",
Style::Google => "google", Style::Google => "google",
Style::Apple => "apple",
} }
} }
pub fn from_value(value: &str) -> Self { pub fn from_value(value: &str) -> Self {
match value { match value {
"google" => Style::Google, "google" => Style::Google,
"apple" => Style::Apple,
_ => Style::Default, _ => Style::Default,
} }
} }
@@ -86,6 +91,7 @@ impl Style {
match self { match self {
Style::Default => None, // No additional stylesheet needed - uses base styles.css Style::Default => None, // No additional stylesheet needed - uses base styles.css
Style::Google => Some("google.css"), // Trunk copies to root level Style::Google => Some("google.css"), // Trunk copies to root level
Style::Apple => Some("apple.css"), // Trunk copies to root level
} }
} }
} }
@@ -100,8 +106,7 @@ impl Default for ViewMode {
pub struct SidebarProps { pub struct SidebarProps {
pub user_info: Option<UserInfo>, pub user_info: Option<UserInfo>,
pub on_logout: Callback<()>, pub on_logout: Callback<()>,
pub on_create_calendar: Callback<()>, pub on_add_calendar: Callback<()>,
pub on_create_external_calendar: Callback<()>,
pub external_calendars: Vec<ExternalCalendar>, pub external_calendars: Vec<ExternalCalendar>,
pub on_external_calendar_toggle: Callback<i32>, pub on_external_calendar_toggle: Callback<i32>,
pub on_external_calendar_delete: Callback<i32>, pub on_external_calendar_delete: Callback<i32>,
@@ -110,6 +115,8 @@ pub struct SidebarProps {
pub on_color_change: Callback<(String, String)>, pub on_color_change: Callback<(String, String)>,
pub on_color_picker_toggle: Callback<String>, pub on_color_picker_toggle: Callback<String>,
pub available_colors: Vec<String>, pub available_colors: Vec<String>,
pub on_color_editor_open: Callback<(usize, String)>, // (index, current_color)
pub refreshing_calendar_id: Option<i32>,
pub on_calendar_context_menu: Callback<(MouseEvent, String)>, pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
pub on_calendar_visibility_toggle: Callback<String>, pub on_calendar_visibility_toggle: Callback<String>,
pub current_view: ViewMode, pub current_view: ViewMode,
@@ -203,9 +210,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
} }
} }
</div> </div>
<nav class="sidebar-nav">
<Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>>
</nav>
{ {
if let Some(ref info) = props.user_info { if let Some(ref info) = props.user_info {
if !info.calendars.is_empty() { if !info.calendars.is_empty() {
@@ -222,6 +226,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
on_color_change={props.on_color_change.clone()} on_color_change={props.on_color_change.clone()}
on_color_picker_toggle={props.on_color_picker_toggle.clone()} on_color_picker_toggle={props.on_color_picker_toggle.clone()}
available_colors={props.available_colors.clone()} available_colors={props.available_colors.clone()}
on_color_editor_open={props.on_color_editor_open.clone()}
on_context_menu={props.on_calendar_context_menu.clone()} on_context_menu={props.on_calendar_context_menu.clone()}
on_visibility_toggle={props.on_calendar_visibility_toggle.clone()} on_visibility_toggle={props.on_calendar_visibility_toggle.clone()}
/> />
@@ -257,9 +262,13 @@ pub fn sidebar(props: &SidebarProps) -> Html {
}; };
html! { html! {
<li class="external-calendar-item" style="position: relative;"> <li class="external-calendar-item">
<div <div
class="external-calendar-info" class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
"external-calendar-info color-picker-active"
} else {
"external-calendar-info"
}}
oncontextmenu={{ oncontextmenu={{
let on_context_menu = on_external_calendar_context_menu.clone(); let on_context_menu = on_external_calendar_context_menu.clone();
let cal_id = cal.id; let cal_id = cal.id;
@@ -276,7 +285,60 @@ pub fn sidebar(props: &SidebarProps) -> Html {
<span <span
class="external-calendar-color" class="external-calendar-color"
style={format!("background-color: {}", cal.color)} style={format!("background-color: {}", cal.color)}
/> onclick={{
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
let external_id = format!("external_{}", cal.id);
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
on_color_picker_toggle.emit(external_id.clone());
})
}}
>
{
if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
html! {
<div class="color-picker-dropdown">
{
props.available_colors.iter().map(|color| {
let color_str = color.clone();
let external_id = format!("external_{}", cal.id);
let on_color_change = props.on_color_change.clone();
let on_color_select = Callback::from(move |_: MouseEvent| {
on_color_change.emit((external_id.clone(), color_str.clone()));
});
let on_color_right_click = {
let on_color_editor_open = props.on_color_editor_open.clone();
let color_index = props.available_colors.iter().position(|c| c == color).unwrap_or(0);
let color_str = color.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
e.stop_propagation();
on_color_editor_open.emit((color_index, color_str.clone()));
})
};
let is_selected = cal.color == *color;
html! {
<div
key={color.clone()}
class={if is_selected { "color-option selected" } else { "color-option" }}
style={format!("background-color: {}", color)}
onclick={on_color_select}
oncontextmenu={on_color_right_click}
/>
}
}).collect::<Html>()
}
</div>
}
} else {
html! {}
}
}
</span>
<span class="external-calendar-name">{&cal.name}</span> <span class="external-calendar-name">{&cal.name}</span>
<div class="external-calendar-actions"> <div class="external-calendar-actions">
{ {
@@ -304,8 +366,15 @@ pub fn sidebar(props: &SidebarProps) -> Html {
on_refresh.emit(cal_id); on_refresh.emit(cal_id);
}) })
}} }}
disabled={props.refreshing_calendar_id == Some(cal.id)}
> >
{"🔄"} {
if props.refreshing_calendar_id == Some(cal.id) {
html! { <i class="fas fa-spinner fa-spin"></i> }
} else {
html! { <i class="fas fa-sync-alt"></i> }
}
}
</button> </button>
</div> </div>
</div> </div>
@@ -352,12 +421,8 @@ pub fn sidebar(props: &SidebarProps) -> Html {
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button"> <button onclick={props.on_add_calendar.reform(|_| ())} class="add-calendar-button">
{"+ Create Calendar"} {"+ Add Calendar"}
</button>
<button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button">
{"+ Add External Calendar"}
</button> </button>
<div class="view-selector"> <div class="view-selector">
@@ -377,6 +442,10 @@ pub fn sidebar(props: &SidebarProps) -> Html {
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option> <option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option>
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option> <option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option>
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option> <option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
<option value="midnight" selected={matches!(props.current_theme, Theme::Midnight)}>{"Midnight"}</option>
<option value="charcoal" selected={matches!(props.current_theme, Theme::Charcoal)}>{"Charcoal"}</option>
<option value="nord" selected={matches!(props.current_theme, Theme::Nord)}>{"Nord"}</option>
<option value="dracula" selected={matches!(props.current_theme, Theme::Dracula)}>{"Dracula"}</option>
</select> </select>
</div> </div>
@@ -384,6 +453,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
<select class="style-selector-dropdown" onchange={on_style_change}> <select class="style-selector-dropdown" onchange={on_style_change}>
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option> <option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option> <option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>
<option value="apple" selected={matches!(props.current_style, Style::Apple)}>{"Apple Calendar"}</option>
</select> </select>
</div> </div>

View File

@@ -33,7 +33,7 @@ pub struct WeekViewProps {
NaiveDateTime, NaiveDateTime,
NaiveDateTime, NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)>, )>,
@@ -42,6 +42,12 @@ pub struct WeekViewProps {
pub context_menus_open: bool, pub context_menus_open: bool,
#[prop_or_default] #[prop_or_default]
pub time_increment: u32, pub time_increment: u32,
#[prop_or_default]
pub print_mode: bool,
#[prop_or_default]
pub print_pixels_per_hour: Option<f64>,
#[prop_or_default]
pub print_start_hour: Option<u32>,
} }
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
@@ -81,6 +87,31 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>); let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
// Current time state for time indicator
let current_time = use_state(|| Local::now());
// Update current time every 5 seconds
{
let current_time = current_time.clone();
use_effect_with((), move |_| {
let interval = gloo_timers::callback::Interval::new(5_000, move || {
current_time.set(Local::now());
});
// Return the interval to keep it alive
move || drop(interval)
});
}
// Helper function to calculate current time indicator position
let calculate_current_time_position = |time_increment: u32| -> f64 {
let now = current_time.time();
let hour = now.hour() as f64;
let minute = now.minute() as f64;
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
(hour + minute / 60.0) * pixels_per_hour
};
// Helper function to get calendar color for an event // Helper function to get calendar color for an event
let get_event_color = |event: &VEvent| -> String { let get_event_color = |event: &VEvent| -> String {
if let Some(calendar_path) = &event.calendar_path { if let Some(calendar_path) = &event.calendar_path {
@@ -254,18 +285,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Calculate the day before this occurrence for UNTIL clause // Calculate the day before this occurrence for UNTIL clause
let until_date = let until_date =
edit.event.dtstart.date_naive() - chrono::Duration::days(1); edit.event.dtstart.date() - chrono::Duration::days(1);
let until_datetime = until_date let until_datetime = until_date
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap()); .and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
let until_utc = let until_naive = until_datetime; // Use local time directly
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
until_datetime,
chrono::Utc,
);
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}", web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
until_utc.format("%Y-%m-%d %H:%M:%S UTC"), until_naive.format("%Y-%m-%d %H:%M:%S"),
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into()); edit.event.dtstart.format("%Y-%m-%d %H:%M:%S")).into());
// Critical: Use the dragged times (new_start/new_end) not the original series times // Critical: Use the dragged times (new_start/new_end) not the original series times
// This ensures the new series reflects the user's drag operation // This ensures the new series reflects the user's drag operation
@@ -286,7 +313,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
new_start, // Dragged start time for new series new_start, // Dragged start time for new series
new_end, // Dragged end time for new series new_end, // Dragged end time for new series
true, // preserve_rrule = true true, // preserve_rrule = true
Some(until_utc), // UNTIL date for original series Some(until_naive), // UNTIL date for original series
Some("this_and_future".to_string()), // Update scope Some("this_and_future".to_string()), // Update scope
Some(occurrence_date), // Date of occurrence being modified Some(occurrence_date), // Date of occurrence being modified
)); ));
@@ -321,6 +348,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
}) })
}; };
html! { html! {
<div class="week-view-container"> <div class="week-view-container">
// Header with weekday names and dates // Header with weekday names and dates
@@ -413,13 +441,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Time labels // Time labels
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}> <div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{ {
time_labels.iter().map(|time| { time_labels.iter().enumerate().map(|(hour, time)| {
let is_quarter_mode = props.time_increment == 15; let is_quarter_mode = props.time_increment == 15;
html! { html! {
<div class={classes!( <div class={classes!(
"time-label", "time-label",
if is_quarter_mode { Some("quarter-mode") } else { None } if is_quarter_mode { Some("quarter-mode") } else { None }
)}> )} data-hour={hour.to_string()}>
{time} {time}
</div> </div>
} }
@@ -586,10 +614,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Keep the original end time // Keep the original end time
let original_end = if let Some(end) = event.dtend { let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local() end } else {
} else {
// If no end time, use start time + 1 hour as default // If no end time, use start time + 1 hour as default
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1) event.dtstart + chrono::Duration::hours(1)
}; };
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time); let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
@@ -620,8 +647,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Calculate new end time based on drag position // Calculate new end time based on drag position
let new_end_time = pixels_to_time(current_drag.current_y, time_increment); let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
// Keep the original start time // Keep the original start time (already local)
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); let original_start = event.dtstart;
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time); let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
@@ -676,10 +703,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
> >
// Time slot backgrounds - 24 hour slots to represent full day // Time slot backgrounds - 24 hour slots to represent full day
{ {
(0..24).map(|_hour| { (0..24).map(|hour| {
let slots_per_hour = 60 / props.time_increment; let slots_per_hour = 60 / props.time_increment;
html! { html! {
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}> <div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })} data-hour={hour.to_string()}>
{ {
(0..slots_per_hour).map(|_slot| { (0..slots_per_hour).map(|_slot| {
let slot_class = if props.time_increment == 15 { let slot_class = if props.time_increment == 15 {
@@ -701,7 +728,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div class="events-container"> <div class="events-container">
{ {
day_events.iter().enumerate().filter_map(|(event_idx, event)| { day_events.iter().enumerate().filter_map(|(event_idx, event)| {
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment); let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
// Skip all-day events (they're rendered in the header) // Skip all-day events (they're rendered in the header)
if is_all_day { if is_all_day {
@@ -730,6 +757,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let event_for_drag = event.clone(); let event_for_drag = event.clone();
let date_for_drag = *date; let date_for_drag = *date;
let time_increment = props.time_increment; let time_increment = props.time_increment;
let print_pixels_per_hour = props.print_pixels_per_hour;
let print_start_hour = props.print_start_hour;
Callback::from(move |e: MouseEvent| { Callback::from(move |e: MouseEvent| {
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
@@ -743,7 +772,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 }; let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
// Get event's current position in day column coordinates // Get event's current position in day column coordinates
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment); let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment, print_pixels_per_hour, print_start_hour);
let event_start_pixels = event_start_pixels as f64; let event_start_pixels = event_start_pixels as f64;
// Convert click position to day column coordinates // Convert click position to day column coordinates
@@ -794,9 +823,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let time_display = if event.all_day { let time_display = if event.all_day {
"All Day".to_string() "All Day".to_string()
} else { } else {
let local_start = event.dtstart.with_timezone(&Local); let local_start = event.dtstart;
if let Some(end) = event.dtend { if let Some(end) = event.dtend {
let local_end = end.with_timezone(&Local); let local_end = end;
// Check if both times are in same AM/PM period to avoid redundancy // Check if both times are in same AM/PM period to avoid redundancy
let start_is_am = local_start.hour() < 12; let start_is_am = local_start.hour() < 12;
@@ -939,7 +968,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Event content // Event content
<div class="event-content"> <div class="event-content">
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div> <div class="event-title-row">
<span class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</span>
if !event.alarms.is_empty() {
<i class="fas fa-bell event-reminder-icon" title="Has reminders"></i>
}
</div>
{if !is_all_day && duration_pixels > 30.0 { {if !is_all_day && duration_pixels > 30.0 {
html! { <div class="event-time">{time_display}</div> } html! { <div class="event-time">{time_display}</div> }
} else { } else {
@@ -1023,14 +1057,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Show the event being resized from the start // Show the event being resized from the start
let new_start_time = pixels_to_time(drag.current_y, props.time_increment); let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
let original_end = if let Some(end) = event.dtend { let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local() end } else {
} else { event.dtstart + chrono::Duration::hours(1)
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
}; };
// Calculate positions for the preview // Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment); let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local()); let original_duration = original_end.signed_duration_since(event.dtstart);
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32); let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
let new_start_pixels = drag.current_y; let new_start_pixels = drag.current_y;
@@ -1056,10 +1089,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
DragType::ResizeEventEnd(event) => { DragType::ResizeEventEnd(event) => {
// Show the event being resized from the end // Show the event being resized from the end
let new_end_time = pixels_to_time(drag.current_y, props.time_increment); let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); let original_start = event.dtstart;
// Calculate positions for the preview // Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment); let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
let new_end_pixels = drag.current_y; let new_end_pixels = drag.current_y;
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0); let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
@@ -1089,6 +1122,29 @@ pub fn week_view(props: &WeekViewProps) -> Html {
html! {} html! {}
} }
} }
// Current time indicator - only show on today
{
if *date == props.today {
let current_time_position = calculate_current_time_position(props.time_increment);
let current_time_str = current_time.time().format("%I:%M %p").to_string();
html! {
<div class="current-time-indicator-container">
<div
class="current-time-indicator"
style={format!("top: {}px;", current_time_position)}
>
<div class="current-time-dot"></div>
<div class="current-time-line"></div>
<div class="current-time-label">{current_time_str}</div>
</div>
</div>
}
} else {
html! {}
}
}
</div> </div>
} }
}).collect::<Html>() }).collect::<Html>()
@@ -1170,18 +1226,15 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap()) NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
} }
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32) -> (f32, f32, bool) { fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
// Convert UTC times to local time for display // Events are already in local time
let local_start = event.dtstart.with_timezone(&Local); let local_start = event.dtstart;
let event_date = local_start.date_naive();
// Position events based on when they appear in local time, not their original date
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
// but should still display on Sunday's column since that's when the user sees it
let should_display_here = event_date == date ||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
if !should_display_here { // Events should display based on their local date, since we now store proper UTC times
// Convert the UTC stored time back to local time to determine display date
let event_date = local_start.date();
if event_date != date {
return (0.0, 0.0, false); // Event not on this date return (0.0, 0.0, false); // Event not on this date
} }
@@ -1190,32 +1243,44 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
} }
// Calculate start position in pixels from midnight // Calculate start position in pixels
let start_hour = local_start.hour() as f32; let start_hour = local_start.hour() as f32;
let start_minute = local_start.minute() as f32; let start_minute = local_start.minute() as f32;
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 }; let pixels_per_hour = if let Some(print_pph) = print_pixels_per_hour {
let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour; print_pph as f32 // Use the dynamic print mode calculation
} else {
if time_increment == 15 { 120.0 } else { 60.0 } // Default values
};
// In print mode, offset by the start hour to show relative position within visible range
let hour_offset = if let Some(print_start) = print_start_hour {
print_start as f32
} else {
0.0 // No offset for normal view (starts at midnight)
};
let start_pixels = ((start_hour + start_minute / 60.0) - hour_offset) * pixels_per_hour;
// Calculate duration and height // Calculate duration and height
let duration_pixels = if let Some(end) = event.dtend { let duration_pixels = if let Some(end) = event.dtend {
let local_end = end.with_timezone(&Local); let local_end = end;
let end_date = local_end.date_naive(); let end_date = local_end.date();
// Handle events that span multiple days by capping at midnight // Handle events that span multiple days by capping at midnight
if end_date > date { if end_date > date {
// Event continues past midnight, cap at 24:00 // Event continues past midnight, cap at end of visible range
let max_pixels = 24.0 * pixels_per_hour; let max_hour = if let Some(_print_start) = print_start_hour { 24.0 } else { 24.0 };
max_pixels - start_pixels let max_pixels = (max_hour - hour_offset) * pixels_per_hour;
(max_pixels - start_pixels).max(20.0)
} else { } else {
let end_hour = local_end.hour() as f32; let end_hour = local_end.hour() as f32;
let end_minute = local_end.minute() as f32; let end_minute = local_end.minute() as f32;
let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour; let end_pixels = ((end_hour + end_minute / 60.0) - hour_offset) * pixels_per_hour;
(end_pixels - start_pixels).max(20.0) // Minimum 20px height (end_pixels - start_pixels).max(20.0) // Minimum 20px height
} }
} else { } else {
pixels_per_hour // Default 1 hour if no end time pixels_per_hour // Default 1 hour if no end time
}; };
(start_pixels, duration_pixels, false) // is_all_day = false (start_pixels, duration_pixels, false) // is_all_day = false
} }
@@ -1226,16 +1291,16 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
return false; return false;
} }
let start1 = event1.dtstart.with_timezone(&Local).naive_local(); let start1 = event1.dtstart;
let end1 = if let Some(end) = event1.dtend { let end1 = if let Some(end) = event1.dtend {
end.with_timezone(&Local).naive_local() end
} else { } else {
start1 + chrono::Duration::hours(1) // Default 1 hour duration start1 + chrono::Duration::hours(1) // Default 1 hour duration
}; };
let start2 = event2.dtstart.with_timezone(&Local).naive_local(); let start2 = event2.dtstart;
let end2 = if let Some(end) = event2.dtend { let end2 = if let Some(end) = event2.dtend {
end.with_timezone(&Local).naive_local() end
} else { } else {
start2 + chrono::Duration::hours(1) // Default 1 hour duration start2 + chrono::Duration::hours(1) // Default 1 hour duration
}; };
@@ -1256,9 +1321,9 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
return None; return None;
} }
let (_, _, _) = calculate_event_position(event, date, time_increment); let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
let local_start = event.dtstart.with_timezone(&Local); let local_start = event.dtstart;
let event_date = local_start.date_naive(); let event_date = local_start.date();
if event_date == date || if event_date == date ||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) { (event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
Some((idx, event)) Some((idx, event))
@@ -1269,7 +1334,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
.collect(); .collect();
// Sort by start time // Sort by start time
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local()); day_events.sort_by_key(|(_, event)| event.dtstart);
// For each event, find all events it overlaps with // For each event, find all events it overlaps with
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns) let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
@@ -1294,7 +1359,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
} else { } else {
// This event overlaps - we need to calculate column layout // This event overlaps - we need to calculate column layout
// Sort the overlapping group by start time // Sort the overlapping group by start time
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart.with_timezone(&Local).naive_local()); overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart);
// Assign columns using a greedy algorithm // Assign columns using a greedy algorithm
let mut columns: Vec<Vec<usize>> = Vec::new(); let mut columns: Vec<Vec<usize>> = Vec::new();
@@ -1342,19 +1407,19 @@ fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool {
let start_date = if event.all_day { let start_date = if event.all_day {
// For all-day events, extract date directly from UTC without timezone conversion // For all-day events, extract date directly from UTC without timezone conversion
// since all-day events are stored at noon UTC to avoid timezone boundary issues // since all-day events are stored at noon UTC to avoid timezone boundary issues
event.dtstart.date_naive() event.dtstart.date()
} else { } else {
event.dtstart.with_timezone(&Local).date_naive() event.dtstart.date()
}; };
let end_date = if let Some(dtend) = event.dtend { let end_date = if let Some(dtend) = event.dtend {
if event.all_day { if event.all_day {
// For all-day events, dtend is set to the day after the last day (RFC 5545) // For all-day events, dtend is set to the day after the last day (RFC 5545)
// Extract date directly from UTC and subtract a day to get actual last day // Extract date directly from UTC and subtract a day to get actual last day
dtend.date_naive() - chrono::Duration::days(1) dtend.date() - chrono::Duration::days(1)
} else { } else {
// For timed events, use timezone conversion // For timed events, use timezone conversion
dtend.with_timezone(&Local).date_naive() dtend.date()
} }
} else { } else {
// Single day event // Single day event

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

@@ -1,4 +1,4 @@
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday}; use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
use gloo_storage::{LocalStorage, Storage}; use gloo_storage::{LocalStorage, Storage};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -6,8 +6,8 @@ use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response}; use web_sys::{Request, RequestInit, RequestMode, Response};
// Import RFC 5545 compliant VEvent from shared library // Import RFC 5545 compliant VEvent and VAlarm from shared library
use calendar_models::VEvent; use calendar_models::{VEvent, VAlarm};
// Create type alias for backward compatibility // Create type alias for backward compatibility
pub type CalendarEvent = VEvent; pub type CalendarEvent = VEvent;
@@ -37,6 +37,12 @@ pub struct UserInfo {
pub username: String, pub username: String,
pub server_url: String, pub server_url: String,
pub calendars: Vec<CalendarInfo>, pub calendars: Vec<CalendarInfo>,
#[serde(default = "default_timestamp")]
pub last_updated: u64,
}
fn default_timestamp() -> u64 {
0
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -241,6 +247,8 @@ impl CalendarService {
if resp.ok() { if resp.ok() {
let events: Vec<CalendarEvent> = serde_json::from_str(&text_string) let events: Vec<CalendarEvent> = serde_json::from_str(&text_string)
.map_err(|e| format!("JSON parsing failed: {}", e))?; .map_err(|e| format!("JSON parsing failed: {}", e))?;
Ok(events) Ok(events)
} else { } else {
Err(format!( Err(format!(
@@ -269,47 +277,69 @@ impl CalendarService {
grouped grouped
} }
/// Convert UTC events to local timezone for display
fn convert_utc_to_local(mut event: VEvent) -> VEvent {
// All-day events should not have timezone conversions applied
if event.all_day {
return event;
}
// Check if event times are in UTC (legacy events from before timezone migration)
let is_utc_event = event.dtstart_tzid.as_ref().map_or(true, |tz| tz == "UTC");
if is_utc_event {
// Get current timezone offset (convert from UTC to local)
let date = js_sys::Date::new_0();
let timezone_offset_minutes = date.get_timezone_offset() as i32;
// Convert start time from UTC to local
// getTimezoneOffset() returns minutes UTC is ahead of local time
// To convert UTC to local, we subtract the offset (add negative offset)
let local_start = event.dtstart + chrono::Duration::minutes(-timezone_offset_minutes as i64);
event.dtstart = local_start;
event.dtstart_tzid = None; // Clear UTC timezone indicator
// Convert end time if present
if let Some(end_utc) = event.dtend {
let local_end = end_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64);
event.dtend = Some(local_end);
event.dtend_tzid = None; // Clear UTC timezone indicator
}
// Convert created/modified times if present
if let Some(created_utc) = event.created {
event.created = Some(created_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64));
event.created_tzid = None;
}
if let Some(modified_utc) = event.last_modified {
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
}
/// Expand recurring events using VEvent (RFC 5545 compliant) /// Expand recurring events using VEvent (RFC 5545 compliant)
pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> { pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
let mut expanded_events = Vec::new(); let mut expanded_events = Vec::new();
let today = chrono::Utc::now().date_naive(); let today = chrono::Local::now().date_naive();
let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events) let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events)
let end_range = today + Duration::days(36500); // Show next 100 years let end_range = today + Duration::days(36500); // Show next 100 years
for event in events { for event in events {
// Convert UTC events to local time for proper display
let event = Self::convert_utc_to_local(event);
if let Some(ref rrule) = event.rrule { if let Some(ref rrule) = event.rrule {
web_sys::console::log_1(
&format!(
"📅 Processing recurring VEvent '{}' with RRULE: {}",
event.summary.as_deref().unwrap_or("Untitled"),
rrule
)
.into(),
);
// Log if event has exception dates
if !event.exdate.is_empty() {
web_sys::console::log_1(
&format!(
"📅 VEvent '{}' has {} exception dates: {:?}",
event.summary.as_deref().unwrap_or("Untitled"),
event.exdate.len(),
event.exdate
)
.into(),
);
}
// Generate occurrences for recurring events using VEvent // Generate occurrences for recurring events using VEvent
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range); let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
web_sys::console::log_1(
&format!(
"📅 Generated {} occurrences for VEvent '{}'",
occurrences.len(),
event.summary.as_deref().unwrap_or("Untitled")
)
.into(),
);
expanded_events.extend(occurrences); expanded_events.extend(occurrences);
} else { } else {
// Non-recurring event - add as-is // Non-recurring event - add as-is
@@ -331,7 +361,6 @@ impl CalendarService {
// Parse RRULE components // Parse RRULE components
let rrule_upper = rrule.to_uppercase(); let rrule_upper = rrule.to_uppercase();
web_sys::console::log_1(&format!("🔄 Parsing RRULE: {}", rrule_upper).into());
let components: HashMap<String, String> = rrule_upper let components: HashMap<String, String> = rrule_upper
.split(';') .split(';')
@@ -366,17 +395,18 @@ impl CalendarService {
// Get UNTIL date if specified // Get UNTIL date if specified
let until_date = components.get("UNTIL").and_then(|until_str| { let until_date = components.get("UNTIL").and_then(|until_str| {
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format // Parse UNTIL date in YYYYMMDDTHHMMSSZ format (treat as local time)
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str( if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(
until_str.trim_end_matches('Z'), until_str.trim_end_matches('Z'),
"%Y%m%dT%H%M%S", "%Y%m%dT%H%M%S",
) { ) {
Some(chrono::Utc.from_utc_datetime(&dt)) Some(dt)
} else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") { } else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") {
Some(dt.with_timezone(&chrono::Utc)) // Convert UTC to local (naive) time for consistency
Some(dt.naive_utc())
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") { } else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") {
// Handle date-only UNTIL // Handle date-only UNTIL
Some(chrono::Utc.from_utc_datetime(&date.and_hms_opt(23, 59, 59).unwrap())) Some(date.and_hms_opt(23, 59, 59).unwrap())
} else { } else {
web_sys::console::log_1( web_sys::console::log_1(
&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into(), &format!("⚠️ Failed to parse UNTIL date: {}", until_str).into(),
@@ -385,11 +415,10 @@ impl CalendarService {
} }
}); });
if let Some(until) = until_date { if let Some(_until) = until_date {
web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into());
} }
let start_date = base_event.dtstart.date_naive(); let start_date = base_event.dtstart.date();
let mut current_date = start_date; let mut current_date = start_date;
let mut occurrence_count = 0; let mut occurrence_count = 0;
@@ -400,10 +429,6 @@ impl CalendarService {
let current_datetime = base_event.dtstart let current_datetime = base_event.dtstart
+ Duration::days(current_date.signed_duration_since(start_date).num_days()); + Duration::days(current_date.signed_duration_since(start_date).num_days());
if current_datetime > until { if current_datetime > until {
web_sys::console::log_1(
&format!("🛑 Stopping at {} due to UNTIL {}", current_datetime, until)
.into(),
);
break; break;
} }
} }
@@ -415,25 +440,14 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE) // Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| { let is_exception = base_event.exdate.iter().any(|exception_date| {
// Compare dates ignoring sub-second precision // EXDATE from server is in local time, but stored as NaiveDateTime
let exception_naive = exception_date.naive_utc(); // We need to compare both as local time (naive datetimes) instead of UTC
let occurrence_naive = occurrence_datetime.naive_utc(); let exception_naive = *exception_date;
let occurrence_naive = occurrence_datetime;
// Check if dates match (within a minute to handle minor time differences) // Check if dates match (within a minute to handle minor time differences)
let diff = occurrence_naive - exception_naive; let diff = occurrence_naive - exception_naive;
let matches = diff.num_seconds().abs() < 60; diff.num_seconds().abs() < 60
if matches {
web_sys::console::log_1(
&format!(
"🚫 Excluding occurrence {} due to EXDATE {}",
occurrence_naive, exception_naive
)
.into(),
);
}
matches
}); });
if !is_exception { if !is_exception {
@@ -549,7 +563,7 @@ impl CalendarService {
interval: i32, interval: i32,
start_range: NaiveDate, start_range: NaiveDate,
end_range: NaiveDate, end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>, until_date: Option<chrono::NaiveDateTime>,
count: usize, count: usize,
) -> Vec<VEvent> { ) -> Vec<VEvent> {
let mut occurrences = Vec::new(); let mut occurrences = Vec::new();
@@ -559,7 +573,7 @@ impl CalendarService {
return occurrences; return occurrences;
} }
let start_date = base_event.dtstart.date_naive(); let start_date = base_event.dtstart.date();
// Find the Monday of the week containing the start_date (reference week) // Find the Monday of the week containing the start_date (reference week)
let reference_week_start = let reference_week_start =
@@ -600,13 +614,6 @@ impl CalendarService {
let days_diff = occurrence_date.signed_duration_since(start_date).num_days(); let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
if occurrence_datetime > until { if occurrence_datetime > until {
web_sys::console::log_1(
&format!(
"🛑 Stopping at {} due to UNTIL {}",
occurrence_datetime, until
)
.into(),
);
return occurrences; return occurrences;
} }
} }
@@ -617,22 +624,11 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE) // Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| { let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc(); // Compare as local time (naive datetimes) instead of UTC
let occurrence_naive = occurrence_datetime.naive_utc(); let exception_naive = *exception_date;
let occurrence_naive = occurrence_datetime;
let diff = occurrence_naive - exception_naive; let diff = occurrence_naive - exception_naive;
let matches = diff.num_seconds().abs() < 60; diff.num_seconds().abs() < 60
if matches {
web_sys::console::log_1(
&format!(
"🚫 Excluding occurrence {} due to EXDATE {}",
occurrence_naive, exception_naive
)
.into(),
);
}
matches
}); });
if !is_exception { if !is_exception {
@@ -669,7 +665,7 @@ impl CalendarService {
interval: i32, interval: i32,
start_range: NaiveDate, start_range: NaiveDate,
end_range: NaiveDate, end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>, until_date: Option<chrono::NaiveDateTime>,
count: usize, count: usize,
) -> Vec<VEvent> { ) -> Vec<VEvent> {
let mut occurrences = Vec::new(); let mut occurrences = Vec::new();
@@ -685,7 +681,7 @@ impl CalendarService {
return occurrences; return occurrences;
} }
let start_date = base_event.dtstart.date_naive(); let start_date = base_event.dtstart.date();
let mut current_month_start = let mut current_month_start =
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap(); NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
let mut total_occurrences = 0; let mut total_occurrences = 0;
@@ -726,13 +722,6 @@ impl CalendarService {
occurrence_date.signed_duration_since(start_date).num_days(); occurrence_date.signed_duration_since(start_date).num_days();
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff); let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
if occurrence_datetime > until { if occurrence_datetime > until {
web_sys::console::log_1(
&format!(
"🛑 Stopping at {} due to UNTIL {}",
occurrence_datetime, until
)
.into(),
);
return occurrences; return occurrences;
} }
} }
@@ -743,9 +732,7 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE) // Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| { let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc(); let diff = occurrence_datetime - *exception_date;
let occurrence_naive = occurrence_datetime.naive_utc();
let diff = occurrence_naive - exception_naive;
diff.num_seconds().abs() < 60 diff.num_seconds().abs() < 60
}); });
@@ -786,14 +773,14 @@ impl CalendarService {
interval: i32, interval: i32,
start_range: NaiveDate, start_range: NaiveDate,
end_range: NaiveDate, end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>, until_date: Option<chrono::NaiveDateTime>,
count: usize, count: usize,
) -> Vec<VEvent> { ) -> Vec<VEvent> {
let mut occurrences = Vec::new(); let mut occurrences = Vec::new();
// Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday) // Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday)
if let Some((position, weekday)) = Self::parse_monthly_byday(byday) { if let Some((position, weekday)) = Self::parse_monthly_byday(byday) {
let start_date = base_event.dtstart.date_naive(); let start_date = base_event.dtstart.date();
let mut current_month_start = let mut current_month_start =
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap(); NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
let mut total_occurrences = 0; let mut total_occurrences = 0;
@@ -824,9 +811,7 @@ impl CalendarService {
// Check EXDATE // Check EXDATE
let is_exception = base_event.exdate.iter().any(|exception_date| { let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc(); let diff = occurrence_datetime - *exception_date;
let occurrence_naive = occurrence_datetime.naive_utc();
let diff = occurrence_naive - exception_naive;
diff.num_seconds().abs() < 60 diff.num_seconds().abs() < 60
}); });
@@ -865,7 +850,7 @@ impl CalendarService {
interval: i32, interval: i32,
start_range: NaiveDate, start_range: NaiveDate,
end_range: NaiveDate, end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>, until_date: Option<chrono::NaiveDateTime>,
count: usize, count: usize,
) -> Vec<VEvent> { ) -> Vec<VEvent> {
let mut occurrences = Vec::new(); let mut occurrences = Vec::new();
@@ -881,7 +866,7 @@ impl CalendarService {
return occurrences; return occurrences;
} }
let start_date = base_event.dtstart.date_naive(); let start_date = base_event.dtstart.date();
let mut current_year = start_date.year(); let mut current_year = start_date.year();
let mut total_occurrences = 0; let mut total_occurrences = 0;
@@ -924,9 +909,7 @@ impl CalendarService {
// Check EXDATE // Check EXDATE
let is_exception = base_event.exdate.iter().any(|exception_date| { let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc(); let diff = occurrence_datetime - *exception_date;
let occurrence_naive = occurrence_datetime.naive_utc();
let diff = occurrence_naive - exception_naive;
diff.num_seconds().abs() < 60 diff.num_seconds().abs() < 60
}); });
@@ -1248,12 +1231,14 @@ impl CalendarService {
organizer: String, organizer: String,
attendees: String, attendees: String,
categories: String, categories: String,
reminder: String, alarms: Vec<VAlarm>,
recurrence: String, recurrence: String,
recurrence_days: Vec<bool>, recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>, recurrence_count: Option<u32>,
recurrence_until: Option<String>, recurrence_until: Option<String>,
calendar_path: Option<String>, calendar_path: Option<String>,
timezone: String,
) -> Result<(), String> { ) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?; let window = web_sys::window().ok_or("No global window exists")?;
@@ -1281,13 +1266,14 @@ impl CalendarService {
"organizer": organizer, "organizer": organizer,
"attendees": attendees, "attendees": attendees,
"categories": categories, "categories": categories,
"reminder": reminder, "alarms": alarms,
"recurrence": recurrence, "recurrence": recurrence,
"recurrence_days": recurrence_days, "recurrence_days": recurrence_days,
"recurrence_interval": 1_u32, // Default interval "recurrence_interval": recurrence_interval,
"recurrence_end_date": recurrence_until, "recurrence_end_date": recurrence_until,
"recurrence_count": recurrence_count, "recurrence_count": recurrence_count,
"calendar_path": calendar_path "calendar_path": calendar_path,
"timezone": timezone
}); });
let url = format!("{}/calendar/events/series/create", self.base_url); let url = format!("{}/calendar/events/series/create", self.base_url);
(body, url) (body, url)
@@ -1308,10 +1294,11 @@ impl CalendarService {
"organizer": organizer, "organizer": organizer,
"attendees": attendees, "attendees": attendees,
"categories": categories, "categories": categories,
"reminder": reminder, "alarms": alarms,
"recurrence": recurrence, "recurrence": recurrence,
"recurrence_days": recurrence_days, "recurrence_days": recurrence_days,
"calendar_path": calendar_path "calendar_path": calendar_path,
"timezone": timezone
}); });
let url = format!("{}/calendar/events/create", self.base_url); let url = format!("{}/calendar/events/create", self.base_url);
(body, url) (body, url)
@@ -1385,13 +1372,14 @@ impl CalendarService {
organizer: String, organizer: String,
attendees: String, attendees: String,
categories: String, categories: String,
reminder: String, alarms: Vec<VAlarm>,
recurrence: String, recurrence: String,
recurrence_days: Vec<bool>, recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>, calendar_path: Option<String>,
exception_dates: Vec<DateTime<Utc>>, timezone: String,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>,
) -> Result<(), String> { ) -> Result<(), String> {
// Forward to update_event_with_scope with default scope // Forward to update_event_with_scope with default scope
self.update_event_with_scope( self.update_event_with_scope(
@@ -1412,13 +1400,14 @@ impl CalendarService {
organizer, organizer,
attendees, attendees,
categories, categories,
reminder, alarms,
recurrence, recurrence,
recurrence_days, recurrence_days,
recurrence_interval,
recurrence_count,
recurrence_until,
calendar_path, calendar_path,
exception_dates, timezone,
update_action,
until_date,
) )
.await .await
} }
@@ -1442,13 +1431,14 @@ impl CalendarService {
organizer: String, organizer: String,
attendees: String, attendees: String,
categories: String, categories: String,
reminder: String, alarms: Vec<VAlarm>,
recurrence: String, recurrence: String,
recurrence_days: Vec<bool>, recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>, calendar_path: Option<String>,
exception_dates: Vec<DateTime<Utc>>, timezone: String,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>,
) -> Result<(), String> { ) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?; let window = web_sys::window().ok_or("No global window exists")?;
@@ -1473,14 +1463,14 @@ impl CalendarService {
"organizer": organizer, "organizer": organizer,
"attendees": attendees, "attendees": attendees,
"categories": categories, "categories": categories,
"reminder": reminder, "alarms": alarms,
"recurrence": recurrence, "recurrence": recurrence,
"recurrence_days": recurrence_days, "recurrence_days": recurrence_days,
"recurrence_interval": recurrence_interval,
"recurrence_count": recurrence_count,
"recurrence_end_date": recurrence_until,
"calendar_path": calendar_path, "calendar_path": calendar_path,
"update_action": update_action, "timezone": timezone
"occurrence_date": null,
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
}); });
let url = format!("{}/calendar/events/update", self.base_url); let url = format!("{}/calendar/events/update", self.base_url);
@@ -1678,14 +1668,16 @@ impl CalendarService {
organizer: String, organizer: String,
attendees: String, attendees: String,
categories: String, categories: String,
reminder: String, alarms: Vec<VAlarm>,
recurrence: String, recurrence: String,
recurrence_days: Vec<bool>, recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>, recurrence_count: Option<u32>,
recurrence_until: Option<String>, recurrence_until: Option<String>,
calendar_path: Option<String>, calendar_path: Option<String>,
update_scope: String, update_scope: String,
occurrence_date: Option<String>, occurrence_date: Option<String>,
timezone: String,
) -> Result<(), String> { ) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?; let window = web_sys::window().ok_or("No global window exists")?;
@@ -1709,15 +1701,16 @@ impl CalendarService {
"organizer": organizer, "organizer": organizer,
"attendees": attendees, "attendees": attendees,
"categories": categories, "categories": categories,
"reminder": reminder, "alarms": alarms,
"recurrence": recurrence, "recurrence": recurrence,
"recurrence_days": recurrence_days, "recurrence_days": recurrence_days,
"recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter "recurrence_interval": recurrence_interval,
"recurrence_end_date": recurrence_until, "recurrence_end_date": recurrence_until,
"recurrence_count": recurrence_count, "recurrence_count": recurrence_count,
"calendar_path": calendar_path, "calendar_path": calendar_path,
"update_scope": update_scope, "update_scope": update_scope,
"occurrence_date": occurrence_date "occurrence_date": occurrence_date,
"timezone": timezone
}); });
let url = format!("{}/calendar/events/series/update", self.base_url); let url = format!("{}/calendar/events/series/update", self.base_url);
@@ -2089,7 +2082,6 @@ impl CalendarService {
#[derive(Deserialize)] #[derive(Deserialize)]
struct ExternalCalendarEventsResponse { struct ExternalCalendarEventsResponse {
events: Vec<VEvent>, events: Vec<VEvent>,
last_fetched: chrono::DateTime<chrono::Utc>,
} }
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json) let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)

View File

@@ -1,4 +1,8 @@
pub mod calendar_service; pub mod calendar_service;
pub mod preferences; pub mod preferences;
pub mod notification_manager;
pub mod alarm_scheduler;
pub use calendar_service::CalendarService; pub use calendar_service::CalendarService;
pub use notification_manager::{NotificationManager, AlarmNotification};
pub use alarm_scheduler::AlarmScheduler;

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

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

View File

@@ -1,51 +0,0 @@
/* Base Styles - Always Loaded */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
color: #333;
line-height: 1.6;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: row;
}
.login-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
}
/* Base Layout */
.main-content {
flex: 1;
margin-left: 280px;
overflow-x: hidden;
}
/* Basic Form Elements */
input, select, textarea, button {
font-family: inherit;
}
/* Utility Classes */
.loading {
opacity: 0.7;
}
.error {
color: #dc3545;
}
.success {
color: #28a745;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff