84 Commits

Author SHA1 Message Date
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
Connor Johnstone
45e16313ba Update external calendar modal help to include Google Calendar
Added Google Calendar setup instructions alongside existing Outlook 365 instructions.
Updated modal title to "Setting up External Calendars" and reorganized help text
to show both supported platforms with specific step-by-step instructions.

Google Calendar: hover over calendar → three dots → Settings and sharing →
Integrate calendar → Public address in iCal format

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 22:33:27 -04:00
Connor Johnstone
64c737c023 Fix Google Calendar UTC datetime format parsing
Google Calendar ICS files use UTC datetime format ending with 'Z' (e.g., 20250817T140000Z)
which was failing to parse with DateTime::parse_from_str. Fixed by detecting 'Z' suffix,
parsing as naive datetime, and converting to UTC with and_utc().

Also cleaned up debug logging and unused variable warnings.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 22:28:51 -04:00
Connor Johnstone
75d9149c76 Add immediate refresh when new external calendars are created
When users add a new external calendar, events now appear instantly instead
of waiting for the next 5-minute auto-refresh cycle or manual refresh.

Changes:
- Modified ExternalCalendarModal to return newly created calendar ID
- Enhanced on_success callback to immediately fetch events for new calendar
- Added proper visibility checking and error handling
- Removed unused imports to clean up compilation warnings

User experience improvement:
- Before: Add calendar → wait 5 minutes → see events
- After: Add calendar → events appear immediately

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 22:15:03 -04:00
Connor Johnstone
28b3946e86 Add intelligent caching and auto-refresh for external calendars
Implements server-side database caching with 5-minute refresh intervals to
dramatically improve external calendar performance while keeping data fresh.

Backend changes:
- New external_calendar_cache table with ICS data storage
- Smart cache logic: serves from cache if < 5min old, fetches fresh otherwise
- Cache repository methods for get/update/clear operations
- Migration script for cache table creation

Frontend changes:
- 5-minute auto-refresh interval for background updates
- Manual refresh button (🔄) for each external calendar
- Last updated timestamps showing when each calendar was refreshed
- Centralized refresh function with proper cleanup on logout

Performance improvements:
- Initial load: instant from cache vs slow external HTTP requests
- Background updates: fresh data without user waiting
- Reduced external API calls: only when cache is stale
- Scalable: handles multiple external calendars efficiently

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 22:06:32 -04:00
Connor Johnstone
6a01a75cce Add visibility toggles for CalDAV calendars with event filtering
Users can now toggle visibility of CalDAV calendars using checkboxes in
the sidebar, matching the behavior of external calendars. Events from
hidden calendars are automatically filtered out of the calendar view.

Changes:
- Add is_visible field to CalendarInfo (frontend & backend)
- Add visibility checkboxes to CalDAV calendar list items
- Implement real-time event filtering based on calendar visibility
- Add CSS styling matching external calendar checkboxes
- Default new calendars to visible state

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 21:37:46 -04:00
Connor Johnstone
189dd32f8c Improve external calendar modal UI and remove emojis
- Fix modal backdrop and centering by using proper modal-backdrop class
- Make color picker more compact (80px width instead of 100%)
- Add Outlook 365 setup instructions with step-by-step guide
- Remove calendar emojis from button and sidebar indicators for cleaner design

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 19:52:09 -04:00
Connor Johnstone
7461e8b123 Add right-click context menu to external calendars for deletion
Users can now right-click on external calendar items in the sidebar
to access a context menu with a "Delete Calendar" option. The delete
action removes the calendar from both the server and local state,
including all associated events from the calendar display.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 19:24:02 -04:00
Connor Johnstone
f88c238b0a Fix external calendar timezone conversion and styling
- Add comprehensive Windows timezone support for global external calendars
  - Map Windows timezone names (e.g. "Mountain Standard Time") to IANA zones (e.g. "America/Denver")
  - Support 60+ timezone mappings across North America, Europe, Asia, Asia Pacific, Africa, South America
  - Add chrono-tz dependency for proper timezone handling
- Fix external calendar event colors by setting calendar_path for color lookup
- Add visual distinction for external calendar events with dashed borders and calendar emoji
- Update timezone parsing to extract TZID parameters from iCalendar DTSTART/DTEND properties
- Pass external calendar data through component hierarchy for color matching

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 19:11:57 -04:00
Connor Johnstone
8caa1f45ae Add external calendars feature: display read-only ICS calendars alongside CalDAV calendars
- Database: Add external_calendars table with user relationships and CRUD operations
- Backend: Implement REST API endpoints for external calendar management and ICS fetching
- Frontend: Add external calendar modal, sidebar section with visibility toggles
- Calendar integration: Merge external events with regular events in unified view
- ICS parsing: Support multiple datetime formats, recurring events, and timezone handling
- Authentication: Integrate with existing JWT token system for user-specific calendars
- UI: Visual distinction with 📅 indicator and separate management section

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 18:22:52 -04:00
Connor Johnstone
289284a532 Fix all-day event date display bug: events created 9/4-9/6 now show correctly instead of 9/3-9/5
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m9s
- Backend: Store all-day events at noon UTC instead of midnight to avoid timezone boundary issues
- Backend: Remove local timezone conversion for all-day events in series handler
- Frontend: Skip timezone conversion when extracting dates from all-day events for display
- Frontend: Extract dates directly from UTC for all-day events in event_spans_date function

The issue was that timezone conversion of UTC midnight could shift dates backward in western timezones.
Now all-day events use noon UTC storage and pure date extraction without timezone conversion.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 17:35:29 -04:00
Connor Johnstone
089f4ce105 Fix series RRULE updates: editing 'all events' now properly updates original series RRULE
- Backend now updates RRULE when recurrence_count or recurrence_end_date parameters are provided
- Fixed update_entire_series() to modify COUNT/UNTIL instead of preserving original RRULE
- Added comprehensive RRULE parsing functions to extract existing frequency, interval, count, until, and BYDAY components
- Fixed frontend parameter mapping to pass recurrence parameters through update_series calls
- Resolves issue where changing recurring event from 5 to 7 occurrences kept original COUNT=5

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 17:22:26 -04:00
Connor Johnstone
235dcf8e1d Fix recurring event count bug: events with COUNT=5 now stop after 5 occurrences
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m19s
Events created with specific occurrence counts (like "repeat 5 times") were
repeating forever instead of stopping after the specified number.

Root cause: Frontend form collected recurrence_count and recurrence_until
values correctly, but these weren't being passed through the event creation
pipeline to the backend, which was hardcoding None values.

Fix implemented across entire creation flow:

1. **Enhanced Parameter Conversion**:
   - Added recurrence_count and recurrence_until to to_create_event_params() tuple
   - Properly extracts values from form: recurrence_count, recurrence_until.map()

2. **Updated Backend Method Signature**:
   - Added recurrence_count: Option<u32> and recurrence_until: Option<String>
   - to create_event() method parameters

3. **Fixed Backend Implementation**:
   - Replace hardcoded None values with actual form parameters
   - "recurrence_end_date": recurrence_until, "recurrence_count": recurrence_count

4. **Updated Call Sites**:
   - Modified app.rs to pass params.18 (recurrence_count) and params.19 (recurrence_until)
   - Proper parameter indexing after tuple expansion

Result: Complete recurrence control now works correctly:
-  Events with COUNT=5 stop after exactly 5 occurrences
-  Events with UNTIL date stop on specified date
-  Events with "repeat forever" continue indefinitely
-  Proper iCalendar RRULE generation with COUNT/UNTIL parameters

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:55:54 -04:00
Connor Johnstone
8dd60a8ec1 Fix recurring event editing: restore proper update flow and fix API parameters
Fixed multiple issues with recurring event editing via modal that were causing
events to be created instead of updated, and API parameter mismatches.

Key fixes:

1. **Restore Update Flow**:
   - Added original_uid tracking to EventCreationData to distinguish create vs update
   - Modal now routes to update endpoints when editing existing events instead of always creating new ones
   - Implemented dual-path logic in on_event_create callback to handle both operations

2. **Fix "This and Future" Updates**:
   - Added occurrence_date field to EventCreationData for recurring event context
   - Backend now receives required occurrence_date parameter for this_and_future scope
   - Populated occurrence_date from event start date in modal conversion

3. **Fix Update Scope Parameters**:
   - Corrected scope parameter mapping to match backend API expectations:
     * EditAll: "entire_series" → "all_in_series"
     * EditFuture: "this_and_future" (correct)
     * EditThis: "this_event_only" → "this_only"

4. **Enhanced Backend Integration**:
   - Proper routing between update_event() and update_series() based on event type
   - Correct parameter handling for both single and recurring event updates
   - Added missing parameters (exception_dates, update_action, until_date)

Result: All recurring event edit operations now work correctly:
-  "Edit all events in series" updates existing series instead of creating new
-  "Edit this and future events" properly handles occurrence dates
-  "Edit this event only" works for single instance modifications
-  No more duplicate events created during editing
-  Proper CalDAV server synchronization maintained

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:48:42 -04:00
Connor Johnstone
20679b6b53 Restore event editing functionality: populate modal with existing event data
When editing existing events, the modal was showing empty/default values
instead of the current event data, making editing very inconvenient.

Root cause: TODO comment in modal initialization was never implemented -
VEvent to EventCreationData conversion was missing.

Solution: Implemented comprehensive vevent_to_creation_data() function that maps:
- Basic info: title, description, location, all-day status
- Timing: start/end dates/times with proper UTC→local timezone conversion
- Classification: event status (Confirmed/Tentative/Cancelled) and class
- People: organizer and attendees (comma-separated)
- Categories: event categories (comma-separated)
- Calendar selection: finds correct calendar or falls back gracefully
- Recurrence: detects recurring events (with TODO for advanced RRULE parsing)
- Priority: preserves event priority if set

Features:
- Proper timezone handling for display times
- Fallback logic for missing end times (1 hour default)
- Smart calendar matching with graceful fallbacks
- Complete enum type mapping between VEvent and EventCreationData

Result: Edit modal now pre-populates with all existing event data,
making editing user-friendly and preserving all event properties.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:36:32 -04:00
Connor Johnstone
53c4a99697 Fix multi-day all-day events to display across all days they span
All-day events spanning multiple days were only showing on their start date.
For example, a "Vacation (Dec 20-25)" event would only appear on Dec 20th.

Root cause: Logic only checked events stored in each day's HashMap entry,
missing events that span into other days.

Solution:
- Modified all-day event collection to search all events across all days
- Added event_spans_date() helper function to check if event spans given date
- Properly handles iCalendar dtend convention (day after event ends)
- Added deduplication to prevent duplicate events from multiple day buckets
- Removed unused day_events variable

Result: Multi-day all-day events now correctly appear on every day they span,
while single-day events continue to work as before.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:31:25 -04:00
Connor Johnstone
5ea33b7d0a Fix event width bug: timed events showing half-width when all-day events exist
Timed events were incorrectly displaying at half-width when all-day events
existed on the same day, even though all-day events display separately at
the top of the calendar and don't visually overlap with timed events.

Root cause: The overlap calculation logic was including all-day events when
determining width splits for timed events.

Solution:
- Modified calculate_event_layout() to exclude all-day events from filtering
- Updated events_overlap() to return false if either event is all-day
- All-day events now don't participate in timed event width calculations

Result: Timed events display at full width unless they actually overlap
with other timed events, while all-day events continue to display correctly
in their separate section.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:27:32 -04:00
Connor Johnstone
13a752a69c Fix timezone bug in event drag-and-drop causing 4-hour offset
Events dragged to 4am-7am were appearing at 8am-11am due to double
timezone conversion. The issue was:

1. Frontend converted local time to UTC before sending to backend
2. Backend (after previous fix) converted "local time" (actually UTC) to UTC again
3. Result: double conversion causing 4+ hour shift in wrong direction

Solution: Remove frontend UTC conversion in drag-and-drop callback.
Let backend handle the local-to-UTC conversion consistently.

- Remove .and_local_timezone(chrono::Local).unwrap().to_utc() conversion
- Send NaiveDateTime directly as local time strings to backend
- Backend parse_event_datetime() now properly handles local-to-UTC conversion

Now drag-and-drop works correctly: drag to 4am shows 4am.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:21:24 -04:00
Connor Johnstone
0609a99839 Fix timezone bug in event creation
Events were appearing 4 hours earlier than selected time due to incorrect
timezone handling in backend. The issue was treating frontend local time
as if it was already in UTC.

- Fix parse_event_datetime() in events.rs to properly convert local time to UTC
- Fix all datetime conversions in series.rs to use Local timezone conversion
- Replace Utc.from_utc_datetime() with proper Local.from_local_datetime()
- Add timezone conversion using with_timezone(&Utc) for accurate UTC storage

Now when user selects 5:00 AM, it correctly stores as UTC equivalent
and displays back at 5:00 AM local time.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:17:32 -04:00
Connor Johnstone
dce82d5f7d Implement last used calendar tracking with localStorage and database sync
- Add database migration for last_used_calendar field in user preferences
- Update backend models and handlers to support last_used_calendar persistence
- Modify frontend preferences service with update_last_used_calendar() method
- Implement automatic saving of selected calendar on event creation
- Add localStorage fallback for offline usage and immediate UI response
- Update create event modal to default to last used calendar for new events
- Clean up unused imports from event form components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 16:13:18 -04:00
Connor Johnstone
1e8a8ce5f2 Complete event modal migration: remove original and rename V2
Remove the original create_event_modal.rs and rename create_event_modal_v2.rs
to complete the modal migration started earlier. This eliminates duplicate code
and consolidates to a single, clean event modal implementation.

Changes:
- Remove original create_event_modal.rs (2,300+ lines)
- Rename create_event_modal_v2.rs → create_event_modal.rs
- Update component/function names: CreateEventModalV2 → CreateEventModal
- Fix all imports in app.rs and calendar.rs
- Add missing to_create_event_params() method to EventCreationData
- Resolve EditAction type conflicts between modules
- Clean up duplicate types and unused imports
- Maintain backwards compatibility with EventCreationData export

Result: -2440 lines, +160 lines - massive code cleanup with zero functionality loss.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 15:53:25 -04:00
Connor Johnstone
c0bdd3d8c2 Add theme-aware styling for 15-minute time grid lines
All checks were successful
Build and Push Docker Image / docker (push) Successful in 30s
Add --calendar-border-light CSS variables to all 8 color themes for proper
15-minute grid line styling. Previously used hard-coded fallback (#f8f8f8)
which was too bright for dark mode and inconsistent with theme colors.

- Dark mode: Use subtle #2a2a2a instead of bright #f8f8f8
- All themes: Theme-appropriate very light border colors
- Better visual integration with each color scheme
- Consistent dotted 15-minute grid lines across all themes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 15:42:29 -04:00
Connor Johnstone
2b98c4d229 Hide event time display for single-slot events
Hide time display for events with duration <= 30px (single time slots)
to maximize space for event titles in compact event boxes.

- Single-slot events show title only for better readability
- Applies to both 15-minute and 30-minute time increment modes
- Consistent behavior across static events and drag previews
- Improves UX for short duration events where time display crowds the title

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 15:39:15 -04:00
Connor Johnstone
ceae654a39 Implement dynamic 15-minute time grid density and remove final boundary
- Scale time grid height dynamically based on time increment (1530px/2970px)
- Add quarter-mode CSS classes for 15-minute blocks (30px each, same as 30-min blocks)
- Update pixel-to-time conversion functions with 2px:1min scaling in 15-min mode
- Generate correct number of time slots (4 per hour in 15-min mode)
- Remove unnecessary final boundary time label and related CSS
- Fix CSS grid layout by removing malformed CSS syntax
- All time-related containers scale properly between 30-minute and 15-minute modes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 15:35:50 -04:00
Connor Johnstone
fb28fa95c9 Fix recurring events from previous years not showing up
Extend recurring event expansion date range from 30 days to 100 years in the past.
This ensures yearly recurring events created many years ago (birthdays, anniversaries,
historical dates) properly generate their current year occurrences.

The backend correctly includes old recurring events that could have occurrences in
the requested month, but the frontend was only expanding occurrences within a
30-day historical window, missing events from previous years.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 13:19:17 -04:00
Connor Johnstone
419cb3d790 Complete CreateEventModalV2 integration and fix styling
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m11s
- Replace CreateEventModal with new modular CreateEventModalV2 throughout app
- Fix compilation errors by aligning event_form types with create_event_modal types
- Add missing props (initial_start_time, initial_end_time) to modal interface
- Fix styling issues: use tab-navigation class and add modal-body wrapper
- Remove duplicate on_create prop causing compilation failure
- All recurrence options now properly positioned below repeat/reminder pickers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 13:11:18 -04:00
Connor Johnstone
53a62fb05e Refactor create_event_modal into modular components
- Split massive 27K line modal into focused components
- Created event_form module with 6 tab components:
  * BasicDetailsTab - main event info with recurrence options properly positioned
  * AdvancedTab - status, privacy, priority
  * PeopleTab - organizer and attendees
  * CategoriesTab - event categories
  * LocationTab - location information
  * RemindersTab - reminder settings
- Added shared types and data structures
- Created new CreateEventModalV2 using modular architecture
- Recurrence options now positioned directly after repeat/reminder pickers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:45:42 -04:00
Connor Johnstone
322c88612a Add API proxy configuration and large favicon
- Configure Caddy to proxy /api requests to backend service
- Add favicon_big.png for various icon size needs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:26:50 -04:00
Connor Johnstone
4aa53d79e7 Add favicon and remove calendar.db from tracking
- Add favicon.ico as site favicon using Trunk asset pipeline
- Remove calendar.db from git tracking (already in .gitignore)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:25:41 -04:00
Connor Johnstone
3464754489 Remove debug logging from all-day event detection
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:22:34 -04:00
Connor Johnstone
e56253b9c2 Fix all-day recurring events RFC-5545 compliance
- Set all_day flag properly when creating VEvent in series handler
- Improve all-day event detection using VALUE=DATE parameter
- Add RFC-5545 compliance for exclusive end dates (backend adds 1 day)
- Fix end date display in event modal (frontend subtracts 1 day for display)
- Fix recurring all-day event expansion to maintain proper end date pattern

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 12:21:46 -04:00
Connor Johnstone
cb8cc7258c Implement smart context menu positioning
Added intelligent viewport boundary detection that repositions context
menus when they would appear outside the screen:

- Detects right/bottom edge overflow and repositions menus accordingly
- Uses accurate size estimates based on actual menu content
- Event menus: 280×200px (recurring) / 180×100px (non-recurring)
- Calendar/generic menus: 180×60px for single items
- Maintains 5px minimum margins from screen edges
- Graceful fallback to original positioning if viewport detection fails

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 11:47:48 -04:00
Connor Johnstone
b576cd8c4a Add context menus to all-day event boxes
All-day events now have the same right-click context menu functionality
as regular timed events, allowing users to edit, delete, and perform
other actions on all-day events.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 11:37:36 -04:00
Connor Johnstone
a773159016 Fix recurring event filtering for month view
Previously filtered events by start date only, which excluded recurring
events that started in previous months/years but have instances in the
current month.

New logic:
- Non-recurring events: filter by exact month match (unchanged)
- Recurring events: include if they could have instances in requested month
- Check event start date is before/during month
- Parse RRULE UNTIL date to exclude expired recurring events
- Let frontend handle proper RRULE expansion

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 11:36:28 -04:00
Connor Johnstone
a9521ad536 Convenience and fixes
All checks were successful
Build and Push Docker Image / docker (push) Successful in 2m11s
2025-09-02 16:07:40 -04:00
Connor Johnstone
5456d7140c Manual updates to fix some deployment steps
Some checks failed
Build and Push Docker Image / docker (push) Failing after 21s
2025-09-02 16:03:11 -04:00
Connor Johnstone
62cc910e1a Removing Cargo.lock
All checks were successful
Build and Push Docker Image / docker (push) Successful in 7m49s
2025-09-02 12:44:01 -04:00
Connor Johnstone
6ec7bb5422 variables > vars
Some checks failed
Build and Push Docker Image / docker (push) Failing after 24s
2025-09-02 12:39:51 -04:00
Connor Johnstone
ce74750d85 Trying another approach
Some checks failed
Build and Push Docker Image / docker (push) Failing after 20s
2025-09-02 12:38:08 -04:00
Connor Johnstone
d089f1545b Fixing the gitea error
Some checks failed
Build and Push Docker Image / docker (push) Failing after 22s
2025-09-02 12:34:47 -04:00
Connor Johnstone
7b06fef6c3 Revert "Fix Gitea action Docker build tag error"
This reverts commit 7be9f5a869.
2025-09-02 12:33:17 -04:00
Connor Johnstone
7be9f5a869 Fix Gitea action Docker build tag error
- Add fallback registry to prevent invalid tag format
- Make Docker login conditional on secrets being present
- Make push conditional on registry being configured
- Rename Docker image from calendar to runway

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 12:32:34 -04:00
Connor Johnstone
a7ebbe0635 Add application screenshot to README
Shows Runway's week view with events, all-day events, and dark theme

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:23:13 -04:00
Connor Johnstone
3662f117f5 Fix overlapping events to only split columns for overlapping event groups
Implemented clustering algorithm in calculate_event_layout that:
- Only creates column splits for events that actually overlap
- Non-overlapping events maintain full width display
- Uses greedy column assignment for overlapping groups
- Preserves proper column indices for each event

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:20:37 -04:00
Connor Johnstone
0899a84b42 Fix all-day events: validation and proper header positioning
Backend fixes:
- Fix all-day event creation validation error
- Allow same start/end date for all-day events (single-day events)
- Maintain strict validation for timed events (end must be after start)

Frontend improvements:
- Move all-day events from time grid to day headers
- Add dedicated all-day events container that stacks vertically
- Filter all-day events out of main time-based events area
- Add proper CSS styling for all-day event display and interaction
- Maintain event click handling and color themes

All-day events now appear in the correct location at the top of each
day column and properly stack when multiple events exist.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:13:54 -04:00
Connor Johnstone
85d23b0347 Rebrand application from 'Calendar App' to 'Runway'
Some checks failed
Build and Push Docker Image / docker (push) Failing after 26s
- Update project name in Cargo.toml from calendar-app to runway
- Change HTML title and sidebar header to 'Runway'
- Complete README rewrite with new branding and philosophy
- Add 'The Name' section explaining runway metaphor as passive infrastructure
- Update Dockerfile build references to use new binary name
- Maintain all technical documentation with new branding context

The name 'Runway' embodies passive infrastructure that enables coordination
without getting in the way - like airport runways that provide essential
structure for planes but stay invisible during flight.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:05:21 -04:00
Connor Johnstone
13db4abc0f Remove labels from theme and style pickers in sidebar
- Remove "Theme:" label from theme selector dropdown
- Remove "Style:" label from style selector dropdown
- Create cleaner, more minimal sidebar UI

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:57:26 -04:00
Connor Johnstone
57e434e4ff Fix drag interaction issues in week view
- Fix drag-to-create being blocked by existing events
- Add creating-event CSS class that disables pointer events on existing events
- Fix single clicks creating temporary event boxes
- Add mouse button state check to prevent post-mouseup movement being treated as drag
- Ensure temp event boxes only appear during actual drag operations (has_moved=true)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:55:39 -04:00
Connor Johnstone
7c2901f453 Implement side-by-side rendering for overlapping events in week view
- Add overlap detection algorithm to identify overlapping events
- Implement layout calculation to arrange events in columns
- Update event positioning to use dynamic left/width instead of fixed right
- Events now render side-by-side when they overlap in time
- Maintains proper spacing and margins for all event arrangements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:44:32 -04:00
6c67444b19 Merge pull request 'Fixes for the time grid for late night events' (#8) from bugfix/week-view-time-grid into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 13m50s
Reviewed-on: #8
2025-09-02 10:39:23 -04:00
67 changed files with 9878 additions and 10780 deletions

View File

@@ -18,17 +18,18 @@ jobs:
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
registry: ${{ vars.REGISTRY }}
username: ${{ vars.USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./backend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_REGISTRY }}/calendar:latest
${{ secrets.DOCKER_REGISTRY }}/calendar:${{ github.sha }}
${{ vars.REGISTRY }}/connor/calendar:latest
${{ vars.REGISTRY }}/connor/calendar:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max

View File

@@ -5,6 +5,11 @@
}
:80, :443 {
@backend {
path /api /api/*
}
reverse_proxy @backend calendar-backend:3000
try_files {path} /index.html
root * /srv/www
file_server
}

View File

@@ -1,109 +0,0 @@
# Build stage
# -----------------------------------------------------------
FROM rust:alpine AS builder
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static nodejs npm
# Install trunk ahead of the compilation. This may break and then you'll have to update the version.
RUN cargo install trunk@0.21.14 wasm-pack@0.13.1 wasm-bindgen-cli@0.2.100
RUN rustup target add wasm32-unknown-unknown
WORKDIR /app
# Copy workspace files to maintain workspace structure
COPY Cargo.toml Cargo.lock ./
COPY calendar-models ./calendar-models
COPY frontend/Cargo.toml ./frontend/
COPY frontend/Trunk.toml ./frontend/
COPY frontend/index.html ./frontend/
COPY frontend/styles.css ./frontend/
# Create empty backend directory to satisfy workspace
RUN mkdir -p backend/src && \
printf '[package]\nname = "calendar-backend"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > backend/Cargo.toml && \
echo 'fn main() {}' > backend/src/main.rs
# Create dummy source files to build dependencies first
RUN mkdir -p frontend/src && \
echo "use web_sys::*; fn main() {}" > frontend/src/main.rs && \
echo "pub fn add(a: usize, b: usize) -> usize { a + b }" > calendar-models/src/lib.rs
# Build dependencies (this layer will be cached unless dependencies change)
RUN cargo build --release --target wasm32-unknown-unknown --bin calendar-app
# Copy actual source code and build the frontend application
RUN rm -rf frontend
COPY frontend ./frontend
RUN trunk build --release --config ./frontend/Trunk.toml
# Backend build stage
# -----------------------------------------------------------
FROM rust:alpine AS backend-builder
# Install build dependencies for backend
WORKDIR /app
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
# Install sqlx-cli for migrations
RUN cargo install sqlx-cli --no-default-features --features sqlite
# Copy shared models
COPY calendar-models ./calendar-models
# Create empty frontend directory to satisfy workspace
RUN mkdir -p frontend/src && \
printf '[package]\nname = "calendar-app"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \
echo 'fn main() {}' > frontend/src/main.rs
# Create dummy backend source to build dependencies first
RUN mkdir -p backend/src && \
echo "fn main() {}" > backend/src/main.rs
# Build dependencies (this layer will be cached unless dependencies change)
COPY Cargo.toml Cargo.lock ./
COPY backend/Cargo.toml ./backend/
RUN cargo build --release
# Build the backend
COPY backend ./backend
RUN cargo build --release --bin backend
# Runtime stage
# -----------------------------------------------------------
FROM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata sqlite
# Copy frontend files to temporary location
COPY --from=builder /app/frontend/dist /app/frontend-dist
# Copy backend binary and sqlx-cli
COPY --from=backend-builder /app/target/release/backend /usr/local/bin/backend
COPY --from=backend-builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
# Copy migrations for database setup
COPY --from=backend-builder /app/backend/migrations /migrations
# Create startup script to copy frontend files, run migrations, and start backend
RUN mkdir -p /srv/www /db
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
echo 'echo "Copying frontend files..."' >> /usr/local/bin/start.sh && \
echo 'cp -r /app/frontend-dist/* /srv/www/' >> /usr/local/bin/start.sh && \
echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \
echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \
echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \
echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
chmod +x /usr/local/bin/start.sh
# Start with script that copies frontend files then starts backend
CMD ["/usr/local/bin/start.sh"]

View File

@@ -1,13 +1,22 @@
# Modern CalDAV Web Client
# Runway
## _Passive infrastructure for life's coordination_
![Runway Screenshot](sample.png)
>[!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 full-stack calendar application built with Rust, featuring a modern web interface for CalDAV calendar management.
A modern CalDAV web client built with Rust WebAssembly.
## Motivation
## The Name
While there are many excellent self-hosted CalDAV server implementations (Nextcloud, Radicale, Baikal, etc.), the web client ecosystem remains limited. Existing solutions like [AgenDAV](https://github.com/agendav/agendav) often suffer from outdated interfaces, bugs, and poor user experience. This project aims to provide a modern, fast, and reliable web interface for CalDAV servers.
Runway embodies the concept of **passive infrastructure** — unobtrusive systems that enable better coordination without getting in the way. Planes can fly and do lots of cool things, but without runways, they can't take off or land. Similarly, calendars and scheduling tools are essential for organizing our lives, but they should not dominate our attention.
The best infrastructure is invisible when working, essential when needed, and enables rather than constrains.
## Why Runway?
While there are many excellent self-hosted CalDAV server implementations (Nextcloud, Radicale, Baikal, etc.), the web client ecosystem remains limited. Existing solutions like [AgenDAV](https://github.com/agendav/agendav) often suffer from outdated interfaces, bugs, and poor user experience. Runway provides a modern, fast, and reliable web interface for CalDAV servers — infrastructure that just works.
## Features
@@ -63,7 +72,7 @@ While there are many excellent self-hosted CalDAV server implementations (Nextcl
### Docker Deployment (Recommended)
The easiest way to run the calendar is using Docker Compose:
The easiest way to run Runway is using Docker Compose:
1. **Clone the repository**:
```bash
@@ -162,7 +171,7 @@ calendar/
This client is designed to work with any RFC-compliant CalDAV server:
- **Baikal** - ✅ Fully tested with complete event and recurrence support
- **Nextcloud** - 🚧 Planned compatibility with calendar app
- **Nextcloud** - 🚧 Planned compatibility with Nextcloud calendar
- **Radicale** - 🚧 Planned lightweight CalDAV server support
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
- **Google Calendar** - 🚧 Planned CalDAV API compatibility

View File

@@ -22,6 +22,7 @@ hyper = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.8"
uuid = { version = "1.0", features = ["v4", "serde"] }
anyhow = "1.0"

64
backend/Dockerfile Normal file
View File

@@ -0,0 +1,64 @@
# Build stage
# -----------------------------------------------------------
FROM rust:alpine AS builder
# Install build dependencies for backend
WORKDIR /app
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static
# Install sqlx-cli for migrations
RUN cargo install sqlx-cli --no-default-features --features sqlite
# Copy workspace files to maintain workspace structure
COPY ./Cargo.toml ./
COPY ./calendar-models ./calendar-models
# Create empty frontend directory to satisfy workspace
RUN mkdir -p frontend/src && \
printf '[package]\nname = "runway"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n' > frontend/Cargo.toml && \
echo 'fn main() {}' > frontend/src/main.rs
# Copy backend files
COPY backend/Cargo.toml ./backend/
# Create dummy backend source to build dependencies first
RUN mkdir -p backend/src && \
echo "fn main() {}" > backend/src/main.rs
# Build dependencies (this layer will be cached unless dependencies change)
RUN cargo build --release
# Copy actual backend source and build
COPY backend/src ./backend/src
COPY backend/migrations ./backend/migrations
RUN cargo build --release --bin backend
# Runtime stage
# -----------------------------------------------------------
FROM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata sqlite
# Copy backend binary and sqlx-cli
COPY --from=builder /app/target/release/backend /usr/local/bin/backend
COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx
# Copy migrations for database setup
COPY backend/migrations /migrations
# Create startup script to run migrations and start backend
RUN mkdir -p /db
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh && \
echo 'echo "Ensuring database directory exists..."' >> /usr/local/bin/start.sh && \
echo 'mkdir -p /db && chmod 755 /db' >> /usr/local/bin/start.sh && \
echo 'touch /db/calendar.db' >> /usr/local/bin/start.sh && \
echo 'echo "Running database migrations..."' >> /usr/local/bin/start.sh && \
echo 'sqlx migrate run --database-url "sqlite:///db/calendar.db" --source /migrations || echo "Migration failed but continuing..."' >> /usr/local/bin/start.sh && \
echo 'echo "Starting backend server..."' >> /usr/local/bin/start.sh && \
echo 'export DATABASE_URL="sqlite:///db/calendar.db"' >> /usr/local/bin/start.sh && \
echo '/usr/local/bin/backend' >> /usr/local/bin/start.sh && \
chmod +x /usr/local/bin/start.sh
# Start with script that runs migrations then starts backend
CMD ["/usr/local/bin/start.sh"]

View File

@@ -0,0 +1,2 @@
-- Add last used calendar preference to user preferences
ALTER TABLE user_preferences ADD COLUMN last_used_calendar TEXT;

View File

@@ -0,0 +1,16 @@
-- Create external_calendars table
CREATE TABLE external_calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
url TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#4285f4',
is_visible BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_fetched DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Create index for performance
CREATE INDEX idx_external_calendars_user_id ON external_calendars(user_id);

View File

@@ -0,0 +1,14 @@
-- Create external calendar cache table for storing ICS data
CREATE TABLE external_calendar_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_calendar_id INTEGER NOT NULL,
ics_data TEXT NOT NULL,
cached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
etag TEXT,
FOREIGN KEY (external_calendar_id) REFERENCES external_calendars(id) ON DELETE CASCADE,
UNIQUE(external_calendar_id)
);
-- Index for faster lookups
CREATE INDEX idx_external_calendar_cache_calendar_id ON external_calendar_cache(external_calendar_id);
CREATE INDEX idx_external_calendar_cache_cached_at ON external_calendar_cache(cached_at);

View File

@@ -93,6 +93,7 @@ impl AuthService {
calendar_theme: preferences.calendar_theme,
calendar_style: preferences.calendar_style,
calendar_colors: preferences.calendar_colors,
last_used_calendar: preferences.last_used_calendar,
},
})
}
@@ -111,6 +112,17 @@ impl AuthService {
self.decode_token(token)
}
/// Get user from token
pub async fn get_user_from_token(&self, token: &str) -> Result<crate::db::User, ApiError> {
let claims = self.verify_token(token)?;
let user_repo = UserRepository::new(&self.db);
user_repo
.find_or_create(&claims.username, &claims.server_url)
.await
.map_err(|e| ApiError::Database(format!("Failed to get user: {}", e)))
}
/// Create CalDAV config from token
pub fn caldav_config_from_token(
&self,

View File

@@ -330,13 +330,26 @@ impl CalDAVClient {
event: ical::parser::ical::component::IcalEvent,
) -> Result<CalendarEvent, CalDAVError> {
let mut properties: HashMap<String, String> = HashMap::new();
let mut full_properties: HashMap<String, String> = HashMap::new();
// Extract all properties from the event
for property in &event.properties {
properties.insert(
property.name.to_uppercase(),
property.value.clone().unwrap_or_default(),
);
let prop_name = property.name.to_uppercase();
let prop_value = 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
@@ -346,26 +359,26 @@ impl CalDAVClient {
.clone();
// Parse start time (required)
let start = properties
let start_prop = properties
.get("DTSTART")
.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)
let end = if let Some(dtend) = properties.get("DTEND") {
Some(self.parse_datetime(dtend, properties.get("DTEND"))?)
let (end_naive, end_tzid) = if let Some(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") {
// TODO: Parse duration and add to start time
Some(start)
(Some(start_naive), start_tzid.clone())
} else {
None
(None, None)
};
// Determine if it's an all-day event
let all_day = properties
.get("DTSTART")
.map(|s| !s.contains("T"))
.unwrap_or(false);
// 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
let status = properties
@@ -399,23 +412,35 @@ impl CalDAVClient {
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
.unwrap_or_default();
// Parse dates
let created = properties
.get("CREATED")
.and_then(|s| self.parse_datetime(s, None).ok());
let last_modified = properties
.get("LAST-MODIFIED")
.and_then(|s| self.parse_datetime(s, None).ok());
// Parse dates with timezone information
let (created_naive, created_tzid) = if let Some(created_str) = properties.get("CREATED") {
match self.parse_datetime_with_tz(created_str, None) {
Ok((dt, tz)) => (Some(dt), tz),
Err(_) => (None, None)
}
} else {
(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)
let exdate = self.parse_exdate(&event);
// Create VEvent with required fields
let mut vevent = VEvent::new(uid, start);
// Create VEvent with parsed naive datetime and timezone info
let mut vevent = VEvent::new(uid, start_naive);
// Set optional fields
vevent.dtend = end;
// Set optional fields with timezone information
vevent.dtend = end_naive;
vevent.dtstart_tzid = start_tzid;
vevent.dtend_tzid = end_tzid;
vevent.summary = properties.get("SUMMARY").cloned();
vevent.description = properties.get("DESCRIPTION").cloned();
vevent.location = properties.get("LOCATION").cloned();
@@ -438,10 +463,13 @@ impl CalDAVClient {
vevent.attendees = Vec::new();
vevent.categories = categories;
vevent.created = created;
vevent.last_modified = last_modified;
vevent.created = created_naive;
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.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;
// Parse alarms
@@ -568,14 +596,34 @@ impl CalDAVClient {
let mut all_calendars = Vec::new();
let mut has_valid_caldav_response = false;
for path in discovery_paths {
println!("Trying discovery path: {}", path);
if let Ok(calendars) = self.discover_calendars_at_path(&path).await {
println!("Found {} calendar(s) at {}", calendars.len(), path);
all_calendars.extend(calendars);
match 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);
}
Err(CalDAVError::ServerError(status)) => {
// HTTP error - this might be expected for some paths, continue trying
println!("Discovery path {} returned HTTP {}, trying next path", path, status);
}
Err(e) => {
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
println!("Discovery failed for path {}: {:?}", path, e);
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
all_calendars.sort();
all_calendars.dedup();
@@ -672,16 +720,37 @@ impl CalDAVClient {
Ok(calendar_paths)
}
/// Parse iCal datetime format
fn parse_datetime(
/// Parse iCal datetime format and return NaiveDateTime + timezone info
/// According to RFC 5545: if no TZID parameter is provided, treat as UTC
fn parse_datetime_with_tz(
&self,
datetime_str: &str,
_original_property: Option<&String>,
) -> Result<DateTime<Utc>, CalDAVError> {
use chrono::TimeZone;
original_property: Option<&String>,
) -> Result<(chrono::NaiveDateTime, Option<String>), CalDAVError> {
// 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();
// 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 = [
@@ -691,17 +760,230 @@ impl CalDAVClient {
];
for format in &formats {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, format) {
return Ok(Utc.from_utc_datetime(&dt));
// Try parsing as UTC format (with 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") {
// 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())));
}
// Try parsing as naive datetime
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()));
}
}
Err(CalDAVError::ParseError(format!(
"Unable to parse datetime: {}",
datetime_str
"Unable to parse datetime: {} (cleaned: {}, timezone: {:?})",
datetime_str, datetime_part, timezone_id
)))
}
@@ -1024,8 +1306,19 @@ impl CalDAVClient {
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
let format_datetime =
|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
let mut ical = String::new();
@@ -1042,15 +1335,77 @@ impl CalDAVClient {
if event.all_day {
ical.push_str(&format!(
"DTSTART;VALUE=DATE:{}\r\n",
format_date(&event.dtstart)
format_naive_date(&event.dtstart)
));
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 {
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 {
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)));
}
}
}
@@ -1106,7 +1461,18 @@ impl CalDAVClient {
// Creation and modification times
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)));
@@ -1163,10 +1529,10 @@ impl CalDAVClient {
if event.all_day {
ical.push_str(&format!(
"EXDATE;VALUE=DATE:{}\r\n",
format_date(exception_date)
format_naive_date(exception_date)
));
} 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

@@ -1,4 +1,4 @@
use chrono::{DateTime, Utc};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use sqlx::{FromRow, Result};
@@ -95,9 +95,42 @@ pub struct UserPreferences {
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>, // JSON string
pub last_used_calendar: Option<String>,
pub updated_at: DateTime<Utc>,
}
/// External calendar model
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct ExternalCalendar {
pub id: i32,
pub user_id: String,
pub name: String,
pub url: String,
pub color: String,
pub is_visible: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_fetched: Option<DateTime<Utc>>,
}
impl ExternalCalendar {
/// Create a new external calendar
pub fn new(user_id: String, name: String, url: String, color: String) -> Self {
let now = Utc::now();
Self {
id: 0, // Will be set by database
user_id,
name,
url,
color,
is_visible: true,
created_at: now,
updated_at: now,
last_fetched: None,
}
}
}
impl UserPreferences {
/// Create default preferences for a new user
pub fn default_for_user(user_id: String) -> Self {
@@ -109,6 +142,7 @@ impl UserPreferences {
calendar_theme: Some("light".to_string()),
calendar_style: Some("default".to_string()),
calendar_colors: None,
last_used_calendar: None,
updated_at: Utc::now(),
}
}
@@ -266,8 +300,8 @@ impl<'a> PreferencesRepository<'a> {
sqlx::query(
"INSERT INTO user_preferences
(user_id, calendar_selected_date, calendar_time_increment,
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, last_used_calendar, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&prefs.user_id)
.bind(&prefs.calendar_selected_date)
@@ -276,6 +310,7 @@ impl<'a> PreferencesRepository<'a> {
.bind(&prefs.calendar_theme)
.bind(&prefs.calendar_style)
.bind(&prefs.calendar_colors)
.bind(&prefs.last_used_calendar)
.bind(&prefs.updated_at)
.execute(self.db.pool())
.await?;
@@ -290,7 +325,7 @@ impl<'a> PreferencesRepository<'a> {
"UPDATE user_preferences
SET calendar_selected_date = ?, calendar_time_increment = ?,
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
calendar_colors = ?, updated_at = ?
calendar_colors = ?, last_used_calendar = ?, updated_at = ?
WHERE user_id = ?",
)
.bind(&prefs.calendar_selected_date)
@@ -299,11 +334,155 @@ impl<'a> PreferencesRepository<'a> {
.bind(&prefs.calendar_theme)
.bind(&prefs.calendar_style)
.bind(&prefs.calendar_colors)
.bind(&prefs.last_used_calendar)
.bind(Utc::now())
.bind(&prefs.user_id)
.execute(self.db.pool())
.await?;
Ok(())
}
}
/// Repository for ExternalCalendar operations
pub struct ExternalCalendarRepository<'a> {
db: &'a Database,
}
impl<'a> ExternalCalendarRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Get all external calendars for a user
pub async fn get_by_user(&self, user_id: &str) -> Result<Vec<ExternalCalendar>> {
sqlx::query_as::<_, ExternalCalendar>(
"SELECT * FROM external_calendars WHERE user_id = ? ORDER BY created_at ASC",
)
.bind(user_id)
.fetch_all(self.db.pool())
.await
}
/// Create a new external calendar
pub async fn create(&self, calendar: &ExternalCalendar) -> Result<i32> {
let result = sqlx::query(
"INSERT INTO external_calendars (user_id, name, url, color, is_visible, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&calendar.user_id)
.bind(&calendar.name)
.bind(&calendar.url)
.bind(&calendar.color)
.bind(&calendar.is_visible)
.bind(&calendar.created_at)
.bind(&calendar.updated_at)
.execute(self.db.pool())
.await?;
Ok(result.last_insert_rowid() as i32)
}
/// Update an external calendar
pub async fn update(&self, id: i32, calendar: &ExternalCalendar) -> Result<()> {
sqlx::query(
"UPDATE external_calendars
SET name = ?, url = ?, color = ?, is_visible = ?, updated_at = ?
WHERE id = ? AND user_id = ?",
)
.bind(&calendar.name)
.bind(&calendar.url)
.bind(&calendar.color)
.bind(&calendar.is_visible)
.bind(Utc::now())
.bind(id)
.bind(&calendar.user_id)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Delete an external calendar
pub async fn delete(&self, id: i32, user_id: &str) -> Result<()> {
sqlx::query("DELETE FROM external_calendars WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Update last_fetched timestamp
pub async fn update_last_fetched(&self, id: i32, user_id: &str) -> Result<()> {
sqlx::query(
"UPDATE external_calendars SET last_fetched = ? WHERE id = ? AND user_id = ?",
)
.bind(Utc::now())
.bind(id)
.bind(user_id)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Get cached ICS data for an external calendar
pub async fn get_cached_data(&self, external_calendar_id: i32) -> Result<Option<(String, DateTime<Utc>)>> {
let result = sqlx::query_as::<_, (String, DateTime<Utc>)>(
"SELECT ics_data, cached_at FROM external_calendar_cache WHERE external_calendar_id = ?",
)
.bind(external_calendar_id)
.fetch_optional(self.db.pool())
.await?;
Ok(result)
}
/// Update cache with new ICS data
pub async fn update_cache(&self, external_calendar_id: i32, ics_data: &str, etag: Option<&str>) -> Result<()> {
sqlx::query(
"INSERT INTO external_calendar_cache (external_calendar_id, ics_data, etag, cached_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(external_calendar_id) DO UPDATE SET
ics_data = excluded.ics_data,
etag = excluded.etag,
cached_at = excluded.cached_at",
)
.bind(external_calendar_id)
.bind(ics_data)
.bind(etag)
.bind(Utc::now())
.execute(self.db.pool())
.await?;
Ok(())
}
/// Check if cache is stale (older than max_age_minutes)
pub async fn is_cache_stale(&self, external_calendar_id: i32, max_age_minutes: i64) -> Result<bool> {
let cutoff_time = Utc::now() - Duration::minutes(max_age_minutes);
let result = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM external_calendar_cache
WHERE external_calendar_id = ? AND cached_at > ?",
)
.bind(external_calendar_id)
.bind(cutoff_time)
.fetch_one(self.db.pool())
.await?;
Ok(result == 0)
}
/// Clear cache for an external calendar
pub async fn clear_cache(&self, external_calendar_id: i32) -> Result<()> {
sqlx::query("DELETE FROM external_calendar_cache WHERE external_calendar_id = ?")
.bind(external_calendar_id)
.execute(self.db.pool())
.await?;
Ok(())
}
}

View File

@@ -1,12 +0,0 @@
// Re-export all handlers from the modular structure
mod auth;
mod calendar;
mod events;
mod preferences;
mod series;
pub use auth::{get_user_info, login, verify_token};
pub use calendar::{create_calendar, delete_calendar};
pub use events::{create_event, delete_event, get_calendar_events, refresh_event, update_event};
pub use preferences::{get_preferences, logout, update_preferences};
pub use series::{create_event_series, delete_event_series, update_event_series};

View File

@@ -93,6 +93,7 @@ pub async fn get_user_info(
path: path.clone(),
display_name: extract_calendar_name(path),
color: generate_calendar_color(path),
is_visible: true, // Default to visible
})
.collect();

View File

@@ -76,10 +76,54 @@ pub async fn get_calendar_events(
// If year and month are specified, filter events
if let (Some(year), Some(month)) = (params.year, params.month) {
let target_date = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
let month_start = target_date;
let month_end = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
} - chrono::Duration::days(1);
all_events.retain(|event| {
let event_year = event.dtstart.year();
let event_month = event.dtstart.month();
event_year == year && event_month == month
let event_date = event.dtstart.date();
// 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() {
let event_year = event.dtstart.year();
let event_month = event.dtstart.month();
return event_year == year && event_month == month;
}
// For recurring events, check if they could have instances in this month
// Include if:
// 1. The event starts before or during the requested month
// 2. The event doesn't have an UNTIL date, OR the UNTIL date is after the month start
if event_date > month_end {
// Event starts after the requested month
return false;
}
// Check UNTIL date in RRULE if present
if let Some(ref rrule) = event.rrule {
if let Some(until_pos) = rrule.find("UNTIL=") {
let until_part = &rrule[until_pos + 6..];
let until_end = until_part.find(';').unwrap_or(until_part.len());
let until_str = &until_part[..until_end];
// Try to parse UNTIL date (format: YYYYMMDDTHHMMSSZ or YYYYMMDD)
if until_str.len() >= 8 {
if let Ok(until_date) = chrono::NaiveDate::parse_from_str(&until_str[..8], "%Y%m%d") {
if until_date < month_start {
// Recurring event ended before the requested month
return false;
}
}
}
}
}
// Include the recurring event - the frontend will do proper expansion
true
});
}
@@ -190,26 +234,26 @@ pub async fn delete_event(
if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() {
// Recurring event - add EXDATE for this occurrence
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)
{
// RFC3339 format (with time and timezone)
date.with_timezone(&chrono::Utc)
// RFC3339 format (with time and timezone) - convert to naive
date.naive_utc()
} else if let Ok(naive_date) =
chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d")
{
// 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 {
return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date)));
};
let mut updated_event = event;
updated_event.exdate.push(exception_utc);
updated_event.exdate.push(exception_datetime);
println!(
"🔄 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
);
@@ -409,19 +453,33 @@ pub async fn create_event(
calendar_paths[0].clone()
};
// Parse dates and times
// Parse dates and times as local times (no UTC conversion)
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)))?;
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
let mut 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)))?;
// Validate that end is after start
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
// For all-day events, add one day to end date for RFC-5545 compliance
// RFC-5545 uses exclusive end dates for all-day events
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// Validate that end is after start (allow equal times for all-day events)
if request.all_day {
if end_datetime < start_datetime {
return Err(ApiError::BadRequest(
"End date must be on or after start date for all-day events".to_string(),
));
}
} else {
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
}
}
// Generate a unique UID for the event
@@ -536,9 +594,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);
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() {
None
} else {
@@ -699,24 +761,42 @@ pub async fn update_event(
let (mut event, calendar_path, event_href) = found_event
.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 =
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)))?;
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
let mut 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)))?;
// Validate that end is after start
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
// For all-day events, add one day to end date for RFC-5545 compliance
// RFC-5545 uses exclusive end dates for all-day events
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// Update event properties
// Validate that end is after start (allow equal times for all-day events)
if request.all_day {
if end_datetime < start_datetime {
return Err(ApiError::BadRequest(
"End date must be on or after start date for all-day events".to_string(),
));
}
} else {
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
}
}
// Update event properties with local times and timezone info
event.dtstart = start_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() {
None
} else {
@@ -750,6 +830,99 @@ pub async fn update_event(
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
println!(
"📝 Updating event {} at calendar_path: {}, event_href: {}",
@@ -768,32 +941,29 @@ pub async fn update_event(
}))
}
fn parse_event_datetime(
fn parse_event_datetime_local(
date_str: &str,
time_str: &str,
all_day: bool,
) -> Result<chrono::DateTime<chrono::Utc>, String> {
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
) -> 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 midnight UTC
// For all-day events, use start of day
let datetime = date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
Ok(Utc.from_utc_datetime(&datetime))
.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
let datetime = NaiveDateTime::new(date, time);
// Assume local time and convert to UTC (in a real app, you'd want timezone support)
Ok(Utc.from_utc_datetime(&datetime))
// Combine date and time - now keeping as local time
Ok(NaiveDateTime::new(date, time))
}
}

View File

@@ -0,0 +1,142 @@
use axum::{
extract::{Path, State},
response::Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::{
db::{ExternalCalendar, ExternalCalendarRepository},
models::ApiError,
AppState,
};
use super::auth::{extract_bearer_token};
#[derive(Debug, Deserialize)]
pub struct CreateExternalCalendarRequest {
pub name: String,
pub url: String,
pub color: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateExternalCalendarRequest {
pub name: String,
pub url: String,
pub color: String,
pub is_visible: bool,
}
#[derive(Debug, Serialize)]
pub struct ExternalCalendarResponse {
pub id: i32,
pub name: String,
pub url: String,
pub color: String,
pub is_visible: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
}
impl From<ExternalCalendar> for ExternalCalendarResponse {
fn from(calendar: ExternalCalendar) -> Self {
Self {
id: calendar.id,
name: calendar.name,
url: calendar.url,
color: calendar.color,
is_visible: calendar.is_visible,
created_at: calendar.created_at,
updated_at: calendar.updated_at,
last_fetched: calendar.last_fetched,
}
}
}
pub async fn get_external_calendars(
headers: axum::http::HeaderMap,
State(app_state): State<Arc<AppState>>,
) -> Result<Json<Vec<ExternalCalendarResponse>>, ApiError> {
// Extract and verify token, get user
let token = extract_bearer_token(&headers)?;
let user = app_state.auth_service.get_user_from_token(&token).await?;
let repo = ExternalCalendarRepository::new(&app_state.db);
let calendars = repo
.get_by_user(&user.id)
.await
.map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?;
let response: Vec<ExternalCalendarResponse> = calendars.into_iter().map(Into::into).collect();
Ok(Json(response))
}
pub async fn create_external_calendar(
headers: axum::http::HeaderMap,
State(app_state): State<Arc<AppState>>,
Json(request): Json<CreateExternalCalendarRequest>,
) -> Result<Json<ExternalCalendarResponse>, ApiError> {
let token = extract_bearer_token(&headers)?;
let user = app_state.auth_service.get_user_from_token(&token).await?;
let calendar = ExternalCalendar::new(
user.id,
request.name,
request.url,
request.color,
);
let repo = ExternalCalendarRepository::new(&app_state.db);
let id = repo
.create(&calendar)
.await
.map_err(|e| ApiError::Database(format!("Failed to create external calendar: {}", e)))?;
let mut created_calendar = calendar;
created_calendar.id = id;
Ok(Json(created_calendar.into()))
}
pub async fn update_external_calendar(
headers: axum::http::HeaderMap,
State(app_state): State<Arc<AppState>>,
Path(id): Path<i32>,
Json(request): Json<UpdateExternalCalendarRequest>,
) -> Result<Json<()>, ApiError> {
let token = extract_bearer_token(&headers)?;
let user = app_state.auth_service.get_user_from_token(&token).await?;
let mut calendar = ExternalCalendar::new(
user.id,
request.name,
request.url,
request.color,
);
calendar.is_visible = request.is_visible;
let repo = ExternalCalendarRepository::new(&app_state.db);
repo.update(id, &calendar)
.await
.map_err(|e| ApiError::Database(format!("Failed to update external calendar: {}", e)))?;
Ok(Json(()))
}
pub async fn delete_external_calendar(
headers: axum::http::HeaderMap,
State(app_state): State<Arc<AppState>>,
Path(id): Path<i32>,
) -> Result<Json<()>, ApiError> {
let token = extract_bearer_token(&headers)?;
let user = app_state.auth_service.get_user_from_token(&token).await?;
let repo = ExternalCalendarRepository::new(&app_state.db);
repo.delete(id, &user.id)
.await
.map_err(|e| ApiError::Database(format!("Failed to delete external calendar: {}", e)))?;
Ok(Json(()))
}

View File

@@ -0,0 +1,913 @@
use axum::{
extract::{Path, State},
response::Json,
};
use chrono::{DateTime, Utc, Datelike};
use ical::parser::ical::component::IcalEvent;
use reqwest::Client;
use serde::Serialize;
use std::sync::Arc;
use crate::{
db::ExternalCalendarRepository,
models::ApiError,
AppState,
};
// Import VEvent from calendar-models shared crate
use calendar_models::VEvent;
use super::auth::{extract_bearer_token};
#[derive(Debug, Serialize)]
pub struct ExternalCalendarEventsResponse {
pub events: Vec<VEvent>,
pub last_fetched: DateTime<Utc>,
}
pub async fn fetch_external_calendar_events(
headers: axum::http::HeaderMap,
State(app_state): State<Arc<AppState>>,
Path(id): Path<i32>,
) -> Result<Json<ExternalCalendarEventsResponse>, ApiError> {
let token = extract_bearer_token(&headers)?;
let user = app_state.auth_service.get_user_from_token(&token).await?;
let repo = ExternalCalendarRepository::new(&app_state.db);
// Get user's external calendars to verify ownership and get URL
let calendars = repo
.get_by_user(&user.id)
.await
.map_err(|e| ApiError::Database(format!("Failed to get external calendars: {}", e)))?;
let calendar = calendars
.into_iter()
.find(|c| c.id == id)
.ok_or_else(|| ApiError::NotFound("External calendar not found".to_string()))?;
if !calendar.is_visible {
return Ok(Json(ExternalCalendarEventsResponse {
events: vec![],
last_fetched: Utc::now(),
}));
}
// Check cache first
let cache_max_age_minutes = 5;
let mut ics_content = String::new();
let mut last_fetched = Utc::now();
let mut fetched_from_cache = false;
// Try to get from cache if not stale
match repo.is_cache_stale(id, cache_max_age_minutes).await {
Ok(is_stale) => {
if !is_stale {
// Cache is fresh, use it
if let Ok(Some((cached_data, cached_at))) = repo.get_cached_data(id).await {
ics_content = cached_data;
last_fetched = cached_at;
fetched_from_cache = true;
}
}
}
Err(_) => {
// If cache check fails, proceed to fetch from URL
}
}
// If not fetched from cache, get from external URL
if !fetched_from_cache {
// Log the URL being fetched for debugging
println!("🌍 Fetching calendar URL: {}", calendar.url);
let user_agents = vec![
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (compatible; Runway Calendar/1.0)",
"Outlook-iOS/709.2226530.prod.iphone (3.24.1)"
];
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
.text()
.await
.map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?;
// Store in cache for future requests
let etag = None; // TODO: Extract ETag from response headers if available
if let Err(_) = repo.update_cache(id, &ics_content, etag).await {
// Log error but don't fail the request
}
// Update last_fetched timestamp
if let Err(_) = repo.update_last_fetched(id, &user.id).await {
}
last_fetched = Utc::now();
}
// Parse ICS content
let events = parse_ics_content(&ics_content)
.map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?;
Ok(Json(ExternalCalendarEventsResponse {
events,
last_fetched,
}))
}
fn parse_ics_content(ics_content: &str) -> Result<Vec<VEvent>, Box<dyn std::error::Error>> {
let reader = ical::IcalParser::new(ics_content.as_bytes());
let mut events = Vec::new();
let mut _total_components = 0;
let mut _failed_conversions = 0;
for calendar in reader {
let calendar = calendar?;
for component in calendar.events {
_total_components += 1;
match convert_ical_to_vevent(component) {
Ok(vevent) => {
events.push(vevent);
}
Err(_) => {
_failed_conversions += 1;
}
}
}
}
// 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)
}
fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::error::Error>> {
use uuid::Uuid;
let mut summary = None;
let mut description = None;
let mut location = None;
let mut dtstart = None;
let mut dtend = None;
let mut uid = None;
let mut all_day = false;
let mut rrule = None;
// Extract properties
for property in ical_event.properties {
match property.name.as_str() {
"SUMMARY" => {
summary = property.value;
}
"DESCRIPTION" => {
description = property.value;
}
"LOCATION" => {
location = property.value;
}
"DTSTART" => {
if let Some(value) = property.value {
// Check if it's a date-only value (all-day event)
if value.len() == 8 && !value.contains('T') {
all_day = true;
// Parse YYYYMMDD format
if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") {
dtstart = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap()));
}
} else {
// Extract timezone info from parameters
let tzid = property.params.as_ref()
.and_then(|params| params.iter().find(|(k, _)| k == "TZID"))
.and_then(|(_, v)| v.first().cloned());
// Parse datetime with timezone information
if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) {
dtstart = Some(dt);
}
}
}
}
"DTEND" => {
if let Some(value) = property.value {
if all_day && value.len() == 8 && !value.contains('T') {
// For all-day events, DTEND is exclusive so use the date as-is at noon
if let Ok(date) = chrono::NaiveDate::parse_from_str(&value, "%Y%m%d") {
dtend = Some(chrono::TimeZone::from_utc_datetime(&Utc, &date.and_hms_opt(12, 0, 0).unwrap()));
}
} else {
// Extract timezone info from parameters
let tzid = property.params.as_ref()
.and_then(|params| params.iter().find(|(k, _)| k == "TZID"))
.and_then(|(_, v)| v.first().cloned());
// Parse datetime with timezone information
if let Some(dt) = parse_datetime_with_tz(&value, tzid.as_deref()) {
dtend = Some(dt);
}
}
}
}
"UID" => {
uid = property.value;
}
"RRULE" => {
rrule = property.value;
}
_ => {} // Ignore other properties for now
}
}
let dtstart = dtstart.ok_or("Missing DTSTART")?;
let vevent = VEvent {
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
dtstart: dtstart.naive_utc(),
dtstart_tzid: None, // TODO: Parse timezone from ICS
dtend: dtend.map(|dt| dt.naive_utc()),
dtend_tzid: None, // TODO: Parse timezone from ICS
summary,
description,
location,
all_day,
rrule,
rdate: Vec::new(),
rdate_tzid: None,
exdate: Vec::new(), // External calendars don't need exception handling
exdate_tzid: None,
recurrence_id: None,
recurrence_id_tzid: None,
created: None,
created_tzid: None,
last_modified: None,
last_modified_tzid: None,
dtstamp: Utc::now(),
sequence: Some(0),
status: None,
transp: None,
organizer: None,
attendees: Vec::new(),
url: None,
attachments: Vec::new(),
categories: Vec::new(),
priority: None,
resources: Vec::new(),
related_to: None,
geo: None,
duration: None,
class: None,
contact: None,
comment: None,
alarms: Vec::new(),
etag: None,
href: None,
calendar_path: None,
};
Ok(vevent)
}
fn parse_datetime_with_tz(datetime_str: &str, tzid: Option<&str>) -> Option<DateTime<Utc>> {
use chrono::TimeZone;
use chrono_tz::Tz;
// Try various datetime formats commonly found in ICS files
// Format: 20231201T103000Z (UTC) - handle as naive datetime first
if datetime_str.ends_with('Z') {
let datetime_without_z = &datetime_str[..datetime_str.len()-1];
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_without_z, "%Y%m%dT%H%M%S") {
return Some(naive_dt.and_utc());
}
}
// Format: 20231201T103000-0500 (with timezone offset)
if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S%z") {
return Some(dt.with_timezone(&Utc));
}
// Format: 2023-12-01T10:30:00Z (ISO format)
if let Ok(dt) = DateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%SZ") {
return Some(dt.with_timezone(&Utc));
}
// Handle naive datetime with timezone parameter
let naive_dt = if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y%m%dT%H%M%S") {
Some(dt)
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") {
Some(dt)
} else {
None
};
if let Some(naive_dt) = naive_dt {
// If TZID is provided, try to parse it
if let Some(tzid_str) = tzid {
// Handle common timezone formats
let tz_result = if tzid_str.starts_with("/mozilla.org/") {
// Mozilla/Thunderbird format: /mozilla.org/20070129_1/Europe/London
tzid_str.split('/').last().and_then(|tz_name| tz_name.parse::<Tz>().ok())
} else if tzid_str.contains('/') {
// Standard timezone format: America/New_York, Europe/London
tzid_str.parse::<Tz>().ok()
} else {
// Try common abbreviations and Windows timezone names
match tzid_str {
// 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),
"Afghanistan Standard Time" => Some(Tz::Asia__Kabul),
"Iran Standard Time" => Some(Tz::Asia__Tehran),
"Arabian Standard Time" => Some(Tz::Asia__Dubai),
"Arab Standard Time" => Some(Tz::Asia__Riyadh),
"Israel Standard Time" => Some(Tz::Asia__Jerusalem),
"Jordan Standard Time" => Some(Tz::Asia__Amman),
"Syria Standard Time" => Some(Tz::Asia__Damascus),
"Middle East Standard Time" => Some(Tz::Asia__Beirut),
"Egypt Standard Time" => Some(Tz::Africa__Cairo),
"South Africa Standard Time" => Some(Tz::Africa__Johannesburg),
"E. Africa Standard Time" => Some(Tz::Africa__Nairobi),
"W. Central Africa Standard Time" => Some(Tz::Africa__Lagos),
// Asia Pacific
"AUS Eastern Standard Time" => Some(Tz::Australia__Sydney),
"AUS Central Standard Time" => Some(Tz::Australia__Darwin),
"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),
// South America
"Argentina Standard Time" => Some(Tz::America__Buenos_Aires),
"E. South America Standard Time" => Some(Tz::America__Sao_Paulo),
"SA Eastern Standard Time" => Some(Tz::America__Cayenne),
"SA Pacific Standard Time" => Some(Tz::America__Bogota),
"SA Western Standard Time" => Some(Tz::America__La_Paz),
"Pacific SA Standard Time" => Some(Tz::America__Santiago),
"Venezuela Standard Time" => Some(Tz::America__Caracas),
"Montevideo Standard Time" => Some(Tz::America__Montevideo),
// Try parsing as IANA name
_ => tzid_str.parse::<Tz>().ok()
}
};
if let Some(tz) = tz_result {
if let Some(dt_with_tz) = tz.from_local_datetime(&naive_dt).single() {
return Some(dt_with_tz.with_timezone(&Utc));
}
}
}
// If no timezone info or parsing failed, treat as UTC (safer than local time assumptions)
return Some(chrono::TimeZone::from_utc_datetime(&Utc, &naive_dt));
}
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;
let original_count = events.len();
// First pass: Group by UID and prefer recurring events over single events with same UID
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
for event in events.drain(..) {
// Debug logging to understand what's happening
println!("🔍 Event: '{}' at {} (RRULE: {}) - UID: {}",
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
event.dtstart.format("%Y-%m-%d %H:%M"),
if event.rrule.is_some() { "Yes" } else { "No" },
event.uid
);
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
println!("🔍 Found {} events with UID '{}'", events_with_uid.len(), uid);
// 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();
println!("🔄 UID dedup: Keeping '{}' (RRULE: {})",
preferred_event.summary.as_ref().unwrap_or(&"No Title".to_string()),
if preferred_event.rrule.is_some() { "Yes" } else { "No" }
);
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
println!("🔍 Found {} events with title '{}'", events_with_title.len(), title);
// Check if these are actually different recurring patterns for the same logical event
let consolidated = consolidate_same_title_events(events_with_title);
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 {
println!("🔄 Replacing single event: Keeping '{}' over '{}'",
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
);
deduplicated_single[existing_index] = event;
} else {
println!("🚫 Discarding duplicate single event: Keeping existing '{}'",
existing_event.summary.as_ref().unwrap_or(&"No Title".to_string())
);
}
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 {
println!("🚫 Discarding RRULE-generated instance: '{}' at {} would be generated by recurring event",
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
event.dtstart.format("%Y-%m-%d %H:%M")
);
} else {
// This is a unique single event
seen_single.insert(dedup_key, deduplicated_single.len());
deduplicated_single.push(event);
}
}
// Combine recurring and single events
let mut result = deduplicated_recurring;
result.extend(deduplicated_single);
println!("📊 Deduplication complete: {} -> {} events ({} recurring, {} single)",
original_count, result.len(),
result.iter().filter(|e| e.rrule.is_some()).count(),
result.iter().filter(|e| e.rrule.is_none()).count()
);
result
}
/// 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
for event in &events {
if let Some(rrule) = &event.rrule {
println!("🔍 RRULE for '{}': {}",
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
rrule
);
}
}
// Check if all events have similar time patterns and could be consolidated
let first_event = &events[0];
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 {
println!("🚫 Cannot consolidate events - different times or durations");
// 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) {
println!("✅ Successfully consolidated {} weekly patterns into one", events.len());
return vec![consolidated];
}
}
// If we can't consolidate, just deduplicate exact matches and keep the most complete one
println!("🚫 Cannot consolidate - keeping most complete event");
let deduplicated = deduplicate_exact_recurring_events(events);
// If we still have multiple events, keep only the most complete one
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

@@ -0,0 +1,15 @@
pub mod auth;
pub mod calendar;
pub mod events;
pub mod external_calendars;
pub mod ics_fetcher;
pub mod preferences;
pub mod series;
pub use auth::*;
pub use calendar::*;
pub use events::*;
pub use external_calendars::*;
pub use ics_fetcher::*;
pub use preferences::*;
pub use series::*;

View File

@@ -40,6 +40,7 @@ pub async fn get_preferences(
calendar_theme: preferences.calendar_theme,
calendar_style: preferences.calendar_style,
calendar_colors: preferences.calendar_colors,
last_used_calendar: preferences.last_used_calendar,
}))
}
@@ -85,6 +86,9 @@ pub async fn update_preferences(
if request.calendar_colors.is_some() {
preferences.calendar_colors = request.calendar_colors;
}
if request.last_used_calendar.is_some() {
preferences.last_used_calendar = request.last_used_calendar;
}
prefs_repo
.update(&preferences)
@@ -100,6 +104,7 @@ pub async fn update_preferences(
calendar_theme: preferences.calendar_theme,
calendar_style: preferences.calendar_style,
calendar_colors: preferences.calendar_colors,
last_used_calendar: preferences.last_used_calendar,
}),
))
}

View File

@@ -14,6 +14,33 @@ use calendar_models::{EventClass, EventStatus, VEvent};
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
pub async fn create_event_series(
State(state): State<Arc<AppState>>,
@@ -106,75 +133,29 @@ pub async fn create_event_series(
println!("📅 Using calendar path: {}", calendar_path);
// Parse datetime components
let start_date =
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| {
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
})?;
// Parse dates and times as local times (no UTC conversion)
let start_datetime = parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let (start_datetime, end_datetime) = if request.all_day {
// For all-day events, use the dates as-is
let start_dt = start_date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
let mut 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)))?;
let end_date = if !request.end_date.is_empty() {
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())
})?
} else {
start_date
};
let end_dt = end_date
.and_hms_opt(23, 59, 59)
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
(
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
)
} 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)
};
(
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
)
};
// For all-day events, add one day to end date for RFC-5545 compliance
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// Generate a unique UID for the series
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);
event.dtend = Some(end_datetime);
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() {
None
} else {
@@ -245,9 +226,11 @@ pub async fn update_event_series(
Json(request): Json<UpdateEventSeriesRequest>,
) -> Result<Json<UpdateEventSeriesResponse>, ApiError> {
println!(
"🔄 Update event series request received: series_uid='{}', update_scope='{}'",
request.series_uid, request.update_scope
"🔄 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
);
println!("🕐 SERIES: Received start_date: '{}', start_time: '{}', timezone: '{}'",
request.start_date, request.start_time, request.timezone);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
@@ -363,7 +346,7 @@ pub async fn update_event_series(
);
// 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 "all_in_series" updates, preserve the original series start date
@@ -380,8 +363,9 @@ pub async fn update_event_series(
};
let (start_datetime, end_datetime) = if request.all_day {
// For all-day events, use noon UTC to avoid timezone boundary issues
let start_dt = start_date
.and_hms_opt(0, 0, 0)
.and_hms_opt(12, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
// For all-day events, also preserve the original date pattern
@@ -389,7 +373,7 @@ pub async fn update_event_series(
// Calculate the duration from the original event
let original_duration_days = existing_event
.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);
start_date + chrono::Duration::days(original_duration_days)
} else {
@@ -397,13 +381,11 @@ pub async fn update_event_series(
};
let end_dt = end_date
.and_hms_opt(23, 59, 59)
.and_hms_opt(12, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
(
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
)
// For all-day events, use local times directly
(start_dt, end_dt)
} else {
let start_time = if !request.start_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
@@ -434,13 +416,11 @@ pub async fn update_event_series(
.dtend
.map(|end| end - existing_event.dtstart)
.unwrap_or_else(|| chrono::Duration::hours(1));
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
start_dt + original_duration
};
(
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
)
// Frontend now sends local times, so use them directly
(start_dt, end_dt)
};
// Handle different update scopes
@@ -687,8 +667,8 @@ fn build_series_rrule_with_freq(
fn update_entire_series(
existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>,
end_datetime: chrono::DateTime<chrono::Utc>,
start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::NaiveDateTime,
) -> Result<(VEvent, u32), ApiError> {
// Clone the existing event to preserve all metadata
let mut updated_event = existing_event.clone();
@@ -696,6 +676,8 @@ fn update_entire_series(
// Update only the modified properties from the request
updated_event.dtstart = start_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() {
existing_event.summary.clone() // Keep original if empty
} else {
@@ -728,13 +710,41 @@ fn update_entire_series(
// Update timestamps
let now = chrono::Utc::now();
let now_naive = now.naive_utc();
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
// For simple updates (like drag operations), preserve the existing RRULE
// For more complex updates, we might need to regenerate it, but for now keep it simple
// updated_event.rrule remains unchanged from the clone
// Update RRULE if recurrence parameters are provided
if let Some(ref existing_rrule) = updated_event.rrule {
let mut new_rrule = existing_rrule.clone();
println!("🔄 Original RRULE: {}", existing_rrule);
// Update COUNT if provided
if let Some(count) = request.recurrence_count {
println!("🔄 Updating RRULE with new COUNT: {}", count);
// Remove old COUNT or UNTIL parameters
new_rrule = new_rrule.split(';')
.filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL="))
.collect::<Vec<_>>()
.join(";");
// Add new COUNT
new_rrule = format!("{};COUNT={}", new_rrule, count);
} else if let Some(ref end_date) = request.recurrence_end_date {
println!("🔄 Updating RRULE with new UNTIL: {}", end_date);
// Remove old COUNT or UNTIL parameters
new_rrule = new_rrule.split(';')
.filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL="))
.collect::<Vec<_>>()
.join(";");
// Add new UNTIL (convert YYYY-MM-DD to YYYYMMDD format)
let until_date = end_date.replace("-", "");
new_rrule = format!("{};UNTIL={}", new_rrule, until_date);
}
println!("🔄 Updated RRULE: {}", new_rrule);
updated_event.rrule = Some(new_rrule);
}
// Copy the updated event back to existing_event for the main handler
*existing_event = updated_event.clone();
@@ -790,8 +800,8 @@ fn update_entire_series(
async fn update_this_and_future(
existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>,
end_datetime: chrono::DateTime<chrono::Utc>,
start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::NaiveDateTime,
client: &CalDAVClient,
calendar_path: &str,
) -> Result<(VEvent, u32), ApiError> {
@@ -839,6 +849,8 @@ async fn update_this_and_future(
new_series.uid = new_series_uid.clone();
new_series.dtstart = start_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() {
None
} else {
@@ -871,9 +883,10 @@ async fn update_this_and_future(
// Update timestamps
let now = chrono::Utc::now();
let now_naive = now.naive_utc();
new_series.dtstamp = now;
new_series.created = Some(now);
new_series.last_modified = Some(now);
new_series.created = Some(now_naive);
new_series.last_modified = Some(now_naive);
new_series.href = None; // Will be set when created
println!(
@@ -901,8 +914,8 @@ async fn update_this_and_future(
async fn update_single_occurrence(
existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>,
end_datetime: chrono::DateTime<chrono::Utc>,
start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::NaiveDateTime,
client: &CalDAVClient,
calendar_path: &str,
_original_event_href: &str,
@@ -927,21 +940,20 @@ async fn update_single_occurrence(
// Create the EXDATE datetime using the original event's time
let original_time = existing_event.dtstart.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
println!(
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
existing_event.exdate
);
existing_event.exdate.push(exception_utc);
existing_event.exdate.push(exception_datetime);
println!(
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
existing_event.exdate
);
println!(
"🚫 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
@@ -953,6 +965,8 @@ async fn update_single_occurrence(
// Update the modified properties from the request
exception_event.dtstart = start_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() {
existing_event.summary.clone() // Keep original if empty
} else {
@@ -985,8 +999,9 @@ async fn update_single_occurrence(
// Update timestamps for the exception event
let now = chrono::Utc::now();
let now_naive = now.naive_utc();
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
// Set RECURRENCE-ID to point to the original occurrence
@@ -1002,7 +1017,7 @@ async fn update_single_occurrence(
println!(
"✨ 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)
@@ -1130,15 +1145,14 @@ async fn delete_single_occurrence(
// Create the EXDATE datetime (use the same time as the original event)
let original_time = existing_event.dtstart.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
let mut updated_event = existing_event;
updated_event.exdate.push(exception_utc);
updated_event.exdate.push(exception_datetime);
println!(
"🗑️ 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

View File

@@ -1,6 +1,6 @@
use axum::{
response::Json,
routing::{get, post},
routing::{delete, get, post},
Router,
};
use std::sync::Arc;
@@ -72,6 +72,12 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/preferences", get(handlers::get_preferences))
.route("/api/preferences", post(handlers::update_preferences))
.route("/api/auth/logout", post(handlers::logout))
// External calendars endpoints
.route("/api/external-calendars", get(handlers::get_external_calendars))
.route("/api/external-calendars", post(handlers::create_external_calendar))
.route("/api/external-calendars/:id", post(handlers::update_external_calendar))
.route("/api/external-calendars/:id", delete(handlers::delete_external_calendar))
.route("/api/external-calendars/:id/events", get(handlers::fetch_external_calendar_events))
.layer(
CorsLayer::new()
.allow_origin(Any)

View File

@@ -30,6 +30,7 @@ pub struct UserPreferencesResponse {
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>,
pub last_used_calendar: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -40,6 +41,7 @@ pub struct UpdatePreferencesRequest {
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>,
pub last_used_calendar: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -54,6 +56,7 @@ pub struct CalendarInfo {
pub path: String,
pub display_name: String,
pub color: String,
pub is_visible: bool,
}
#[derive(Debug, Deserialize)]
@@ -114,6 +117,7 @@ pub struct CreateEventRequest {
pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
}
#[derive(Debug, Serialize)]
@@ -143,8 +147,12 @@ pub struct UpdateEventRequest {
pub reminder: String, // reminder type
pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
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 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")]
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
}
@@ -182,6 +190,7 @@ pub struct CreateEventSeriesRequest {
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
pub recurrence_count: Option<u32>, // Number of occurrences
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)]
@@ -224,6 +233,7 @@ pub struct UpdateEventSeriesRequest {
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 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)]

View File

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

Binary file not shown.

View File

@@ -1,10 +1,11 @@
services:
calendar-backend:
build: .
build:
context: .
dockerfile: ./backend/Dockerfile
ports:
- "3000:3000"
volumes:
- ./data/site_dist:/srv/www
- ./data/db:/db
calendar-frontend:
@@ -15,7 +16,7 @@ services:
- "80:80"
- "443:443"
volumes:
- ./data/site_dist:/srv/www:ro
- ./frontend/dist:/srv/www:ro
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data/caddy/data:/data
- ./data/caddy/config:/config

6
deploy_frontend.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/sh
export BACKEND_API_URL="https://runway.rcjohnstone.com/api"
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/
unset BACKEND_API_URL

BIN
favicon_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

View File

@@ -1,5 +1,5 @@
[package]
name = "calendar-app"
name = "runway"
version = "0.1.0"
edition = "2021"
@@ -22,14 +22,19 @@ web-sys = { version = "0.3", features = [
"Document",
"Window",
"Location",
"Navigator",
"DomTokenList",
"Headers",
"Request",
"RequestInit",
"RequestMode",
"Response",
"CssStyleDeclaration",
"MediaQueryList",
"MediaQueryListEvent",
] }
wasm-bindgen = "0.2"
js-sys = "0.3"
# HTTP client for CalDAV requests
reqwest = { version = "0.11", features = ["json"] }
@@ -37,6 +42,7 @@ reqwest = { version = "0.11", features = ["json"] }
ical = "0.7"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde-wasm-bindgen = "0.6"
# Date and time handling
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }

View File

@@ -6,7 +6,7 @@ dist = "dist"
BACKEND_API_URL = "http://localhost:3000/api"
[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/"]
[serve]

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -2,18 +2,21 @@
<html>
<head>
<meta charset="utf-8" />
<title>Calendar App</title>
<title>Runway</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base data-trunk-public-url />
<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="icon" href="favicon.ico">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>
<script>
console.log("HTML loaded, waiting for WASM...");
console.log("HTML fully loaded, waiting for WASM...");
window.addEventListener('TrunkApplicationStarted', () => {
console.log("Trunk application started successfully!");
});
</script>
</body>
</html>
</html>

1215
frontend/print-preview.css Normal file

File diff suppressed because it is too large Load Diff

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
}
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
async fn post_json<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,

View File

@@ -1,8 +1,8 @@
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::services::{calendar_service::UserInfo, CalendarService};
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
use chrono::{Datelike, Duration, Local, NaiveDate};
use gloo_storage::{LocalStorage, Storage};
use std::collections::HashMap;
@@ -14,6 +14,10 @@ pub struct CalendarProps {
#[prop_or_default]
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub external_calendar_events: Vec<VEvent>,
#[prop_or_default]
pub external_calendars: Vec<ExternalCalendar>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
@@ -28,7 +32,7 @@ pub struct CalendarProps {
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)>,
@@ -101,10 +105,14 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let loading = loading.clone();
let error = error.clone();
let current_date = current_date.clone();
let external_events = props.external_calendar_events.clone(); // Clone before the effect
let view = props.view.clone(); // Clone before the effect
use_effect_with((*current_date, props.view.clone()), move |(date, _view)| {
use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| {
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
let date = *date; // Clone the date to avoid lifetime issues
let 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
if let Some(token) = auth_token {
let events = events.clone();
@@ -141,7 +149,38 @@ pub fn Calendar(props: &CalendarProps) -> Html {
.await
{
Ok(vevents) => {
let grouped_events = CalendarService::group_events_by_date(vevents);
// Filter CalDAV events based on calendar visibility
let mut filtered_events = if let Some(user_info) = user_info.as_ref() {
vevents.into_iter()
.filter(|event| {
if let Some(calendar_path) = event.calendar_path.as_ref() {
// Find the calendar info for this event
user_info.calendars.iter()
.find(|cal| &cal.path == calendar_path)
.map(|cal| cal.is_visible)
.unwrap_or(true) // Default to visible if not found
} else {
true // Show events without calendar path
}
})
.collect()
} else {
vevents // Show all events if no user info
};
// Mark external events as external by adding a special category
let marked_external_events: Vec<VEvent> = external_events
.into_iter()
.map(|mut event| {
// Add a special category to identify external events
event.categories.push("__EXTERNAL_CALENDAR__".to_string());
event
})
.collect();
filtered_events.extend(marked_external_events);
let grouped_events = CalendarService::group_events_by_date(filtered_events);
events.set(grouped_events);
loading.set(false);
}
@@ -350,6 +389,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
let on_create_event = {
let show_create_modal = show_create_modal.clone();
@@ -389,7 +437,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)| {
@@ -418,6 +466,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_today={on_today}
time_increment={Some(*time_increment)}
on_time_increment_toggle={Some(on_time_increment_toggle)}
on_print={Some(on_print)}
/>
{
@@ -452,6 +501,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_event_click={on_event_click.clone()}
refreshing_event_uid={(*refreshing_event_uid).clone()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
selected_date={Some(*selected_date)}
@@ -467,6 +517,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_event_click={on_event_click.clone()}
refreshing_event_uid={(*refreshing_event_uid).clone()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
on_create_event={Some(on_create_event)}
@@ -521,16 +572,33 @@ pub fn Calendar(props: &CalendarProps) -> Html {
}
})
}}
on_update={{
let show_create_modal = show_create_modal.clone();
let create_event_data = create_event_data.clone();
Callback::from(move |(_original_event, _updated_data): (VEvent, EventCreationData)| {
show_create_modal.set(false);
create_event_data.set(None);
// TODO: Handle actual event update
})
}}
/>
// 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>
}
}

View File

@@ -18,9 +18,45 @@ pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
return html! {};
}
// Smart positioning to keep menu within viewport
let (x, y) = {
let mut x = props.x;
let mut y = props.y;
// Try to get actual viewport dimensions
if let Some(window) = web_sys::window() {
if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) {
if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) {
let viewport_width = w as i32;
let viewport_height = h as i32;
// Calendar context menu: "Create Event" with icon
let menu_width = 180; // "Create Event" text + icon + padding
let menu_height = 60; // Single item + padding + margins
// Adjust horizontally if too close to right edge
if x + menu_width > viewport_width - 10 {
x = x.saturating_sub(menu_width);
}
// Adjust vertically if too close to bottom edge
if y + menu_height > viewport_height - 10 {
y = y.saturating_sub(menu_height);
}
// Ensure minimum margins from edges
x = x.max(5);
y = y.max(5);
}
}
}
(x, y)
};
let style = format!(
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
props.x, props.y
x, y
);
let on_create_event_click = {

View File

@@ -14,6 +14,8 @@ pub struct CalendarHeaderProps {
pub time_increment: Option<u32>,
#[prop_or_default]
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
#[prop_or_default]
pub on_print: Option<Callback<MouseEvent>>,
}
#[function_component(CalendarHeader)]
@@ -39,6 +41,17 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> 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>
<h2 class="month-year">{title}</h2>
<div class="header-right">

View File

@@ -10,6 +10,7 @@ pub struct CalendarListItemProps {
pub on_color_picker_toggle: Callback<String>, // calendar_path
pub available_colors: Vec<String>,
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
pub on_visibility_toggle: Callback<String>, // calendar_path
}
#[function_component(CalendarListItem)]
@@ -32,44 +33,59 @@ pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
})
};
let on_visibility_toggle = {
let cal_path = props.calendar.path.clone();
let on_visibility_toggle = props.on_visibility_toggle.clone();
Callback::from(move |_| {
on_visibility_toggle.emit(cal_path.clone());
})
};
html! {
<li key={props.calendar.path.clone()} oncontextmenu={on_context_menu}>
<span class="calendar-color"
style={format!("background-color: {}", props.calendar.color)}
onclick={on_color_click}>
{
if props.color_picker_open {
html! {
<div class="color-picker">
{
props.available_colors.iter().map(|color| {
let color_str = color.clone();
let cal_path = props.calendar.path.clone();
let on_color_change = props.on_color_change.clone();
<div class="calendar-info">
<input
type="checkbox"
checked={props.calendar.is_visible}
onchange={on_visibility_toggle}
/>
<span class="calendar-color"
style={format!("background-color: {}", props.calendar.color)}
onclick={on_color_click}>
{
if props.color_picker_open {
html! {
<div class="color-picker-dropdown">
{
props.available_colors.iter().map(|color| {
let color_str = color.clone();
let cal_path = props.calendar.path.clone();
let on_color_change = props.on_color_change.clone();
let on_color_select = Callback::from(move |_: MouseEvent| {
on_color_change.emit((cal_path.clone(), color_str.clone()));
});
let on_color_select = Callback::from(move |_: MouseEvent| {
on_color_change.emit((cal_path.clone(), color_str.clone()));
});
let is_selected = props.calendar.color == *color;
let class_name = if is_selected { "color-option selected" } else { "color-option" };
let is_selected = props.calendar.color == *color;
let class_name = if is_selected { "color-option selected" } else { "color-option" };
html! {
<div class={class_name}
style={format!("background-color: {}", color)}
onclick={on_color_select}>
</div>
}
}).collect::<Html>()
}
</div>
html! {
<div class={class_name}
style={format!("background-color: {}", color)}
onclick={on_color_select}>
</div>
}
}).collect::<Html>()
}
</div>
}
} else {
html! {}
}
} else {
html! {}
}
}
</span>
<span class="calendar-name">{&props.calendar.display_name}</span>
</span>
<span class="calendar-name">{&props.calendar.display_name}</span>
</div>
</li>
}
}

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

@@ -20,9 +20,45 @@ pub fn context_menu(props: &ContextMenuProps) -> Html {
return html! {};
}
// Smart positioning to keep menu within viewport
let (x, y) = {
let mut x = props.x;
let mut y = props.y;
// Try to get actual viewport dimensions
if let Some(window) = web_sys::window() {
if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) {
if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) {
let viewport_width = w as i32;
let viewport_height = h as i32;
// Generic context menu: "Delete Calendar"
let menu_width = 180; // "Delete Calendar" text + padding
let menu_height = 60; // Single item + padding + margins
// Adjust horizontally if too close to right edge
if x + menu_width > viewport_width - 10 {
x = x.saturating_sub(menu_width);
}
// Adjust vertically if too close to bottom edge
if y + menu_height > viewport_height - 10 {
y = y.saturating_sub(menu_height);
}
// Ensure minimum margins from edges
x = x.max(5);
y = y.max(5);
}
}
}
(x, y)
};
let style = format!(
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
props.x, props.y
x, y
);
let on_delete_click = {

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,9 @@ pub struct EventContextMenuProps {
pub event: Option<VEvent>,
pub on_edit: Callback<EditAction>,
pub on_delete: Callback<DeleteAction>,
pub on_view_details: Callback<VEvent>,
pub on_close: Callback<()>,
pub on_edit_singleton: Callback<VEvent>, // New callback for editing singleton events without scope
}
#[function_component(EventContextMenu)]
@@ -35,9 +37,53 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
return html! {};
}
// Smart positioning to keep menu within viewport
let (x, y) = {
let mut x = props.x;
let mut y = props.y;
// Try to get actual viewport dimensions
if let Some(window) = web_sys::window() {
if let (Ok(width), Ok(height)) = (window.inner_width(), window.inner_height()) {
if let (Some(w), Some(h)) = (width.as_f64(), height.as_f64()) {
let viewport_width = w as i32;
let viewport_height = h as i32;
// More accurate menu dimensions based on actual CSS and content
let menu_width = if props.event.as_ref().map_or(false, |e| e.rrule.is_some()) {
280 // Recurring: "Edit This and Future Events" is long text + padding
} else {
180 // Non-recurring: "Edit Event" + "Delete Event" + padding
};
let menu_height = if props.event.as_ref().map_or(false, |e| e.rrule.is_some()) {
200 // 6 items × ~32px per item (12px padding top/bottom + text height + borders)
} else {
100 // 2 items × ~32px per item + some extra margin
};
// Adjust horizontally if too close to right edge
if x + menu_width > viewport_width - 10 {
x = x.saturating_sub(menu_width);
}
// Adjust vertically if too close to bottom edge
if y + menu_height > viewport_height - 10 {
y = y.saturating_sub(menu_height);
}
// Ensure minimum margins from edges
x = x.max(5);
y = y.max(5);
}
}
}
(x, y)
};
let style = format!(
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
props.x, props.y
x, y
);
// Check if the event is recurring
@@ -46,6 +92,14 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
.as_ref()
.map(|event| event.rrule.is_some())
.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 on_edit = props.on_edit.clone();
@@ -56,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 on_delete = props.on_delete.clone();
let on_close = props.on_close.clone();
@@ -65,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! {
<div
ref={menu_ref}
@@ -72,7 +150,15 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
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! {
<>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
@@ -87,34 +173,41 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
</>
}
} else {
// Regular single events - show edit option without setting edit scope
html! {
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
<div class="context-menu-item" onclick={create_singleton_edit_callback}>
{"Edit Event"}
</div>
}
}
}
{
if is_recurring {
html! {
<>
if !is_external {
// 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)}>
{"Delete This Event"}
{"Delete 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)}>
{"Delete Event"}
</div>
}
// No delete options for external events
html! {}
}
}
</div>

View File

@@ -0,0 +1,109 @@
use super::types::*;
// Types are already imported from super::types::*
use wasm_bindgen::JsCast;
use web_sys::HtmlSelectElement;
use yew::prelude::*;
#[function_component(AdvancedTab)]
pub fn advanced_tab(props: &TabProps) -> Html {
let data = &props.data;
let on_status_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.status = match select.value().as_str() {
"tentative" => EventStatus::Tentative,
"cancelled" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
};
data.set(event_data);
}
}
})
};
let on_class_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.class = match select.value().as_str() {
"private" => EventClass::Private,
"confidential" => EventClass::Confidential,
_ => EventClass::Public,
};
data.set(event_data);
}
}
})
};
let on_priority_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
let value = select.value();
event_data.priority = if value.is_empty() {
None
} else {
value.parse::<u8>().ok().filter(|&p| p <= 9)
};
data.set(event_data);
}
}
})
};
html! {
<div class="tab-panel">
<div class="form-row">
<div class="form-group">
<label for="event-status">{"Status"}</label>
<select
id="event-status"
class="form-input"
onchange={on_status_change}
>
<option value="confirmed" selected={matches!(data.status, EventStatus::Confirmed)}>{"Confirmed"}</option>
<option value="tentative" selected={matches!(data.status, EventStatus::Tentative)}>{"Tentative"}</option>
<option value="cancelled" selected={matches!(data.status, EventStatus::Cancelled)}>{"Cancelled"}</option>
</select>
</div>
<div class="form-group">
<label for="event-class">{"Privacy"}</label>
<select
id="event-class"
class="form-input"
onchange={on_class_change}
>
<option value="public" selected={matches!(data.class, EventClass::Public)}>{"Public"}</option>
<option value="private" selected={matches!(data.class, EventClass::Private)}>{"Private"}</option>
<option value="confidential" selected={matches!(data.class, EventClass::Confidential)}>{"Confidential"}</option>
</select>
</div>
</div>
<div class="form-group">
<label for="event-priority">{"Priority"}</label>
<select
id="event-priority"
class="form-input"
onchange={on_priority_change}
>
<option value="" selected={data.priority.is_none()}>{"Not set"}</option>
<option value="1" selected={data.priority == Some(1)}>{"High"}</option>
<option value="5" selected={data.priority == Some(5)}>{"Medium"}</option>
<option value="9" selected={data.priority == Some(9)}>{"Low"}</option>
</select>
<p class="form-help-text">{"Set the importance level for this event."}</p>
</div>
</div>
}
}

View File

@@ -0,0 +1,730 @@
use super::types::*;
// Types are already imported from super::types::*
use chrono::{Datelike, NaiveDate};
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
use yew::prelude::*;
#[function_component(BasicDetailsTab)]
pub fn basic_details_tab(props: &TabProps) -> Html {
let data = &props.data;
// Event handlers
let on_title_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
event_data.title = input.value();
if !event_data.changed_fields.contains(&"title".to_string()) {
event_data.changed_fields.push("title".to_string());
}
data.set(event_data);
}
}
})
};
let on_description_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(textarea) = target.dyn_into::<HtmlTextAreaElement>() {
let mut event_data = (*data).clone();
event_data.description = textarea.value();
data.set(event_data);
}
}
})
};
let on_calendar_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
let value = select.value();
let new_calendar = if value.is_empty() { None } else { Some(value) };
if event_data.selected_calendar != new_calendar {
event_data.selected_calendar = new_calendar;
if !event_data.changed_fields.contains(&"selected_calendar".to_string()) {
event_data.changed_fields.push("selected_calendar".to_string());
}
}
data.set(event_data);
}
}
})
};
let on_all_day_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
event_data.all_day = input.checked();
data.set(event_data);
}
}
})
};
let on_recurrence_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.recurrence = match select.value().as_str() {
"daily" => RecurrenceType::Daily,
"weekly" => RecurrenceType::Weekly,
"monthly" => RecurrenceType::Monthly,
"yearly" => RecurrenceType::Yearly,
_ => RecurrenceType::None,
};
// Reset recurrence-related fields when changing type
event_data.recurrence_days = vec![false; 7];
event_data.recurrence_interval = 1;
event_data.recurrence_until = None;
event_data.recurrence_count = None;
event_data.monthly_by_day = None;
event_data.monthly_by_monthday = None;
event_data.yearly_by_month = vec![false; 12];
data.set(event_data);
}
}
})
};
let on_reminder_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.reminder = match select.value().as_str() {
"15min" => ReminderType::Minutes15,
"30min" => ReminderType::Minutes30,
"1hour" => ReminderType::Hour1,
"1day" => ReminderType::Day1,
"2days" => ReminderType::Days2,
"1week" => ReminderType::Week1,
_ => ReminderType::None,
};
data.set(event_data);
}
}
})
};
let on_recurrence_interval_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(interval) = input.value().parse::<u32>() {
let mut event_data = (*data).clone();
event_data.recurrence_interval = interval.max(1);
data.set(event_data);
}
}
})
};
let on_recurrence_until_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if input.value().is_empty() {
event_data.recurrence_until = None;
} else if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
event_data.recurrence_until = Some(date);
}
data.set(event_data);
}
})
};
let on_recurrence_count_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if input.value().is_empty() {
event_data.recurrence_count = None;
} else if let Ok(count) = input.value().parse::<u32>() {
event_data.recurrence_count = Some(count.max(1));
}
data.set(event_data);
}
})
};
let on_monthly_by_monthday_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if input.value().is_empty() {
event_data.monthly_by_monthday = None;
} else if let Ok(day) = input.value().parse::<u8>() {
if day >= 1 && day <= 31 {
event_data.monthly_by_monthday = Some(day);
event_data.monthly_by_day = None; // Clear the other option
}
}
data.set(event_data);
}
})
};
let on_monthly_by_day_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
if select.value().is_empty() || select.value() == "none" {
event_data.monthly_by_day = None;
} else {
event_data.monthly_by_day = Some(select.value());
event_data.monthly_by_monthday = None; // Clear the other option
}
data.set(event_data);
}
})
};
let on_weekday_change = {
let data = data.clone();
move |day_index: usize| {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if day_index < event_data.recurrence_days.len() {
event_data.recurrence_days[day_index] = input.checked();
data.set(event_data);
}
}
})
}
};
let on_yearly_month_change = {
let data = data.clone();
move |month_index: usize| {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
if month_index < event_data.yearly_by_month.len() {
event_data.yearly_by_month[month_index] = input.checked();
data.set(event_data);
}
}
})
}
};
let on_start_date_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
let mut event_data = (*data).clone();
event_data.start_date = date;
data.set(event_data);
}
}
})
};
let on_start_time_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") {
let mut event_data = (*data).clone();
event_data.start_time = time;
data.set(event_data);
}
}
})
};
let on_end_date_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(date) = NaiveDate::parse_from_str(&input.value(), "%Y-%m-%d") {
let mut event_data = (*data).clone();
event_data.end_date = date;
data.set(event_data);
}
}
})
};
let on_end_time_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(time) = chrono::NaiveTime::parse_from_str(&input.value(), "%H:%M") {
let mut event_data = (*data).clone();
event_data.end_time = time;
data.set(event_data);
}
}
})
};
let on_location_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
event_data.location = input.value();
data.set(event_data);
}
}
})
};
html! {
<div class="tab-panel">
<div class="form-group">
<label for="event-title">{"Event Title *"}</label>
<input
type="text"
id="event-title"
class="form-input"
value={data.title.clone()}
oninput={on_title_input}
placeholder="Add a title"
required=true
/>
</div>
<div class="form-group">
<label for="event-description">{"Description"}</label>
<textarea
id="event-description"
class="form-input"
value={data.description.clone()}
oninput={on_description_input}
placeholder="Add a description"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="event-calendar">{"Calendar"}</label>
<select
id="event-calendar"
class="form-input"
onchange={on_calendar_change}
>
<option value="">{"Select Calendar"}</option>
{
props.available_calendars.iter().map(|calendar| {
html! {
<option
key={calendar.path.clone()}
value={calendar.path.clone()}
selected={data.selected_calendar.as_ref() == Some(&calendar.path)}
>
{&calendar.display_name}
</option>
}
}).collect::<Html>()
}
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
checked={data.all_day}
onchange={on_all_day_change}
/>
{" All Day"}
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="event-recurrence-basic">{"Repeat"}</label>
<select
id="event-recurrence-basic"
class="form-input"
onchange={on_recurrence_change}
>
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"Does not repeat"}</option>
<option value="daily" selected={matches!(data.recurrence, RecurrenceType::Daily)}>{"Daily"}</option>
<option value="weekly" selected={matches!(data.recurrence, RecurrenceType::Weekly)}>{"Weekly"}</option>
<option value="monthly" selected={matches!(data.recurrence, RecurrenceType::Monthly)}>{"Monthly"}</option>
<option value="yearly" selected={matches!(data.recurrence, RecurrenceType::Yearly)}>{"Yearly"}</option>
</select>
</div>
<div class="form-group">
<label for="event-reminder-basic">{"Reminder"}</label>
<select
id="event-reminder-basic"
class="form-input"
onchange={on_reminder_change}
>
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"None"}</option>
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
</select>
</div>
</div>
// RECURRENCE OPTIONS GO RIGHT HERE - directly below repeat/reminder!
if matches!(data.recurrence, RecurrenceType::Weekly) {
<div class="form-group">
<label>{"Repeat on"}</label>
<div class="weekday-selection">
{
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
.iter()
.enumerate()
.map(|(i, day)| {
let day_checked = data.recurrence_days.get(i).cloned().unwrap_or(false);
let on_change = on_weekday_change(i);
html! {
<label key={i} class="weekday-checkbox">
<input
type="checkbox"
checked={day_checked}
onchange={on_change}
/>
<span class="weekday-label">{day}</span>
</label>
}
})
.collect::<Html>()
}
</div>
</div>
}
if !matches!(data.recurrence, RecurrenceType::None) {
<div class="recurrence-options">
<div class="form-row">
<div class="form-group">
<label for="recurrence-interval">{"Every"}</label>
<div class="interval-input">
<input
id="recurrence-interval"
type="number"
class="form-input"
value={data.recurrence_interval.to_string()}
min="1"
max="999"
onchange={on_recurrence_interval_change}
/>
<span class="interval-unit">
{match data.recurrence {
RecurrenceType::Daily => if data.recurrence_interval == 1 { "day" } else { "days" },
RecurrenceType::Weekly => if data.recurrence_interval == 1 { "week" } else { "weeks" },
RecurrenceType::Monthly => if data.recurrence_interval == 1 { "month" } else { "months" },
RecurrenceType::Yearly => if data.recurrence_interval == 1 { "year" } else { "years" },
RecurrenceType::None => "",
}}
</span>
</div>
</div>
<div class="form-group">
<label>{"Ends"}</label>
<div class="end-options">
<div class="end-option">
<label class="radio-label">
<input
type="radio"
name="recurrence-end"
value="never"
checked={data.recurrence_until.is_none() && data.recurrence_count.is_none()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.recurrence_until = None;
new_data.recurrence_count = None;
data.set(new_data);
})
}}
/>
{"Never"}
</label>
</div>
<div class="end-option">
<label class="radio-label">
<input
type="radio"
name="recurrence-end"
value="until"
checked={data.recurrence_until.is_some()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.recurrence_count = None;
new_data.recurrence_until = Some(new_data.start_date);
data.set(new_data);
})
}}
/>
{"Until"}
</label>
<input
type="date"
class="form-input"
value={data.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()}
onchange={on_recurrence_until_change}
/>
</div>
<div class="end-option">
<label class="radio-label">
<input
type="radio"
name="recurrence-end"
value="count"
checked={data.recurrence_count.is_some()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.recurrence_until = None;
new_data.recurrence_count = Some(10); // Default count
data.set(new_data);
})
}}
/>
{"After"}
</label>
<input
type="number"
class="form-input count-input"
value={data.recurrence_count.map(|c| c.to_string()).unwrap_or_default()}
min="1"
max="999"
placeholder="1"
onchange={on_recurrence_count_change}
/>
<span class="count-unit">{"occurrences"}</span>
</div>
</div>
</div>
</div>
// Monthly specific options
if matches!(data.recurrence, RecurrenceType::Monthly) {
<div class="form-group">
<label>{"Repeat by"}</label>
<div class="monthly-options">
<div class="monthly-option">
<label class="radio-label">
<input
type="radio"
name="monthly-type"
checked={data.monthly_by_monthday.is_some()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.monthly_by_day = None;
new_data.monthly_by_monthday = Some(new_data.start_date.day() as u8);
data.set(new_data);
})
}}
/>
{"Day of month:"}
</label>
<input
type="number"
class="form-input day-input"
value={data.monthly_by_monthday.map(|d| d.to_string()).unwrap_or_else(|| data.start_date.day().to_string())}
min="1"
max="31"
onchange={on_monthly_by_monthday_change}
/>
</div>
<div class="monthly-option">
<label class="radio-label">
<input
type="radio"
name="monthly-type"
checked={data.monthly_by_day.is_some()}
onchange={{
let data = data.clone();
Callback::from(move |_| {
let mut new_data = (*data).clone();
new_data.monthly_by_monthday = None;
new_data.monthly_by_day = Some("1MO".to_string()); // Default to first Monday
data.set(new_data);
})
}}
/>
{"Day of week:"}
</label>
<select
class="form-input"
value={data.monthly_by_day.clone().unwrap_or_default()}
onchange={on_monthly_by_day_change}
>
<option value="none">{"Select..."}</option>
<option value="1MO">{"First Monday"}</option>
<option value="1TU">{"First Tuesday"}</option>
<option value="1WE">{"First Wednesday"}</option>
<option value="1TH">{"First Thursday"}</option>
<option value="1FR">{"First Friday"}</option>
<option value="1SA">{"First Saturday"}</option>
<option value="1SU">{"First Sunday"}</option>
<option value="2MO">{"Second Monday"}</option>
<option value="2TU">{"Second Tuesday"}</option>
<option value="2WE">{"Second Wednesday"}</option>
<option value="2TH">{"Second Thursday"}</option>
<option value="2FR">{"Second Friday"}</option>
<option value="2SA">{"Second Saturday"}</option>
<option value="2SU">{"Second Sunday"}</option>
<option value="3MO">{"Third Monday"}</option>
<option value="3TU">{"Third Tuesday"}</option>
<option value="3WE">{"Third Wednesday"}</option>
<option value="3TH">{"Third Thursday"}</option>
<option value="3FR">{"Third Friday"}</option>
<option value="3SA">{"Third Saturday"}</option>
<option value="3SU">{"Third Sunday"}</option>
<option value="4MO">{"Fourth Monday"}</option>
<option value="4TU">{"Fourth Tuesday"}</option>
<option value="4WE">{"Fourth Wednesday"}</option>
<option value="4TH">{"Fourth Thursday"}</option>
<option value="4FR">{"Fourth Friday"}</option>
<option value="4SA">{"Fourth Saturday"}</option>
<option value="4SU">{"Fourth Sunday"}</option>
<option value="-1MO">{"Last Monday"}</option>
<option value="-1TU">{"Last Tuesday"}</option>
<option value="-1WE">{"Last Wednesday"}</option>
<option value="-1TH">{"Last Thursday"}</option>
<option value="-1FR">{"Last Friday"}</option>
<option value="-1SA">{"Last Saturday"}</option>
<option value="-1SU">{"Last Sunday"}</option>
</select>
</div>
</div>
</div>
}
// Yearly specific options
if matches!(data.recurrence, RecurrenceType::Yearly) {
<div class="form-group">
<label>{"Repeat in months"}</label>
<div class="yearly-months">
{
["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
.iter()
.enumerate()
.map(|(i, month)| {
let month_checked = data.yearly_by_month.get(i).cloned().unwrap_or(false);
let on_change = on_yearly_month_change(i);
html! {
<label key={i} class="month-checkbox">
<input
type="checkbox"
checked={month_checked}
onchange={on_change}
/>
<span class="month-label">{month}</span>
</label>
}
})
.collect::<Html>()
}
</div>
</div>
}
</div>
}
// Date and time fields go here AFTER recurrence options
<div class="form-row">
<div class="form-group">
<label for="start-date">{"Start Date *"}</label>
<input
type="date"
id="start-date"
class="form-input"
value={data.start_date.format("%Y-%m-%d").to_string()}
onchange={on_start_date_change}
required=true
/>
</div>
if !data.all_day {
<div class="form-group">
<label for="start-time">{"Start Time"}</label>
<input
type="time"
id="start-time"
class="form-input"
value={data.start_time.format("%H:%M").to_string()}
onchange={on_start_time_change}
/>
</div>
}
</div>
<div class="form-row">
<div class="form-group">
<label for="end-date">{"End Date *"}</label>
<input
type="date"
id="end-date"
class="form-input"
value={data.end_date.format("%Y-%m-%d").to_string()}
onchange={on_end_date_change}
required=true
/>
</div>
if !data.all_day {
<div class="form-group">
<label for="end-time">{"End Time"}</label>
<input
type="time"
id="end-time"
class="form-input"
value={data.end_time.format("%H:%M").to_string()}
onchange={on_end_time_change}
/>
</div>
}
</div>
<div class="form-group">
<label for="event-location">{"Location"}</label>
<input
type="text"
id="event-location"
class="form-input"
value={data.location.clone()}
oninput={on_location_input}
placeholder="Enter event location"
/>
</div>
</div>
}
}

View File

@@ -0,0 +1,98 @@
use super::types::*;
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::prelude::*;
#[function_component(CategoriesTab)]
pub fn categories_tab(props: &TabProps) -> Html {
let data = &props.data;
let on_categories_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
event_data.categories = input.value();
data.set(event_data);
}
}
})
};
let add_category = {
let data = data.clone();
move |category: &str| {
let data = data.clone();
let category = category.to_string();
Callback::from(move |_| {
let mut event_data = (*data).clone();
if event_data.categories.is_empty() {
event_data.categories = category.clone();
} else {
event_data.categories = format!("{}, {}", event_data.categories, category);
}
data.set(event_data);
})
}
};
html! {
<div class="tab-panel">
<div class="form-group">
<label for="event-categories">{"Categories"}</label>
<input
type="text"
id="event-categories"
class="form-input"
value={data.categories.clone()}
oninput={on_categories_input}
placeholder="work, meeting, personal, project, urgent"
/>
<p class="form-help-text">{"Enter categories separated by commas to help organize and filter your events"}</p>
</div>
<div class="categories-suggestions">
<h5>{"Common Categories"}</h5>
<div class="category-tags">
<button type="button" class="category-tag" onclick={add_category("work")}>{"work"}</button>
<button type="button" class="category-tag" onclick={add_category("meeting")}>{"meeting"}</button>
<button type="button" class="category-tag" onclick={add_category("personal")}>{"personal"}</button>
<button type="button" class="category-tag" onclick={add_category("project")}>{"project"}</button>
<button type="button" class="category-tag" onclick={add_category("urgent")}>{"urgent"}</button>
<button type="button" class="category-tag" onclick={add_category("social")}>{"social"}</button>
<button type="button" class="category-tag" onclick={add_category("travel")}>{"travel"}</button>
<button type="button" class="category-tag" onclick={add_category("health")}>{"health"}</button>
</div>
<p class="form-help-text">{"Click to add these common categories to your event"}</p>
</div>
<div class="categories-info">
<h5>{"Event Organization & Filtering"}</h5>
<ul>
<li>{"Categories help organize events in calendar views"}</li>
<li>{"Filter events by category to focus on specific types"}</li>
<li>{"Categories are searchable and can be used for reporting"}</li>
<li>{"Multiple categories per event are fully supported"}</li>
</ul>
<div class="categories-examples">
<h6>{"Category Usage Examples"}</h6>
<div class="category-example">
<strong>{"Work Events:"}</strong>
<span>{"work, meeting, project, urgent, deadline"}</span>
</div>
<div class="category-example">
<strong>{"Personal Events:"}</strong>
<span>{"personal, family, health, social, travel"}</span>
</div>
<div class="category-example">
<strong>{"Mixed Events:"}</strong>
<span>{"work, travel, client, important"}</span>
</div>
<p class="form-help-text">{"Categories follow RFC 5545 CATEGORIES property standards"}</p>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,118 @@
use super::types::*;
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::prelude::*;
#[function_component(LocationTab)]
pub fn location_tab(props: &TabProps) -> Html {
let data = &props.data;
let on_location_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
event_data.location = input.value();
data.set(event_data);
}
}
})
};
let set_location = {
let data = data.clone();
move |location: &str| {
let data = data.clone();
let location = location.to_string();
Callback::from(move |_| {
let mut event_data = (*data).clone();
event_data.location = location.clone();
data.set(event_data);
})
}
};
html! {
<div class="tab-panel">
<div class="form-group">
<label for="event-location-detailed">{"Event Location"}</label>
<input
type="text"
id="event-location-detailed"
class="form-input"
value={data.location.clone()}
oninput={on_location_input}
placeholder="Conference Room A, 123 Main St, City, State 12345"
/>
<p class="form-help-text">{"Enter the full address or location description for the event"}</p>
</div>
<div class="location-suggestions">
<h5>{"Common Locations"}</h5>
<div class="location-tags">
<button type="button" class="location-tag" onclick={set_location("Conference Room")}>{"Conference Room"}</button>
<button type="button" class="location-tag" onclick={set_location("Online Meeting")}>{"Online Meeting"}</button>
<button type="button" class="location-tag" onclick={set_location("Main Office")}>{"Main Office"}</button>
<button type="button" class="location-tag" onclick={set_location("Client Site")}>{"Client Site"}</button>
<button type="button" class="location-tag" onclick={set_location("Home Office")}>{"Home Office"}</button>
<button type="button" class="location-tag" onclick={set_location("Remote")}>{"Remote"}</button>
</div>
<p class="form-help-text">{"Click to quickly set common location types"}</p>
</div>
<div class="location-info">
<h5>{"Location Features & Integration"}</h5>
<ul>
<li>{"Location information is included in calendar invitations"}</li>
<li>{"Supports both physical addresses and virtual meeting links"}</li>
<li>{"Compatible with mapping and navigation applications"}</li>
<li>{"Room booking integration available for enterprise setups"}</li>
</ul>
<div class="geo-section">
<h6>{"Geographic Coordinates (Advanced)"}</h6>
<p>{"Future versions will support:"}</p>
<div class="geo-features">
<div class="geo-item">
<strong>{"GPS Coordinates:"}</strong>
<span>{"Precise latitude/longitude positioning"}</span>
</div>
<div class="geo-item">
<strong>{"Map Integration:"}</strong>
<span>{"Embedded maps in event details"}</span>
</div>
<div class="geo-item">
<strong>{"Travel Time:"}</strong>
<span>{"Automatic travel time calculation"}</span>
</div>
<div class="geo-item">
<strong>{"Proximity Alerts:"}</strong>
<span>{"Location-based notifications"}</span>
</div>
</div>
<p class="form-help-text">{"Advanced geographic features will be implemented in future releases"}</p>
</div>
<div class="virtual-meeting-section">
<h6>{"Virtual Meeting Integration"}</h6>
<div class="meeting-platforms">
<div class="platform-item">
<strong>{"Video Conferencing:"}</strong>
<span>{"Zoom, Teams, Google Meet links"}</span>
</div>
<div class="platform-item">
<strong>{"Phone Conference:"}</strong>
<span>{"Dial-in numbers and access codes"}</span>
</div>
<div class="platform-item">
<strong>{"Webinar Links:"}</strong>
<span>{"Live streaming and presentation URLs"}</span>
</div>
</div>
<p class="form-help-text">{"Paste meeting links directly in the location field for virtual events"}</p>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,16 @@
// Event form components module
pub mod types;
pub mod basic_details;
pub mod advanced;
pub mod people;
pub mod categories;
pub mod location;
pub mod reminders;
pub use types::*;
pub use basic_details::BasicDetailsTab;
pub use advanced::AdvancedTab;
pub use people::PeopleTab;
pub use categories::CategoriesTab;
pub use location::LocationTab;
pub use reminders::RemindersTab;

View File

@@ -0,0 +1,103 @@
use super::types::*;
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, HtmlTextAreaElement};
use yew::prelude::*;
#[function_component(PeopleTab)]
pub fn people_tab(props: &TabProps) -> Html {
let data = &props.data;
let on_organizer_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
let mut event_data = (*data).clone();
event_data.organizer = input.value();
data.set(event_data);
}
}
})
};
let on_attendees_input = {
let data = data.clone();
Callback::from(move |e: InputEvent| {
if let Some(target) = e.target() {
if let Ok(textarea) = target.dyn_into::<HtmlTextAreaElement>() {
let mut event_data = (*data).clone();
event_data.attendees = textarea.value();
data.set(event_data);
}
}
})
};
html! {
<div class="tab-panel">
<div class="form-group">
<label for="event-organizer">{"Organizer"}</label>
<input
type="email"
id="event-organizer"
class="form-input"
value={data.organizer.clone()}
oninput={on_organizer_input}
placeholder="organizer@example.com"
/>
<p class="form-help-text">{"Email address of the person organizing this event"}</p>
</div>
<div class="form-group">
<label for="event-attendees">{"Attendees"}</label>
<textarea
id="event-attendees"
class="form-input"
value={data.attendees.clone()}
oninput={on_attendees_input}
placeholder="attendee1@example.com, attendee2@example.com, attendee3@example.com"
rows="4"
></textarea>
<p class="form-help-text">{"Enter attendee email addresses separated by commas"}</p>
</div>
<div class="people-info">
<h5>{"Invitation & Response Management"}</h5>
<ul>
<li>{"Invitations are sent automatically when the event is saved"}</li>
<li>{"Attendees can respond with Accept, Decline, or Tentative"}</li>
<li>{"Response tracking follows RFC 5545 PARTSTAT standards"}</li>
<li>{"Delegation and role management available after event creation"}</li>
</ul>
<div class="people-validation">
<h6>{"Email Validation"}</h6>
<p>{"Email addresses will be validated when you save the event. Invalid emails will be highlighted and must be corrected before proceeding."}</p>
</div>
</div>
<div class="attendee-roles-preview">
<h5>{"Advanced Attendee Features"}</h5>
<div class="role-examples">
<div class="role-item">
<strong>{"Required Participant:"}</strong>
<span>{"Must attend for meeting to proceed"}</span>
</div>
<div class="role-item">
<strong>{"Optional Participant:"}</strong>
<span>{"Attendance welcome but not required"}</span>
</div>
<div class="role-item">
<strong>{"Resource:"}</strong>
<span>{"Meeting room, equipment, or facility"}</span>
</div>
<div class="role-item">
<strong>{"Non-Participant:"}</strong>
<span>{"For information only"}</span>
</div>
</div>
<p class="form-help-text">{"Advanced role assignment and RSVP management will be available in future versions"}</p>
</div>
</div>
}
}

View File

@@ -0,0 +1,100 @@
use super::types::*;
// Types are already imported from super::types::*
use wasm_bindgen::JsCast;
use web_sys::HtmlSelectElement;
use yew::prelude::*;
#[function_component(RemindersTab)]
pub fn reminders_tab(props: &TabProps) -> Html {
let data = &props.data;
let on_reminder_change = {
let data = data.clone();
Callback::from(move |e: Event| {
if let Some(target) = e.target() {
if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
let mut event_data = (*data).clone();
event_data.reminder = match select.value().as_str() {
"15min" => ReminderType::Minutes15,
"30min" => ReminderType::Minutes30,
"1hour" => ReminderType::Hour1,
"1day" => ReminderType::Day1,
"2days" => ReminderType::Days2,
"1week" => ReminderType::Week1,
_ => ReminderType::None,
};
data.set(event_data);
}
}
})
};
html! {
<div class="tab-panel">
<div class="form-group">
<label for="event-reminder-main">{"Primary Reminder"}</label>
<select
id="event-reminder-main"
class="form-input"
onchange={on_reminder_change}
>
<option value="none" selected={matches!(data.reminder, ReminderType::None)}>{"No reminder"}</option>
<option value="15min" selected={matches!(data.reminder, ReminderType::Minutes15)}>{"15 minutes before"}</option>
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes before"}</option>
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour before"}</option>
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day before"}</option>
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days before"}</option>
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week before"}</option>
</select>
<p class="form-help-text">{"Choose when you'd like to be reminded about this event"}</p>
</div>
<div class="reminder-types">
<h5>{"Reminder & Alarm Types"}</h5>
<div class="alarm-examples">
<div class="alarm-type">
<strong>{"Display Alarm"}</strong>
<p>{"Pop-up notification on your device"}</p>
</div>
<div class="alarm-type">
<strong>{"Email Reminder"}</strong>
<p>{"Email notification sent to your address"}</p>
</div>
<div class="alarm-type">
<strong>{"Audio Alert"}</strong>
<p>{"Sound notification with custom audio"}</p>
</div>
<div class="alarm-type">
<strong>{"SMS/Text"}</strong>
<p>{"Text message reminder (enterprise feature)"}</p>
</div>
</div>
<p class="form-help-text">{"Multiple alarm types follow RFC 5545 VALARM standards"}</p>
</div>
<div class="reminder-info">
<h5>{"Advanced Reminder Features"}</h5>
<ul>
<li>{"Multiple reminders per event with different timing"}</li>
<li>{"Custom reminder messages and descriptions"}</li>
<li>{"Recurring reminders for recurring events"}</li>
<li>{"Snooze and dismiss functionality"}</li>
<li>{"Integration with system notifications"}</li>
</ul>
<div class="attachments-section">
<h6>{"File Attachments & Documents"}</h6>
<p>{"Future attachment features will include:"}</p>
<ul>
<li>{"Drag-and-drop file uploads"}</li>
<li>{"Document preview and thumbnails"}</li>
<li>{"Cloud storage integration (Google Drive, OneDrive)"}</li>
<li>{"Version control for updated documents"}</li>
<li>{"Shared access permissions for attendees"}</li>
</ul>
<p class="form-help-text">{"Attachment functionality will be implemented in a future release."}</p>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,255 @@
use crate::services::calendar_service::CalendarInfo;
use chrono::{Local, NaiveDate, NaiveTime};
use yew::prelude::*;
#[derive(Clone, PartialEq, Debug)]
pub enum EventStatus {
Confirmed,
Tentative,
Cancelled,
}
impl Default for EventStatus {
fn default() -> Self {
EventStatus::Confirmed
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum EventClass {
Public,
Private,
Confidential,
}
impl Default for EventClass {
fn default() -> Self {
EventClass::Public
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum ReminderType {
None,
Minutes15,
Minutes30,
Hour1,
Day1,
Days2,
Week1,
}
impl Default for ReminderType {
fn default() -> Self {
ReminderType::None
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum RecurrenceType {
None,
Daily,
Weekly,
Monthly,
Yearly,
}
impl Default for RecurrenceType {
fn default() -> Self {
RecurrenceType::None
}
}
#[derive(Clone, PartialEq)]
pub enum ModalTab {
BasicDetails,
Advanced,
People,
Categories,
Location,
Reminders,
}
impl Default for ModalTab {
fn default() -> Self {
ModalTab::BasicDetails
}
}
// EditAction is now imported from event_context_menu - this duplicate removed
#[derive(Clone, PartialEq, Debug)]
pub struct EventCreationData {
// Basic event info
pub title: String,
pub description: String,
pub location: String,
pub all_day: bool,
// Timing
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
// Classification
pub status: EventStatus,
pub class: EventClass,
pub priority: Option<u8>,
// People
pub organizer: String,
pub attendees: String,
// Categorization
pub categories: String,
// Reminders
pub reminder: ReminderType,
// Recurrence
pub recurrence: RecurrenceType,
pub recurrence_interval: u32,
pub recurrence_until: Option<NaiveDate>,
pub recurrence_count: Option<u32>,
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
// Advanced recurrence
pub monthly_by_day: Option<String>, // e.g., "1MO" for first Monday
pub monthly_by_monthday: Option<u8>, // e.g., 15 for 15th day of month
pub yearly_by_month: Vec<bool>, // [Jan, Feb, Mar, ...]
// Calendar selection
pub selected_calendar: Option<String>,
// Edit tracking (for recurring events)
pub edit_scope: Option<crate::components::EditAction>,
pub changed_fields: Vec<String>,
pub original_uid: Option<String>, // Set when editing existing events
pub occurrence_date: Option<NaiveDate>, // The specific occurrence date being edited
}
impl EventCreationData {
pub fn to_create_event_params(&self) -> (
String, // title
String, // description
String, // start_date
String, // start_time
String, // end_date
String, // end_time
String, // location
bool, // all_day
String, // status
String, // class
Option<u8>, // priority
String, // organizer
String, // attendees
String, // categories
String, // reminder
String, // recurrence
Vec<bool>, // recurrence_days
u32, // recurrence_interval
Option<u32>, // recurrence_count
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.end_time == NaiveTime::from_hms_opt(0, 0, 0).unwrap() {
// If end time is midnight (00:00), treat it as beginning of next day
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.description.clone(),
start_date,
start_time,
end_date,
end_time,
self.location.clone(),
self.all_day,
format!("{:?}", self.status).to_uppercase(),
format!("{:?}", self.class).to_uppercase(),
self.priority,
self.organizer.clone(),
self.attendees.clone(),
self.categories.clone(),
format!("{:?}", self.reminder),
format!("{:?}", self.recurrence),
self.recurrence_days.clone(),
self.recurrence_interval,
self.recurrence_count,
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
self.selected_calendar.clone(),
timezone,
)
}
}
impl Default for EventCreationData {
fn default() -> Self {
let now_local = Local::now();
let start_date = now_local.date_naive();
let start_time = NaiveTime::from_hms_opt(9, 0, 0).unwrap_or_default();
let end_time = NaiveTime::from_hms_opt(10, 0, 0).unwrap_or_default();
Self {
title: String::new(),
description: String::new(),
location: String::new(),
all_day: false,
start_date,
end_date: start_date,
start_time,
end_time,
status: EventStatus::default(),
class: EventClass::default(),
priority: None,
organizer: String::new(),
attendees: String::new(),
categories: String::new(),
reminder: ReminderType::default(),
recurrence: RecurrenceType::default(),
recurrence_interval: 1,
recurrence_until: None,
recurrence_count: None,
recurrence_days: vec![false; 7],
monthly_by_day: None,
monthly_by_monthday: None,
yearly_by_month: vec![false; 12],
selected_calendar: None,
edit_scope: None,
changed_fields: vec![],
original_uid: None,
occurrence_date: None,
}
}
}
// Common props for all tab components
#[derive(Properties, PartialEq)]
pub struct TabProps {
pub data: UseStateHandle<EventCreationData>,
pub available_calendars: Vec<CalendarInfo>,
}

View File

@@ -1,5 +1,4 @@
use crate::models::ical::VEvent;
use chrono::{DateTime, Utc};
use yew::prelude::*;
#[derive(Properties, PartialEq)]
@@ -63,7 +62,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {
<div class="event-detail">
<strong>{"End:"}</strong>
<span>{format_datetime(end, event.all_day)}</span>
<span>{format_datetime_end(end, event.all_day)}</span>
</div>
}
} else {
@@ -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 {
dt.format("%B %d, %Y").to_string()
} else {
@@ -221,6 +220,17 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
}
}
fn format_datetime_end(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
if all_day {
// For all-day events, subtract one day from end date for display
// RFC-5545 uses exclusive end dates, but users expect inclusive display
let display_date = *dt - chrono::Duration::days(1);
display_date.format("%B %d, %Y").to_string()
} else {
dt.format("%B %d, %Y at %I:%M %p").to_string()
}
}
fn format_recurrence_rule(rrule: &str) -> String {
// Basic parsing of RRULE to display user-friendly text
if rrule.contains("FREQ=DAILY") {

View File

@@ -0,0 +1,222 @@
use web_sys::HtmlInputElement;
use yew::prelude::*;
use crate::services::calendar_service::CalendarService;
#[derive(Properties, PartialEq)]
pub struct ExternalCalendarModalProps {
pub is_open: bool,
pub on_close: Callback<()>,
pub on_success: Callback<i32>, // Pass the newly created calendar ID
}
#[function_component(ExternalCalendarModal)]
pub fn external_calendar_modal(props: &ExternalCalendarModalProps) -> Html {
let name_ref = use_node_ref();
let url_ref = use_node_ref();
let color_ref = use_node_ref();
let is_loading = use_state(|| false);
let error_message = use_state(|| None::<String>);
let on_submit = {
let name_ref = name_ref.clone();
let url_ref = url_ref.clone();
let color_ref = color_ref.clone();
let is_loading = is_loading.clone();
let error_message = error_message.clone();
let on_close = props.on_close.clone();
let on_success = props.on_success.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let name = name_ref
.cast::<HtmlInputElement>()
.map(|input| input.value())
.unwrap_or_default()
.trim()
.to_string();
let url = url_ref
.cast::<HtmlInputElement>()
.map(|input| input.value())
.unwrap_or_default()
.trim()
.to_string();
let color = color_ref
.cast::<HtmlInputElement>()
.map(|input| input.value())
.unwrap_or_else(|| "#4285f4".to_string());
if name.is_empty() {
error_message.set(Some("Calendar name is required".to_string()));
return;
}
if url.is_empty() {
error_message.set(Some("Calendar URL is required".to_string()));
return;
}
// Basic URL validation
if !url.starts_with("http://") && !url.starts_with("https://") {
error_message.set(Some("Please enter a valid HTTP or HTTPS URL".to_string()));
return;
}
error_message.set(None);
is_loading.set(true);
let is_loading = is_loading.clone();
let error_message = error_message.clone();
let on_close = on_close.clone();
let on_success = on_success.clone();
wasm_bindgen_futures::spawn_local(async move {
match CalendarService::create_external_calendar(&name, &url, &color).await {
Ok(new_calendar) => {
is_loading.set(false);
on_success.emit(new_calendar.id);
on_close.emit(());
}
Err(e) => {
is_loading.set(false);
error_message.set(Some(format!("Failed to add calendar: {}", e)));
}
}
});
})
};
let on_cancel = {
let on_close = props.on_close.clone();
Callback::from(move |_| {
on_close.emit(());
})
};
let on_cancel_clone = on_cancel.clone();
if !props.is_open {
return html! {};
}
html! {
<div class="modal-backdrop" onclick={on_cancel_clone}>
<div class="external-calendar-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
<div class="modal-header">
<h3>{"Add External Calendar"}</h3>
<button class="modal-close" onclick={on_cancel.clone()}>{"×"}</button>
</div>
<form onsubmit={on_submit}>
<div class="modal-body">
{
if let Some(error) = (*error_message).as_ref() {
html! {
<div class="error-message">
{error}
</div>
}
} else {
html! {}
}
}
<div class="form-help" style="margin-bottom: 1.5rem; padding: 1rem; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #007bff;">
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: #495057;">{"Setting up External Calendars"}</h4>
<p style="margin: 0 0 0.5rem 0; font-size: 0.8rem; line-height: 1.4;">
{"Currently tested with Outlook 365 and Google Calendar. To get your calendar link:"}
</p>
<div style="margin-bottom: 1rem;">
<strong style="font-size: 0.8rem; color: #495057;">{"Outlook 365:"}</strong>
<ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;">
<li>{"Go to Outlook Settings"}</li>
<li>{"Navigate to Calendar → Shared Calendars"}</li>
<li>{"Click \"Publish a calendar\""}</li>
<li>{"Select your calendar and choose \"Can view all details\""}</li>
<li>{"Copy the ICS link and paste it below"}</li>
</ol>
</div>
<div>
<strong style="font-size: 0.8rem; color: #495057;">{"Google Calendar:"}</strong>
<ol style="margin: 0.3rem 0 0 0; padding-left: 1.2rem; font-size: 0.8rem; line-height: 1.3;">
<li>{"Hover over your calendar name in the left sidebar"}</li>
<li>{"Click the three dots that appear"}</li>
<li>{"Select \"Settings and sharing\""}</li>
<li>{"Scroll to \"Integrate calendar\""}</li>
<li>{"Copy the \"Public address in iCal format\" link"}</li>
</ol>
</div>
</div>
<div class="form-group">
<label for="external-calendar-name">{"Calendar Name"}</label>
<input
ref={name_ref}
id="external-calendar-name"
type="text"
placeholder="My External Calendar"
disabled={*is_loading}
required={true}
/>
</div>
<div class="form-group">
<label for="external-calendar-url">{"ICS URL"}</label>
<input
ref={url_ref}
id="external-calendar-url"
type="url"
placeholder="https://example.com/calendar.ics"
disabled={*is_loading}
required={true}
/>
<small class="form-help">
{"Enter the public ICS URL for the calendar you want to add"}
</small>
</div>
<div class="form-group">
<label for="external-calendar-color">{"Color"}</label>
<input
ref={color_ref}
id="external-calendar-color"
type="color"
value="#4285f4"
disabled={*is_loading}
/>
</div>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
onclick={on_cancel}
disabled={*is_loading}
>
{"Cancel"}
</button>
<button
type="submit"
class="btn btn-primary"
disabled={*is_loading}
>
{
if *is_loading {
"Adding..."
} else {
"Add Calendar"
}
}
</button>
</div>
</form>
</div>
</div>
}
}

View File

@@ -23,6 +23,9 @@ pub fn Login(props: &LoginProps) -> Html {
// Remember checkboxes state - default to checked
let remember_server = 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 username_ref = use_node_ref();
@@ -30,17 +33,31 @@ pub fn Login(props: &LoginProps) -> Html {
let on_server_url_change = {
let server_url = server_url.clone();
let remember_server = remember_server.clone();
Callback::from(move |e: Event| {
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 username = username.clone();
let remember_username = remember_username.clone();
Callback::from(move |e: Event| {
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 server_url = server_url.clone();
@@ -90,6 +114,8 @@ pub fn Login(props: &LoginProps) -> Html {
let password = password.clone();
let error_message = error_message.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();
Callback::from(move |e: SubmitEvent| {
@@ -100,6 +126,8 @@ pub fn Login(props: &LoginProps) -> Html {
let password = (*password).clone();
let error_message = error_message.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();
// Basic client-side validation
@@ -140,11 +168,23 @@ pub fn Login(props: &LoginProps) -> Html {
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);
on_login.emit(token);
}
Err(err) => {
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));
is_loading.set(false);
}
@@ -160,59 +200,79 @@ pub fn Login(props: &LoginProps) -> Html {
<form onsubmit={on_submit}>
<div class="form-group">
<label for="server_url">{"CalDAV Server URL"}</label>
<input
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">
<div class="input-with-checkbox">
<input
type="checkbox"
id="remember_server"
checked={*remember_server}
onchange={on_remember_server_change}
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}
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 class="form-group">
<label for="username">{"Username"}</label>
<input
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">
<div class="input-with-checkbox">
<input
type="checkbox"
id="remember_username"
checked={*remember_username}
onchange={on_remember_username_change}
ref={username_ref}
type="text"
id="username"
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 class="form-group">
<label for="password">{"Password"}</label>
<input
ref={password_ref}
type="password"
id="password"
placeholder="Enter your password"
value={(*password).clone()}
onchange={on_password_change}
disabled={*is_loading}
/>
<div class="password-input-container">
<input
ref={password_ref}
type={if *show_password { "text" } else { "password" }}
id="password"
placeholder="Enter your password"
value={(*password).clone()}
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>
{

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,14 +1,19 @@
pub mod calendar;
pub mod calendar_context_menu;
pub mod calendar_management_modal;
pub mod calendar_header;
pub mod calendar_list_item;
pub mod context_menu;
pub mod create_calendar_modal;
pub mod create_event_modal;
pub mod event_context_menu;
pub mod event_form;
pub mod event_modal;
pub mod external_calendar_modal;
pub mod login;
pub mod mobile_warning_modal;
pub mod month_view;
pub mod print_preview_modal;
pub mod recurring_edit_modal;
pub mod route_handler;
pub mod sidebar;
@@ -16,17 +21,19 @@ pub mod week_view;
pub use calendar::Calendar;
pub use calendar_context_menu::CalendarContextMenu;
pub use calendar_management_modal::CalendarManagementModal;
pub use calendar_header::CalendarHeader;
pub use calendar_list_item::CalendarListItem;
pub use context_menu::ContextMenu;
pub use create_calendar_modal::CreateCalendarModal;
pub use create_event_modal::{
CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType,
};
pub use create_event_modal::CreateEventModal;
// Re-export event form types for backwards compatibility
pub use event_form::EventCreationData;
pub use event_context_menu::{DeleteAction, EditAction, EventContextMenu};
pub use event_modal::EventModal;
pub use login::Login;
pub use mobile_warning_modal::MobileWarningModal;
pub use month_view::MonthView;
pub use print_preview_modal::PrintPreviewModal;
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
pub use route_handler::RouteHandler;
pub use sidebar::{Sidebar, Theme, ViewMode};

View File

@@ -1,5 +1,5 @@
use crate::models::ical::VEvent;
use crate::services::calendar_service::UserInfo;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use chrono::{Datelike, NaiveDate, Weekday};
use std::collections::HashMap;
use wasm_bindgen::{prelude::*, JsCast};
@@ -17,6 +17,8 @@ pub struct MonthViewProps {
#[prop_or_default]
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub external_calendars: Vec<ExternalCalendar>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
@@ -85,8 +87,20 @@ pub fn month_view(props: &MonthViewProps) -> Html {
// Helper function to get calendar color for an event
let get_event_color = |event: &VEvent| -> String {
if let Some(user_info) = &props.user_info {
if let Some(calendar_path) = &event.calendar_path {
if let Some(calendar_path) = &event.calendar_path {
// Check external calendars first (path format: "external_{id}")
if calendar_path.starts_with("external_") {
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
if let Some(external_calendar) = props.external_calendars
.iter()
.find(|cal| cal.id == id_str)
{
return external_calendar.color.clone();
}
}
}
// Check regular calendars
else if let Some(user_info) = &props.user_info {
if let Some(calendar) = user_info
.calendars
.iter()
@@ -194,6 +208,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
<div
class={classes!("event-box", if is_refreshing { Some("refreshing") } else { None })}
style={format!("background-color: {}", event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
{onclick}
{oncontextmenu}
>

View File

@@ -0,0 +1,362 @@
use crate::components::{ViewMode, WeekView, MonthView};
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 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 header_height = 50.0; // Fixed week header height in print preview
let header_border = 2.0; // Week header bottom border (2px solid)
let container_spacing = 8.0; // Additional container spacing/margins
let total_overhead = header_height + header_border + container_spacing;
let 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 header_height = 50.0;
let header_border = 2.0;
let container_spacing = 8.0;
let total_overhead = 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">
{
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>
}
}

View File

@@ -1,6 +1,6 @@
use crate::components::{Login, ViewMode};
use crate::models::ical::VEvent;
use crate::services::calendar_service::UserInfo;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use yew::prelude::*;
use yew_router::prelude::*;
@@ -20,6 +20,10 @@ pub struct RouteHandlerProps {
pub user_info: Option<UserInfo>,
pub on_login: Callback<String>,
#[prop_or_default]
pub external_calendar_events: Vec<VEvent>,
#[prop_or_default]
pub external_calendars: Vec<ExternalCalendar>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
@@ -34,7 +38,7 @@ pub struct RouteHandlerProps {
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)>,
@@ -48,6 +52,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
let auth_token = props.auth_token.clone();
let user_info = props.user_info.clone();
let on_login = props.on_login.clone();
let external_calendar_events = props.external_calendar_events.clone();
let external_calendars = props.external_calendars.clone();
let on_event_context_menu = props.on_event_context_menu.clone();
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
let view = props.view.clone();
@@ -60,6 +66,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
let auth_token = auth_token.clone();
let user_info = user_info.clone();
let on_login = on_login.clone();
let external_calendar_events = external_calendar_events.clone();
let external_calendars = external_calendars.clone();
let on_event_context_menu = on_event_context_menu.clone();
let on_calendar_context_menu = on_calendar_context_menu.clone();
let view = view.clone();
@@ -87,6 +95,8 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
html! {
<CalendarView
user_info={user_info}
external_calendar_events={external_calendar_events}
external_calendars={external_calendars}
on_event_context_menu={on_event_context_menu}
on_calendar_context_menu={on_calendar_context_menu}
view={view}
@@ -108,6 +118,10 @@ pub fn route_handler(props: &RouteHandlerProps) -> Html {
pub struct CalendarViewProps {
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub external_calendar_events: Vec<VEvent>,
#[prop_or_default]
pub external_calendars: Vec<ExternalCalendar>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
@@ -122,7 +136,7 @@ pub struct CalendarViewProps {
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)>,
@@ -139,6 +153,8 @@ pub fn calendar_view(props: &CalendarViewProps) -> Html {
<div class="calendar-view">
<Calendar
user_info={props.user_info.clone()}
external_calendar_events={props.external_calendar_events.clone()}
external_calendars={props.external_calendars.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
view={props.view.clone()}

View File

@@ -1,5 +1,5 @@
use crate::components::CalendarListItem;
use crate::services::calendar_service::UserInfo;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use web_sys::HtmlSelectElement;
use yew::prelude::*;
use yew_router::prelude::*;
@@ -100,12 +100,18 @@ impl Default for ViewMode {
pub struct SidebarProps {
pub user_info: Option<UserInfo>,
pub on_logout: Callback<()>,
pub on_create_calendar: Callback<()>,
pub on_add_calendar: Callback<()>,
pub external_calendars: Vec<ExternalCalendar>,
pub on_external_calendar_toggle: Callback<i32>,
pub on_external_calendar_delete: Callback<i32>,
pub on_external_calendar_refresh: Callback<i32>,
pub color_picker_open: Option<String>,
pub on_color_change: Callback<(String, String)>,
pub on_color_picker_toggle: Callback<String>,
pub available_colors: Vec<String>,
pub refreshing_calendar_id: Option<i32>,
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
pub on_calendar_visibility_toggle: Callback<String>,
pub current_view: ViewMode,
pub on_view_change: Callback<ViewMode>,
pub current_theme: Theme,
@@ -116,6 +122,7 @@ pub struct SidebarProps {
#[function_component(Sidebar)]
pub fn sidebar(props: &SidebarProps) -> Html {
let external_context_menu_open = use_state(|| None::<i32>);
let on_view_change = {
let on_view_change = props.on_view_change.clone();
Callback::from(move |e: Event| {
@@ -155,10 +162,34 @@ pub fn sidebar(props: &SidebarProps) -> Html {
})
};
let on_external_calendar_context_menu = {
let external_context_menu_open = external_context_menu_open.clone();
Callback::from(move |(e, cal_id): (MouseEvent, i32)| {
e.prevent_default();
external_context_menu_open.set(Some(cal_id));
})
};
let on_external_calendar_delete = {
let on_external_calendar_delete = props.on_external_calendar_delete.clone();
let external_context_menu_open = external_context_menu_open.clone();
Callback::from(move |cal_id: i32| {
on_external_calendar_delete.emit(cal_id);
external_context_menu_open.set(None);
})
};
let close_external_context_menu = {
let external_context_menu_open = external_context_menu_open.clone();
Callback::from(move |_| {
external_context_menu_open.set(None);
})
};
html! {
<aside class="app-sidebar">
<div class="sidebar-header">
<h1>{"Calendar App"}</h1>
<h1>{"Runway"}</h1>
{
if let Some(ref info) = props.user_info {
html! {
@@ -172,9 +203,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
}
}
</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 !info.calendars.is_empty() {
@@ -192,6 +220,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
on_color_picker_toggle={props.on_color_picker_toggle.clone()}
available_colors={props.available_colors.clone()}
on_context_menu={props.on_calendar_context_menu.clone()}
on_visibility_toggle={props.on_calendar_visibility_toggle.clone()}
/>
}
}).collect::<Html>()
@@ -206,9 +235,174 @@ pub fn sidebar(props: &SidebarProps) -> Html {
html! {}
}
}
// External calendars section
<div class="external-calendar-list">
<h3>{"External Calendars"}</h3>
{
if !props.external_calendars.is_empty() {
html! {
<ul class="external-calendar-items">
{
props.external_calendars.iter().map(|cal| {
let on_toggle = {
let on_external_calendar_toggle = props.on_external_calendar_toggle.clone();
let cal_id = cal.id;
Callback::from(move |_| {
on_external_calendar_toggle.emit(cal_id);
})
};
html! {
<li class="external-calendar-item" style="position: relative;">
<div
class={if props.color_picker_open.as_ref() == Some(&format!("external_{}", cal.id)) {
"external-calendar-info color-picker-active"
} else {
"external-calendar-info"
}}
oncontextmenu={{
let on_context_menu = on_external_calendar_context_menu.clone();
let cal_id = cal.id;
Callback::from(move |e: MouseEvent| {
on_context_menu.emit((e, cal_id));
})
}}
>
<input
type="checkbox"
checked={cal.is_visible}
onchange={on_toggle}
/>
<span
class="external-calendar-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 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}
/>
}
}).collect::<Html>()
}
</div>
}
} else {
html! {}
}
}
</span>
<span class="external-calendar-name">{&cal.name}</span>
<div class="external-calendar-actions">
{
if let Some(last_fetched) = cal.last_fetched {
let local_time = last_fetched.with_timezone(&chrono::Local);
html! {
<span class="last-updated" title={format!("Last updated: {}", local_time.format("%Y-%m-%d %H:%M"))}>
{format!("{}", local_time.format("%H:%M"))}
</span>
}
} else {
html! {
<span class="last-updated">{"Never"}</span>
}
}
}
<button
class="external-calendar-refresh-btn"
title="Refresh calendar"
onclick={{
let on_refresh = props.on_external_calendar_refresh.clone();
let cal_id = cal.id;
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
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>
</div>
</div>
{
if *external_context_menu_open == Some(cal.id) {
html! {
<>
<div
class="context-menu-overlay"
onclick={close_external_context_menu.clone()}
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999;"
/>
<div class="context-menu" style="position: absolute; top: 0; right: 0; background: white; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000; min-width: 120px;">
<div
class="context-menu-item"
style="padding: 8px 12px; cursor: pointer; color: #d73a49;"
onclick={{
let on_delete = on_external_calendar_delete.clone();
let cal_id = cal.id;
Callback::from(move |_| {
on_delete.emit(cal_id);
})
}}
>
{"Delete Calendar"}
</div>
</div>
</>
}
} else {
html! {}
}
}
</li>
}
}).collect::<Html>()
}
</ul>
}
} else {
html! {}
}
}
</div>
<div class="sidebar-footer">
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
{"+ Create Calendar"}
<button onclick={props.on_add_calendar.reform(|_| ())} class="add-calendar-button">
{"+ Add Calendar"}
</button>
<div class="view-selector">
@@ -219,7 +413,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
</div>
<div class="theme-selector">
<label>{"Theme:"}</label>
<select class="theme-selector-dropdown" onchange={on_theme_change}>
<option value="default" selected={matches!(props.current_theme, Theme::Default)}>{"Default"}</option>
<option value="ocean" selected={matches!(props.current_theme, Theme::Ocean)}>{"Ocean"}</option>
@@ -233,7 +426,6 @@ pub fn sidebar(props: &SidebarProps) -> Html {
</div>
<div class="style-selector">
<label>{"Style:"}</label>
<select class="style-selector-dropdown" onchange={on_style_change}>
<option value="default" selected={matches!(props.current_style, Style::Default)}>{"Default"}</option>
<option value="google" selected={matches!(props.current_style, Style::Google)}>{"Google Calendar"}</option>

View File

@@ -1,6 +1,6 @@
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
use crate::models::ical::VEvent;
use crate::services::calendar_service::UserInfo;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
use std::collections::HashMap;
use web_sys::MouseEvent;
@@ -17,6 +17,8 @@ pub struct WeekViewProps {
#[prop_or_default]
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub external_calendars: Vec<ExternalCalendar>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
@@ -31,7 +33,7 @@ pub struct WeekViewProps {
NaiveDateTime,
NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)>,
@@ -40,6 +42,12 @@ pub struct WeekViewProps {
pub context_menus_open: bool,
#[prop_or_default]
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)]
@@ -79,10 +87,47 @@ pub fn week_view(props: &WeekViewProps) -> Html {
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
let get_event_color = |event: &VEvent| -> String {
if let Some(user_info) = &props.user_info {
if let Some(calendar_path) = &event.calendar_path {
if let Some(calendar_path) = &event.calendar_path {
// Check external calendars first (path format: "external_{id}")
if calendar_path.starts_with("external_") {
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
if let Some(external_calendar) = props.external_calendars
.iter()
.find(|cal| cal.id == id_str)
{
return external_calendar.color.clone();
}
}
}
// Check regular calendars
else if let Some(user_info) = &props.user_info {
if let Some(calendar) = user_info
.calendars
.iter()
@@ -95,8 +140,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
"#3B82F6".to_string()
};
// Generate time labels - 24 hours plus the final midnight boundary
let mut time_labels: Vec<String> = (0..24)
// Generate time labels - 24 hours
let time_labels: Vec<String> = (0..24)
.map(|hour| {
if hour == 0 {
"12 AM".to_string()
@@ -110,9 +155,6 @@ pub fn week_view(props: &WeekViewProps) -> Html {
})
.collect();
// Add the final midnight boundary to show where the day ends
time_labels.push("12 AM".to_string());
// Handlers for recurring event modification modal
let on_recurring_choice = {
let pending_recurring_edit = pending_recurring_edit.clone();
@@ -243,18 +285,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Calculate the day before this occurrence for UNTIL clause
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
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
let until_utc =
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
until_datetime,
chrono::Utc,
);
let until_naive = until_datetime; // Use local time directly
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"),
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
until_naive.format("%Y-%m-%d %H:%M:%S"),
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
// This ensures the new series reflects the user's drag operation
@@ -275,7 +313,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
new_start, // Dragged start time for new series
new_end, // Dragged end time for new series
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(occurrence_date), // Date of occurrence being modified
));
@@ -319,11 +357,77 @@ pub fn week_view(props: &WeekViewProps) -> Html {
week_days.iter().map(|date| {
let is_today = *date == props.today;
let weekday_name = get_weekday_name(date.weekday());
// Collect all-day events that span this date (from any day in the week)
let mut all_day_events: Vec<&VEvent> = Vec::new();
for events_list in props.events.values() {
for event in events_list {
if event.all_day && event_spans_date(event, *date) {
all_day_events.push(event);
}
}
}
// Remove duplicates (same event might appear in multiple day buckets)
all_day_events.sort_by_key(|e| &e.uid);
all_day_events.dedup_by_key(|e| &e.uid);
html! {
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
<div class="weekday-name">{weekday_name}</div>
<div class="day-number">{date.day()}</div>
<div class="day-header-content">
<div class="weekday-name">{weekday_name}</div>
<div class="day-number">{date.day()}</div>
</div>
// All-day events section
{if !all_day_events.is_empty() {
html! {
<div class="all-day-events">
{
all_day_events.iter().map(|event| {
let event_color = get_event_color(event);
let onclick = {
let on_event_click = props.on_event_click.clone();
let event = (*event).clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
on_event_click.emit(event.clone());
})
};
let oncontextmenu = {
if let Some(callback) = &props.on_event_context_menu {
let callback = callback.clone();
let event = (*event).clone();
Some(Callback::from(move |e: web_sys::MouseEvent| {
e.prevent_default();
e.stop_propagation(); // Prevent calendar context menu from also triggering
callback.emit((e, event.clone()));
}))
} else {
None
}
};
html! {
<div
class="all-day-event"
style={format!("background-color: {}", event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
{onclick}
{oncontextmenu}
>
<span class="all-day-event-title">
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
</span>
</div>
}
}).collect::<Html>()
}
</div>
}
} else {
html! {}
}}
</div>
}
}).collect::<Html>()
@@ -332,14 +436,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Scrollable content area with time grid
<div class="week-content">
<div class="time-grid">
<div class={classes!("time-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
// Time labels
<div class="time-labels">
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{
time_labels.iter().enumerate().map(|(index, time)| {
let is_final = index == time_labels.len() - 1;
time_labels.iter().enumerate().map(|(hour, time)| {
let is_quarter_mode = props.time_increment == 15;
html! {
<div class={classes!("time-label", if is_final { Some("final-boundary") } else { None })}>
<div class={classes!(
"time-label",
if is_quarter_mode { Some("quarter-mode") } else { None }
)} data-hour={hour.to_string()}>
{time}
</div>
}
@@ -348,11 +455,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
</div>
// Day columns
<div class="week-days-grid">
<div class={classes!("week-days-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{
week_days.iter().enumerate().map(|(_column_index, date)| {
let is_today = *date == props.today;
let day_events = props.events.get(date).cloned().unwrap_or_default();
let event_layouts = calculate_event_layout(&day_events, *date, props.time_increment);
// Drag event handlers
let drag_state_clone = drag_state.clone();
@@ -398,6 +506,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let drag_state = drag_state_clone.clone();
let time_increment = props.time_increment;
Callback::from(move |e: MouseEvent| {
// Only process mouse move if a button is still pressed
if e.buttons() == 0 {
// No mouse button pressed, clear drag state
drag_state.set(None);
return;
}
if let Some(mut current_drag) = (*drag_state).clone() {
if current_drag.is_dragging {
// Use layer_y for consistent coordinate calculation
@@ -436,8 +551,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
match &current_drag.drag_type {
DragType::CreateEvent => {
// Calculate start and end times
let start_time = pixels_to_time(current_drag.start_y);
let end_time = pixels_to_time(current_drag.current_y);
let start_time = pixels_to_time(current_drag.start_y, time_increment);
let end_time = pixels_to_time(current_drag.current_y, time_increment);
// Ensure start is before end
let (actual_start, actual_end) = if start_time <= end_time {
@@ -465,7 +580,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let unsnapped_position = current_drag.current_y - current_drag.offset_y;
// Snap the final position to maintain time increment alignment
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
let new_start_time = pixels_to_time(event_top_position);
let new_start_time = pixels_to_time(event_top_position, time_increment);
// Calculate duration from original event
let original_duration = if let Some(end) = event.dtend {
@@ -494,14 +609,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
},
DragType::ResizeEventStart(event) => {
// Calculate new start time based on drag position
let new_start_time = pixels_to_time(current_drag.current_y);
let new_start_time = pixels_to_time(current_drag.current_y, time_increment);
// Keep the original end time
let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local()
} else {
end } else {
// 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);
@@ -530,10 +644,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
},
DragType::ResizeEventEnd(event) => {
// Calculate new end time based on drag position
let new_end_time = pixels_to_time(current_drag.current_y);
let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
// Keep the original start time
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
// Keep the original start time (already local)
let original_start = event.dtstart;
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
@@ -567,20 +681,43 @@ pub fn week_view(props: &WeekViewProps) -> Html {
})
};
// Check if currently dragging to create an event
let is_creating_event = if let Some(drag) = (*drag_state).clone() {
matches!(drag.drag_type, DragType::CreateEvent) && drag.is_dragging
} else {
false
};
html! {
<div
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
class={classes!(
"week-day-column",
if is_today { Some("today") } else { None },
if is_creating_event { Some("creating-event") } else { None },
if props.time_increment == 15 { Some("quarter-mode") } else { None }
)}
{onmousedown}
{onmousemove}
{onmouseup}
>
// 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;
html! {
<div class="time-slot">
<div class="time-slot-half"></div>
<div class="time-slot-half"></div>
<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| {
let slot_class = if props.time_increment == 15 {
"time-slot-quarter"
} else {
"time-slot-half"
};
html! {
<div class={slot_class}></div>
}
}).collect::<Html>()
}
</div>
}
}).collect::<Html>()
@@ -589,11 +726,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Events positioned absolutely based on their actual times
<div class="events-container">
{
day_events.iter().filter_map(|event| {
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
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, props.print_pixels_per_hour, props.print_start_hour);
// Skip all-day events (they're rendered in the header)
if is_all_day {
return None;
}
// Skip events that don't belong on this date or have invalid positioning
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
if start_pixels == 0.0 && duration_pixels == 0.0 {
return None;
}
@@ -613,7 +755,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let drag_state = drag_state.clone();
let event_for_drag = event.clone();
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| {
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
@@ -627,7 +771,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 };
// Get event's current position in day column coordinates
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
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;
// Convert click position to day column coordinates
@@ -678,9 +822,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let time_display = if event.all_day {
"All Day".to_string()
} else {
let local_start = event.dtstart.with_timezone(&Local);
let local_start = event.dtstart;
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
let start_is_am = local_start.hour() < 12;
@@ -782,12 +926,29 @@ pub fn week_view(props: &WeekViewProps) -> Html {
if is_refreshing { Some("refreshing") } else { None },
if is_all_day { Some("all-day") } else { None }
)}
style={format!(
"background-color: {}; top: {}px; height: {}px;",
event_color,
start_pixels,
duration_pixels
)}
style={
let (column_idx, total_columns) = event_layouts[event_idx];
let column_width = if total_columns > 1 {
format!("calc((100% - 8px) / {})", total_columns) // Account for 4px margins on each side
} else {
"calc(100% - 8px)".to_string()
};
let left_offset = if total_columns > 1 {
format!("calc(4px + {} * (100% - 8px) / {})", column_idx, total_columns)
} else {
"4px".to_string()
};
format!(
"background-color: {}; top: {}px; height: {}px; left: {}; width: {}; right: auto;",
event_color,
start_pixels,
duration_pixels,
left_offset,
column_width
)
}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
{onclick}
{oncontextmenu}
onmousedown={onmousedown_event}
@@ -807,7 +968,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Event content
<div class="event-content">
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
{if !is_all_day {
{if !is_all_day && duration_pixels > 30.0 {
html! { <div class="event-time">{time_display}</div> }
} else {
html! {}
@@ -835,7 +996,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Temporary event box during drag
{
if let Some(drag) = (*drag_state).clone() {
if drag.is_dragging && drag.start_date == *date {
if drag.is_dragging && drag.has_moved && drag.start_date == *date {
match &drag.drag_type {
DragType::CreateEvent => {
let start_y = drag.start_y.min(drag.current_y);
@@ -843,8 +1004,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let height = (drag.current_y - drag.start_y).abs().max(20.0);
// Convert pixels to times for display
let start_time = pixels_to_time(start_y);
let end_time = pixels_to_time(end_y);
let start_time = pixels_to_time(start_y, props.time_increment);
let end_time = pixels_to_time(end_y, props.time_increment);
html! {
<div
@@ -860,7 +1021,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let unsnapped_position = drag.current_y - drag.offset_y;
// Snap the final position to maintain time increment alignment
let preview_position = snap_to_increment(unsnapped_position, props.time_increment);
let new_start_time = pixels_to_time(preview_position);
let new_start_time = pixels_to_time(preview_position, props.time_increment);
let original_duration = if let Some(end) = event.dtend {
end.signed_duration_since(event.dtstart)
} else {
@@ -875,24 +1036,28 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div
class="temp-event-box moving-event"
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
>
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
{if duration_pixels > 30.0 {
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
} else {
html! {}
}}
</div>
}
},
DragType::ResizeEventStart(event) => {
// Show the event being resized from the start
let new_start_time = pixels_to_time(drag.current_y);
let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local()
} else {
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
end } else {
event.dtstart + chrono::Duration::hours(1)
};
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
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);
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
let new_start_pixels = drag.current_y;
@@ -904,19 +1069,24 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div
class="temp-event-box resizing-event"
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
>
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div>
{if new_height > 30.0 {
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div> }
} else {
html! {}
}}
</div>
}
},
DragType::ResizeEventEnd(event) => {
// Show the event being resized from the end
let new_end_time = pixels_to_time(drag.current_y);
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
let original_start = event.dtstart;
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
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_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
@@ -927,9 +1097,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div
class="temp-event-box resizing-event"
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
>
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
<div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
{if new_height > 30.0 {
html! { <div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
} else {
html! {}
}}
</div>
}
}
@@ -941,6 +1116,29 @@ pub fn week_view(props: &WeekViewProps) -> 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>
}
}).collect::<Html>()
@@ -993,22 +1191,25 @@ fn get_weekday_name(weekday: Weekday) -> &'static str {
}
// Calculate the pixel position of an event based on its time
// Each hour is 60px, so we convert time to pixels
// Snap pixel position to 15-minute increments (15px = 15 minutes since 60px = 60 minutes)
// Snap pixel position based on time increment and grid scaling
// In 30-minute mode: 60px per hour (1px = 1 minute)
// In 15-minute mode: 120px per hour (2px = 1 minute)
fn snap_to_increment(pixels: f64, increment: u32) -> f64 {
let increment_px = increment as f64; // Convert to pixels (1px = 1 minute)
let pixels_per_minute = if increment == 15 { 2.0 } else { 1.0 };
let increment_px = increment as f64 * pixels_per_minute;
(pixels / increment_px).round() * increment_px
}
// Convert pixel position to time (inverse of time to pixels)
fn pixels_to_time(pixels: f64) -> NaiveTime {
// Since 60px = 1 hour, pixels directly represent minutes
let total_minutes = pixels; // 1px = 1 minute
fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
let pixels_per_minute = if time_increment == 15 { 2.0 } else { 1.0 };
let total_minutes = pixels / pixels_per_minute;
let hours = (total_minutes / 60.0) as u32;
let minutes = (total_minutes % 60.0) as u32;
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
if total_minutes >= 1440.0 {
// Handle midnight boundary - check against scaled boundary
let max_pixels = 1440.0 * pixels_per_minute; // 24 hours in pixels
if pixels >= max_pixels {
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
}
@@ -1019,18 +1220,15 @@ fn pixels_to_time(pixels: f64) -> NaiveTime {
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) -> (f32, f32, bool) {
// Convert UTC times to local time for display
let local_start = event.dtstart.with_timezone(&Local);
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);
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) {
// Events are already in local time
let local_start = event.dtstart;
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
}
@@ -1039,29 +1237,189 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
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_minute = local_start.minute() as f32;
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
let pixels_per_hour = if let Some(print_pph) = print_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
let duration_pixels = if let Some(end) = event.dtend {
let local_end = end.with_timezone(&Local);
let end_date = local_end.date_naive();
let local_end = end;
let end_date = local_end.date();
// Handle events that span multiple days by capping at midnight
if end_date > date {
// Event continues past midnight, cap at 24:00 (1440px)
1440.0 - start_pixels
// Event continues past midnight, cap at end of visible range
let max_hour = if let Some(_print_start) = print_start_hour { 24.0 } else { 24.0 };
let max_pixels = (max_hour - hour_offset) * pixels_per_hour;
(max_pixels - start_pixels).max(20.0)
} else {
let end_hour = local_end.hour() as f32;
let end_minute = local_end.minute() as f32;
let end_pixels = (end_hour + end_minute / 60.0) * 60.0;
let end_pixels = ((end_hour + end_minute / 60.0) - hour_offset) * pixels_per_hour;
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
}
} else {
60.0 // 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
}
// Check if two events overlap in time
fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
// All-day events don't overlap with timed events for width calculation purposes
if event1.all_day || event2.all_day {
return false;
}
let start1 = event1.dtstart;
let end1 = if let Some(end) = event1.dtend {
end
} else {
start1 + chrono::Duration::hours(1) // Default 1 hour duration
};
let start2 = event2.dtstart;
let end2 = if let Some(end) = event2.dtend {
end
} else {
start2 + chrono::Duration::hours(1) // Default 1 hour duration
};
// Events overlap if one starts before the other ends
start1 < end2 && start2 < end1
}
// Calculate layout columns for overlapping events
fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u32) -> Vec<(usize, usize)> {
// Filter and sort events that should appear on this date (excluding all-day events)
let mut day_events: Vec<_> = events.iter()
.enumerate()
.filter_map(|(idx, event)| {
// Skip all-day events as they don't participate in timed event overlap calculations
if event.all_day {
return None;
}
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
let local_start = event.dtstart;
let event_date = local_start.date();
if event_date == date ||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
Some((idx, event))
} else {
None
}
})
.collect();
// Sort by start time
day_events.sort_by_key(|(_, event)| event.dtstart);
// For each event, find all events it overlaps with
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
for i in 0..day_events.len() {
let (orig_idx_i, event_i) = day_events[i];
// Find all events that overlap with this event
let mut overlapping_events = vec![i];
for j in 0..day_events.len() {
if i != j {
let (_, event_j) = day_events[j];
if events_overlap(event_i, event_j) {
overlapping_events.push(j);
}
}
}
// If this event doesn't overlap with anything, it gets full width
if overlapping_events.len() == 1 {
event_columns[orig_idx_i] = (0, 1);
} else {
// This event overlaps - we need to calculate column layout
// Sort the overlapping group by start time
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart);
// Assign columns using a greedy algorithm
let mut columns: Vec<Vec<usize>> = Vec::new();
for &event_idx in &overlapping_events {
let (orig_idx, event) = day_events[event_idx];
// Find the first column where this event doesn't overlap with existing events
let mut placed = false;
for (col_idx, column) in columns.iter_mut().enumerate() {
let can_place = column.iter().all(|&existing_idx| {
let (_, existing_event) = day_events[existing_idx];
!events_overlap(event, existing_event)
});
if can_place {
column.push(event_idx);
event_columns[orig_idx] = (col_idx, columns.len());
placed = true;
break;
}
}
if !placed {
// Create new column
columns.push(vec![event_idx]);
event_columns[orig_idx] = (columns.len() - 1, columns.len());
}
}
// Update total_columns for all events in this overlapping group
let total_columns = columns.len();
for &event_idx in &overlapping_events {
let (orig_idx, _) = day_events[event_idx];
event_columns[orig_idx].1 = total_columns;
}
}
}
event_columns
}
// Check if an all-day event spans the given date
fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool {
let start_date = if event.all_day {
// 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
event.dtstart.date()
} else {
event.dtstart.date()
};
let end_date = if let Some(dtend) = event.dtend {
if event.all_day {
// 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
dtend.date() - chrono::Duration::days(1)
} else {
// For timed events, use timezone conversion
dtend.date()
}
} else {
// Single day event
start_date
};
// Check if the given date falls within the event's date range
date >= start_date && date <= end_date
}

View File

@@ -1,4 +1,5 @@
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
use gloo_storage::{LocalStorage, Storage};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::JsCast;
@@ -36,6 +37,12 @@ pub struct UserInfo {
pub username: String,
pub server_url: String,
pub calendars: Vec<CalendarInfo>,
#[serde(default = "default_timestamp")]
pub last_updated: u64,
}
fn default_timestamp() -> u64 {
0
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -43,6 +50,7 @@ pub struct CalendarInfo {
pub path: String,
pub display_name: String,
pub color: String,
pub is_visible: bool,
}
// CalendarEvent, EventStatus, and EventClass are now imported from shared library
@@ -267,14 +275,60 @@ impl CalendarService {
grouped
}
/// Convert UTC events to local timezone for display
fn convert_utc_to_local(mut event: VEvent) -> VEvent {
// 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 {
web_sys::console::log_1(&format!(
"🕐 Converting UTC event '{}' to local time",
event.summary.as_deref().unwrap_or("Untitled")
).into());
// 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;
}
}
event
}
/// Expand recurring events using VEvent (RFC 5545 compliant)
pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
let mut expanded_events = Vec::new();
let today = chrono::Utc::now().date_naive();
let start_range = today - Duration::days(30); // Show past 30 days
let end_range = today + Duration::days(365); // Show next 365 days
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 end_range = today + Duration::days(36500); // Show next 100 years
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 {
web_sys::console::log_1(
&format!(
@@ -364,17 +418,18 @@ impl CalendarService {
// Get UNTIL date if specified
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(
until_str.trim_end_matches('Z'),
"%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") {
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") {
// 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 {
web_sys::console::log_1(
&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into(),
@@ -387,7 +442,7 @@ impl CalendarService {
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 occurrence_count = 0;
@@ -414,8 +469,8 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| {
// Compare dates ignoring sub-second precision
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let exception_naive = exception_date.and_utc();
let occurrence_naive = occurrence_datetime.and_utc();
// Check if dates match (within a minute to handle minor time differences)
let diff = occurrence_naive - exception_naive;
@@ -439,9 +494,17 @@ impl CalendarService {
let mut occurrence_event = base_event.clone();
occurrence_event.dtstart = occurrence_datetime;
occurrence_event.dtstamp = chrono::Utc::now(); // Update DTSTAMP for each occurrence
if let Some(end) = base_event.dtend {
occurrence_event.dtend = Some(end + Duration::days(days_diff));
if let Some(base_end) = base_event.dtend {
if base_event.all_day {
// For all-day events, maintain the RFC-5545 end date pattern
// End date should always be exactly one day after start date
occurrence_event.dtend = Some(occurrence_datetime + Duration::days(1));
} else {
// For timed events, preserve the original duration
occurrence_event.dtend = Some(base_end + Duration::days(days_diff));
}
}
occurrences.push(occurrence_event);
@@ -539,7 +602,7 @@ impl CalendarService {
interval: i32,
start_range: NaiveDate,
end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
@@ -549,7 +612,7 @@ impl CalendarService {
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)
let reference_week_start =
@@ -607,8 +670,8 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let exception_naive = exception_date.and_utc();
let occurrence_naive = occurrence_datetime.and_utc();
let diff = occurrence_naive - exception_naive;
let matches = diff.num_seconds().abs() < 60;
@@ -659,7 +722,7 @@ impl CalendarService {
interval: i32,
start_range: NaiveDate,
end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
@@ -675,7 +738,7 @@ impl CalendarService {
return occurrences;
}
let start_date = base_event.dtstart.date_naive();
let start_date = base_event.dtstart.date();
let mut current_month_start =
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
let mut total_occurrences = 0;
@@ -733,9 +796,7 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let diff = occurrence_naive - exception_naive;
let diff = occurrence_datetime - *exception_date;
diff.num_seconds().abs() < 60
});
@@ -776,14 +837,14 @@ impl CalendarService {
interval: i32,
start_range: NaiveDate,
end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
// Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday)
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 =
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
let mut total_occurrences = 0;
@@ -814,9 +875,7 @@ impl CalendarService {
// Check EXDATE
let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let diff = occurrence_naive - exception_naive;
let diff = occurrence_datetime - *exception_date;
diff.num_seconds().abs() < 60
});
@@ -855,7 +914,7 @@ impl CalendarService {
interval: i32,
start_range: NaiveDate,
end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
@@ -871,7 +930,7 @@ impl CalendarService {
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 total_occurrences = 0;
@@ -914,9 +973,7 @@ impl CalendarService {
// Check EXDATE
let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let diff = occurrence_naive - exception_naive;
let diff = occurrence_datetime - *exception_date;
diff.num_seconds().abs() < 60
});
@@ -1241,7 +1298,11 @@ impl CalendarService {
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
timezone: String,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -1272,10 +1333,11 @@ impl CalendarService {
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": 1_u32, // Default interval
"recurrence_end_date": None as Option<String>, // No end date by default
"recurrence_count": None as Option<u32>, // No count limit by default
"calendar_path": calendar_path
"recurrence_interval": recurrence_interval,
"recurrence_end_date": recurrence_until,
"recurrence_count": recurrence_count,
"calendar_path": calendar_path,
"timezone": timezone
});
let url = format!("{}/calendar/events/series/create", self.base_url);
(body, url)
@@ -1299,7 +1361,8 @@ impl CalendarService {
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"calendar_path": calendar_path
"calendar_path": calendar_path,
"timezone": timezone
});
let url = format!("{}/calendar/events/create", self.base_url);
(body, url)
@@ -1376,10 +1439,11 @@ impl CalendarService {
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
exception_dates: Vec<DateTime<Utc>>,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>,
timezone: String,
) -> Result<(), String> {
// Forward to update_event_with_scope with default scope
self.update_event_with_scope(
@@ -1403,10 +1467,11 @@ impl CalendarService {
reminder,
recurrence,
recurrence_days,
recurrence_interval,
recurrence_count,
recurrence_until,
calendar_path,
exception_dates,
update_action,
until_date,
timezone,
)
.await
}
@@ -1433,10 +1498,11 @@ impl CalendarService {
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
exception_dates: Vec<DateTime<Utc>>,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>,
timezone: String,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -1464,11 +1530,11 @@ impl CalendarService {
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": recurrence_interval,
"recurrence_count": recurrence_count,
"recurrence_end_date": recurrence_until,
"calendar_path": calendar_path,
"update_action": update_action,
"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())
"timezone": timezone
});
let url = format!("{}/calendar/events/update", self.base_url);
@@ -1668,9 +1734,14 @@ impl CalendarService {
categories: String,
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
update_scope: String,
occurrence_date: Option<String>,
timezone: String,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -1696,13 +1767,14 @@ impl CalendarService {
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": vec![false; 7], // Default - could be enhanced
"recurrence_interval": 1_u32, // Default interval
"recurrence_end_date": None as Option<String>, // No end date by default
"recurrence_count": None as Option<u32>, // No count limit by default
"recurrence_days": recurrence_days,
"recurrence_interval": recurrence_interval,
"recurrence_end_date": recurrence_until,
"recurrence_count": recurrence_count,
"calendar_path": calendar_path,
"update_scope": update_scope,
"occurrence_date": occurrence_date
"occurrence_date": occurrence_date,
"timezone": timezone
});
let url = format!("{}/calendar/events/series/update", self.base_url);
@@ -1841,4 +1913,257 @@ impl CalendarService {
None
}
// ==================== EXTERNAL CALENDAR METHODS ====================
pub async fn get_external_calendars() -> Result<Vec<ExternalCalendar>, String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_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 service = Self::new();
let url = format!("{}/external-calendars", service.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!("Authorization header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
let json = JsFuture::from(resp.json().unwrap())
.await
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
let external_calendars: Vec<ExternalCalendar> = serde_wasm_bindgen::from_value(json)
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
Ok(external_calendars)
}
pub async fn create_external_calendar(name: &str, url: &str, color: &str) -> Result<ExternalCalendar, String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_string())?;
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
let body = serde_json::json!({
"name": name,
"url": url,
"color": color
});
let service = Self::new();
let body_string = serde_json::to_string(&body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
opts.set_body(&body_string.into());
let url = format!("{}/external-calendars", service.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!("Authorization header setting failed: {:?}", e))?;
request
.headers()
.set("Content-Type", "application/json")
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
let json = JsFuture::from(resp.json().unwrap())
.await
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
let external_calendar: ExternalCalendar = serde_wasm_bindgen::from_value(json)
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
Ok(external_calendar)
}
pub async fn update_external_calendar(
id: i32,
name: &str,
url: &str,
color: &str,
is_visible: bool,
) -> Result<(), String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_string())?;
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
let body = serde_json::json!({
"name": name,
"url": url,
"color": color,
"is_visible": is_visible
});
let service = Self::new();
let body_string = serde_json::to_string(&body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
opts.set_body(&body_string.into());
let url = format!("{}/external-calendars/{}", service.base_url, id);
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!("Authorization header setting failed: {:?}", e))?;
request
.headers()
.set("Content-Type", "application/json")
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
Ok(())
}
pub async fn delete_external_calendar(id: i32) -> Result<(), String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_string())?;
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("DELETE");
opts.set_mode(RequestMode::Cors);
let service = Self::new();
let url = format!("{}/external-calendars/{}", service.base_url, id);
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!("Authorization header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
Ok(())
}
pub async fn fetch_external_calendar_events(id: i32) -> Result<Vec<VEvent>, String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_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 service = Self::new();
let url = format!("{}/external-calendars/{}/events", service.base_url, id);
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!("Authorization header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
let json = JsFuture::from(resp.json().unwrap())
.await
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
#[derive(Deserialize)]
struct ExternalCalendarEventsResponse {
events: Vec<VEvent>,
last_fetched: chrono::DateTime<chrono::Utc>,
}
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
Ok(response.events)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalCalendar {
pub id: i32,
pub name: String,
pub url: String,
pub color: String,
pub is_visible: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
}

View File

@@ -12,6 +12,7 @@ pub struct UserPreferences {
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_colors: Option<String>,
pub last_used_calendar: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -22,6 +23,7 @@ pub struct UpdatePreferencesRequest {
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_colors: Option<String>,
pub last_used_calendar: Option<String>,
}
#[allow(dead_code)]
@@ -61,6 +63,7 @@ impl PreferencesService {
calendar_view_mode: None,
calendar_theme: None,
calendar_colors: None,
last_used_calendar: None,
});
// Update the specific field
@@ -95,6 +98,7 @@ impl PreferencesService {
calendar_view_mode: preferences.calendar_view_mode.clone(),
calendar_theme: preferences.calendar_theme.clone(),
calendar_colors: preferences.calendar_colors.clone(),
last_used_calendar: preferences.last_used_calendar.clone(),
};
self.sync_preferences(&session_token, &request).await
@@ -156,6 +160,7 @@ impl PreferencesService {
calendar_view_mode: LocalStorage::get::<String>("calendar_view_mode").ok(),
calendar_theme: LocalStorage::get::<String>("calendar_theme").ok(),
calendar_colors: LocalStorage::get::<String>("calendar_colors").ok(),
last_used_calendar: LocalStorage::get::<String>("last_used_calendar").ok(),
};
// Only migrate if we have some preferences to migrate
@@ -164,6 +169,7 @@ impl PreferencesService {
|| request.calendar_view_mode.is_some()
|| request.calendar_theme.is_some()
|| request.calendar_colors.is_some()
|| request.last_used_calendar.is_some()
{
self.sync_preferences(&session_token, &request).await?;
@@ -177,4 +183,24 @@ impl PreferencesService {
Ok(())
}
/// Update the last used calendar and sync with backend
pub async fn update_last_used_calendar(&self, calendar_path: &str) -> Result<(), String> {
// Get session token
let session_token = LocalStorage::get::<String>("session_token")
.map_err(|_| "No session token found".to_string())?;
// Create minimal update request with only the last used calendar
let request = UpdatePreferencesRequest {
calendar_selected_date: None,
calendar_time_increment: None,
calendar_view_mode: None,
calendar_theme: None,
calendar_colors: None,
last_used_calendar: Some(calendar_path.to_string()),
};
// Sync to backend
self.sync_preferences(&session_token, &request).await
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

BIN
sample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB