145 Commits

Author SHA1 Message Date
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
Connor Johnstone
970b0a07da Fix compiler warnings
- Remove unused import in auth handler
- Remove unused PreferencesService export
- Fix unused mutable variable in preferences
- Remove unused display_name method from Style enum
- Add dead_code attributes for future preferences service

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:38:13 -04:00
Connor Johnstone
e2e5813b54 Fix week view time grid display and late night event rendering
- Remove unnecessary boundary slot from week view time grid
- Adjust container heights to display full 11 PM - midnight time slot
- Fix timezone issue preventing events at 8 PM or later from rendering
- Update date matching logic to handle UTC-4 timezone offset correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 10:36:26 -04:00
Connor Johnstone
73567c185c Implement comprehensive style system with Google Calendar theme
This commit adds a complete style system alongside the existing theme system, allowing users to switch between different UI styles while maintaining theme color variations.

**Core Features:**
- Style enum (Default, Google Calendar) separate from Theme enum
- Hot-swappable stylesheets with dynamic loading
- Style preference persistence (localStorage + database)
- Style picker UI in sidebar below theme picker

**Frontend Implementation:**
- Add Style enum to sidebar.rs with value/display methods
- Implement dynamic stylesheet loading in app.rs
- Add style picker dropdown with proper styling
- Handle style state management and persistence
- Add web-sys features for HtmlLinkElement support

**Backend Integration:**
- Add calendar_style column to user_preferences table
- Update all database operations (insert/update/select)
- Extend API models for style preference
- Add migration for existing users

**Google Calendar Style:**
- Clean Material Design-inspired interface
- White sidebar with proper contrast
- Enhanced calendar grid with subtle shadows
- Improved event styling with hover effects
- Google Sans typography throughout
- Professional color scheme and spacing

**Technical Details:**
- Trunk asset management for stylesheet copying
- High CSS specificity to override theme styles
- Modular CSS architecture for easy extensibility
- Comprehensive text contrast fixes
- Enhanced calendar cells and navigation

Users can now choose between the original gradient design (Default) and a clean Google Calendar-inspired interface (Google Calendar), with full preference persistence across sessions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 20:08:05 -04:00
0587762bbb Merge pull request 'Fixed some auth stuff and deployment stuff' (#7) from bugfix/sqlite-auth-docker-deployment into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 2s
Reviewed-on: #7
2025-09-01 19:29:17 -04:00
Connor Johnstone
cd6e9c3619 Update README with Docker deployment instructions and auth features
- Added comprehensive Docker Compose deployment section as recommended method
- Documented automatic database migrations and persistent storage
- Updated architecture section to mention SQLite auth system
- Added new User Experience section highlighting session management
- Reorganized development setup with local database migration instructions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:28:24 -04:00
Connor Johnstone
d8c3997f24 Fix Docker database directory and permissions
- Create /db directory in container
- Ensure database directory exists and has proper permissions in startup script
- Remove problematic conditional COPY command that was causing build failures

This fixes the 'unable to open database file' error by ensuring the
database directory exists before migrations run.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:21:38 -04:00
Connor Johnstone
e44d49e190 Add database migrations to Docker entrypoint
- Install sqlx-cli in backend builder stage
- Copy migrations and sqlx binary to runtime image
- Run database migrations automatically on container startup
- Add error handling to prevent startup failure if migrations already applied

This ensures the database schema is always up to date when deploying
with Docker, eliminating manual migration steps.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:13:04 -04:00
4d2aad404b Merge pull request 'Improved auth system' (#6) from feature/sqlite-auth-system into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 2s
Reviewed-on: #6
2025-09-01 19:03:30 -04:00
Connor Johnstone
0453763c98 Make remember checkboxes more subtle and checked by default
- Remember checkboxes now default to checked for better user experience
- Reduced visual prominence with smaller size, lighter colors, and lower opacity
- Users get convenience by default while still being able to opt out

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 19:02:29 -04:00
Connor Johnstone
03c0011445 Implement lightweight auth system with SQLite
Added SQLite database for session management and user preferences storage,
allowing users to have consistent settings across different sessions and devices.

Backend changes:
- Added SQLite database with users, sessions, and preferences tables
- Implemented session-based authentication alongside JWT tokens
- Created preference storage/retrieval API endpoints
- Database migrations for schema setup
- Session validation and cleanup functionality

Frontend changes:
- Added "Remember server" and "Remember username" checkboxes to login
- Created preferences service for syncing settings with backend
- Updated auth flow to handle session tokens and preferences
- Store remembered values in LocalStorage (not database) for convenience

Key features:
- User preferences persist across sessions and devices
- CalDAV passwords never stored, only passed through
- Sessions expire after 24 hours
- Remember checkboxes only affect local browser storage

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 18:55:09 -04:00
Connor Johnstone
79f287ed61 Fix calendar event fetching to use visible date range
Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m7s
Moved event fetching logic from CalendarView to Calendar component to properly
use the visible date range instead of hardcoded current month. The Calendar
component already tracks the current visible date through navigation, so events
now load correctly for August and other months when navigating.

Changes:
- Calendar component now manages its own events state and fetching
- Event fetching responds to current_date changes from navigation
- CalendarView simplified to just render Calendar component
- Fixed cargo fmt/clippy formatting across codebase

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 18:31:51 -04:00
Connor Johnstone
e55e6bf4dd Clean up obsolete CalDAV environment variables
Some checks failed
Build and Push Docker Image / docker (push) Failing after 2s
## Removed Obsolete Environment Variables:
- `CALDAV_SERVER_URL` - provided by user login
- `CALDAV_USERNAME` - provided by user login
- `CALDAV_PASSWORD` - provided by user login
- `CALDAV_TASKS_PATH` - not used in any features

## Kept with Intelligent Discovery:
- `CALDAV_CALENDAR_PATH` - optional override, defaults to smart discovery

## Changes:
### Backend
- Remove `CalDAVConfig::from_env()` method (not used in main app)
- Add `CalDAVConfig::new()` constructor with credentials
- Remove `tasks_path` field from CalDAVConfig
- Update auth service to use new constructor
- Update tests to use hardcoded test values instead of env vars
- Update debug tools to use test credentials

### Frontend
- Remove unused `config.rs` file entirely (frontend uses backend API)

## Current Authentication Flow:
1. User provides CalDAV credentials via login API
2. Backend creates CalDAVConfig dynamically from login request
3. Backend tests authentication via calendar discovery
4. Optional `CALDAV_CALENDAR_PATH` env var can override discovery
5. No environment variables required for normal operation

This simplifies deployment - users only need to provide CalDAV
credentials through the web interface, no server-side configuration required.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 19:48:13 -04:00
1fa3bf44b6 Merge pull request 'Editing Series Events via the Modal' (#5) from feature/modal-series-editing into main
Some checks failed
Build and Push Docker Image / docker (push) Failing after 3s
Reviewed-on: #5
2025-08-31 19:10:26 -04:00
Connor Johnstone
51d5552156 Fix modal series editing to use correct backend endpoint
The modal update flow was calling the regular event update endpoint
instead of the series endpoint, preventing proper handling of the
three edit types (this event, this and future, all events).

## Changes:
- Add logic to detect when edit_scope is set for recurring events
- Route to update_series() when edit_scope is present and event has RRULE
- Map EditAction enum to backend update_scope strings:
  - EditThis → "this_only" (creates exception + EXDATE)
  - EditFuture → "this_and_future" (new series + UNTIL on original)
  - EditAll → "all_in_series" (update existing series)
- Pass occurrence date for single/future edits using original event date
- Fall back to regular update_event() for non-recurring events

Now the modal properly leverages the existing robust series endpoint
that handles RFC 5545 compliant recurring event modifications.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 19:06:11 -04:00
Connor Johnstone
5a12c0e0d0 Implement comprehensive event series editing via modal
## Frontend Changes:
- Add EditAction enum (EditThis, EditFuture, EditAll) to event context menu
- Update context menu to show 3 edit options for recurring events
- Enhance EventCreationData with edit_scope and changed_fields tracking
- Update app component to handle EditAction types and pass to modal
- Add field change tracking infrastructure to CreateEventModal

## Backend Changes:
- Add changed_fields parameter to UpdateEventSeriesRequest for optimization
- Existing series endpoint already supports the three update types:
  - "this_only" - creates exception with EXDATE
  - "this_and_future" - creates new series with UNTIL on original
  - "all_in_series" - updates existing series in-place

## Implementation Details:
- Event context menu shows single edit option for non-recurring events
- Recurring events get three options: "Edit This Event", "Edit This and Future Events", "Edit All Events in Series"
- Modal tracks which fields user actually changed for efficient updates
- Backend series endpoint already has the logic for all three update scenarios
- Full RFC 5545 compliance with proper EXDATE and UNTIL handling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 18:49:56 -04:00
Connor Johnstone
ee181cf6cb Add Gitea Actions CI pipeline for Docker builds
Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m3s
- Builds and pushes Docker image on main branch commits
- Tags with both latest and commit SHA
- Uses build cache for faster builds

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 18:03:50 -04:00
Connor Johnstone
74d636117d Added the volume mounts 2025-08-31 17:55:06 -04:00
Connor Johnstone
ed458e6c3a Implement complete Docker Compose CI/CD setup with optimized builds
- Add multi-stage Dockerfile with dependency caching for both frontend and backend
- Implement Docker Compose configuration with separate frontend/backend services
- Configure Caddy as reverse proxy with proper WASM and static file serving
- Add volume mounting for frontend assets shared between containers
- Optimize build process with staged compilation and workspace handling
- Add debug logging and WASM initialization tracking for production deployment
- Update README with project motivation and "vibe coded" disclaimer

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 17:54:44 -04:00
Connor Johnstone
9b9378477a Remove old development files and cleanup root directory
- Remove conversation log file (2025-08-30-conversation.txt)
- Remove outdated integration plan (integration_plan.md)
- Remove test script (test_backend_url.js)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 10:50:52 -04:00
Connor Johnstone
1b4a26e31a Remove CLAUDE.md from version control and add to gitignore
- Updated CLAUDE.md with current architecture and recent improvements
- Removed from git tracking to keep as local development resource
- Added to gitignore to prevent future commits

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 10:37:36 -04:00
Connor Johnstone
45c32a6d1e Update README with comprehensive project overview and CalDAV motivation
- Add project motivation highlighting gap in quality CalDAV web clients
- Expand feature list with advanced recurring event and drag-drop capabilities
- Detail architecture with frontend/backend technical implementation
- Include comprehensive project structure and CalDAV server compatibility
- Note current testing status (Baikal tested, others planned)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 00:53:29 -04:00
Connor Johnstone
63968280b8 Clean up unused code and compiler warnings
Removed unused functions and variables identified after RRULE parameter fix:
- Remove unused build_series_rrule function from backend series handler
- Remove unused RecurrenceType::from_rrule and helper functions from frontend
- Prefix unused state variables with underscores to suppress warnings

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 00:45:56 -04:00
Connor Johnstone
3ccf31f479 Remove debug logging from RRULE handling
Clean up extensive console logging that was added during RRULE debugging.
Removed debug logs from:
- Frontend RRULE generation in create event modal
- Frontend RRULE parsing in calendar service
- Weekly/monthly/yearly occurrence generation functions
- Backend RRULE processing in events and series handlers

The core functionality remains unchanged - this is purely a cleanup
of temporary debugging output that is no longer needed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 00:35:57 -04:00
Connor Johnstone
c599598390 Fix recurring event RRULE INTERVAL and COUNT parameter loss
This commit fixes a critical bug where INTERVAL and COUNT parameters
were being stripped from recurring events during backend processing.

Frontend was correctly generating complete RRULE strings like:
FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,FR;COUNT=6

But backend was ignoring the complete RRULE and rebuilding from scratch,
resulting in simplified RRULEs like:
FREQ=WEEKLY;BYDAY=TU,FR (missing INTERVAL and COUNT)

Changes:
- Modified both events and series handlers to detect complete RRULE strings
- Added logic to use frontend RRULE directly when it starts with "FREQ="
- Maintained backwards compatibility with simple recurrence types
- Added comprehensive debug logging for RRULE generation
- Fixed weekly BYDAY occurrence counting to respect COUNT parameter
- Enhanced frontend RRULE generation with detailed logging

This ensures all RFC 5545 RRULE parameters are preserved from
frontend creation through CalDAV storage and retrieval.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 00:28:41 -04:00
Connor Johnstone
d0aa6fda08 Fix critical weekly recurring event BYDAY rendering bug
This commit resolves a significant bug where weekly recurring events with multiple selected days (BYDAY parameter) were only displaying the first 2 chronologically selected days instead of all selected days.

## Root Cause:
The `next_weekday_occurrence` function was designed for single-occurrence processing, causing it to:
- Find the first matching weekday in the current week
- Return immediately, skipping subsequent selected days
- Repeat this pattern across weeks, showing only the same first day repeatedly

## Solution:
- **New Function**: `generate_weekly_byday_occurrences()` handles multiple days per week
- **Week-by-Week Processing**: Generates events for ALL selected weekdays in each interval
- **Comprehensive Logic**: Properly handles INTERVAL, COUNT, UNTIL, and EXDATE constraints
- **Performance Optimized**: More efficient than single-occurrence iteration

## Technical Details:
- Replaced linear occurrence processing with specialized weekly BYDAY handler
- Added comprehensive debug logging for troubleshooting
- Maintains full RFC 5545 RRULE compliance
- Preserves existing functionality for non-BYDAY weekly events

## Expected Result:
Users creating weekly recurring events with multiple days (e.g., Mon/Wed/Fri/Sat) will now see events appear on ALL selected days in each week interval, not just the first two.

Example: "Every week on Mon, Wed, Fri, Sat" now correctly generates 4 events per week instead of just Monday events.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 23:32:21 -04:00
Connor Johnstone
62c39b8aa5 Implement comprehensive RRULE-based recurrence system with conditional UI
This commit introduces a complete RFC 5545-compliant recurrence management system that extends the event creation modal with sophisticated recurring event capabilities.

## New Features:

### Conditional Recurrence UI:
- **Interval Support**: "Every N days/weeks/months/years" with dynamic pluralization
- **End Conditions**: Never/Until date/After N occurrences with radio button interface
- **Weekly Options**: Enhanced weekday selection with existing checkbox interface
- **Monthly Options**: Choose between day-of-month (1-31) or positioned weekdays ("First Monday", "Last Friday")
- **Yearly Options**: Month selection grid allowing multiple months per year

### RRULE Parser & Generator:
- **Comprehensive Parser**: Handles FREQ, INTERVAL, BYDAY, BYMONTHDAY, BYMONTH, UNTIL, COUNT parameters
- **Smart Field Population**: Existing recurring events properly populate all recurrence fields from RRULE
- **RFC 5545 Compliance**: Full compliance with iCalendar recurrence specification
- **Round-trip Accuracy**: Parse → Edit → Generate produces identical RRULE

### Enhanced Data Model:
- **Extended EventCreationData**: Added 6 new fields for advanced recurrence options
- **Type Safety**: Strong typing with validation and bounds checking
- **Efficient Parsing**: Single-pass RRULE parsing with optimized data structures

### Professional Styling:
- **Responsive Design**: Mobile-friendly layout with proper spacing and grid systems
- **Visual Hierarchy**: Clean organization with grouped sections and proper labeling
- **User Experience**: Smart defaults, mutual exclusion logic, and intuitive workflows

## Technical Implementation:

### RRULE Examples:
- **Weekly**: `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;COUNT=10`
- **Monthly**: `FREQ=MONTHLY;BYDAY=1MO;UNTIL=20241231T000000Z`
- **Yearly**: `FREQ=YEARLY;BYMONTH=3,5;INTERVAL=2`

### Test Coverage:
- **7 Test Cases**: Complete coverage of parsing, building, and transformation logic
- **Edge Cases**: Empty values, positioning logic, format validation
- **Integration Tests**: End-to-end RRULE round-trip verification

This implementation provides enterprise-grade recurrence management while maintaining backward compatibility with existing simple recurrence patterns. Users can now create and edit sophisticated recurring events with full fidelity to RFC 5545 standards.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 23:12:06 -04:00
Connor Johnstone
75eddcf85d Remove all user-facing emojis and improve modal styling
This commit enhances the professional appearance of the calendar application by removing all user-facing emojis while preserving debug logging functionality. Additionally, includes modal layout and styling improvements for better usability.

## Changes Made:

### Emoji Removal:
- **Event creation modal tabs**: Removed emojis from all 6 tab buttons (📅 Basic Details → Basic Details, etc.)
- **Modal content**: Removed emojis from alarm types, attachment types, pattern examples, and completion status
- **Theme picker**: Removed emojis from all 8 theme options (🌊 Ocean → Ocean, etc.)
- **Context menus**: Removed emojis from event context menu (edit/delete actions) and calendar context menu

### Modal Styling Improvements:
- **Width expansion**: Increased modal max-width from 500px to 900px (80% wider)
- **Enhanced padding**: Added more padding to modal header (2rem 3rem 1.5rem) and tab content areas
- **Responsive design**: Improved mobile adjustments while maintaining desktop experience
- **Checkbox fix**: Override width inheritance for "All Day" checkbox with auto width and inline-block display

### Form Layout Enhancement:
- **Field reordering**: Moved Repeat and Reminder options above date/time pickers for better workflow
- **Visual consistency**: Maintained clean, professional appearance throughout the interface

The application now presents a clean, professional interface suitable for business environments while retaining full functionality. Debug logging with emojis is preserved for development purposes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 22:45:10 -04:00
Connor Johnstone
0babfc90f4 Implement comprehensive tabbed event creation modal with full VEvent support
Transform the basic event creation modal into a professional 6-tab interface
exposing all major RFC 5545 VEvent properties with enhanced UX:

• Basic Details: Essential fields (title, calendar, dates, location, basic recurrence/reminders)
• Advanced: Status, priority, classification, extended reminders/recurrence
• People: Organizer and attendee management with validation
• Categories: Interactive tagging system with quick-add buttons
• Location: Enhanced location handling with common shortcuts and geo features preview
• Reminders: Comprehensive alarm configuration with attachment features preview

Features:
- Complete RFC 5545 compliance throughout all tabs
- Interactive elements: 30+ clickable tags and quick-action buttons
- Professional styling with full theme compatibility (including dark mode)
- Mobile-responsive design with optimized layouts
- Educational content explaining calendar system capabilities
- Smooth tab navigation with active state management
- Form validation and smart defaults
- Future-proof extensible architecture

Technical implementation:
- Type-safe Rust/Yew state management with proper event handling
- Modular tab-based architecture for maintainability
- Performance optimized with efficient state updates
- JsCast integration for proper DOM element handling
- Comprehensive CSS with theme variants and responsive breakpoints

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 22:13:05 -04:00
Connor Johnstone
7538054b20 Refactor update_entire_series to use consistent clone-and-modify pattern
Aligns update_entire_series with the same metadata preservation approach
used in this_only and this_and_future update methods.

## Key Changes:

1. **Clone-and-Modify Pattern**:
   - Clone existing event to preserve all metadata (organizer, attendees, etc.)
   - Only modify specific properties that need to change
   - Maintains consistency with other update methods

2. **Smart Field Handling**:
   - Preserve original values when request fields are empty
   - Only overwrite when new values are explicitly provided
   - Same selective update logic as other scopes

3. **RRULE Preservation**:
   - Keep existing recurrence pattern unchanged for simple updates
   - Suitable for drag operations that just change start/end times
   - Avoids breaking complex RRULE patterns unnecessarily

4. **Proper Timestamp Management**:
   - Update dtstamp and last_modified to current time
   - Preserve original created timestamp for event history
   - Consistent timestamp handling across all update types

## Benefits:
- All three update scopes now follow the same metadata preservation pattern
- Simple time changes (drag operations) work without side effects
- Complex event properties maintained across all modification types
- Better RFC 5545 compliance through proper event structure preservation

## Removed:
- Complex RRULE regeneration logic (build_series_rrule function now unused)
- Manual field-by-field assignment replaced with selective clone modification

This ensures consistent behavior whether users modify single occurrences,
future events, or entire series - all maintain original event metadata.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 21:31:29 -04:00
Connor Johnstone
117dd2cc75 Fix this_only backend logic for proper RFC 5545 exception handling
Resolves multiple issues with single occurrence modification to implement
correct CalDAV/RFC 5545 exception patterns:

## Key Fixes:

1. **Eliminate Double Updates**:
   - Removed redundant client.update_event() call from update_single_occurrence
   - Main handler now performs single CalDAV update, preventing conflicts

2. **Preserve Event Metadata**:
   - Changed from creating new event to cloning existing event
   - Maintains organizer, attendees, categories, and all original properties
   - Only modifies necessary fields (times, title, recurrence rules)

3. **Fix UID Conflicts**:
   - Generate unique UID for exception event (exception-{uuid})
   - Prevents CalDAV from treating exception as update to original series
   - Original series keeps its UID, exception gets separate identity

4. **Correct Date/Time Handling**:
   - Use occurrence_date for both this_only and this_and_future scopes
   - Exception event now gets dragged date/time instead of original series date
   - Properly reflects user's drag operation in the exception event

## Implementation Details:
- Exception event clones original with unique UID and RECURRENCE-ID
- Original series gets EXDATE to exclude the modified occurrence
- Main handler performs single atomic CalDAV update
- Smart field preservation (keeps original values when request is empty)

## Result:
Single occurrence modifications now work correctly with proper RFC 5545
EXDATE + exception event pattern, maintaining all event metadata while
reflecting user modifications at the correct date/time.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 21:16:25 -04:00
Connor Johnstone
b9e8778f8f Fix this_only concurrent request cancellation issue
Resolves the same "Failed to fetch" cancellation issue that was occurring
in the "modify single event in a series" flow by eliminating concurrent
HTTP requests from the frontend.

## Problem:
The RecurringEditAction::ThisEvent handler was making two concurrent requests:
1. UPDATE request via update_callback.emit()
2. CREATE request via create_callback.emit()

This caused the same race condition and HTTP cancellation (~700-900ms) that
we previously fixed in the "this_and_future" flow.

## Solution:
- **Remove concurrent CREATE request** from frontend
- **Use single UPDATE request** with "this_only" scope
- **Backend handles both operations** atomically:
  1. Add EXDATE to original series (exclude occurrence)
  2. Create exception event with RECURRENCE-ID (user modifications)

## Implementation:
- Frontend sends single request with occurrence_date and dragged times
- Backend update_single_occurrence() already handled both operations
- Added comprehensive RFC 5545 documentation for single occurrence modification
- Cleaned up unused imports and variables

## Benefits:
- No more HTTP request cancellation for single event modifications
- Proper RFC 5545 EXDATE + RECURRENCE-ID exception handling
- Atomic operations ensure data consistency
- Matches the pattern used in this_and_future fix

The "modify single event" drag operations now work reliably without
network errors, completing the fix for all recurring event modification flows.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 20:29:02 -04:00
Connor Johnstone
9536158f58 Clean up debug logs and add comprehensive documentation for this_and_future logic
This commit improves code maintainability by:

1. **Removing excessive debug logging**:
   - Cleaned up verbose datetime parsing and CalDAV operation logs
   - Kept essential error logging and status messages
   - Simplified request flow logging for better readability

2. **Adding comprehensive documentation**:
   - Detailed RFC 5545 compliant series splitting explanation
   - Clear operation overview with real-world examples
   - Frontend/backend interaction documentation
   - CalDAV operation sequencing and race condition prevention
   - Error handling and parameter validation details

The documentation explains how "this and future events" works:
- **Backend**: Creates comprehensive function-level docs with examples
- **Frontend**: Explains the user interaction flow and technical implementation
- **Integration**: Documents the atomic request handling and parameter passing

This makes the codebase more maintainable and helps future developers
understand the complex recurring event modification logic.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 20:23:48 -04:00
Connor Johnstone
783e13eb10 Fix recurring event series modification via drag and drop operations
This commit resolves the "Failed to fetch" errors when updating recurring
event series through drag operations by implementing proper request
sequencing and fixing time parameter handling.

Key fixes:
- Eliminate HTTP request cancellation by sequencing operations properly
- Add global mutex to prevent CalDAV HTTP race conditions
- Implement complete RFC 5545-compliant series splitting for "this_and_future"
- Fix frontend to pass dragged times instead of original times
- Add comprehensive error handling and request timing logs
- Backend now handles both UPDATE (add UNTIL) and CREATE (new series) in single request

Technical changes:
- Frontend: Remove concurrent CREATE request, pass dragged times to backend
- Backend: Implement full this_and_future logic with sequential operations
- CalDAV: Add mutex serialization and detailed error tracking
- Series: Create new series with occurrence date + dragged times

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 20:17:36 -04:00
Connor Johnstone
1794cf9a59 Fix non-recurring event update functionality by using actual CalDAV hrefs
This commit resolves the 404 error when dragging/updating non-recurring events.

The issue was in the regular event update handler where it was generating fake
event hrefs based on the UID (format: "{uid}.ics") instead of using the actual
href returned by the CalDAV server during event fetching.

Changes:
- Use event.href from the stored event data instead of generating fake hrefs
- Add comprehensive debug logging to track event updates
- Fallback to generated href only when the stored href is missing

Debug logging added:
- "🔍 Found event {uid} with href: {href}" - shows which href is being used
- "📝 Updating event {uid} at calendar_path: {path}, event_href: {href}" - tracks update parameters
- " Successfully updated event {uid}" - confirms successful CalDAV updates

This matches the fix previously applied to the deletion functionality and ensures
that CalDAV PUT requests target the correct resource URLs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 18:44:55 -04:00
Connor Johnstone
ee1c6ee299 Fix single event deletion functionality with proper recurring vs non-recurring handling
This commit resolves multiple issues with event deletion:

Backend fixes:
- Fix CalDAV URL construction for DELETE requests (missing slash separator)
- Improve event lookup by href with exact matching and fallback to UID extraction
- Add support for both RFC3339 and simple YYYY-MM-DD date formats in occurrence parsing
- Implement proper logic to distinguish recurring vs non-recurring events in delete_this action
- For non-recurring events: delete entire event from CalDAV server
- For recurring events: add EXDATE to exclude specific occurrences
- Add comprehensive debug logging for troubleshooting deletion issues

Frontend fixes:
- Update callback signatures to support series endpoint parameters (7-parameter tuples)
- Add update_series method to CalendarService for series-specific operations
- Route single occurrence modifications through series endpoint with proper scoping
- Fix all component prop definitions to use new callback signature
- Update all emit calls to pass correct number of parameters

The deletion process now works correctly:
- Single events are completely removed from the calendar
- Recurring event occurrences are properly excluded via EXDATE
- Debug logging helps identify and resolve CalDAV communication issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 18:40:48 -04:00
Connor Johnstone
a6aac42c78 Fix frontend to use series endpoint for single occurrence modifications
- Extended update callback signature to include update_scope and occurrence_date parameters
- Modified app.rs to detect when series endpoint should be used vs regular endpoint
- Updated calendar_service to automatically set occurrence_date for "this_only" updates
- Modified all callback emit calls throughout frontend to include new parameters
- Week_view now properly calls series endpoint with "this_only" scope for single occurrence edits

This ensures that single occurrence modifications (RecurringEditAction::ThisEvent)
now go through the proper series endpoint which will:
- Add EXDATE to the original recurring series
- Create exception event with RECURRENCE-ID
- Show proper debug logging in backend

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 14:01:30 -04:00
Connor Johnstone
071fc3099f Fix series update to preserve original date when dragging events
- When dragging recurring events, preserve original series start date
- Only update time components, not date, to prevent series from shifting days
- For timed events: use original date with new start/end times from drag
- For all-day events: preserve original date and duration pattern
- Added debug logging to track date preservation behavior
- Maintains event duration for both timed and all-day recurring events

Fixes issue where dragging a recurring event would change the date
for all occurrences instead of just updating the time.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 13:44:54 -04:00
Connor Johnstone
78f1db7203 Refactor handlers.rs into modular structure for better maintainability
- Split 1921-line handlers.rs into focused modules:
  - handlers/auth.rs: Authentication handlers (login, verify_token, get_user_info)
  - handlers/calendar.rs: Calendar management (create_calendar, delete_calendar)
  - handlers/events.rs: Event operations (CRUD operations, fetch events)
  - handlers/series.rs: Event series operations (recurring events management)
- Main handlers.rs now serves as clean re-export module
- All tests passing (14 integration + 7 unit + 3 doc tests)
- Maintains backward compatibility with existing API routes
- Improves code organization and separation of concerns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 13:35:13 -04:00
Connor Johnstone
e21430f6ff Implement complete event series endpoints with full CRUD support
## Backend Implementation
- Add dedicated series endpoints: create, update, delete
- Implement RFC 5545 compliant RRULE generation and modification
- Support all scope operations: this_only, this_and_future, all_in_series
- Add comprehensive series-specific request/response models
- Implement EXDATE and RRULE modification for precise occurrence control

## Frontend Integration
- Add automatic series detection and smart endpoint routing
- Implement scope-aware event operations with backward compatibility
- Enhance API payloads with series-specific fields
- Integrate existing RecurringEditModal for scope selection UI

## Testing
- Add comprehensive integration tests for all series endpoints
- Validate scope handling, RRULE generation, and error scenarios
- All 14 integration tests passing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 13:21:44 -04:00
Connor Johnstone
b195208ddc Fix doctest compilation errors in config.rs
- Fix doctest import paths to use calendar_backend::config::CalDAVConfig
- Add proper Result handling in doctest example with ?operator
- All doctests now passing (3/3)
- Complete test coverage: 7 unit + 9 integration + 3 doc = 19 tests

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 12:18:30 -04:00
Connor Johnstone
5cb77235da Add comprehensive integration tests for all API endpoints
- Add 9 integration tests covering all HTTP endpoints
- Test authentication flow: login, verify, user info
- Test calendar operations: create, delete, list events
- Test event CRUD: create, update, delete, refresh
- Add TestServer utility for automated server setup
- Test both success and error scenarios (401, 400, 404)
- Integration with real CalDAV server using .env credentials
- Fix test module visibility by making handlers public
- Move misplaced unit tests into proper test module
- All tests passing: 7 unit + 9 integration = 16 total

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 12:17:09 -04:00
Connor Johnstone
a6d72ce37f Remove v2 API endpoints and fix warnings
- Remove all v2 API routes (/api/v2/calendar/events/*)
- Delete models_v2.rs file and associated types
- Remove create_event_v2, update_event_v2, delete_event_v2 handlers
- Remove unused occurrence_date and exception_dates from UpdateEventRequest
- Remove unused ConfigError variant from CalDAVError
- Simplify backend to single unified v1 API using VEvent structures

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 12:07:33 -04:00
Connor Johnstone
663b322d97 Complete frontend migration to shared VEvent model
- Update frontend to use shared calendar-models library
- Add display methods to VEvent for UI compatibility
- Remove duplicate VEvent definitions in frontend
- Fix JSON field name mismatch (dtstart/dtend vs start/end)
- Create CalendarEvent type alias for backward compatibility
- Resolve "missing field start" parsing error

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 11:58:37 -04:00
Connor Johnstone
15f2d0c6d9 Implement shared RFC 5545 VEvent library with workspace restructuring
- Created calendar-models/ shared library with RFC 5545-compliant VEvent structures
- Migrated backend to use shared VEvent with proper field mappings (dtstart/dtend, rrule, exdate, etc.)
- Converted CalDAV client to parse into VEvent structures with structured types
- Updated all CRUD handlers to use VEvent with CalendarUser, Attendee, VAlarm types
- Restructured project as Cargo workspace with frontend/, backend/, calendar-models/
- Updated Trunk configuration for new directory structure
- Fixed all compilation errors and field references throughout codebase
- Updated documentation and build instructions for workspace structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 11:45:58 -04:00
Connor Johnstone
6887e0b389 Fix create event functionality with proper timezone conversion
- Add UTC conversion to EventCreationData.to_create_event_params() method
- Restore app.rs event creation callback using existing create_event API
- Convert local datetime inputs to UTC before sending to backend
- Fix time format from HH:MM:SS to HH:MM as expected by server

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 20:01:10 -04:00
Connor Johnstone
f266d3f304 Implement RFC 5545-compliant calendar system with v2 API
This major refactor eliminates manual string parsing throughout the
codebase and introduces proper RFC 5545 iCalendar specification
compliance with significant code simplification benefits.

## Backend Improvements
- Add complete RFC 5545-compliant data structures (VEvent, VTodo, etc.)
- Create simplified v2 API endpoints with direct DateTime support
- Eliminate ~150 lines of manual string parsing in handlers
- Add structured attendee and alarm support
- Maintain backward compatibility with existing v1 APIs

## Frontend Improvements
- Replace 16+ parameter create_event calls with single structured request
- Add automatic date/time conversion in EventCreationData
- Eliminate enum-to-string conversions throughout event creation
- Add v2 API models with proper type safety

## Technical Benefits
- Direct DateTime<Utc> usage instead of error-prone string parsing
- Proper RFC 5545 compliance with DTSTAMP, SEQUENCE fields
- Vec<AttendeeV2> instead of comma-separated strings
- Structured alarm system with multiple reminder types
- Enhanced RRULE support for complex recurrence patterns

## Code Quality
- Reduced create_event call from 16 parameters to 1 structured request
- Added comprehensive integration plan documentation
- Both backend and frontend compile successfully
- Maintained full backward compatibility during transition

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 17:06:22 -04:00
Connor Johnstone
4af4aafd98 Implement comprehensive theme system with calendar view support
- Add 8 attractive themes (Default, Ocean, Forest, Sunset, Purple, Dark, Rose, Mint)
- Extend theme system to calendar header, month view, and week view components
- Add dynamic theme-aware event color palettes that change with selected theme
- Implement CSS custom properties for consistent theming across all components
- Add localStorage persistence for user theme preferences
- Create theme-aware calendar styling including day states, headers, and grid lines
- Optimize dark theme with proper contrast and readability improvements
- Add reactive event color system that updates when themes change

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 16:42:25 -04:00
Connor Johnstone
81805289e4 Implement complete recurring event drag modification system
- Add recurring edit modal with three modification options:
  • "Only this event" - Creates exception for single occurrence
  • "This and future events" - Splits series from occurrence forward
  • "All occurrences in this series" - Updates entire series time

- Enhance backend update API to support series modifications:
  • Add update_action parameter for recurring event operations
  • Implement time-only updates that preserve original start dates
  • Convert timestamped occurrence UIDs to base UIDs for series updates
  • Preserve recurrence rules during series modifications

- Fix recurring event drag operations:
  • Show modal for recurring events instead of direct updates
  • Handle EXDATE creation for single occurrence modifications
  • Support series splitting with UNTIL clause modifications
  • Maintain proper UID management for different modification types

- Clean up debug logging and restore page refresh for data consistency

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 15:22:34 -04:00
Connor Johnstone
9f2f58e23e Fix recurring event positioning by removing CSS positioning conflict
- Remove conflicting `position: relative` CSS rule for .week-event
- This was causing `top: XXXpx` values to be applied as relative offsets
  instead of absolute positioning from container top
- Recurring events now position correctly at their calculated times
- Both regular and recurring events display at proper grid positions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 14:16:14 -04:00
Connor Johnstone
53ea5e3fc1 Fix time snapping for event drag operations in week view
- Apply snapping to final event position after accounting for click offset
- Ensure preview position matches snapped final position
- Maintain time increment alignment during drag operations
- Fix scope issue for time_increment in mouseup handler

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 13:12:12 -04:00
Connor Johnstone
d35fc11267 Fix drag positioning to accurately follow mouse cursor in week view
- Fix coordinate system mismatch between event clicks and drag preview
- Convert event-relative coordinates to day-column-relative coordinates
- Add movement threshold to prevent accidental drag updates on clicks
- Ensure dragged events maintain proper offset from click position

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 13:09:25 -04:00
Connor Johnstone
697eb64dd4 Add event resize functionality with drag handles in week view
- Extend DragType enum to support ResizeEventStart and ResizeEventEnd operations
- Add visual resize handles at top/bottom edges of events for left-click resizing
- Implement start time resize by dragging top handle (preserves end time)
- Implement end time resize by dragging bottom handle (preserves start time)
- Add visual feedback with resizing event preview during drag operations
- Integrate resize operations with existing CalDAV update system
- Add CSS styling for resize handles with hover effects and resize cursors
- Maintain minimum 15-minute event duration during resize operations
- Preserve context menu functionality while preventing conflicts during drag
- Clean up code by removing experimental right-click drag functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 12:54:45 -04:00
Connor Johnstone
d36609d8c2 Implement drag-to-move functionality for events in week view with CalDAV server integration
- Add drag-to-move event handlers to existing events in week view
- Extend drag state management to support both create and move operations
- Implement visual feedback with event preview during drag and hidden original
- Calculate new start/end times while preserving event duration
- Add CalDAV server update integration via calendar service
- Wire event update callbacks through component hierarchy (WeekView → Calendar → RouteHandler → App)
- Preserve all original event properties (title, description, location, reminders, etc.)
- Handle timezone conversion from local to UTC for server storage
- Add error handling with user feedback and success confirmation
- Include moving event CSS styling with enhanced visual feedback

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 12:36:29 -04:00
Connor Johnstone
e23278d71e Implement fixed-height month view rows with event overflow handling
- Change calendar grid to use equal row heights instead of min-height on cells
- Add "+n more" indicator for days with too many events to display
- Limit visible events to fit available space (default 3 events per day)
- Add window resize handler to recalculate event limits dynamically
- Remove gaps between calendar rows for cleaner appearance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 12:27:24 -04:00
Connor Johnstone
edd209238f Add configurable time increment toggle for event creation
- Add toggle button (15/30 minutes) in calendar header next to navigation arrows
- Implement circular frosted styling consistent with nav buttons
- Add configurable snapping for drag-to-create events in week view
- Persist time increment setting across browser sessions using localStorage
- Update snap function to accept configurable increment instead of hardcoded 15 minutes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 12:17:31 -04:00
Connor Johnstone
4fbef8a5dc Fix context menu click-outside behavior to prevent underlying actions
When a context menu is open, clicking outside should only close the menu
without triggering underlying actions (like drag-to-create in week view).

Implementation:
- Enhanced global click handler to prevent default actions when menus are open
- Added context menu state propagation through component hierarchy
- Modified interactive components to check context menu state before acting
- Provides double protection at both global and component levels

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 11:58:17 -04:00
Connor Johnstone
edb216347d Fix event calendar change to use delete + create instead of update
When changing an event's calendar, the system now properly handles this as:
- Delete the event from the original calendar
- Create a new event in the target calendar

This ensures proper CalDAV compliance and prevents issues with cross-calendar updates.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 11:47:45 -04:00
Connor Johnstone
508c4f129f Fix drag-to-create event bugs and improve week view UX
- Restrict drag-to-create to left-click only (ignore right-clicks)
- Prevent drag-to-create when clicking on existing events
- Remove unnecessary right-click context menu from week view empty spaces

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 11:43:03 -04:00
Connor Johnstone
1c0140292f Complete drag-to-create event functionality with proper timezone handling
Integrate drag-to-create functionality with the full event creation pipeline:

- Connect WeekView drag events to CreateEventModal via callback chain
- Add event creation request callback through Calendar → RouteHandler → App
- Implement proper timezone conversion throughout the entire flow
- Fix pixels_to_time calculation (1px = 1 minute, not complex formula)
- Add initial_start_time/initial_end_time props to CreateEventModal
- Convert local times to UTC in both event creation and update functions
- Ensure modal displays correct local times while backend receives UTC
- Support both temporary drag events and real server events in modal

The complete flow now works: drag selection → modal with correct times →
proper UTC conversion → backend storage → correct display in calendar.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 11:33:14 -04:00
Connor Johnstone
53815c4814 Add 15-minute snapping and time display to drag-to-create functionality
Enhance the drag-to-create event feature with precision improvements:

- Add snap_to_15_minutes() function to align drag coordinates to 15-minute increments
- Update mousedown and mousemove handlers to use coordinate snapping
- Replace pixel coordinate display with actual time ranges in temporary event box
- Display times in 12-hour format with AM/PM (e.g., "2:15 PM - 3:30 PM")
- Remove unused wasm_bindgen::JsCast import for cleaner code

Users can now create events that automatically align to 15-minute boundaries with clear visual feedback showing the exact time range being selected.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 11:04:13 -04:00
Connor Johnstone
df714a43a2 Implement drag-to-create event functionality in week view
Add interactive drag-to-create event functionality that allows users to click and drag in empty spaces of the week view to create new events. Features include:

- Mouse event handlers for drag interaction (mousedown, mousemove, mouseup)
- Real-time temporary event box display with visual feedback during drag
- Proper coordinate calculation using layer_y() for accurate time positioning
- Minimum 15-minute event duration enforcement
- Integration with event creation modal via callback with pre-filled start/end times
- CSS pointer-events optimizations to prevent child element interference
- Time-to-pixel and pixel-to-time conversion functions for accurate positioning

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 11:00:32 -04:00
Connor Johnstone
a8bb2c8164 Implement comprehensive calendar UX improvements
- Add time range display to week view events showing "start - end" format
- Optimize time display with smart AM/PM formatting to reduce redundancy
- Fix context menu overlap by adding stop_propagation to event handlers
- Implement persistent view mode (Month/Week) across page refreshes using localStorage
- Replace month-based tracking with intelligent selected date tracking
- Add day selection in month view with visual feedback and click handlers
- Fix view switching to navigate to week containing selected day, not first week of month
- Separate selected_date from display_date for proper context switching
- Simplify week view header to show "Month Year" instead of "Week of Month Day"
- Add backward compatibility for existing localStorage keys

Greatly improves calendar navigation and user experience with persistent state

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 10:44:44 -04:00
Connor Johnstone
5d0628878b Implement accurate time-based event positioning in week view
- Add calculate_event_position() function to convert UTC times to pixel coordinates
- Position events based on actual start/end times with 60px per hour scaling
- Handle timezone conversion from UTC to local time for proper display
- Implement dynamic event heights that stretch from start to end time
- Add special handling for all-day events positioned at top of day columns
- Create enhanced event display with title and formatted time information
- Style all-day events distinctly with gradient background and italic text
- Filter events to show only those belonging to specific dates
- Add CSS styling for event titles, times, and all-day event appearance
- Support minimum 20px height for very short events and multi-day capping

Events now render at their correct times making week view much more useful for scheduling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 10:31:09 -04:00
Connor Johnstone
dacc18fe5d Implement time-based week view with scrollable 24-hour layout
- Add comprehensive time grid structure with hourly and half-hourly divisions
- Implement scrollable week view with sticky header and time labels
- Create 25 time labels (12 AM through 11 PM plus boundary) with proper formatting
- Add 25 matching time slot backgrounds for visual alignment
- Style time labels with appropriate sizing and boundary indicators
- Position events absolutely over time grid (basic positioning for now)
- Set proper container heights and scrollable content area

Note: Time slot alignment still needs refinement for complete 24-hour coverage

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 10:27:24 -04:00
Connor Johnstone
9ab6377d16 Refactor calendar component into modular architecture with view switching
- Split monolithic Calendar component into focused sub-components:
  - CalendarHeader: Navigation buttons and title display
  - MonthView: Monthly calendar grid layout and event rendering
  - WeekView: Weekly calendar view with full-height day containers
- Add ViewMode enum for Month/Week view switching in sidebar dropdown
- Fix event styling by correcting CSS class from "event" to "event-box"
- Implement proper week view layout with full-height day containers
- Maintain all existing functionality: event handling, context menus, localStorage persistence

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 10:14:53 -04:00
Connor Johnstone
197157cecb Persist calendar month across page refreshes using localStorage
- Load saved month from localStorage on calendar initialization
- Fall back to current date if no saved month exists
- Save current month to localStorage on all navigation actions:
  - Previous month navigation
  - Next month navigation
  - Today button clicks
- Use YYYY-MM-DD format for localStorage storage
- Maintain month view after page refreshes from event creation/editing
- Preserve user's selected month during manual browser refreshes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 09:51:21 -04:00
Connor Johnstone
c273a8625a Add 'Today' button to calendar header for quick navigation to current month
- Add go_to_today callback that navigates to first day of current month
- Position Today button between month title and next month arrow
- Center month/year title using absolute positioning
- Group Today button and next arrow in header-right container with 0.5rem gap
- Style Today button with pill shape, semi-transparent background, and hover effects
- Add responsive styling for mobile screens with smaller text and padding
- Maintain clean, balanced header layout on all screen sizes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 09:48:36 -04:00
Connor Johnstone
2a2666e75f Implement complete event editing functionality with backend update endpoint
Frontend Changes:
- Add edit context menu option to EventContextMenu with pencil icon
- Enhance CreateEventModal to support both create and edit modes
- Add event data conversion methods for pre-populating edit forms
- Implement conditional submit logic (on_create vs on_update callbacks)
- Add update_event method to CalendarService with POST /calendar/events/update

Backend Changes:
- Add UpdateEventRequest and UpdateEventResponse models
- Implement update_event handler with event search by UID across calendars
- Add POST /api/calendar/events/update route
- Full validation and parsing of all event properties for updates
- Integrate with existing CalDAV client update_event functionality

Users can now right-click events, select "Edit Event", modify properties in the modal, and successfully update existing events instead of creating duplicates.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 09:41:16 -04:00
Connor Johnstone
1b57adab98 Implement complete recurring event deletion with EXDATE and RRULE UNTIL support
Frontend Changes:
- Add DeleteAction enum with DeleteThis, DeleteFollowing, DeleteSeries options
- Update EventContextMenu to show different delete options for recurring events
- Add exception_dates field to CalendarEvent struct
- Fix occurrence generation to respect EXDATE exclusions
- Add comprehensive RRULE parsing with UNTIL date support
- Fix UNTIL date parsing to handle backend format (YYYYMMDDTHHMMSSZ)
- Enhanced debugging for RRULE processing and occurrence generation

Backend Changes:
- Add exception_dates field to CalendarEvent struct with EXDATE parsing/generation
- Implement update_event method for CalDAV client
- Add fetch_event_by_href helper function
- Update DeleteEventRequest model with delete_action and occurrence_date fields
- Implement proper delete_this logic with EXDATE addition
- Implement delete_following logic with RRULE UNTIL modification
- Add comprehensive logging for delete operations

CalDAV Integration:
- Proper EXDATE generation in iCal format for excluded occurrences
- RRULE modification with UNTIL clause for partial series deletion
- Event updating via CalDAV PUT operations
- Full iCal RFC 5545 compliance for recurring event modifications

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 09:18:35 -04:00
Connor Johnstone
e1578ed11c Add calendar selection dropdown and fix multi-calendar event display
- Add calendar selection dropdown to event creation modal
- Update EventCreationData to include selected_calendar field
- Pass available calendars from user info to modal component
- Initialize dropdown with first available calendar as default
- Fix backend to fetch events from ALL calendars, not just the first
- Update refresh_event to search across all calendars
- Events created in any calendar now properly display in UI

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 08:45:40 -04:00
104 changed files with 23981 additions and 5956 deletions

View File

@@ -1,6 +1,9 @@
# Build artifacts
target/
dist/
frontend/dist/
backend/target/
# Allow backend binary for multi-stage builds
!backend/target/release/backend
# Git
.git/
@@ -21,8 +24,18 @@ Thumbs.db
# Documentation
README.md
*.md
# Docker
Dockerfile
.dockerignore
# Development files
CLAUDE.md
*.txt
test_*.js
# Database files
*.db
calendar.db
# Test files
**/tests/
# Migrations (not needed for builds)
migrations/

View File

@@ -0,0 +1,35 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
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: |
${{ vars.REGISTRY }}/connor/calendar:latest
${{ vars.REGISTRY }}/connor/calendar:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

13
.gitignore vendored
View File

@@ -16,4 +16,15 @@ dist/
# Environment variables (secrets)
.env
.env.local
.env.*.local
.env.*.local
# Development notes (keep local)
CLAUDE.md
data/
# SQLite database
*.db
*.db-shm
*.db-wal
calendar.db

15
Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
{
default_sni rcjohnstone.com
key_type rsa4096
email c@rcjohnstone.com
}
: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,65 +1,14 @@
[package]
name = "calendar-app"
version = "0.1.0"
edition = "2021"
[workspace]
members = [
"frontend",
"backend",
"calendar-models"
]
resolver = "2"
# Frontend binary only
[dependencies]
yew = { version = "0.21", features = ["csr"] }
web-sys = { version = "0.3", features = [
"console",
"HtmlSelectElement",
"HtmlInputElement",
"HtmlTextAreaElement",
"Event",
"MouseEvent",
"InputEvent",
"Element",
"Document",
"Window",
"Location",
"Headers",
"Request",
"RequestInit",
"RequestMode",
"Response",
] }
wasm-bindgen = "0.2"
# HTTP client for CalDAV requests
reqwest = { version = "0.11", features = ["json"] }
# Calendar and iCal parsing
ical = "0.7"
[workspace.dependencies]
calendar-models = { path = "calendar-models" }
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Date and time handling
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }
chrono-tz = "0.8"
# Error handling
anyhow = "1.0"
thiserror = "1.0"
# Logging
log = "0.4"
console_log = "1.0"
# UUID generation for calendar events
uuid = { version = "1.0", features = ["v4", "wasm-bindgen", "serde"] }
getrandom = { version = "0.2", features = ["js"] }
# Environment variable handling
dotenvy = "0.15"
base64 = "0.21"
# XML/Regex parsing
regex = "1.0"
# Yew routing and local storage (WASM only)
yew-router = "0.18"
gloo-storage = "0.3"
gloo-timers = "0.3"
wasm-bindgen-futures = "0.4"
uuid = { version = "1.0", features = ["v4", "serde"] }

View File

@@ -1,67 +0,0 @@
# Build stage
# ---------------------------------------
FROM rust:alpine AS builder
# Install build dependencies
RUN apk add --no-cache \
musl-dev \
pkgconfig \
openssl-dev \
nodejs \
npm
# Install trunk for building Yew apps
RUN cargo install trunk wasm-pack
# Add wasm32 target
RUN rustup target add wasm32-unknown-unknown
# Set working directory
WORKDIR /app
# Copy dependency files
COPY Cargo.toml ./
COPY src ./src
# Copy web assets
COPY index.html ./
COPY Trunk.toml ./
# Build the application
RUN trunk build --release
# Runtime stage
# ---------------------------------------
FROM docker.io/nginx:alpine
# Remove default nginx content
RUN rm -rf /usr/share/nginx/html/*
# Copy built application from builder stage
COPY --from=builder /app/dist/* /usr/share/nginx/html/
# Add nginx configuration for SPA
RUN echo 'server { \
listen 80; \
server_name localhost; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
# Enable gzip compression \
gzip on; \
gzip_types text/css application/javascript application/wasm; \
}' > /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

173
README.md
View File

@@ -1,46 +1,128 @@
# Calendar App
# Runway
## _Passive infrastructure for life's coordination_
A full-stack calendar application built with Rust, featuring a Yew frontend and Axum backend with CalDAV integration.
![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.
A modern CalDAV web client built with Rust WebAssembly.
## The Name
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
- Interactive calendar interface
- Event creation and management
- CalDAV server integration
- User authentication with JWT
- iCal format support
- Weekly recurrence patterns
- Responsive web design
### Calendar Management
- **Interactive Calendar Views**: Month and week views with intuitive navigation
- **Event Creation & Editing**: Comprehensive event forms with all standard iCalendar properties
- **Drag & Drop**: Move events between dates and times with automatic timezone conversion
- **CalDAV Integration**: Full bidirectional sync with any RFC-compliant CalDAV server
### Recurring Events
- **RFC 5545 Compliance**: Complete RRULE support with proper parsing and generation
- **Flexible Patterns**: Daily, weekly, monthly, and yearly recurrence with custom intervals
- **Advanced Options**: BYDAY rules, COUNT limits, UNTIL dates, and exception handling
- **Series Management**: Edit entire series or "this and future" events with proper UNTIL handling
### Modern Web Experience
- **Fast & Responsive**: Rust WebAssembly frontend for native-like performance
- **Clean Interface**: Modern, intuitive design built with web standards
- **Real-time Updates**: Seamless synchronization with CalDAV servers
- **Timezone Aware**: Proper local time display with UTC storage
### User Experience
- **Persistent Preferences**: Settings sync across devices and sessions
- **Remember Me**: Optional server/username remembering for convenience
- **Session Management**: Secure session tokens with automatic expiry
- **Cross-Device Sync**: User preferences stored in database, not just browser
## Architecture
- **Frontend**: Yew (Rust WebAssembly)
- **Backend**: Axum (Rust async web framework)
- **Protocol**: CalDAV for calendar synchronization
- **Database**: SQLite (via migrations)
- **Build Tool**: Trunk for frontend bundling
### Frontend (Yew WebAssembly)
- **Framework**: Yew for component-based UI development
- **Performance**: Rust WebAssembly for near-native browser performance
- **Models**: RFC 5545-compliant VEvent structures throughout
- **Services**: HTTP client for backend API communication
- **Views**: Responsive month/week calendar views with drag-and-drop
### Backend (Axum)
- **Framework**: Axum async web framework with CORS support
- **Authentication**: SQLite-backed session management with JWT tokens
- **Database**: SQLite for user preferences and session storage
- **CalDAV Client**: Full CalDAV protocol implementation for server sync
- **API Design**: RESTful endpoints following calendar operation patterns
- **Data Flow**: Proxy between frontend and CalDAV servers with proper authentication
### Key Technical Features
- **RFC 5545 Compliance**: Complete iCalendar standard implementation
- **RRULE Processing**: Advanced recurrence rule parsing and generation
- **Timezone Handling**: Local time in UI, UTC for storage and CalDAV sync
- **Event Series**: Proper handling of recurring event modifications and exceptions
- **Build System**: Trunk for frontend bundling, Cargo workspaces for organization
## Getting Started
### Prerequisites
### Docker Deployment (Recommended)
The easiest way to run Runway is using Docker Compose:
1. **Clone the repository**:
```bash
git clone <repository-url>
cd calendar
```
2. **Start the application**:
```bash
docker compose up
```
3. **Access the application** at `http://localhost`
The Docker setup includes:
- **Automatic database migrations** on startup
- **Persistent data storage** in `./data/db/` volume
- **Frontend served via Caddy** on port 80
- **Backend API** accessible on port 3000
### Development Setup
#### Prerequisites
- Rust (latest stable version)
- Trunk (`cargo install trunk`)
### Development
#### Local Development
1. Start the backend server:
1. **Start the backend server** (serves API at http://localhost:3000):
```bash
cd backend
cargo run
cargo run --manifest-path=backend/Cargo.toml
```
2. Start the frontend development server:
2. **Start the frontend development server** (serves at http://localhost:8080):
```bash
trunk serve
```
3. Open your browser to `http://localhost:8080`
3. **Access the application** at `http://localhost:8080`
#### Database Setup
For local development, run the database migrations:
```bash
# Install sqlx-cli if not already installed
cargo install sqlx-cli --features sqlite
# Run migrations
sqlx migrate run --database-url "sqlite:calendar.db" --source backend/migrations
```
### Building for Production
@@ -48,9 +130,50 @@ A full-stack calendar application built with Rust, featuring a Yew frontend and
trunk build --release
```
### Development Commands
- `cargo check` - Check frontend compilation
- `cargo check --manifest-path=backend/Cargo.toml` - Check backend compilation
- `trunk serve` - Start frontend development server with hot reload
## Project Structure
- `src/` - Frontend Yew application
- `backend/` - Axum backend server
- `migrations/` - Database schema migrations
- `dist/` - Built frontend assets
```
calendar/
├── frontend/ # Yew WebAssembly frontend
│ ├── src/
│ │ ├── app.rs # Main app component with routing
│ │ ├── components/ # UI components
│ │ │ ├── calendar.rs # Main calendar container
│ │ │ ├── month_view.rs # Month calendar view
│ │ │ ├── week_view.rs # Week calendar view
│ │ │ ├── create_event_modal.rs # Event creation form
│ │ │ └── ...
│ │ ├── models/
│ │ │ └── ical.rs # RFC 5545 VEvent structures
│ │ └── services/
│ │ └── calendar_service.rs # HTTP client & RRULE logic
│ ├── index.html # HTML template
│ └── Trunk.toml # Frontend build config
├── backend/ # Axum REST API server
│ └── src/
│ ├── main.rs # Server entry point
│ ├── handlers/ # API endpoint handlers
│ │ ├── events.rs # Event CRUD operations
│ │ └── series.rs # Recurring event operations
│ ├── auth.rs # JWT authentication
│ └── calendar.rs # CalDAV client implementation
└── CLAUDE.md # Development instructions
```
## CalDAV Compatibility
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 Nextcloud calendar
- **Radicale** - 🚧 Planned lightweight CalDAV server support
- **Apple Calendar Server** - 🚧 Planned standards-compliant operation
- **Google Calendar** - 🚧 Planned CalDAV API compatibility
*Note: While the client follows RFC standards and should work with any compliant CalDAV server, we have currently only tested extensively with Baikal. Testing with other servers is planned.*

View File

@@ -1,16 +0,0 @@
[build]
target = "index.html"
dist = "dist"
[env]
BACKEND_API_URL = "http://localhost:3000/api"
[watch]
watch = ["src", "Cargo.toml", "styles.css", "index.html"]
ignore = ["backend/"]
[serve]
address = "127.0.0.1"
port = 8080
open = false

View File

@@ -8,6 +8,8 @@ name = "backend"
path = "src/main.rs"
[dependencies]
calendar-models = { workspace = true }
# Backend authentication dependencies
jsonwebtoken = "9.0"
tokio = { version = "1.0", features = ["full"] }
@@ -20,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"
@@ -30,6 +33,14 @@ regex = "1.0"
dotenvy = "0.15"
base64 = "0.21"
thiserror = "1.0"
lazy_static = "1.4"
# Database dependencies
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "uuid", "chrono", "json"] }
tokio-rusqlite = "0.5"
[dev-dependencies]
tokio = { version = "1.0", features = ["macros", "rt"] }
tokio = { version = "1.0", features = ["macros", "rt"] }
reqwest = { version = "0.11", features = ["json"] }
tower = { version = "0.4", features = ["util"] }
hyper = "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,8 @@
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
server_url TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(username, server_url)
);

View File

@@ -0,0 +1,16 @@
-- Create sessions table
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_accessed TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Index for faster token lookups
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
-- Index for cleanup of expired sessions
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);

View File

@@ -0,0 +1,11 @@
-- Create user preferences table
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY,
calendar_selected_date TEXT,
calendar_time_increment INTEGER,
calendar_view_mode TEXT,
calendar_theme TEXT,
calendar_colors TEXT, -- JSON string for calendar color mappings
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,2 @@
-- Add calendar style preference to user preferences
ALTER TABLE user_preferences ADD COLUMN calendar_style TEXT DEFAULT 'default';

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

@@ -1,27 +1,30 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::models::{CalDAVLoginRequest, AuthResponse, ApiError};
use crate::config::CalDAVConfig;
use crate::calendar::CalDAVClient;
use crate::config::CalDAVConfig;
use crate::db::{Database, PreferencesRepository, Session, SessionRepository, UserRepository};
use crate::models::{ApiError, AuthResponse, CalDAVLoginRequest, UserPreferencesResponse};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub username: String,
pub server_url: String,
pub exp: i64, // Expiration time
pub iat: i64, // Issued at
pub exp: i64, // Expiration time
pub iat: i64, // Issued at
}
#[derive(Clone)]
pub struct AuthService {
jwt_secret: String,
db: Database,
}
impl AuthService {
pub fn new(jwt_secret: String) -> Self {
Self { jwt_secret }
pub fn new(jwt_secret: String, db: Database) -> Self {
Self { jwt_secret, db }
}
/// Authenticate user directly against CalDAV server
@@ -31,36 +34,75 @@ impl AuthService {
println!("✅ Input validation passed");
// Create CalDAV config with provided credentials
let caldav_config = CalDAVConfig {
server_url: request.server_url.clone(),
username: request.username.clone(),
password: request.password.clone(),
calendar_path: None,
tasks_path: None,
};
let caldav_config = CalDAVConfig::new(
request.server_url.clone(),
request.username.clone(),
request.password.clone(),
);
println!("📝 Created CalDAV config");
// Test authentication against CalDAV server
let caldav_client = CalDAVClient::new(caldav_config.clone());
println!("🔗 Created CalDAV client, attempting to discover calendars...");
// Try to discover calendars as an authentication test
match caldav_client.discover_calendars().await {
Ok(calendars) => {
println!("✅ Authentication successful! Found {} calendars", calendars.len());
// Authentication successful, generate JWT token
let token = self.generate_token(&request.username, &request.server_url)?;
println!(
" Authentication successful! Found {} calendars",
calendars.len()
);
// Find or create user in database
let user_repo = UserRepository::new(&self.db);
let user = user_repo
.find_or_create(&request.username, &request.server_url)
.await
.map_err(|e| ApiError::Database(format!("Failed to create user: {}", e)))?;
// Generate JWT token
let jwt_token = self.generate_token(&request.username, &request.server_url)?;
// Generate session token
let session_token = format!("sess_{}", Uuid::new_v4());
// Create session in database
let session = Session::new(user.id.clone(), session_token.clone(), 24);
let session_repo = SessionRepository::new(&self.db);
session_repo
.create(&session)
.await
.map_err(|e| ApiError::Database(format!("Failed to create session: {}", e)))?;
// Get or create user preferences
let prefs_repo = PreferencesRepository::new(&self.db);
let preferences = prefs_repo
.get_or_create(&user.id)
.await
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
Ok(AuthResponse {
token,
token: jwt_token,
session_token,
username: request.username,
server_url: request.server_url,
preferences: UserPreferencesResponse {
calendar_selected_date: preferences.calendar_selected_date,
calendar_time_increment: preferences.calendar_time_increment,
calendar_view_mode: preferences.calendar_view_mode,
calendar_theme: preferences.calendar_theme,
calendar_style: preferences.calendar_style,
calendar_colors: preferences.calendar_colors,
last_used_calendar: preferences.last_used_calendar,
},
})
}
Err(err) => {
println!("❌ Authentication failed: {:?}", err);
// Authentication failed
Err(ApiError::Unauthorized("Invalid CalDAV credentials or server unavailable".to_string()))
Err(ApiError::Unauthorized(
"Invalid CalDAV credentials or server unavailable".to_string(),
))
}
}
}
@@ -70,17 +112,30 @@ impl AuthService {
self.decode_token(token)
}
/// Create CalDAV config from token
pub fn caldav_config_from_token(&self, token: &str, password: &str) -> Result<CalDAVConfig, ApiError> {
/// 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)?;
Ok(CalDAVConfig {
server_url: claims.server_url,
username: claims.username,
password: password.to_string(),
calendar_path: None,
tasks_path: None,
})
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,
token: &str,
password: &str,
) -> Result<CalDAVConfig, ApiError> {
let claims = self.verify_token(token)?;
Ok(CalDAVConfig::new(
claims.server_url,
claims.username,
password.to_string(),
))
}
fn validate_login(&self, request: &CalDAVLoginRequest) -> Result<(), ApiError> {
@@ -97,14 +152,17 @@ impl AuthService {
}
// Basic URL validation
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://") {
return Err(ApiError::BadRequest("Server URL must start with http:// or https://".to_string()));
if !request.server_url.starts_with("http://") && !request.server_url.starts_with("https://")
{
return Err(ApiError::BadRequest(
"Server URL must start with http:// or https://".to_string(),
));
}
Ok(())
}
fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
pub fn generate_token(&self, username: &str, server_url: &str) -> Result<String, ApiError> {
let now = Utc::now();
let expires_at = now + Duration::hours(24); // Token valid for 24 hours
@@ -135,4 +193,33 @@ impl AuthService {
Ok(token_data.claims)
}
}
/// Validate session token
pub async fn validate_session(&self, session_token: &str) -> Result<String, ApiError> {
let session_repo = SessionRepository::new(&self.db);
let session = session_repo
.find_by_token(session_token)
.await
.map_err(|e| ApiError::Database(format!("Failed to find session: {}", e)))?
.ok_or_else(|| ApiError::Unauthorized("Invalid session token".to_string()))?;
if session.is_expired() {
return Err(ApiError::Unauthorized("Session expired".to_string()));
}
Ok(session.user_id)
}
/// Logout user by deleting session
pub async fn logout(&self, session_token: &str) -> Result<(), ApiError> {
let session_repo = SessionRepository::new(&self.db);
session_repo
.delete(session_token)
.await
.map_err(|e| ApiError::Database(format!("Failed to delete session: {}", e)))?;
Ok(())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,30 @@
use base64::prelude::*;
use serde::{Deserialize, Serialize};
use std::env;
use base64::prelude::*;
/// Configuration for CalDAV server connection and authentication.
///
///
/// This struct holds all the necessary information to connect to a CalDAV server,
/// including server URL, credentials, and optional collection paths.
///
///
/// # Security Note
///
///
/// The password field contains sensitive information and should be handled carefully.
/// This struct implements `Debug` but in production, consider implementing a custom
/// `Debug` that masks the password field.
///
///
/// # Example
///
///
/// ```rust
/// use crate::config::CalDAVConfig;
///
/// // Load configuration from environment variables
/// let config = CalDAVConfig::from_env()?;
///
/// # use calendar_backend::config::CalDAVConfig;
/// let config = CalDAVConfig {
/// server_url: "https://caldav.example.com".to_string(),
/// username: "user@example.com".to_string(),
/// password: "password".to_string(),
/// calendar_path: None,
/// tasks_path: None,
/// };
///
/// // Use the configuration for HTTP requests
/// let auth_header = format!("Basic {}", config.get_basic_auth());
/// ```
@@ -28,103 +32,66 @@ use base64::prelude::*;
pub struct CalDAVConfig {
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
pub server_url: String,
/// Username for authentication with the CalDAV server
pub username: String,
/// Password for authentication with the CalDAV server
///
///
/// **Security Note**: This contains sensitive information
pub password: String,
/// Optional path to the calendar collection on the server
///
/// If not provided, the client will need to discover available calendars
///
/// If not provided, the client will discover available calendars
/// through CalDAV PROPFIND requests
pub calendar_path: Option<String>,
/// Optional path to the tasks/todo collection on the server
///
/// Some CalDAV servers store tasks separately from calendar events
pub tasks_path: Option<String>,
}
impl CalDAVConfig {
/// Creates a new CalDAVConfig by loading values from environment variables.
///
/// This method will attempt to load a `.env` file from the current directory
/// and then read the following required environment variables:
///
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
/// - `CALDAV_USERNAME`: Username for authentication
/// - `CALDAV_PASSWORD`: Password for authentication
///
/// Optional environment variables:
///
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
///
/// # Errors
///
/// Returns `ConfigError::MissingVar` if any required environment variable
/// is not set or cannot be read.
///
/// Creates a new CalDAVConfig with the given credentials.
///
/// # Arguments
///
/// * `server_url` - The base URL of the CalDAV server
/// * `username` - Username for authentication
/// * `password` - Password for authentication
///
/// # Example
///
///
/// ```rust
/// use crate::config::CalDAVConfig;
///
/// match CalDAVConfig::from_env() {
/// Ok(config) => {
/// println!("Loaded config for server: {}", config.server_url);
/// }
/// Err(e) => {
/// eprintln!("Failed to load config: {}", e);
/// }
/// }
/// # use calendar_backend::config::CalDAVConfig;
/// let config = CalDAVConfig::new(
/// "https://caldav.example.com".to_string(),
/// "user@example.com".to_string(),
/// "password".to_string()
/// );
/// ```
pub fn from_env() -> Result<Self, ConfigError> {
// Attempt to load .env file, but don't fail if it doesn't exist
dotenvy::dotenv().ok();
let server_url = env::var("CALDAV_SERVER_URL")
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
let username = env::var("CALDAV_USERNAME")
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
let password = env::var("CALDAV_PASSWORD")
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
// Optional paths - it's fine if these are not set
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
Ok(CalDAVConfig {
pub fn new(server_url: String, username: String, password: String) -> Self {
Self {
server_url,
username,
password,
calendar_path,
tasks_path,
})
calendar_path: env::var("CALDAV_CALENDAR_PATH").ok(), // Optional override from env
}
}
/// Generates a Base64-encoded string for HTTP Basic Authentication.
///
///
/// This method combines the username and password in the format
/// `username:password` and encodes it using Base64, which is the
/// standard format for the `Authorization: Basic` HTTP header.
///
///
/// # Returns
///
///
/// A Base64-encoded string that can be used directly in the
/// `Authorization` header: `Authorization: Basic <returned_value>`
///
///
/// # Example
///
///
/// ```rust
/// use crate::config::CalDAVConfig;
///
/// # use calendar_backend::config::CalDAVConfig;
///
/// let config = CalDAVConfig {
/// server_url: "https://example.com".to_string(),
/// username: "user".to_string(),
@@ -132,7 +99,7 @@ impl CalDAVConfig {
/// calendar_path: None,
/// tasks_path: None,
/// };
///
///
/// let auth_value = config.get_basic_auth();
/// let auth_header = format!("Basic {}", auth_value);
/// ```
@@ -146,15 +113,15 @@ impl CalDAVConfig {
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
/// A required environment variable is missing or cannot be read.
///
///
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
/// or `CALDAV_PASSWORD`) is not set.
#[error("Missing environment variable: {0}")]
MissingVar(String),
/// The configuration contains invalid or malformed values.
///
///
/// This could include malformed URLs, invalid authentication credentials,
/// or other configuration issues that prevent proper CalDAV operation.
#[error("Invalid configuration: {0}")]
@@ -172,7 +139,6 @@ mod tests {
username: "testuser".to_string(),
password: "testpass".to_string(),
calendar_path: None,
tasks_path: None,
};
let auth = config.get_basic_auth();
@@ -181,18 +147,21 @@ mod tests {
}
/// Integration test that authenticates with the actual Baikal CalDAV server
///
///
/// This test requires a valid .env file with:
/// - CALDAV_SERVER_URL
/// - CALDAV_USERNAME
/// - CALDAV_PASSWORD
///
///
/// Run with: `cargo test test_baikal_auth`
#[tokio::test]
async fn test_baikal_auth() {
// Load config from .env
let config = CalDAVConfig::from_env()
.expect("Failed to load CalDAV config from environment");
// Use test config - update these values to test with real server
let config = CalDAVConfig::new(
"https://example.com".to_string(),
"test_user".to_string(),
"test_password".to_string(),
);
println!("Testing authentication to: {}", config.server_url);
@@ -202,7 +171,10 @@ mod tests {
// Make a simple OPTIONS request to test authentication
let response = client
.request(reqwest::Method::OPTIONS, &config.server_url)
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
.header(
"Authorization",
format!("Basic {}", config.get_basic_auth()),
)
.header("User-Agent", "calendar-app/0.1.0")
.send()
.await
@@ -220,9 +192,9 @@ mod tests {
// For Baikal/CalDAV servers, we should see DAV headers
assert!(
response.headers().contains_key("dav") ||
response.headers().contains_key("DAV") ||
response.status().is_success(),
response.headers().contains_key("dav")
|| response.headers().contains_key("DAV")
|| response.status().is_success(),
"Server doesn't appear to be a CalDAV server - missing DAV headers"
);
@@ -230,14 +202,18 @@ mod tests {
}
/// Test making a PROPFIND request to discover calendars
///
///
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
///
///
/// Run with: `cargo test test_propfind_calendars`
#[tokio::test]
async fn test_propfind_calendars() {
let config = CalDAVConfig::from_env()
.expect("Failed to load CalDAV config from environment");
// Use test config - update these values to test with real server
let config = CalDAVConfig::new(
"https://example.com".to_string(),
"test_user".to_string(),
"test_password".to_string(),
);
let client = reqwest::Client::new();
@@ -253,8 +229,14 @@ mod tests {
</d:propfind>"#;
let response = client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
.request(
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
&config.server_url,
)
.header(
"Authorization",
format!("Basic {}", config.get_basic_auth()),
)
.header("Content-Type", "application/xml")
.header("Depth", "1")
.header("User-Agent", "calendar-app/0.1.0")
@@ -265,7 +247,7 @@ mod tests {
let status = response.status();
println!("PROPFIND Response status: {}", status);
let body = response.text().await.expect("Failed to read response body");
println!("PROPFIND Response body: {}", body);
@@ -277,8 +259,11 @@ mod tests {
);
// The response should contain XML with calendar information
assert!(body.contains("calendar"), "Response should contain calendar information");
assert!(
body.contains("calendar"),
"Response should contain calendar information"
);
println!("✓ PROPFIND calendars test passed!");
}
}
}

488
backend/src/db.rs Normal file
View File

@@ -0,0 +1,488 @@
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use sqlx::{FromRow, Result};
use std::sync::Arc;
use uuid::Uuid;
/// Database connection pool wrapper
#[derive(Clone)]
pub struct Database {
pool: Arc<SqlitePool>,
}
impl Database {
/// Create a new database connection pool
pub async fn new(database_url: &str) -> Result<Self> {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(database_url)
.await?;
// Run migrations
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(Self {
pool: Arc::new(pool),
})
}
/// Get a reference to the connection pool
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
}
/// User model representing a CalDAV user
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: String, // UUID as string for SQLite
pub username: String,
pub server_url: String,
pub created_at: DateTime<Utc>,
}
impl User {
/// Create a new user with generated UUID
pub fn new(username: String, server_url: String) -> Self {
Self {
id: Uuid::new_v4().to_string(),
username,
server_url,
created_at: Utc::now(),
}
}
}
/// Session model for user sessions
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Session {
pub id: String, // UUID as string
pub user_id: String, // Foreign key to User
pub token: String, // Session token
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub last_accessed: DateTime<Utc>,
}
impl Session {
/// Create a new session for a user
pub fn new(user_id: String, token: String, expires_in_hours: i64) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
user_id,
token,
created_at: now,
expires_at: now + chrono::Duration::hours(expires_in_hours),
last_accessed: now,
}
}
/// Check if the session has expired
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
}
/// User preferences model
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct UserPreferences {
pub user_id: String,
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
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 {
Self {
user_id,
calendar_selected_date: None,
calendar_time_increment: Some(15),
calendar_view_mode: Some("month".to_string()),
calendar_theme: Some("light".to_string()),
calendar_style: Some("default".to_string()),
calendar_colors: None,
last_used_calendar: None,
updated_at: Utc::now(),
}
}
}
/// Repository for User operations
pub struct UserRepository<'a> {
db: &'a Database,
}
impl<'a> UserRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Find or create a user by username and server URL
pub async fn find_or_create(
&self,
username: &str,
server_url: &str,
) -> Result<User> {
// Try to find existing user
let existing = sqlx::query_as::<_, User>(
"SELECT * FROM users WHERE username = ? AND server_url = ?",
)
.bind(username)
.bind(server_url)
.fetch_optional(self.db.pool())
.await?;
if let Some(user) = existing {
Ok(user)
} else {
// Create new user
let user = User::new(username.to_string(), server_url.to_string());
sqlx::query(
"INSERT INTO users (id, username, server_url, created_at) VALUES (?, ?, ?, ?)",
)
.bind(&user.id)
.bind(&user.username)
.bind(&user.server_url)
.bind(&user.created_at)
.execute(self.db.pool())
.await?;
Ok(user)
}
}
/// Find a user by ID
pub async fn find_by_id(&self, user_id: &str) -> Result<Option<User>> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(self.db.pool())
.await
}
}
/// Repository for Session operations
pub struct SessionRepository<'a> {
db: &'a Database,
}
impl<'a> SessionRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Create a new session
pub async fn create(&self, session: &Session) -> Result<()> {
sqlx::query(
"INSERT INTO sessions (id, user_id, token, created_at, expires_at, last_accessed)
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&session.id)
.bind(&session.user_id)
.bind(&session.token)
.bind(&session.created_at)
.bind(&session.expires_at)
.bind(&session.last_accessed)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Find a session by token and update last_accessed
pub async fn find_by_token(&self, token: &str) -> Result<Option<Session>> {
let session = sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token = ?")
.bind(token)
.fetch_optional(self.db.pool())
.await?;
if let Some(ref s) = session {
if !s.is_expired() {
// Update last_accessed time
sqlx::query("UPDATE sessions SET last_accessed = ? WHERE id = ?")
.bind(Utc::now())
.bind(&s.id)
.execute(self.db.pool())
.await?;
}
}
Ok(session)
}
/// Delete a session (logout)
pub async fn delete(&self, token: &str) -> Result<()> {
sqlx::query("DELETE FROM sessions WHERE token = ?")
.bind(token)
.execute(self.db.pool())
.await?;
Ok(())
}
/// Clean up expired sessions
pub async fn cleanup_expired(&self) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
.bind(Utc::now())
.execute(self.db.pool())
.await?;
Ok(result.rows_affected())
}
}
/// Repository for UserPreferences operations
pub struct PreferencesRepository<'a> {
db: &'a Database,
}
impl<'a> PreferencesRepository<'a> {
pub fn new(db: &'a Database) -> Self {
Self { db }
}
/// Get user preferences, creating defaults if not exist
pub async fn get_or_create(&self, user_id: &str) -> Result<UserPreferences> {
let existing = sqlx::query_as::<_, UserPreferences>(
"SELECT * FROM user_preferences WHERE user_id = ?",
)
.bind(user_id)
.fetch_optional(self.db.pool())
.await?;
if let Some(prefs) = existing {
Ok(prefs)
} else {
// Create default preferences
let prefs = UserPreferences::default_for_user(user_id.to_string());
sqlx::query(
"INSERT INTO user_preferences
(user_id, calendar_selected_date, calendar_time_increment,
calendar_view_mode, calendar_theme, calendar_style, calendar_colors, last_used_calendar, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&prefs.user_id)
.bind(&prefs.calendar_selected_date)
.bind(&prefs.calendar_time_increment)
.bind(&prefs.calendar_view_mode)
.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?;
Ok(prefs)
}
}
/// Update user preferences
pub async fn update(&self, prefs: &UserPreferences) -> Result<()> {
sqlx::query(
"UPDATE user_preferences
SET calendar_selected_date = ?, calendar_time_increment = ?,
calendar_view_mode = ?, calendar_theme = ?, calendar_style = ?,
calendar_colors = ?, last_used_calendar = ?, updated_at = ?
WHERE user_id = ?",
)
.bind(&prefs.calendar_selected_date)
.bind(&prefs.calendar_time_increment)
.bind(&prefs.calendar_view_mode)
.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

@@ -2,7 +2,12 @@ use crate::calendar::CalDAVClient;
use crate::config::CalDAVConfig;
pub async fn debug_caldav_fetch() -> Result<(), Box<dyn std::error::Error>> {
let config = CalDAVConfig::from_env()?;
// Use debug/test configuration
let config = CalDAVConfig::new(
"https://example.com".to_string(),
"debug_user".to_string(),
"debug_password".to_string()
);
let client = CalDAVClient::new(config);
println!("=== DEBUG: CalDAV Fetch ===");

View File

@@ -1,608 +0,0 @@
use axum::{
extract::{State, Query, Path},
http::HeaderMap,
response::Json,
};
use serde::Deserialize;
use std::sync::Arc;
use chrono::Datelike;
use crate::{AppState, models::{CalDAVLoginRequest, AuthResponse, ApiError, UserInfo, CalendarInfo, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest, DeleteCalendarResponse, DeleteEventRequest, DeleteEventResponse, CreateEventRequest, CreateEventResponse}};
use crate::calendar::{CalDAVClient, CalendarEvent};
#[derive(Deserialize)]
pub struct CalendarQuery {
pub year: Option<i32>,
pub month: Option<u32>,
}
pub async fn get_calendar_events(
State(state): State<Arc<AppState>>,
Query(params): Query<CalendarQuery>,
headers: HeaderMap,
) -> Result<Json<Vec<CalendarEvent>>, ApiError> {
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
println!("🔑 API call with password length: {}", password.len());
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
let calendar_paths = client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
if calendar_paths.is_empty() {
return Ok(Json(vec![])); // No calendars found
}
// Fetch events from the first calendar
let calendar_path = &calendar_paths[0];
let events = client.fetch_events(calendar_path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch events: {}", e)))?;
// Filter events by month if specified
let filtered_events = if let (Some(year), Some(month)) = (params.year, params.month) {
events.into_iter().filter(|event| {
let event_date = event.start.date_naive();
event_date.year() == year && event_date.month() == month
}).collect()
} else {
events
};
Ok(Json(filtered_events))
}
pub async fn refresh_event(
State(state): State<Arc<AppState>>,
Path(uid): Path<String>,
headers: HeaderMap,
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
let calendar_paths = client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
if calendar_paths.is_empty() {
return Ok(Json(None)); // No calendars found
}
// Fetch the specific event by UID from the first calendar
let calendar_path = &calendar_paths[0];
let event = client.fetch_event_by_uid(calendar_path, &uid)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?;
Ok(Json(event))
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(request): Json<CalDAVLoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
println!("🔐 Login attempt:");
println!(" Server URL: {}", request.server_url);
println!(" Username: {}", request.username);
println!(" Password length: {}", request.password.len());
let response = state.auth_service.login(request).await?;
Ok(Json(response))
}
pub async fn verify_token(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, ApiError> {
let token = extract_bearer_token(&headers)?;
let claims = state.auth_service.verify_token(&token)?;
Ok(Json(serde_json::json!({
"valid": true,
"username": claims.username,
"server_url": claims.server_url
})))
}
pub async fn get_user_info(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<UserInfo>, ApiError> {
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
let claims = state.auth_service.verify_token(&token)?;
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars
let calendar_paths = client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
// Convert paths to CalendarInfo structs with display names, filtering out generic collections
let calendars: Vec<CalendarInfo> = calendar_paths.into_iter()
.filter_map(|path| {
let display_name = extract_calendar_name(&path);
// Skip generic collection names
if display_name.eq_ignore_ascii_case("calendar") ||
display_name.eq_ignore_ascii_case("calendars") ||
display_name.eq_ignore_ascii_case("collection") {
None
} else {
Some(CalendarInfo {
path: path.clone(),
display_name,
color: generate_calendar_color(&path),
})
}
}).collect();
Ok(Json(UserInfo {
username: claims.username,
server_url: claims.server_url,
calendars,
}))
}
// Helper function to generate a consistent color for a calendar based on its path
fn generate_calendar_color(path: &str) -> String {
// Predefined set of attractive, accessible colors for calendars
let colors = [
"#3B82F6", // Blue
"#10B981", // Emerald
"#F59E0B", // Amber
"#EF4444", // Red
"#8B5CF6", // Violet
"#06B6D4", // Cyan
"#84CC16", // Lime
"#F97316", // Orange
"#EC4899", // Pink
"#6366F1", // Indigo
"#14B8A6", // Teal
"#F3B806", // Yellow
"#8B5A2B", // Brown
"#6B7280", // Gray
"#DC2626", // Red-600
"#7C3AED", // Violet-600
];
// Create a simple hash from the path to ensure consistent color assignment
let mut hash: u32 = 0;
for byte in path.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
}
// Use the hash to select a color from our palette
let color_index = (hash as usize) % colors.len();
colors[color_index].to_string()
}
// Helper function to extract a readable calendar name from path
fn extract_calendar_name(path: &str) -> String {
// Extract the last meaningful part of the path
// e.g., "/calendars/user/personal/" -> "personal"
// or "/calendars/user/work-calendar/" -> "work-calendar"
let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
if let Some(last_part) = parts.last() {
if !last_part.is_empty() && *last_part != "calendars" {
// Convert kebab-case or snake_case to title case
last_part
.replace('-', " ")
.replace('_', " ")
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
} else if parts.len() > 1 {
// If the last part is empty or "calendars", try the second to last
extract_calendar_name(&parts[..parts.len()-1].join("/"))
} else {
"Calendar".to_string()
}
} else {
"Calendar".to_string()
}
}
// Helper functions
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
if let Some(auth_header) = headers.get("authorization") {
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid authorization header".to_string()))?;
if let Some(token) = auth_str.strip_prefix("Bearer ") {
Ok(token.to_string())
} else {
Err(ApiError::Unauthorized("Authorization header must start with 'Bearer '".to_string()))
}
} else {
Err(ApiError::Unauthorized("Authorization header required".to_string()))
}
}
fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
if let Some(password_header) = headers.get("x-caldav-password") {
let password = password_header
.to_str()
.map_err(|_| ApiError::BadRequest("Invalid password header".to_string()))?;
Ok(password.to_string())
} else {
Err(ApiError::BadRequest("X-CalDAV-Password header required".to_string()))
}
}
pub async fn create_calendar(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<CreateCalendarRequest>,
) -> Result<Json<CreateCalendarResponse>, ApiError> {
println!("📝 Create calendar request received: name='{}', description={:?}, color={:?}",
request.name, request.description, request.color);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.name.trim().is_empty() {
return Err(ApiError::BadRequest("Calendar name is required".to_string()));
}
if request.name.len() > 100 {
return Err(ApiError::BadRequest("Calendar name too long (max 100 characters)".to_string()));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Create the calendar
client.create_calendar(
&request.name,
request.description.as_deref(),
request.color.as_deref()
)
.await
.map_err(|e| ApiError::Internal(format!("Failed to create calendar: {}", e)))?;
Ok(Json(CreateCalendarResponse {
success: true,
message: "Calendar created successfully".to_string(),
}))
}
pub async fn delete_calendar(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<DeleteCalendarRequest>,
) -> Result<Json<DeleteCalendarResponse>, ApiError> {
println!("🗑️ Delete calendar request received: path='{}'", request.path);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.path.trim().is_empty() {
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Delete the calendar
client.delete_calendar(&request.path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete calendar: {}", e)))?;
Ok(Json(DeleteCalendarResponse {
success: true,
message: "Calendar deleted successfully".to_string(),
}))
}
pub async fn delete_event(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<DeleteEventRequest>,
) -> Result<Json<DeleteEventResponse>, ApiError> {
println!("🗑️ Delete event request received: calendar_path='{}', event_href='{}'", request.calendar_path, request.event_href);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.calendar_path.trim().is_empty() {
return Err(ApiError::BadRequest("Calendar path is required".to_string()));
}
if request.event_href.trim().is_empty() {
return Err(ApiError::BadRequest("Event href is required".to_string()));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Delete the event
client.delete_event(&request.calendar_path, &request.event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
Ok(Json(DeleteEventResponse {
success: true,
message: "Event deleted successfully".to_string(),
}))
}
pub async fn create_event(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<CreateEventRequest>,
) -> Result<Json<CreateEventResponse>, ApiError> {
println!("📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
request.title, request.all_day, request.calendar_path);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.title.trim().is_empty() {
return Err(ApiError::BadRequest("Event title is required".to_string()));
}
if request.title.len() > 200 {
return Err(ApiError::BadRequest("Event title too long (max 200 characters)".to_string()));
}
// Create CalDAV config from token and password
let config = state.auth_service.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Determine which calendar to use
let calendar_path = if let Some(path) = request.calendar_path {
path
} else {
// Use the first available calendar
let calendar_paths = client.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
if calendar_paths.is_empty() {
return Err(ApiError::BadRequest("No calendars available for event creation".to_string()));
}
calendar_paths[0].clone()
};
// Parse dates and times
let start_datetime = parse_event_datetime(&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)
.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()));
}
// Generate a unique UID for the event
let uid = format!("{}-{}", uuid::Uuid::new_v4(), chrono::Utc::now().timestamp());
// Parse status
let status = match request.status.to_lowercase().as_str() {
"tentative" => crate::calendar::EventStatus::Tentative,
"cancelled" => crate::calendar::EventStatus::Cancelled,
_ => crate::calendar::EventStatus::Confirmed,
};
// Parse class
let class = match request.class.to_lowercase().as_str() {
"private" => crate::calendar::EventClass::Private,
"confidential" => crate::calendar::EventClass::Confidential,
_ => crate::calendar::EventClass::Public,
};
// Parse attendees (comma-separated email list)
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
Vec::new()
} else {
request.attendees
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse categories (comma-separated list)
let categories: Vec<String> = if request.categories.trim().is_empty() {
Vec::new()
} else {
request.categories
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse reminders and convert to EventReminder structs
let reminders: Vec<crate::calendar::EventReminder> = match request.reminder.to_lowercase().as_str() {
"15min" => vec![crate::calendar::EventReminder {
minutes_before: 15,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"30min" => vec![crate::calendar::EventReminder {
minutes_before: 30,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1hour" => vec![crate::calendar::EventReminder {
minutes_before: 60,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2hours" => vec![crate::calendar::EventReminder {
minutes_before: 120,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1day" => vec![crate::calendar::EventReminder {
minutes_before: 1440, // 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"2days" => vec![crate::calendar::EventReminder {
minutes_before: 2880, // 48 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
"1week" => vec![crate::calendar::EventReminder {
minutes_before: 10080, // 7 * 24 * 60
action: crate::calendar::ReminderAction::Display,
description: None,
}],
_ => Vec::new(),
};
// Parse recurrence with BYDAY support for weekly recurrence
let recurrence_rule = match request.recurrence.to_lowercase().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.push_str(&format!(";BYDAY={}", selected_days.join(",")));
}
}
Some(rrule)
},
"monthly" => Some("FREQ=MONTHLY".to_string()),
"yearly" => Some("FREQ=YEARLY".to_string()),
_ => None,
};
// Create the CalendarEvent struct
let event = crate::calendar::CalendarEvent {
uid,
summary: Some(request.title.clone()),
description: if request.description.trim().is_empty() {
None
} else {
Some(request.description.clone())
},
start: start_datetime,
end: Some(end_datetime),
location: if request.location.trim().is_empty() {
None
} else {
Some(request.location.clone())
},
status,
class,
priority: request.priority,
organizer: if request.organizer.trim().is_empty() {
None
} else {
Some(request.organizer.clone())
},
attendees,
categories,
created: Some(chrono::Utc::now()),
last_modified: Some(chrono::Utc::now()),
recurrence_rule,
all_day: request.all_day,
reminders,
etag: None,
href: None,
calendar_path: Some(calendar_path.clone()),
};
// Create the event on the CalDAV server
let event_href = client.create_event(&calendar_path, &event)
.await
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
Ok(Json(CreateEventResponse {
success: true,
message: "Event created successfully".to_string(),
event_href: Some(event_href),
}))
}
/// Parse date and time strings into a UTC DateTime
fn parse_event_datetime(date_str: &str, time_str: &str, all_day: bool) -> Result<chrono::DateTime<chrono::Utc>, String> {
use chrono::{NaiveDate, NaiveTime, NaiveDateTime, Utc, TimeZone};
// 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
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))
} 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))
}
}

View File

@@ -0,0 +1,144 @@
use axum::{extract::State, http::HeaderMap, response::Json};
use std::sync::Arc;
use crate::calendar::CalDAVClient;
use crate::{
models::{ApiError, AuthResponse, CalDAVLoginRequest, CalendarInfo, UserInfo},
AppState,
};
pub fn extract_bearer_token(headers: &HeaderMap) -> Result<String, ApiError> {
let auth_header = headers
.get("authorization")
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::BadRequest("Invalid Authorization header".to_string()))?;
if let Some(token) = auth_str.strip_prefix("Bearer ") {
Ok(token.to_string())
} else {
Err(ApiError::BadRequest(
"Authorization header must be Bearer token".to_string(),
))
}
}
pub fn extract_password_header(headers: &HeaderMap) -> Result<String, ApiError> {
let password_header = headers
.get("x-caldav-password")
.ok_or_else(|| ApiError::BadRequest("Missing X-CalDAV-Password header".to_string()))?;
password_header
.to_str()
.map(|s| s.to_string())
.map_err(|_| ApiError::BadRequest("Invalid X-CalDAV-Password header".to_string()))
}
pub async fn login(
State(state): State<Arc<AppState>>,
Json(request): Json<CalDAVLoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
println!("🔐 Login attempt:");
println!(" Server URL: {}", request.server_url);
println!(" Username: {}", request.username);
println!(" Password length: {}", request.password.len());
// Use the auth service login method which now handles database, sessions, and preferences
let response = state.auth_service.login(request).await?;
println!("✅ Login successful with session management");
Ok(Json(response))
}
pub async fn verify_token(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, ApiError> {
let token = extract_bearer_token(&headers)?;
let is_valid = state.auth_service.verify_token(&token).is_ok();
Ok(Json(serde_json::json!({ "valid": is_valid })))
}
pub async fn get_user_info(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<UserInfo>, ApiError> {
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Create CalDAV config from token and password
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config.clone());
// Discover calendars
let calendar_paths = client
.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
println!(
"✅ Authentication successful! Found {} calendars",
calendar_paths.len()
);
let calendars: Vec<CalendarInfo> = calendar_paths
.iter()
.map(|path| CalendarInfo {
path: path.clone(),
display_name: extract_calendar_name(path),
color: generate_calendar_color(path),
is_visible: true, // Default to visible
})
.collect();
Ok(Json(UserInfo {
username: config.username,
server_url: config.server_url,
calendars,
}))
}
fn generate_calendar_color(path: &str) -> String {
// Generate a consistent color based on the calendar path
// This is a simple hash-based approach
let mut hash: u32 = 0;
for byte in path.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
}
// Define a set of pleasant colors
let colors = [
"#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
"#EC4899", "#6366F1", "#14B8A6", "#F3B806", "#8B5A2B", "#6B7280", "#DC2626", "#7C3AED",
"#059669", "#D97706", "#BE185D", "#4F46E5",
];
colors[(hash as usize) % colors.len()].to_string()
}
fn extract_calendar_name(path: &str) -> String {
// Extract calendar name from path
// E.g., "/calendars/user/calendar-name/" -> "Calendar Name"
path.split('/')
.filter(|s| !s.is_empty())
.last()
.unwrap_or("Calendar")
.replace('-', " ")
.replace('_', " ")
.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<String>>()
.join(" ")
}

View File

@@ -0,0 +1,94 @@
use axum::{extract::State, http::HeaderMap, response::Json};
use std::sync::Arc;
use crate::calendar::CalDAVClient;
use crate::{
models::{
ApiError, CreateCalendarRequest, CreateCalendarResponse, DeleteCalendarRequest,
DeleteCalendarResponse,
},
AppState,
};
use super::auth::{extract_bearer_token, extract_password_header};
pub async fn create_calendar(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<CreateCalendarRequest>,
) -> Result<Json<CreateCalendarResponse>, ApiError> {
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.name.trim().is_empty() {
return Err(ApiError::BadRequest(
"Calendar name is required".to_string(),
));
}
// Create CalDAV config from token and password
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Create calendar on CalDAV server
match client
.create_calendar(
&request.name,
request.description.as_deref(),
request.color.as_deref(),
)
.await
{
Ok(_) => Ok(Json(CreateCalendarResponse {
success: true,
message: "Calendar created successfully".to_string(),
})),
Err(e) => {
eprintln!("Failed to create calendar: {}", e);
Err(ApiError::Internal(format!(
"Failed to create calendar: {}",
e
)))
}
}
}
pub async fn delete_calendar(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<DeleteCalendarRequest>,
) -> Result<Json<DeleteCalendarResponse>, ApiError> {
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.path.trim().is_empty() {
return Err(ApiError::BadRequest(
"Calendar path is required".to_string(),
));
}
// Create CalDAV config from token and password
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Delete calendar on CalDAV server
match client.delete_calendar(&request.path).await {
Ok(_) => Ok(Json(DeleteCalendarResponse {
success: true,
message: "Calendar deleted successfully".to_string(),
})),
Err(e) => {
eprintln!("Failed to delete calendar: {}", e);
Err(ApiError::Internal(format!(
"Failed to delete calendar: {}",
e
)))
}
}
}

View File

@@ -0,0 +1,872 @@
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
response::Json,
};
use chrono::Datelike;
use serde::Deserialize;
use std::sync::Arc;
use crate::calendar::{CalDAVClient, CalendarEvent};
use crate::{
models::{
ApiError, CreateEventRequest, CreateEventResponse, DeleteEventRequest, DeleteEventResponse,
UpdateEventRequest, UpdateEventResponse,
},
AppState,
};
use calendar_models::{
AlarmAction, AlarmTrigger, Attendee, CalendarUser, EventClass, EventStatus, VAlarm, VEvent,
};
use super::auth::{extract_bearer_token, extract_password_header};
#[derive(Deserialize)]
pub struct CalendarQuery {
pub year: Option<i32>,
pub month: Option<u32>,
}
pub async fn get_calendar_events(
State(state): State<Arc<AppState>>,
Query(params): Query<CalendarQuery>,
headers: HeaderMap,
) -> Result<Json<Vec<CalendarEvent>>, ApiError> {
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
println!("🔑 API call with password length: {}", password.len());
// Create CalDAV config from token and password
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars if needed
let calendar_paths = client
.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
if calendar_paths.is_empty() {
return Ok(Json(vec![])); // No calendars found
}
// Fetch events from all calendars
let mut all_events = Vec::new();
for calendar_path in &calendar_paths {
match client.fetch_events(calendar_path).await {
Ok(mut events) => {
// Set calendar_path for each event to identify which calendar it belongs to
for event in &mut events {
event.calendar_path = Some(calendar_path.clone());
}
all_events.extend(events);
}
Err(e) => {
eprintln!(
"Failed to fetch events from calendar {}: {}",
calendar_path, e
);
// Continue with other calendars instead of failing completely
}
}
}
// 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_date = event.dtstart.date_naive();
// 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
});
}
println!("📅 Returning {} events", all_events.len());
Ok(Json(all_events))
}
pub async fn refresh_event(
State(state): State<Arc<AppState>>,
Path(uid): Path<String>,
headers: HeaderMap,
) -> Result<Json<Option<CalendarEvent>>, ApiError> {
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Create CalDAV config from token and password
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Discover calendars
let calendar_paths = client
.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
// Search for the event by UID across all calendars
for calendar_path in &calendar_paths {
if let Ok(Some(mut event)) = client.fetch_event_by_uid(calendar_path, &uid).await {
event.calendar_path = Some(calendar_path.clone());
return Ok(Json(Some(event)));
}
}
Ok(Json(None))
}
async fn fetch_event_by_href(
client: &CalDAVClient,
calendar_path: &str,
event_href: &str,
) -> Result<Option<CalendarEvent>, crate::calendar::CalDAVError> {
// This is a simplified implementation - in a real scenario, you'd want to fetch the specific event by href
// For now, we'll fetch all events and find the matching one by href (inefficient but functional)
let events = client.fetch_events(calendar_path).await?;
println!("🔍 fetch_event_by_href: looking for href='{}'", event_href);
println!(
"🔍 Available events with hrefs: {:?}",
events.iter().map(|e| (&e.uid, &e.href)).collect::<Vec<_>>()
);
// First try to match by exact href
for event in &events {
if let Some(stored_href) = &event.href {
if stored_href == event_href {
println!("✅ Found matching event by exact href: {}", event.uid);
return Ok(Some(event.clone()));
}
}
}
// Fallback: try to match by UID extracted from href filename
let filename = event_href.split('/').last().unwrap_or(event_href);
let uid_from_href = filename.trim_end_matches(".ics");
println!(
"🔍 Fallback: trying UID match. filename='{}', uid='{}'",
filename, uid_from_href
);
for event in events {
if event.uid == uid_from_href {
println!("✅ Found matching event by UID: {}", event.uid);
return Ok(Some(event));
}
}
println!("❌ No matching event found for href: {}", event_href);
Ok(None)
}
pub async fn delete_event(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<DeleteEventRequest>,
) -> Result<Json<DeleteEventResponse>, ApiError> {
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Create CalDAV config from token and password
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Handle different delete actions for recurring events
match request.delete_action.as_str() {
"delete_this" => {
if let Some(event) =
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
{
// Check if this is a recurring 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) =
chrono::DateTime::parse_from_rfc3339(occurrence_date)
{
// RFC3339 format (with time and timezone)
date.with_timezone(&chrono::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()
} 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);
println!(
"🔄 Adding EXDATE {} to recurring event {}",
exception_utc.format("%Y%m%dT%H%M%SZ"),
updated_event.uid
);
// Update the event with the new EXDATE
client
.update_event(
&request.calendar_path,
&updated_event,
&request.event_href,
)
.await
.map_err(|e| {
ApiError::Internal(format!(
"Failed to update event with EXDATE: {}",
e
))
})?;
println!("✅ Successfully updated recurring event with EXDATE");
Ok(Json(DeleteEventResponse {
success: true,
message: "Single occurrence deleted successfully".to_string(),
}))
} else {
Err(ApiError::BadRequest("Occurrence date is required for single occurrence deletion of recurring events".to_string()))
}
} else {
// Non-recurring event - delete the entire event
println!("🗑️ Deleting non-recurring event: {}", event.uid);
client
.delete_event(&request.calendar_path, &request.event_href)
.await
.map_err(|e| {
ApiError::Internal(format!("Failed to delete event: {}", e))
})?;
println!("✅ Successfully deleted non-recurring event");
Ok(Json(DeleteEventResponse {
success: true,
message: "Event deleted successfully".to_string(),
}))
}
} else {
Err(ApiError::NotFound("Event not found".to_string()))
}
}
"delete_following" => {
// For "this and following" deletion, we need to:
// 1. Fetch the recurring event
// 2. Modify the RRULE to end before this occurrence
// 3. Update the event
if let Some(mut event) =
fetch_event_by_href(&client, &request.calendar_path, &request.event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch event: {}", e)))?
{
if let Some(occurrence_date) = &request.occurrence_date {
let until_date = if let Ok(date) =
chrono::DateTime::parse_from_rfc3339(occurrence_date)
{
// RFC3339 format (with time and timezone)
date.with_timezone(&chrono::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()
} else {
return Err(ApiError::BadRequest(format!(
"Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD",
occurrence_date
)));
};
// Modify the RRULE to add an UNTIL clause
if let Some(rrule) = &event.rrule {
// Remove existing UNTIL if present and add new one
let parts: Vec<&str> = rrule
.split(';')
.filter(|part| {
!part.starts_with("UNTIL=") && !part.starts_with("COUNT=")
})
.collect();
let new_rrule = format!(
"{};UNTIL={}",
parts.join(";"),
until_date.format("%Y%m%dT%H%M%SZ")
);
event.rrule = Some(new_rrule);
// Update the event with the modified RRULE
client
.update_event(&request.calendar_path, &event, &request.event_href)
.await
.map_err(|e| {
ApiError::Internal(format!(
"Failed to update event with modified RRULE: {}",
e
))
})?;
Ok(Json(DeleteEventResponse {
success: true,
message: "This and following occurrences deleted successfully"
.to_string(),
}))
} else {
// No RRULE, just delete the single event
client
.delete_event(&request.calendar_path, &request.event_href)
.await
.map_err(|e| {
ApiError::Internal(format!("Failed to delete event: {}", e))
})?;
Ok(Json(DeleteEventResponse {
success: true,
message: "Event deleted successfully".to_string(),
}))
}
} else {
Err(ApiError::BadRequest(
"Occurrence date is required for following deletion".to_string(),
))
}
} else {
Err(ApiError::NotFound("Event not found".to_string()))
}
}
"delete_series" | _ => {
// Delete the entire event/series
client
.delete_event(&request.calendar_path, &request.event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete event: {}", e)))?;
Ok(Json(DeleteEventResponse {
success: true,
message: "Event deleted successfully".to_string(),
}))
}
}
}
pub async fn create_event(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<CreateEventRequest>,
) -> Result<Json<CreateEventResponse>, ApiError> {
println!(
"📝 Create event request received: title='{}', all_day={}, calendar_path={:?}",
request.title, request.all_day, request.calendar_path
);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.title.trim().is_empty() {
return Err(ApiError::BadRequest("Event title is required".to_string()));
}
if request.title.len() > 200 {
return Err(ApiError::BadRequest(
"Event title too long (max 200 characters)".to_string(),
));
}
// Create CalDAV config from token and password
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Determine which calendar to use
let calendar_path = if let Some(path) = request.calendar_path {
path
} else {
// Use the first available calendar
let calendar_paths = client
.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?;
if calendar_paths.is_empty() {
return Err(ApiError::BadRequest(
"No calendars available for event creation".to_string(),
));
}
calendar_paths[0].clone()
};
// Parse dates and times
let start_datetime =
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// For all-day events, add one day to end date for RFC-5545 compliance
// RFC-5545 uses exclusive end dates for all-day events
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// 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
let uid = format!(
"{}-{}",
uuid::Uuid::new_v4(),
chrono::Utc::now().timestamp()
);
// Parse status
let status = match request.status.to_lowercase().as_str() {
"tentative" => EventStatus::Tentative,
"cancelled" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
};
// Parse class
let class = match request.class.to_lowercase().as_str() {
"private" => EventClass::Private,
"confidential" => EventClass::Confidential,
_ => EventClass::Public,
};
// Parse attendees (comma-separated email list)
let attendees: Vec<String> = if request.attendees.trim().is_empty() {
Vec::new()
} else {
request
.attendees
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse categories (comma-separated list)
let categories: Vec<String> = if request.categories.trim().is_empty() {
Vec::new()
} else {
request
.categories
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
// Parse alarms - convert from minutes string to EventReminder structs
let alarms: Vec<crate::calendar::EventReminder> = if request.reminder.trim().is_empty() {
Vec::new()
} else {
match request.reminder.parse::<i32>() {
Ok(minutes) => vec![crate::calendar::EventReminder {
minutes_before: minutes,
action: crate::calendar::ReminderAction::Display,
description: None,
}],
Err(_) => Vec::new(),
}
};
// Check if recurrence is already a full RRULE or just a simple type
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 {
// Legacy path: Parse recurrence with BYDAY support for weekly recurrence
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,
}
};
// Create the VEvent struct (RFC 5545 compliant)
let mut event = VEvent::new(uid, start_datetime);
event.dtend = Some(end_datetime);
event.summary = if request.title.trim().is_empty() {
None
} else {
Some(request.title.clone())
};
event.description = if request.description.trim().is_empty() {
None
} else {
Some(request.description)
};
event.location = if request.location.trim().is_empty() {
None
} else {
Some(request.location)
};
event.status = Some(status);
event.class = Some(class);
event.priority = request.priority;
event.organizer = if request.organizer.trim().is_empty() {
None
} else {
Some(CalendarUser {
cal_address: request.organizer,
common_name: None,
dir_entry_ref: None,
sent_by: None,
language: None,
})
};
event.attendees = attendees
.into_iter()
.map(|email| Attendee {
cal_address: email,
common_name: None,
role: None,
part_stat: None,
rsvp: None,
cu_type: None,
member: Vec::new(),
delegated_to: Vec::new(),
delegated_from: Vec::new(),
sent_by: None,
dir_entry_ref: None,
language: None,
})
.collect();
event.categories = categories;
event.rrule = rrule;
event.all_day = request.all_day;
event.alarms = alarms
.into_iter()
.map(|reminder| VAlarm {
action: AlarmAction::Display,
trigger: AlarmTrigger::Duration(chrono::Duration::minutes(
-reminder.minutes_before as i64,
)),
duration: None,
repeat: None,
description: reminder.description,
summary: None,
attendees: Vec::new(),
attach: Vec::new(),
})
.collect();
event.calendar_path = Some(calendar_path.clone());
// Create the event on the CalDAV server
let event_href = client
.create_event(&calendar_path, &event)
.await
.map_err(|e| ApiError::Internal(format!("Failed to create event: {}", e)))?;
println!(
"✅ Event created successfully with UID: {} at href: {}",
event.uid, event_href
);
Ok(Json(CreateEventResponse {
success: true,
message: "Event created successfully".to_string(),
event_href: Some(event_href),
}))
}
pub async fn update_event(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<UpdateEventRequest>,
) -> Result<Json<UpdateEventResponse>, ApiError> {
// Handle update request
// Extract and verify token
let token = extract_bearer_token(&headers)?;
let password = extract_password_header(&headers)?;
// Validate request
if request.uid.trim().is_empty() {
return Err(ApiError::BadRequest("Event UID is required".to_string()));
}
if request.title.trim().is_empty() {
return Err(ApiError::BadRequest("Event title is required".to_string()));
}
if request.title.len() > 200 {
return Err(ApiError::BadRequest(
"Event title too long (max 200 characters)".to_string(),
));
}
// Create CalDAV config from token and password
let config = state
.auth_service
.caldav_config_from_token(&token, &password)?;
let client = CalDAVClient::new(config);
// Find the event across all calendars (or in the specified calendar)
let calendar_paths = if let Some(path) = &request.calendar_path {
vec![path.clone()]
} else {
client
.discover_calendars()
.await
.map_err(|e| ApiError::Internal(format!("Failed to discover calendars: {}", e)))?
};
let mut found_event: Option<(CalendarEvent, String, String)> = None; // (event, calendar_path, event_href)
for calendar_path in &calendar_paths {
match client.fetch_events(calendar_path).await {
Ok(events) => {
for event in events {
if event.uid == request.uid {
// Use the actual href from the event, or generate one if missing
let event_href = event
.href
.clone()
.unwrap_or_else(|| format!("{}.ics", event.uid));
println!("🔍 Found event {} with href: {}", event.uid, event_href);
found_event = Some((event, calendar_path.clone(), event_href));
break;
}
}
if found_event.is_some() {
break;
}
}
Err(e) => {
eprintln!(
"Failed to fetch events from calendar {}: {}",
calendar_path, e
);
continue;
}
}
}
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
let start_datetime =
parse_event_datetime(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let mut end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// For all-day events, add one day to end date for RFC-5545 compliance
// RFC-5545 uses exclusive end dates for all-day events
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// 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
event.dtstart = start_datetime;
event.dtend = Some(end_datetime);
event.summary = if request.title.trim().is_empty() {
None
} else {
Some(request.title)
};
event.description = if request.description.trim().is_empty() {
None
} else {
Some(request.description)
};
event.location = if request.location.trim().is_empty() {
None
} else {
Some(request.location)
};
event.all_day = request.all_day;
// Parse and update status
event.status = Some(match request.status.to_lowercase().as_str() {
"tentative" => EventStatus::Tentative,
"cancelled" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
});
// Parse and update class
event.class = Some(match request.class.to_lowercase().as_str() {
"private" => EventClass::Private,
"confidential" => EventClass::Confidential,
_ => EventClass::Public,
});
event.priority = request.priority;
// Update the event on the CalDAV server
println!(
"📝 Updating event {} at calendar_path: {}, event_href: {}",
event.uid, calendar_path, event_href
);
client
.update_event(&calendar_path, &event, &event_href)
.await
.map_err(|e| ApiError::Internal(format!("Failed to update event: {}", e)))?;
println!("✅ Successfully updated event {}", event.uid);
Ok(Json(UpdateEventResponse {
success: true,
message: "Event updated successfully".to_string(),
}))
}
fn parse_event_datetime(
date_str: &str,
time_str: &str,
all_day: bool,
) -> Result<chrono::DateTime<chrono::Utc>, String> {
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
// 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 noon UTC to avoid timezone boundary issues
// This ensures the date remains correct when converted to any local timezone
let datetime = date
.and_hms_opt(12, 0, 0)
.ok_or_else(|| "Failed to create noon datetime".to_string())?;
Ok(Utc.from_utc_datetime(&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);
// Frontend now sends UTC times, so treat as UTC directly
Ok(Utc.from_utc_datetime(&datetime))
}
}

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,906 @@
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,
dtend,
summary,
description,
location,
all_day,
rrule,
exdate: Vec::new(), // External calendars don't need exception handling
recurrence_id: None,
created: None,
last_modified: 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,
rdate: Vec::new(),
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_naive() - recurring_event.dtstart.date_naive()).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_naive() - recurring_event.dtstart.date_naive()).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

@@ -0,0 +1,133 @@
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use std::sync::Arc;
use crate::{
db::PreferencesRepository,
models::{ApiError, UpdatePreferencesRequest, UserPreferencesResponse},
AppState,
};
/// Get user preferences
pub async fn get_preferences(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, ApiError> {
// Extract session token from headers
let session_token = headers
.get("X-Session-Token")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
// Validate session and get user ID
let user_id = state.auth_service.validate_session(session_token).await?;
// Get preferences from database
let prefs_repo = PreferencesRepository::new(&state.db);
let preferences = prefs_repo
.get_or_create(&user_id)
.await
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
Ok(Json(UserPreferencesResponse {
calendar_selected_date: preferences.calendar_selected_date,
calendar_time_increment: preferences.calendar_time_increment,
calendar_view_mode: preferences.calendar_view_mode,
calendar_theme: preferences.calendar_theme,
calendar_style: preferences.calendar_style,
calendar_colors: preferences.calendar_colors,
last_used_calendar: preferences.last_used_calendar,
}))
}
/// Update user preferences
pub async fn update_preferences(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<UpdatePreferencesRequest>,
) -> Result<impl IntoResponse, ApiError> {
// Extract session token from headers
let session_token = headers
.get("X-Session-Token")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
// Validate session and get user ID
let user_id = state.auth_service.validate_session(session_token).await?;
// Update preferences in database
let prefs_repo = PreferencesRepository::new(&state.db);
let mut preferences = prefs_repo
.get_or_create(&user_id)
.await
.map_err(|e| ApiError::Database(format!("Failed to get preferences: {}", e)))?;
// Update only provided fields
if request.calendar_selected_date.is_some() {
preferences.calendar_selected_date = request.calendar_selected_date;
}
if request.calendar_time_increment.is_some() {
preferences.calendar_time_increment = request.calendar_time_increment;
}
if request.calendar_view_mode.is_some() {
preferences.calendar_view_mode = request.calendar_view_mode;
}
if request.calendar_theme.is_some() {
preferences.calendar_theme = request.calendar_theme;
}
if request.calendar_style.is_some() {
preferences.calendar_style = request.calendar_style;
}
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)
.await
.map_err(|e| ApiError::Database(format!("Failed to update preferences: {}", e)))?;
Ok((
StatusCode::OK,
Json(UserPreferencesResponse {
calendar_selected_date: preferences.calendar_selected_date,
calendar_time_increment: preferences.calendar_time_increment,
calendar_view_mode: preferences.calendar_view_mode,
calendar_theme: preferences.calendar_theme,
calendar_style: preferences.calendar_style,
calendar_colors: preferences.calendar_colors,
last_used_calendar: preferences.last_used_calendar,
}),
))
}
/// Logout user
pub async fn logout(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, ApiError> {
// Extract session token from headers
let session_token = headers
.get("X-Session-Token")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| ApiError::Unauthorized("Missing session token".to_string()))?;
// Delete session
state.auth_service.logout(session_token).await?;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"success": true,
"message": "Logged out successfully"
})),
))
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,45 @@
use axum::{
response::Json,
routing::{get, post},
routing::{delete, get, post},
Router,
};
use tower_http::cors::{CorsLayer, Any};
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
mod auth;
mod models;
mod handlers;
mod calendar;
mod config;
pub mod auth;
pub mod calendar;
pub mod config;
pub mod db;
pub mod handlers;
pub mod models;
use auth::AuthService;
use db::Database;
#[derive(Clone)]
pub struct AppState {
pub auth_service: AuthService,
pub db: Database,
}
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
println!("🚀 Starting Calendar Backend Server");
// Initialize database
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite:calendar.db".to_string());
let db = Database::new(&database_url).await?;
println!("✅ Database initialized");
// Create auth service
let jwt_secret = std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
let auth_service = AuthService::new(jwt_secret);
let app_state = AppState { auth_service };
let auth_service = AuthService::new(jwt_secret, db.clone());
let app_state = AppState { auth_service, db };
// Build our application with routes
let app = Router::new()
@@ -42,8 +52,32 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
.route("/api/calendar/delete", post(handlers::delete_calendar))
.route("/api/calendar/events", get(handlers::get_calendar_events))
.route("/api/calendar/events/create", post(handlers::create_event))
.route("/api/calendar/events/update", post(handlers::update_event))
.route("/api/calendar/events/delete", post(handlers::delete_event))
.route("/api/calendar/events/:uid", get(handlers::refresh_event))
// Event series-specific endpoints
.route(
"/api/calendar/events/series/create",
post(handlers::create_event_series),
)
.route(
"/api/calendar/events/series/update",
post(handlers::update_event_series),
)
.route(
"/api/calendar/events/series/delete",
post(handlers::delete_event_series),
)
// User preferences endpoints
.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)
@@ -55,7 +89,7 @@ pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
// Start server
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
println!("📡 Server listening on http://0.0.0.0:3000");
axum::serve(listener, app).await?;
Ok(())
@@ -71,4 +105,4 @@ async fn health_check() -> Json<serde_json::Value> {
"service": "calendar-backend",
"version": "0.1.0"
}))
}
}

View File

@@ -4,4 +4,4 @@ use calendar_backend::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
run_server().await
}
}

View File

@@ -16,8 +16,32 @@ pub struct CalDAVLoginRequest {
#[derive(Debug, Serialize)]
pub struct AuthResponse {
pub token: String,
pub session_token: String,
pub username: String,
pub server_url: String,
pub preferences: UserPreferencesResponse,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserPreferencesResponse {
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>,
pub last_used_calendar: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePreferencesRequest {
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>,
pub last_used_calendar: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -32,6 +56,7 @@ pub struct CalendarInfo {
pub path: String,
pub display_name: String,
pub color: String,
pub is_visible: bool,
}
#[derive(Debug, Deserialize)]
@@ -62,6 +87,8 @@ pub struct DeleteCalendarResponse {
pub struct DeleteEventRequest {
pub calendar_path: String,
pub event_href: String,
pub delete_action: String, // "delete_this", "delete_following", or "delete_series"
pub occurrence_date: Option<String>, // ISO date string for the specific occurrence
}
#[derive(Debug, Serialize)]
@@ -74,21 +101,21 @@ pub struct DeleteEventResponse {
pub struct CreateEventRequest {
pub title: String,
pub description: String,
pub start_date: String, // YYYY-MM-DD format
pub start_time: String, // HH:MM format
pub end_date: String, // YYYY-MM-DD format
pub end_time: String, // HH:MM format
pub start_date: String, // YYYY-MM-DD format
pub start_time: String, // HH:MM format
pub end_date: String, // YYYY-MM-DD format
pub end_time: String, // HH:MM format
pub location: String,
pub all_day: bool,
pub status: String, // confirmed, tentative, cancelled
pub class: String, // public, private, confidential
pub priority: Option<u8>, // 0-9 priority level
pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories
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 status: String, // confirmed, tentative, cancelled
pub class: String, // public, private, confidential
pub priority: Option<u8>, // 0-9 priority level
pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories
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 calendar_path: Option<String>, // Optional - use first calendar if not specified
}
@@ -99,6 +126,135 @@ pub struct CreateEventResponse {
pub event_href: Option<String>, // The created event's href/filename
}
#[derive(Debug, Deserialize)]
pub struct UpdateEventRequest {
pub uid: String, // Event UID to identify which event to update
pub title: String,
pub description: String,
pub start_date: String, // YYYY-MM-DD format
pub start_time: String, // HH:MM format
pub end_date: String, // YYYY-MM-DD format
pub end_time: String, // HH:MM format
pub location: String,
pub all_day: bool,
pub status: String, // confirmed, tentative, cancelled
pub class: String, // public, private, confidential
pub priority: Option<u8>, // 0-9 priority level
pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories
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 calendar_path: Option<String>, // Optional - search all calendars if not specified
pub update_action: Option<String>, // "update_series" for recurring events
#[serde(skip_serializing_if = "Option::is_none")]
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
}
#[derive(Debug, Serialize)]
pub struct UpdateEventResponse {
pub success: bool,
pub message: String,
}
// ==================== EVENT SERIES MODELS ====================
#[derive(Debug, Deserialize)]
pub struct CreateEventSeriesRequest {
pub title: String,
pub description: String,
pub start_date: String, // YYYY-MM-DD format
pub start_time: String, // HH:MM format
pub end_date: String, // YYYY-MM-DD format
pub end_time: String, // HH:MM format
pub location: String,
pub all_day: bool,
pub status: String, // confirmed, tentative, cancelled
pub class: String, // public, private, confidential
pub priority: Option<u8>, // 0-9 priority level
pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories
pub reminder: String, // reminder type
// Series-specific fields
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
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_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
}
#[derive(Debug, Serialize)]
pub struct CreateEventSeriesResponse {
pub success: bool,
pub message: String,
pub series_uid: Option<String>, // The base UID for the series
pub occurrences_created: Option<u32>, // Number of individual events created
pub event_href: Option<String>, // The created series' href/filename
}
#[derive(Debug, Deserialize)]
pub struct UpdateEventSeriesRequest {
pub series_uid: String, // Series UID to identify which series to update
pub title: String,
pub description: String,
pub start_date: String, // YYYY-MM-DD format
pub start_time: String, // HH:MM format
pub end_date: String, // YYYY-MM-DD format
pub end_time: String, // HH:MM format
pub location: String,
pub all_day: bool,
pub status: String, // confirmed, tentative, cancelled
pub class: String, // public, private, confidential
pub priority: Option<u8>, // 0-9 priority level
pub organizer: String, // organizer email
pub attendees: String, // comma-separated attendee emails
pub categories: String, // comma-separated categories
pub reminder: String, // reminder type
// Series-specific fields
pub recurrence: String, // recurrence type (daily, weekly, monthly, yearly)
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_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
// Update scope control
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)
}
#[derive(Debug, Serialize)]
pub struct UpdateEventSeriesResponse {
pub success: bool,
pub message: String,
pub series_uid: Option<String>,
pub occurrences_affected: Option<u32>, // Number of events updated
}
#[derive(Debug, Deserialize)]
pub struct DeleteEventSeriesRequest {
pub series_uid: String, // Series UID to identify which series to delete
pub calendar_path: String,
pub event_href: String,
// Delete scope control
pub delete_scope: String, // "this_only", "this_and_future", "all_in_series"
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being deleted
}
#[derive(Debug, Serialize)]
pub struct DeleteEventSeriesResponse {
pub success: bool,
pub message: String,
pub occurrences_affected: Option<u32>, // Number of events deleted
}
// Error handling
#[derive(Debug)]
pub enum ApiError {
@@ -143,4 +299,4 @@ impl std::fmt::Display for ApiError {
}
}
impl std::error::Error for ApiError {}
impl std::error::Error for ApiError {}

View File

@@ -0,0 +1,726 @@
use axum::{
response::Json,
routing::{get, post},
Router,
};
use calendar_backend::auth::AuthService;
use calendar_backend::AppState;
use reqwest::Client;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
use tower_http::cors::{Any, CorsLayer};
/// Test utilities for integration testing
mod test_utils {
use super::*;
pub struct TestServer {
pub base_url: String,
pub client: Client,
}
impl TestServer {
pub async fn start() -> Self {
// Create auth service
let jwt_secret = "test-secret-key-for-integration-tests".to_string();
let auth_service = AuthService::new(jwt_secret);
let app_state = AppState { auth_service };
// Build application with routes
let app = Router::new()
.route("/", get(root))
.route("/api/health", get(health_check))
.route("/api/auth/login", post(calendar_backend::handlers::login))
.route(
"/api/auth/verify",
get(calendar_backend::handlers::verify_token),
)
.route(
"/api/user/info",
get(calendar_backend::handlers::get_user_info),
)
.route(
"/api/calendar/create",
post(calendar_backend::handlers::create_calendar),
)
.route(
"/api/calendar/delete",
post(calendar_backend::handlers::delete_calendar),
)
.route(
"/api/calendar/events",
get(calendar_backend::handlers::get_calendar_events),
)
.route(
"/api/calendar/events/create",
post(calendar_backend::handlers::create_event),
)
.route(
"/api/calendar/events/update",
post(calendar_backend::handlers::update_event),
)
.route(
"/api/calendar/events/delete",
post(calendar_backend::handlers::delete_event),
)
.route(
"/api/calendar/events/:uid",
get(calendar_backend::handlers::refresh_event),
)
// Event series-specific endpoints
.route(
"/api/calendar/events/series/create",
post(calendar_backend::handlers::create_event_series),
)
.route(
"/api/calendar/events/series/update",
post(calendar_backend::handlers::update_event_series),
)
.route(
"/api/calendar/events/series/delete",
post(calendar_backend::handlers::delete_event_series),
)
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
)
.with_state(Arc::new(app_state));
// Start server on a random port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base_url = format!("http://127.0.0.1:{}", addr.port());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
// Wait for server to start
sleep(Duration::from_millis(100)).await;
let client = Client::new();
TestServer { base_url, client }
}
pub async fn login(&self) -> String {
let login_payload = json!({
"username": "test".to_string(),
"password": "test".to_string(),
"server_url": "https://example.com".to_string()
});
let response = self
.client
.post(&format!("{}/api/auth/login", self.base_url))
.json(&login_payload)
.send()
.await
.expect("Failed to send login request");
assert!(
response.status().is_success(),
"Login failed with status: {}",
response.status()
);
let login_response: serde_json::Value = response.json().await.unwrap();
login_response["token"]
.as_str()
.expect("Login response should contain token")
.to_string()
}
}
async fn root() -> &'static str {
"Calendar Backend API v0.1.0"
}
async fn health_check() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "healthy",
"service": "calendar-backend",
"version": "0.1.0"
}))
}
}
#[cfg(test)]
mod tests {
use super::test_utils::*;
use super::*;
/// Test the health endpoint
#[tokio::test]
async fn test_health_endpoint() {
let server = TestServer::start().await;
let response = server
.client
.get(&format!("{}/api/health", server.base_url))
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let health_response: serde_json::Value = response.json().await.unwrap();
assert_eq!(health_response["status"], "healthy");
assert_eq!(health_response["service"], "calendar-backend");
println!("✓ Health endpoint test passed");
}
/// Test authentication login endpoint
#[tokio::test]
async fn test_auth_login() {
let server = TestServer::start().await;
// Use test credentials
let username = "test".to_string();
let password = "test".to_string();
let server_url = "https://example.com".to_string();
let login_payload = json!({
"username": username,
"password": password,
"server_url": server_url
});
let response = server
.client
.post(&format!("{}/api/auth/login", server.base_url))
.json(&login_payload)
.send()
.await
.unwrap();
assert!(
response.status().is_success(),
"Login failed with status: {}",
response.status()
);
let login_response: serde_json::Value = response.json().await.unwrap();
assert!(
login_response["token"].is_string(),
"Login response should contain a token"
);
assert!(
login_response["username"].is_string(),
"Login response should contain username"
);
println!("✓ Authentication login test passed");
}
/// Test authentication verify endpoint
#[tokio::test]
async fn test_auth_verify() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
let response = server
.client
.get(&format!("{}/api/auth/verify", server.base_url))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let verify_response: serde_json::Value = response.json().await.unwrap();
assert!(verify_response["valid"].as_bool().unwrap_or(false));
println!("✓ Authentication verify test passed");
}
/// Test user info endpoint
#[tokio::test]
async fn test_user_info() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
// Load password from env for CalDAV requests
dotenvy::dotenv().ok();
let password = "test".to_string();
let response = server
.client
.get(&format!("{}/api/user/info", server.base_url))
.header("Authorization", format!("Bearer {}", token))
.header("X-CalDAV-Password", password)
.send()
.await
.unwrap();
// Note: This might fail if CalDAV server discovery fails, which can happen
if response.status().is_success() {
let user_info: serde_json::Value = response.json().await.unwrap();
assert!(user_info["username"].is_string());
println!("✓ User info test passed");
} else {
println!(
"⚠ User info test skipped (CalDAV server issues): {}",
response.status()
);
}
}
/// Test calendar events listing endpoint
#[tokio::test]
async fn test_get_calendar_events() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
// Load password from env for CalDAV requests
dotenvy::dotenv().ok();
let password = "test".to_string();
let response = server
.client
.get(&format!(
"{}/api/calendar/events?year=2024&month=12",
server.base_url
))
.header("Authorization", format!("Bearer {}", token))
.header("X-CalDAV-Password", password)
.send()
.await
.unwrap();
assert!(
response.status().is_success(),
"Get events failed with status: {}",
response.status()
);
let events: serde_json::Value = response.json().await.unwrap();
assert!(events.is_array());
println!(
"✓ Get calendar events test passed (found {} events)",
events.as_array().unwrap().len()
);
}
/// Test event creation endpoint
#[tokio::test]
async fn test_create_event() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
// Load password from env for CalDAV requests
dotenvy::dotenv().ok();
let password = "test".to_string();
let create_payload = json!({
"title": "Integration Test Event",
"description": "Created by integration test",
"start_date": "2024-12-25",
"start_time": "10:00",
"end_date": "2024-12-25",
"end_time": "11:00",
"location": "Test Location",
"all_day": false,
"status": "confirmed",
"class": "public",
"priority": 5,
"organizer": "test@example.com",
"attendees": "",
"categories": "test",
"reminder": "15min",
"recurrence": "none",
"recurrence_days": [false, false, false, false, false, false, false]
});
let response = server
.client
.post(&format!("{}/api/calendar/events/create", server.base_url))
.header("Authorization", format!("Bearer {}", token))
.header("X-CalDAV-Password", password)
.json(&create_payload)
.send()
.await
.unwrap();
let status = response.status();
println!("Create event response status: {}", status);
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
if status.is_success() {
let create_response: serde_json::Value = response.json().await.unwrap();
assert!(create_response["success"].as_bool().unwrap_or(false));
println!("✓ Create event test passed");
} else {
println!("⚠ Create event test skipped (CalDAV server not accessible)");
}
}
/// Test event refresh endpoint
#[tokio::test]
async fn test_refresh_event() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
// Load password from env for CalDAV requests
dotenvy::dotenv().ok();
let password = "test".to_string();
// Use a dummy UID for testing - this will likely return 404 but we're testing the endpoint structure
let test_uid = "test-event-uid";
let response = server
.client
.get(&format!(
"{}/api/calendar/events/{}",
server.base_url, test_uid
))
.header("Authorization", format!("Bearer {}", token))
.header("X-CalDAV-Password", password)
.send()
.await
.unwrap();
// We expect either 200 (if event exists) or 404 (if not found) - both are valid responses
assert!(
response.status() == 200 || response.status() == 404,
"Refresh event failed with unexpected status: {}",
response.status()
);
println!("✓ Refresh event endpoint test passed");
}
/// Test invalid authentication
#[tokio::test]
async fn test_invalid_auth() {
let server = TestServer::start().await;
let response = server
.client
.get(&format!("{}/api/user/info", server.base_url))
.header("Authorization", "Bearer invalid-token")
.send()
.await
.unwrap();
// Accept both 400 and 401 as valid responses for invalid tokens
assert!(
response.status() == 401 || response.status() == 400,
"Expected 401 or 400 for invalid token, got {}",
response.status()
);
println!("✓ Invalid authentication test passed");
}
/// Test missing authentication
#[tokio::test]
async fn test_missing_auth() {
let server = TestServer::start().await;
let response = server
.client
.get(&format!("{}/api/user/info", server.base_url))
.send()
.await
.unwrap();
assert_eq!(response.status(), 401);
println!("✓ Missing authentication test passed");
}
// ==================== EVENT SERIES TESTS ====================
/// Test event series creation endpoint
#[tokio::test]
async fn test_create_event_series() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
// Load password from env for CalDAV requests
dotenvy::dotenv().ok();
let password = "test".to_string();
let create_payload = json!({
"title": "Integration Test Series",
"description": "Created by integration test for series",
"start_date": "2024-12-25",
"start_time": "10:00",
"end_date": "2024-12-25",
"end_time": "11:00",
"location": "Test Series Location",
"all_day": false,
"status": "confirmed",
"class": "public",
"priority": 5,
"organizer": "test@example.com",
"attendees": "",
"categories": "test-series",
"reminder": "15min",
"recurrence": "weekly",
"recurrence_days": [false, true, false, false, false, false, false], // Monday only
"recurrence_interval": 1,
"recurrence_count": 4,
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
});
let response = server
.client
.post(&format!(
"{}/api/calendar/events/series/create",
server.base_url
))
.header("Authorization", format!("Bearer {}", token))
.header("X-CalDAV-Password", password)
.json(&create_payload)
.send()
.await
.unwrap();
let status = response.status();
println!("Create series response status: {}", status);
// Note: This might fail if CalDAV server is not accessible, which is expected in CI
if status.is_success() {
let create_response: serde_json::Value = response.json().await.unwrap();
assert!(create_response["success"].as_bool().unwrap_or(false));
assert!(create_response["series_uid"].is_string());
println!("✓ Create event series test passed");
} else {
println!("⚠ Create event series test skipped (CalDAV server not accessible)");
}
}
/// Test event series update endpoint
#[tokio::test]
async fn test_update_event_series() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
// Load password from env for CalDAV requests
dotenvy::dotenv().ok();
let password = "test".to_string();
let update_payload = json!({
"series_uid": "test-series-uid",
"title": "Updated Series Title",
"description": "Updated by integration test",
"start_date": "2024-12-26",
"start_time": "14:00",
"end_date": "2024-12-26",
"end_time": "15:00",
"location": "Updated Location",
"all_day": false,
"status": "confirmed",
"class": "public",
"priority": 3,
"organizer": "test@example.com",
"attendees": "attendee@example.com",
"categories": "updated-series",
"reminder": "30min",
"recurrence": "daily",
"recurrence_days": [false, false, false, false, false, false, false],
"recurrence_interval": 2,
"recurrence_count": 10,
"update_scope": "all_in_series",
"calendar_path": "/calendars/test/default/" // Provide explicit path to bypass discovery
});
let response = server
.client
.post(&format!(
"{}/api/calendar/events/series/update",
server.base_url
))
.header("Authorization", format!("Bearer {}", token))
.header("X-CalDAV-Password", password)
.json(&update_payload)
.send()
.await
.unwrap();
let status = response.status();
println!("Update series response status: {}", status);
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
if status.is_success() {
let update_response: serde_json::Value = response.json().await.unwrap();
assert!(update_response["success"].as_bool().unwrap_or(false));
assert_eq!(
update_response["series_uid"].as_str().unwrap(),
"test-series-uid"
);
println!("✓ Update event series test passed");
} else if status == 404 {
println!(
"⚠ Update event series test skipped (event not found - expected for test data)"
);
} else {
println!("⚠ Update event series test skipped (CalDAV server not accessible)");
}
}
/// Test event series deletion endpoint
#[tokio::test]
async fn test_delete_event_series() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
// Load password from env for CalDAV requests
dotenvy::dotenv().ok();
let password = "test".to_string();
let delete_payload = json!({
"series_uid": "test-series-to-delete",
"calendar_path": "/calendars/test/default/",
"event_href": "test-series.ics",
"delete_scope": "all_in_series"
});
let response = server
.client
.post(&format!(
"{}/api/calendar/events/series/delete",
server.base_url
))
.header("Authorization", format!("Bearer {}", token))
.header("X-CalDAV-Password", password)
.json(&delete_payload)
.send()
.await
.unwrap();
let status = response.status();
println!("Delete series response status: {}", status);
// Note: This might fail if CalDAV server is not accessible or event doesn't exist, which is expected in CI
if status.is_success() {
let delete_response: serde_json::Value = response.json().await.unwrap();
assert!(delete_response["success"].as_bool().unwrap_or(false));
println!("✓ Delete event series test passed");
} else if status == 404 {
println!(
"⚠ Delete event series test skipped (event not found - expected for test data)"
);
} else {
println!("⚠ Delete event series test skipped (CalDAV server not accessible)");
}
}
/// Test invalid update scope
#[tokio::test]
async fn test_invalid_update_scope() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
let invalid_payload = json!({
"series_uid": "test-series-uid",
"title": "Test Title",
"description": "Test",
"start_date": "2024-12-25",
"start_time": "10:00",
"end_date": "2024-12-25",
"end_time": "11:00",
"location": "Test",
"all_day": false,
"status": "confirmed",
"class": "public",
"organizer": "test@example.com",
"attendees": "",
"categories": "",
"reminder": "none",
"recurrence": "none",
"recurrence_days": [false, false, false, false, false, false, false],
"update_scope": "invalid_scope" // This should cause a 400 error
});
let response = server
.client
.post(&format!(
"{}/api/calendar/events/series/update",
server.base_url
))
.header("Authorization", format!("Bearer {}", token))
.json(&invalid_payload)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
400,
"Expected 400 for invalid update scope"
);
println!("✓ Invalid update scope test passed");
}
/// Test non-recurring event rejection in series endpoint
#[tokio::test]
async fn test_non_recurring_series_rejection() {
let server = TestServer::start().await;
// First login to get a token
let token = server.login().await;
let non_recurring_payload = json!({
"title": "Non-recurring Event",
"description": "This should be rejected",
"start_date": "2024-12-25",
"start_time": "10:00",
"end_date": "2024-12-25",
"end_time": "11:00",
"location": "Test",
"all_day": false,
"status": "confirmed",
"class": "public",
"organizer": "test@example.com",
"attendees": "",
"categories": "",
"reminder": "none",
"recurrence": "none", // This should cause rejection
"recurrence_days": [false, false, false, false, false, false, false]
});
let response = server
.client
.post(&format!(
"{}/api/calendar/events/series/create",
server.base_url
))
.header("Authorization", format!("Bearer {}", token))
.json(&non_recurring_payload)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
400,
"Expected 400 for non-recurring event in series endpoint"
);
println!("✓ Non-recurring series rejection test passed");
}
}

View File

@@ -0,0 +1,13 @@
[package]
name = "calendar-models"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
[features]
default = []
wasm = ["chrono/wasm-bindgen", "uuid/wasm-bindgen"]

View File

@@ -0,0 +1,220 @@
//! Common types and enums used across calendar components
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
// ==================== ENUMS AND COMMON TYPES ====================
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum EventStatus {
Tentative,
Confirmed,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum EventClass {
Public,
Private,
Confidential,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TimeTransparency {
Opaque, // OPAQUE - time is not available
Transparent, // TRANSPARENT - time is available
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TodoStatus {
NeedsAction,
Completed,
InProcess,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AttendeeRole {
Chair,
ReqParticipant,
OptParticipant,
NonParticipant,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ParticipationStatus {
NeedsAction,
Accepted,
Declined,
Tentative,
Delegated,
Completed,
InProcess,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum AlarmAction {
Audio,
Display,
Email,
Procedure,
}
// ==================== STRUCTURED TYPES ====================
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CalendarUser {
pub cal_address: String, // Calendar user address (usually email)
pub common_name: Option<String>, // CN parameter - display name
pub dir_entry_ref: Option<String>, // DIR parameter - directory entry
pub sent_by: Option<String>, // SENT-BY parameter
pub language: Option<String>, // LANGUAGE parameter
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Attendee {
pub cal_address: String, // Calendar user address
pub common_name: Option<String>, // CN parameter
pub role: Option<AttendeeRole>, // ROLE parameter
pub part_stat: Option<ParticipationStatus>, // PARTSTAT parameter
pub rsvp: Option<bool>, // RSVP parameter
pub cu_type: Option<String>, // CUTYPE parameter (INDIVIDUAL, GROUP, RESOURCE, ROOM, UNKNOWN)
pub member: Vec<String>, // MEMBER parameter
pub delegated_to: Vec<String>, // DELEGATED-TO parameter
pub delegated_from: Vec<String>, // DELEGATED-FROM parameter
pub sent_by: Option<String>, // SENT-BY parameter
pub dir_entry_ref: Option<String>, // DIR parameter
pub language: Option<String>, // LANGUAGE parameter
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VAlarm {
pub action: AlarmAction, // Action (ACTION) - REQUIRED
pub trigger: AlarmTrigger, // Trigger (TRIGGER) - REQUIRED
pub duration: Option<Duration>, // Duration (DURATION)
pub repeat: Option<u32>, // Repeat count (REPEAT)
pub description: Option<String>, // Description for DISPLAY/EMAIL
pub summary: Option<String>, // Summary for EMAIL
pub attendees: Vec<Attendee>, // Attendees for EMAIL
pub attach: Vec<Attachment>, // Attachments for AUDIO/EMAIL
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AlarmTrigger {
DateTime(DateTime<Utc>), // Absolute trigger time
Duration(Duration), // Duration relative to start/end
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Attachment {
pub format_type: Option<String>, // FMTTYPE parameter (MIME type)
pub encoding: Option<String>, // ENCODING parameter
pub value: Option<String>, // VALUE parameter (BINARY or URI)
pub uri: Option<String>, // URI reference
pub binary_data: Option<Vec<u8>>, // Binary data (when ENCODING=BASE64)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GeographicPosition {
pub latitude: f64, // Latitude in decimal degrees
pub longitude: f64, // Longitude in decimal degrees
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VTimeZone {
pub tzid: String, // Time zone ID (TZID) - REQUIRED
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED)
pub tzurl: Option<String>, // Time zone URL (TZURL)
pub standard_components: Vec<TimeZoneComponent>, // STANDARD components
pub daylight_components: Vec<TimeZoneComponent>, // DAYLIGHT components
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TimeZoneComponent {
pub dtstart: DateTime<Utc>, // Start of this time zone definition
pub tzoffset_to: String, // UTC offset for this component
pub tzoffset_from: String, // UTC offset before this component
pub rrule: Option<String>, // Recurrence rule
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates
pub tzname: Vec<String>, // Time zone names
pub comment: Vec<String>, // Comments
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VJournal {
// Required properties
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
pub uid: String, // Unique identifier (UID) - REQUIRED
// Optional properties
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
pub summary: Option<String>, // Summary/title (SUMMARY)
pub description: Option<String>, // Description (DESCRIPTION)
// Classification and status
pub class: Option<EventClass>, // Classification (CLASS)
pub status: Option<String>, // Status (STATUS)
// People and organization
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
// Categorization
pub categories: Vec<String>, // Categories (CATEGORIES)
// 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)
// 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)
// Attachments
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VFreeBusy {
// Required properties
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED
pub uid: String, // Unique identifier (UID) - REQUIRED
// Optional date-time properties
pub dtstart: Option<DateTime<Utc>>, // Start date-time (DTSTART)
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
// People
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
// Free/busy time
pub freebusy: Vec<FreeBusyTime>, // Free/busy time periods
pub url: Option<String>, // URL (URL)
pub comment: Vec<String>, // Comments (COMMENT)
pub contact: Option<String>, // Contact information (CONTACT)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FreeBusyTime {
pub fb_type: FreeBusyType, // Free/busy type
pub periods: Vec<Period>, // Time periods
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum FreeBusyType {
Free,
Busy,
BusyUnavailable,
BusyTentative,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Period {
pub start: DateTime<Utc>, // Period start
pub end: Option<DateTime<Utc>>, // Period end
pub duration: Option<Duration>, // Period duration (alternative to end)
}

View File

@@ -0,0 +1,10 @@
//! RFC 5545 Compliant Calendar Models
//!
//! This crate provides shared data structures for calendar applications
//! that comply with RFC 5545 (iCalendar) specification.
pub mod common;
pub mod vevent;
pub use common::*;
pub use vevent::*;

View File

@@ -0,0 +1,185 @@
//! VEvent - RFC 5545 compliant calendar event structure
use crate::common::*;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
// ==================== VEVENT COMPONENT ====================
#[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
// Optional properties (commonly used)
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND)
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
pub summary: Option<String>, // Summary/title (SUMMARY)
pub description: Option<String>, // Description (DESCRIPTION)
pub location: Option<String>, // Location (LOCATION)
// Classification and status
pub class: Option<EventClass>, // Classification (CLASS)
pub status: Option<EventStatus>, // Status (STATUS)
pub transp: Option<TimeTransparency>, // Time transparency (TRANSP)
pub priority: Option<u8>, // Priority 0-9 (PRIORITY)
// People and organization
pub organizer: Option<CalendarUser>, // Organizer (ORGANIZER)
pub attendees: Vec<Attendee>, // Attendees (ATTENDEE)
pub contact: Option<String>, // Contact information (CONTACT)
// Categorization and relationships
pub categories: Vec<String>, // Categories (CATEGORIES)
pub comment: Option<String>, // Comment (COMMENT)
pub resources: Vec<String>, // Resources (RESOURCES)
pub related_to: Option<String>, // Related component (RELATED-TO)
pub url: Option<String>, // URL (URL)
// Geographical
pub geo: Option<GeographicPosition>, // Geographic position (GEO)
// 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)
// 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)
// Alarms and attachments
pub alarms: Vec<VAlarm>, // VALARM components
pub attachments: Vec<Attachment>, // Attachments (ATTACH)
// CalDAV specific (for implementation)
pub etag: Option<String>, // ETag for CalDAV
pub href: Option<String>, // Href for CalDAV
pub calendar_path: Option<String>, // Calendar path
pub all_day: bool, // All-day event flag
}
impl VEvent {
/// Create a new VEvent with required fields
pub fn new(uid: String, dtstart: DateTime<Utc>) -> Self {
Self {
dtstamp: Utc::now(),
uid,
dtstart,
dtend: None,
duration: None,
summary: None,
description: None,
location: None,
class: None,
status: None,
transp: None,
priority: None,
organizer: None,
attendees: Vec::new(),
contact: None,
categories: Vec::new(),
comment: None,
resources: Vec::new(),
related_to: None,
url: None,
geo: None,
sequence: None,
created: Some(Utc::now()),
last_modified: Some(Utc::now()),
rrule: None,
rdate: Vec::new(),
exdate: Vec::new(),
recurrence_id: None,
alarms: Vec::new(),
attachments: Vec::new(),
etag: None,
href: None,
calendar_path: None,
all_day: false,
}
}
/// Helper method to get effective end time (dtend or dtstart + duration)
pub fn get_end_time(&self) -> DateTime<Utc> {
if let Some(dtend) = self.dtend {
dtend
} else if let Some(duration) = self.duration {
self.dtstart + duration
} else {
// Default to 1 hour if no end or duration specified
self.dtstart + Duration::hours(1)
}
}
/// Helper method to get event duration
pub fn get_duration(&self) -> Duration {
if let Some(duration) = self.duration {
duration
} else if let Some(dtend) = self.dtend {
dtend - self.dtstart
} else {
Duration::hours(1) // Default duration
}
}
/// Helper method to get display title (summary or "Untitled Event")
pub fn get_title(&self) -> String {
self.summary
.clone()
.unwrap_or_else(|| "Untitled Event".to_string())
}
/// Helper method to get start date for UI compatibility
pub fn get_date(&self) -> chrono::NaiveDate {
self.dtstart.date_naive()
}
/// Check if event is recurring
pub fn is_recurring(&self) -> bool {
self.rrule.is_some()
}
/// Check if this is an exception to a recurring series
pub fn is_exception(&self) -> bool {
self.recurrence_id.is_some()
}
/// Get display string for status
pub fn get_status_display(&self) -> &'static str {
match &self.status {
Some(EventStatus::Tentative) => "Tentative",
Some(EventStatus::Confirmed) => "Confirmed",
Some(EventStatus::Cancelled) => "Cancelled",
None => "Confirmed", // Default
}
}
/// Get display string for class
pub fn get_class_display(&self) -> &'static str {
match &self.class {
Some(EventClass::Public) => "Public",
Some(EventClass::Private) => "Private",
Some(EventClass::Confidential) => "Confidential",
None => "Public", // Default
}
}
/// Get display string for priority
pub fn get_priority_display(&self) -> String {
match self.priority {
None => "Not set".to_string(),
Some(0) => "Undefined".to_string(),
Some(1) => "High".to_string(),
Some(p) if p <= 4 => "High".to_string(),
Some(5) => "Medium".to_string(),
Some(p) if p <= 8 => "Low".to_string(),
Some(9) => "Low".to_string(),
Some(p) => format!("Priority {}", p),
}
}
}

Binary file not shown.

22
compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
calendar-backend:
build:
context: .
dockerfile: ./backend/Dockerfile
ports:
- "3000:3000"
volumes:
- ./data/db:/db
calendar-frontend:
image: caddy
environment:
- BACKEND_API_URL=http://localhost:3000/api
ports:
- "80:80"
- "443:443"
volumes:
- ./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

75
frontend/Cargo.toml Normal file
View File

@@ -0,0 +1,75 @@
[package]
name = "runway"
version = "0.1.0"
edition = "2021"
# Frontend binary only
[dependencies]
calendar-models = { workspace = true, features = ["wasm"] }
yew = { version = "0.21", features = ["csr"] }
web-sys = { version = "0.3", features = [
"console",
"HtmlSelectElement",
"HtmlInputElement",
"HtmlTextAreaElement",
"HtmlLinkElement",
"HtmlHeadElement",
"Event",
"MouseEvent",
"InputEvent",
"Element",
"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"] }
# Calendar and iCal parsing
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"] }
chrono-tz = "0.8"
# Error handling
anyhow = "1.0"
thiserror = "1.0"
# Logging
log = "0.4"
console_log = "1.0"
# UUID generation for calendar events
uuid = { version = "1.0", features = ["v4", "wasm-bindgen", "serde"] }
getrandom = { version = "0.2", features = ["js"] }
# Environment variable handling
dotenvy = "0.15"
base64 = "0.21"
# XML/Regex parsing
regex = "1.0"
# Yew routing and local storage (WASM only)
yew-router = "0.18"
gloo-storage = "0.3"
gloo-timers = "0.3"
wasm-bindgen-futures = "0.4"

16
frontend/Trunk.toml Normal file
View File

@@ -0,0 +1,16 @@
[build]
target = "index.html"
dist = "dist"
[env]
BACKEND_API_URL = "http://localhost:3000/api"
[watch]
watch = ["src", "Cargo.toml", "../calendar-models/src", "styles.css", "print-preview.css", "index.html"]
ignore = ["../backend/", "../target/"]
[serve]
addresses = ["127.0.0.1"]
port = 8080
open = false

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

22
frontend/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<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 fully loaded, waiting for WASM...");
window.addEventListener('TrunkApplicationStarted', () => {
console.log("Trunk application started successfully!");
});
</script>
</body>
</html>

1215
frontend/print-preview.css Normal file

File diff suppressed because it is too large Load Diff

1710
frontend/src/app.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,22 @@ pub struct CalDAVLoginRequest {
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserPreferencesResponse {
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
pub calendar_view_mode: Option<String>,
pub calendar_theme: Option<String>,
pub calendar_colors: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthResponse {
pub token: String,
pub session_token: String,
pub username: String,
pub server_url: String,
pub preferences: UserPreferencesResponse,
}
#[derive(Debug, Deserialize)]
@@ -34,14 +45,58 @@ impl AuthService {
let base_url = option_env!("BACKEND_API_URL")
.unwrap_or("http://localhost:3000/api")
.to_string();
Self { base_url }
}
pub async fn login(&self, request: CalDAVLoginRequest) -> Result<AuthResponse, String> {
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,
@@ -49,9 +104,9 @@ impl AuthService {
body: &T,
) -> Result<R, String> {
let window = web_sys::window().ok_or("No global window exists")?;
let json_body = serde_json::to_string(body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
let json_body =
serde_json::to_string(body).map_err(|e| format!("JSON serialization failed: {}", e))?;
let opts = RequestInit::new();
opts.set_method("POST");
@@ -62,23 +117,27 @@ impl AuthService {
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request.headers().set("Content-Type", "application/json")
request
.headers()
.set("Content-Type", "application/json")
.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()
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response cast failed: {:?}", e))?;
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 = 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")?;
let text_string = text.as_string().ok_or("Response text is not a string")?;
if resp.ok() {
serde_json::from_str::<R>(&text_string)
@@ -92,4 +151,4 @@ impl AuthService {
}
}
}
}
}

View File

@@ -0,0 +1,604 @@
use crate::components::{
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView,
};
use crate::models::ical::VEvent;
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
use chrono::{Datelike, Duration, Local, NaiveDate};
use gloo_storage::{LocalStorage, Storage};
use std::collections::HashMap;
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
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)>>,
#[prop_or_default]
pub view: ViewMode,
#[prop_or_default]
pub on_create_event_request: Option<Callback<EventCreationData>>,
#[prop_or_default]
pub on_event_update_request: Option<
Callback<(
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)>,
>,
#[prop_or_default]
pub context_menus_open: bool,
}
#[function_component]
pub fn Calendar(props: &CalendarProps) -> Html {
let today = Local::now().date_naive();
// Event management state
let events = use_state(|| HashMap::<NaiveDate, Vec<VEvent>>::new());
let loading = use_state(|| true);
let error = use_state(|| None::<String>);
let refreshing_event_uid = use_state(|| None::<String>);
// Track the currently selected date (the actual day the user has selected)
let selected_date = use_state(|| {
// Try to load saved selected date from localStorage
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_selected_date") {
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
saved_date
} else {
today
}
} else {
// Check for old key for backward compatibility
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_current_month") {
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
saved_date
} else {
today
}
} else {
today
}
}
});
// Track the display date (what to show in the view)
let current_date = use_state(|| match props.view {
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
ViewMode::Week => *selected_date,
});
let selected_event = use_state(|| None::<VEvent>);
// State for create event modal
let show_create_modal = use_state(|| false);
let create_event_data =
use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
// State for time increment snapping (15 or 30 minutes)
let time_increment = use_state(|| {
// Try to load saved time increment from localStorage
if let Ok(saved_increment) = LocalStorage::get::<u32>("calendar_time_increment") {
if saved_increment == 15 || saved_increment == 30 {
saved_increment
} else {
15
}
} else {
15
}
});
// Fetch events when current_date changes
{
let events = events.clone();
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, 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();
let loading = loading.clone();
let error = error.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) =
LocalStorage::get::<String>("caldav_credentials")
{
if let Ok(credentials) =
serde_json::from_str::<serde_json::Value>(&credentials_str)
{
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
let current_year = date.year();
let current_month = date.month();
match calendar_service
.fetch_events_for_month_vevent(
&token,
&password,
current_year,
current_month,
)
.await
{
Ok(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);
}
Err(err) => {
error.set(Some(format!("Failed to load events: {}", err)));
loading.set(false);
}
}
});
} else {
loading.set(false);
error.set(Some("No authentication token found".to_string()));
}
|| ()
});
}
// Handle event click to refresh individual events
let on_event_click = {
let events = events.clone();
let refreshing_event_uid = refreshing_event_uid.clone();
Callback::from(move |event: VEvent| {
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
if let Some(token) = auth_token {
let events = events.clone();
let refreshing_event_uid = refreshing_event_uid.clone();
let uid = event.uid.clone();
refreshing_event_uid.set(Some(uid.clone()));
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) =
LocalStorage::get::<String>("caldav_credentials")
{
if let Ok(credentials) =
serde_json::from_str::<serde_json::Value>(&credentials_str)
{
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service
.refresh_event(&token, &password, &uid)
.await
{
Ok(Some(refreshed_event)) => {
let refreshed_vevent = refreshed_event;
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
if refreshed_vevent.rrule.is_some() {
let new_occurrences =
CalendarService::expand_recurring_events(vec![
refreshed_vevent.clone(),
]);
for occurrence in new_occurrences {
let date = occurrence.get_date();
updated_events
.entry(date)
.or_insert_with(Vec::new)
.push(occurrence);
}
} else {
let date = refreshed_vevent.get_date();
updated_events
.entry(date)
.or_insert_with(Vec::new)
.push(refreshed_vevent);
}
events.set(updated_events);
}
Ok(None) => {
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
events.set(updated_events);
}
Err(_err) => {}
}
refreshing_event_uid.set(None);
});
}
})
};
// Handle view mode changes - adjust current_date format when switching between month/week
{
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
use_effect_with(view, move |view_mode| {
let selected = *selected_date;
let new_display_date = match view_mode {
ViewMode::Month => selected.with_day(1).unwrap_or(selected),
ViewMode::Week => selected, // Show the week containing the selected date
};
current_date.set(new_display_date);
|| {}
});
}
let on_prev = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
Callback::from(move |_: MouseEvent| {
let (new_selected, new_display) = match view {
ViewMode::Month => {
// Go to previous month, select the 1st day
let prev_month = *current_date - Duration::days(1);
let first_of_prev = prev_month.with_day(1).unwrap();
(first_of_prev, first_of_prev)
}
ViewMode::Week => {
// Go to previous week
let prev_week = *selected_date - Duration::weeks(1);
(prev_week, prev_week)
}
};
selected_date.set(new_selected);
current_date.set(new_display);
let _ = LocalStorage::set(
"calendar_selected_date",
new_selected.format("%Y-%m-%d").to_string(),
);
})
};
let on_next = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
Callback::from(move |_: MouseEvent| {
let (new_selected, new_display) = match view {
ViewMode::Month => {
// Go to next month, select the 1st day
let next_month = if current_date.month() == 12 {
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
.unwrap()
};
(next_month, next_month)
}
ViewMode::Week => {
// Go to next week
let next_week = *selected_date + Duration::weeks(1);
(next_week, next_week)
}
};
selected_date.set(new_selected);
current_date.set(new_display);
let _ = LocalStorage::set(
"calendar_selected_date",
new_selected.format("%Y-%m-%d").to_string(),
);
})
};
let on_today = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
Callback::from(move |_| {
let today = Local::now().date_naive();
let (new_selected, new_display) = match view {
ViewMode::Month => {
let first_of_today = today.with_day(1).unwrap();
(today, first_of_today) // Select today, but display the month
}
ViewMode::Week => (today, today), // Select and display today
};
selected_date.set(new_selected);
current_date.set(new_display);
let _ = LocalStorage::set(
"calendar_selected_date",
new_selected.format("%Y-%m-%d").to_string(),
);
})
};
// Handle time increment toggle
let on_time_increment_toggle = {
let time_increment = time_increment.clone();
Callback::from(move |_: MouseEvent| {
let current = *time_increment;
let next = if current == 15 { 30 } else { 15 };
time_increment.set(next);
let _ = LocalStorage::set("calendar_time_increment", next);
})
};
// 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();
let create_event_data = create_event_data.clone();
Callback::from(
move |(_date, start_datetime, end_datetime): (
NaiveDate,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
)| {
// For drag-to-create, we don't need the temporary event approach
// Instead, just pass the local times directly via initial_time props
create_event_data.set(Some((
start_datetime.date(),
start_datetime.time(),
end_datetime.time(),
)));
show_create_modal.set(true);
},
)
};
// Handle drag-to-move event
let on_event_update = {
let on_event_update_request = props.on_event_update_request.clone();
Callback::from(
move |(
event,
new_start,
new_end,
preserve_rrule,
until_date,
update_scope,
occurrence_date,
): (
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)| {
if let Some(callback) = &on_event_update_request {
callback.emit((
event,
new_start,
new_end,
preserve_rrule,
until_date,
update_scope,
occurrence_date,
));
}
},
)
};
html! {
<div class={classes!("calendar", match props.view { ViewMode::Week => Some("week-view"), _ => None })}>
<CalendarHeader
current_date={*current_date}
view_mode={props.view.clone()}
on_prev={on_prev}
on_next={on_next}
on_today={on_today}
time_increment={Some(*time_increment)}
on_time_increment_toggle={Some(on_time_increment_toggle)}
on_print={Some(on_print)}
/>
{
if *loading {
html! {
<div class="calendar-loading">
<p>{"Loading calendar events..."}</p>
</div>
}
} else if let Some(err) = (*error).clone() {
html! {
<div class="calendar-error">
<p>{format!("Error: {}", err)}</p>
</div>
}
} else {
match props.view {
ViewMode::Month => {
let on_day_select = {
let selected_date = selected_date.clone();
Callback::from(move |date: NaiveDate| {
selected_date.set(date);
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
})
};
html! {
<MonthView
current_month={*current_date}
today={today}
events={(*events).clone()}
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)}
on_day_select={Some(on_day_select)}
/>
}
},
ViewMode::Week => html! {
<WeekView
current_date={*current_date}
today={today}
events={(*events).clone()}
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)}
on_create_event_request={props.on_create_event_request.clone()}
on_event_update={Some(on_event_update)}
context_menus_open={props.context_menus_open}
time_increment={*time_increment}
/>
},
}
}
}
// Event details modal
<EventModal
event={(*selected_event).clone()}
on_close={{
let selected_event_clone = selected_event.clone();
Callback::from(move |_| {
selected_event_clone.set(None);
})
}}
/>
// Create event modal
<CreateEventModal
is_open={*show_create_modal}
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
event_to_edit={None}
available_calendars={props.user_info.as_ref().map(|info| info.calendars.clone()).unwrap_or_default()}
initial_start_time={create_event_data.as_ref().map(|(_, start_time, _)| *start_time)}
initial_end_time={create_event_data.as_ref().map(|(_, _, end_time)| *end_time)}
on_close={{
let show_create_modal = show_create_modal.clone();
let create_event_data = create_event_data.clone();
Callback::from(move |_| {
show_create_modal.set(false);
create_event_data.set(None);
})
}}
on_create={{
let show_create_modal = show_create_modal.clone();
let create_event_data = create_event_data.clone();
let on_create_event_request = props.on_create_event_request.clone();
Callback::from(move |event_data: EventCreationData| {
show_create_modal.set(false);
create_event_data.set(None);
// Emit the create event request to parent
if let Some(callback) = &on_create_event_request {
callback.emit(event_data);
}
})
}}
/>
// 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

@@ -0,0 +1,83 @@
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CalendarContextMenuProps {
pub is_open: bool,
pub x: i32,
pub y: i32,
pub on_close: Callback<()>,
pub on_create_event: Callback<MouseEvent>,
}
#[function_component(CalendarContextMenu)]
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
let menu_ref = use_node_ref();
if !props.is_open {
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;",
x, y
);
let on_create_event_click = {
let on_create_event = props.on_create_event.clone();
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
on_create_event.emit(e);
on_close.emit(());
})
};
html! {
<div
ref={menu_ref}
class="context-menu"
style={style}
>
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
<span class="context-menu-icon">{"+"}</span>
{"Create Event"}
</div>
</div>
}
}

View File

@@ -0,0 +1,81 @@
use crate::components::ViewMode;
use chrono::{Datelike, NaiveDate};
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CalendarHeaderProps {
pub current_date: NaiveDate,
pub view_mode: ViewMode,
pub on_prev: Callback<MouseEvent>,
pub on_next: Callback<MouseEvent>,
pub on_today: Callback<MouseEvent>,
#[prop_or_default]
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)]
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
let title = format!(
"{} {}",
get_month_name(props.current_date.month()),
props.current_date.year()
);
html! {
<div class="calendar-header">
<div class="header-left">
<button class="nav-button" onclick={props.on_prev.clone()}>{""}</button>
{
if let (Some(increment), Some(callback)) = (props.time_increment, &props.on_time_increment_toggle) {
html! {
<button class="time-increment-button" onclick={callback.clone()}>
{format!("{}", increment)}
</button>
}
} else {
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">
<button class="today-button" onclick={props.on_today.clone()}>{"Today"}</button>
<button class="nav-button" onclick={props.on_next.clone()}>{""}</button>
</div>
</div>
}
}
fn get_month_name(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "Invalid",
}
}

View File

@@ -0,0 +1,91 @@
use crate::services::calendar_service::CalendarInfo;
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CalendarListItemProps {
pub calendar: CalendarInfo,
pub color_picker_open: bool,
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
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)]
pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
let on_color_click = {
let cal_path = props.calendar.path.clone();
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
on_color_picker_toggle.emit(cal_path.clone());
})
};
let on_context_menu = {
let cal_path = props.calendar.path.clone();
let on_context_menu = props.on_context_menu.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
on_context_menu.emit((e, cal_path.clone()));
})
};
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}>
<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 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>
}
} else {
html! {}
}
}
</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

@@ -0,0 +1,84 @@
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct ContextMenuProps {
pub is_open: bool,
pub x: i32,
pub y: i32,
pub on_delete: Callback<MouseEvent>,
pub on_close: Callback<()>,
}
#[function_component(ContextMenu)]
pub fn context_menu(props: &ContextMenuProps) -> Html {
let menu_ref = use_node_ref();
// Close menu when clicking outside (handled by parent component)
if !props.is_open {
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;",
x, y
);
let on_delete_click = {
let on_delete = props.on_delete.clone();
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
on_delete.emit(e);
on_close.emit(());
})
};
html! {
<div
ref={menu_ref}
class="context-menu"
style={style}
>
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
{"Delete Calendar"}
</div>
</div>
}
}

View File

@@ -39,30 +39,32 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
let error_message = error_message.clone();
let is_creating = is_creating.clone();
let on_create = props.on_create.clone();
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let name = (*calendar_name).trim();
if name.is_empty() {
error_message.set(Some("Calendar name is required".to_string()));
return;
}
if name.len() > 100 {
error_message.set(Some("Calendar name too long (max 100 characters)".to_string()));
error_message.set(Some(
"Calendar name too long (max 100 characters)".to_string(),
));
return;
}
error_message.set(None);
is_creating.set(true);
let desc = if (*description).trim().is_empty() {
None
} else {
Some((*description).clone())
};
on_create.emit((name.to_string(), desc, (*selected_color).clone()));
})
};
@@ -90,7 +92,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
{"×"}
</button>
</div>
<form class="modal-body" onsubmit={on_submit}>
{
if let Some(ref error) = *error_message {
@@ -103,10 +105,10 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
html! {}
}
}
<div class="form-group">
<label for="calendar-name">{"Calendar Name *"}</label>
<input
<input
id="calendar-name"
type="text"
value={(*calendar_name).clone()}
@@ -116,7 +118,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
disabled={*is_creating}
/>
</div>
<div class="form-group">
<label for="calendar-description">{"Description"}</label>
<textarea
@@ -128,7 +130,7 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
disabled={*is_creating}
/>
</div>
<div class="form-group">
<label>{"Calendar Color"}</label>
<div class="color-grid">
@@ -143,13 +145,13 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
selected_color.set(Some(color.clone()));
})
};
let class_name = if is_selected {
"color-option selected"
} else {
"color-option"
let class_name = if is_selected {
"color-option selected"
} else {
"color-option"
};
html! {
<button
key={index}
@@ -165,18 +167,18 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
</div>
<p class="color-help-text">{"Optional: Choose a color for your calendar"}</p>
</div>
<div class="modal-actions">
<button
type="button"
<button
type="button"
class="cancel-button"
onclick={props.on_close.reform(|_| ())}
disabled={*is_creating}
>
{"Cancel"}
</button>
<button
type="submit"
<button
type="submit"
class="create-button"
disabled={*is_creating}
>
@@ -193,4 +195,4 @@ pub fn CreateCalendarModal(props: &CreateCalendarModalProps) -> Html {
</div>
</div>
}
}
}

View File

@@ -0,0 +1,455 @@
use crate::components::event_form::*;
use crate::components::EditAction;
use crate::models::ical::VEvent;
use crate::services::calendar_service::CalendarInfo;
use gloo_storage::{LocalStorage, Storage};
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct CreateEventModalProps {
pub is_open: bool,
pub on_close: Callback<()>,
pub on_create: Callback<EventCreationData>,
pub available_calendars: Vec<CalendarInfo>,
pub selected_date: Option<chrono::NaiveDate>,
pub initial_start_time: Option<chrono::NaiveTime>,
pub initial_end_time: Option<chrono::NaiveTime>,
#[prop_or_default]
pub event_to_edit: Option<VEvent>,
#[prop_or_default]
pub edit_scope: Option<EditAction>,
}
#[function_component(CreateEventModal)]
pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
let active_tab = use_state(|| ModalTab::default());
let event_data = use_state(|| EventCreationData::default());
// Initialize data when modal opens
{
let event_data = event_data.clone();
let is_open = props.is_open;
let event_to_edit = props.event_to_edit.clone();
let selected_date = props.selected_date;
let initial_start_time = props.initial_start_time;
let initial_end_time = props.initial_end_time;
let edit_scope = props.edit_scope.clone();
let available_calendars = props.available_calendars.clone();
use_effect_with(is_open, move |&is_open| {
if is_open {
let mut data = if let Some(event) = &event_to_edit {
// Convert VEvent to EventCreationData for editing
vevent_to_creation_data(event, &available_calendars)
} else if let Some(date) = selected_date {
let mut data = EventCreationData::default();
data.start_date = date;
data.end_date = date;
if let Some(start_time) = initial_start_time {
data.start_time = start_time;
}
if let Some(end_time) = initial_end_time {
data.end_time = end_time;
}
data
} else {
EventCreationData::default()
};
// Set default calendar
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
// For new events, try to use the last used calendar
if event_to_edit.is_none() {
// Try to get last used calendar from localStorage
if let Ok(last_used_calendar) = LocalStorage::get::<String>("last_used_calendar") {
// Check if the last used calendar is still available
if available_calendars.iter().any(|cal| cal.path == last_used_calendar) {
data.selected_calendar = Some(last_used_calendar);
} else {
// Fall back to first available calendar
data.selected_calendar = Some(available_calendars[0].path.clone());
}
} else {
// No last used calendar, use first available
data.selected_calendar = Some(available_calendars[0].path.clone());
}
} else {
// For editing existing events, keep the current calendar as default
data.selected_calendar = Some(available_calendars[0].path.clone());
}
}
// Set edit scope if provided
if let Some(scope) = &edit_scope {
data.edit_scope = Some(scope.clone());
}
event_data.set(data);
}
|| ()
});
}
if !props.is_open {
return html! {};
}
let on_backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
if e.target() == e.current_target() {
on_close.emit(());
}
})
};
let switch_to_tab = {
let active_tab = active_tab.clone();
Callback::from(move |tab: ModalTab| {
active_tab.set(tab);
})
};
let on_save = {
let event_data = event_data.clone();
let on_create = props.on_create.clone();
let event_to_edit = props.event_to_edit.clone();
Callback::from(move |_: MouseEvent| {
let mut data = (*event_data).clone();
// If we're editing an existing event, mark it as an update operation
if let Some(ref original_event) = event_to_edit {
// Set the original UID so the backend knows to update instead of create
data.original_uid = Some(original_event.uid.clone());
}
on_create.emit(data);
})
};
let on_close = props.on_close.clone();
let on_close_header = on_close.clone();
let tab_props = TabProps {
data: event_data.clone(),
available_calendars: props.available_calendars.clone(),
};
html! {
<div class="modal-backdrop" onclick={on_backdrop_click}>
<div class="modal-content create-event-modal">
<div class="modal-header">
<h3>
{if props.event_to_edit.is_some() { "Edit Event" } else { "Create Event" }}
</h3>
<button class="modal-close" onclick={Callback::from(move |_| on_close_header.emit(()))}>
{"×"}
</button>
</div>
<div class="modal-tabs">
<div class="tab-navigation">
<button
class={if *active_tab == ModalTab::BasicDetails { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::BasicDetails))
}}
>
{"Basic"}
</button>
<button
class={if *active_tab == ModalTab::Advanced { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Advanced))
}}
>
{"Advanced"}
</button>
<button
class={if *active_tab == ModalTab::People { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::People))
}}
>
{"People"}
</button>
<button
class={if *active_tab == ModalTab::Categories { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Categories))
}}
>
{"Categories"}
</button>
<button
class={if *active_tab == ModalTab::Location { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Location))
}}
>
{"Location"}
</button>
<button
class={if *active_tab == ModalTab::Reminders { "tab-button active" } else { "tab-button" }}
onclick={{
let switch_to_tab = switch_to_tab.clone();
Callback::from(move |_| switch_to_tab.emit(ModalTab::Reminders))
}}
>
{"Reminders"}
</button>
</div>
<div class="modal-body">
<div class="tab-content">
{
match *active_tab {
ModalTab::BasicDetails => html! { <BasicDetailsTab ..tab_props /> },
ModalTab::Advanced => html! { <AdvancedTab ..tab_props /> },
ModalTab::People => html! { <PeopleTab ..tab_props /> },
ModalTab::Categories => html! { <CategoriesTab ..tab_props /> },
ModalTab::Location => html! { <LocationTab ..tab_props /> },
ModalTab::Reminders => html! { <RemindersTab ..tab_props /> },
}
}
</div>
</div>
</div>
<div class="modal-footer">
<div class="modal-actions">
<button class="btn btn-secondary" onclick={Callback::from(move |_| on_close.emit(()))}>
{"Cancel"}
</button>
<button class="btn btn-primary" onclick={on_save}>
{if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }}
</button>
</div>
</div>
</div>
</div>
}
}
// Convert VEvent to EventCreationData for editing
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
use chrono::Local;
// Convert start datetime from UTC to local
let start_local = event.dtstart.with_timezone(&Local).naive_local();
let end_local = if let Some(dtend) = event.dtend {
dtend.with_timezone(&Local).naive_local()
} else {
// Default to 1 hour after start if no end time
start_local + chrono::Duration::hours(1)
};
EventCreationData {
// Basic event info
title: event.summary.clone().unwrap_or_default(),
description: event.description.clone().unwrap_or_default(),
location: event.location.clone().unwrap_or_default(),
all_day: event.all_day,
// Timing
start_date: start_local.date(),
end_date: end_local.date(),
start_time: start_local.time(),
end_time: end_local.time(),
// Classification
status: match event.status {
Some(crate::models::ical::EventStatus::Tentative) => EventStatus::Tentative,
Some(crate::models::ical::EventStatus::Confirmed) => EventStatus::Confirmed,
Some(crate::models::ical::EventStatus::Cancelled) => EventStatus::Cancelled,
None => EventStatus::Confirmed,
},
class: match event.class {
Some(crate::models::ical::EventClass::Public) => EventClass::Public,
Some(crate::models::ical::EventClass::Private) => EventClass::Private,
Some(crate::models::ical::EventClass::Confidential) => EventClass::Confidential,
None => EventClass::Public,
},
priority: event.priority,
// People
organizer: event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
attendees: event.attendees.iter()
.map(|a| a.cal_address.clone())
.collect::<Vec<_>>()
.join(","),
// Categorization
categories: event.categories.join(","),
// Reminders - TODO: Parse alarm from VEvent if needed
reminder: ReminderType::None,
// Recurrence - Parse RRULE if present
recurrence: if let Some(ref rrule_str) = event.rrule {
parse_rrule_frequency(rrule_str)
} else {
RecurrenceType::None
},
recurrence_interval: if let Some(ref rrule_str) = event.rrule {
parse_rrule_interval(rrule_str)
} else {
1
},
recurrence_until: if let Some(ref rrule_str) = event.rrule {
parse_rrule_until(rrule_str)
} else {
None
},
recurrence_count: if let Some(ref rrule_str) = event.rrule {
parse_rrule_count(rrule_str)
} else {
None
},
recurrence_days: if let Some(ref rrule_str) = event.rrule {
parse_rrule_days(rrule_str)
} else {
vec![false; 7]
},
// Advanced recurrence
monthly_by_day: None,
monthly_by_monthday: None,
yearly_by_month: vec![false; 12],
// Calendar selection - try to find the calendar this event belongs to
selected_calendar: if let Some(ref calendar_path) = event.calendar_path {
if available_calendars.iter().any(|cal| cal.path == *calendar_path) {
Some(calendar_path.clone())
} else if let Some(first_calendar) = available_calendars.first() {
Some(first_calendar.path.clone())
} else {
None
}
} else if let Some(first_calendar) = available_calendars.first() {
Some(first_calendar.path.clone())
} else {
None
},
// Edit tracking
edit_scope: None, // Will be set by the modal after creation
changed_fields: vec![],
original_uid: Some(event.uid.clone()), // Preserve original UID for editing
occurrence_date: Some(start_local.date()), // The occurrence date being edited
}
}
// Parse RRULE frequency component
fn parse_rrule_frequency(rrule: &str) -> RecurrenceType {
if rrule.contains("FREQ=DAILY") {
RecurrenceType::Daily
} else if rrule.contains("FREQ=WEEKLY") {
RecurrenceType::Weekly
} else if rrule.contains("FREQ=MONTHLY") {
RecurrenceType::Monthly
} else if rrule.contains("FREQ=YEARLY") {
RecurrenceType::Yearly
} else {
RecurrenceType::None
}
}
// Parse RRULE interval component
fn parse_rrule_interval(rrule: &str) -> u32 {
if let Some(start) = rrule.find("INTERVAL=") {
let interval_part = &rrule[start + 9..];
if let Some(end) = interval_part.find(';') {
interval_part[..end].parse().unwrap_or(1)
} else {
interval_part.parse().unwrap_or(1)
}
} else {
1
}
}
// Parse RRULE count component
fn parse_rrule_count(rrule: &str) -> Option<u32> {
if let Some(start) = rrule.find("COUNT=") {
let count_part = &rrule[start + 6..];
if let Some(end) = count_part.find(';') {
count_part[..end].parse().ok()
} else {
count_part.parse().ok()
}
} else {
None
}
}
// Parse RRULE until component
fn parse_rrule_until(rrule: &str) -> Option<chrono::NaiveDate> {
if let Some(start) = rrule.find("UNTIL=") {
let until_part = &rrule[start + 6..];
let until_str = if let Some(end) = until_part.find(';') {
&until_part[..end]
} else {
until_part
};
// UNTIL can be in format YYYYMMDD or YYYYMMDDTHHMMSSZ
let date_part = if until_str.len() >= 8 {
&until_str[..8]
} else {
until_str
};
// Parse YYYYMMDD format
if date_part.len() == 8 {
if let (Ok(year), Ok(month), Ok(day)) = (
date_part[0..4].parse::<i32>(),
date_part[4..6].parse::<u32>(),
date_part[6..8].parse::<u32>(),
) {
chrono::NaiveDate::from_ymd_opt(year, month, day)
} else {
None
}
} else {
None
}
} else {
None
}
}
// Parse RRULE BYDAY component for weekly recurrence
fn parse_rrule_days(rrule: &str) -> Vec<bool> {
let mut days = vec![false; 7]; // [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
if let Some(start) = rrule.find("BYDAY=") {
let byday_part = &rrule[start + 6..];
let byday_str = if let Some(end) = byday_part.find(';') {
&byday_part[..end]
} else {
byday_part
};
// Parse comma-separated day codes: SU,MO,TU,WE,TH,FR,SA
for day_code in byday_str.split(',') {
match day_code.trim() {
"SU" => days[0] = true, // Sunday
"MO" => days[1] = true, // Monday
"TU" => days[2] = true, // Tuesday
"WE" => days[3] = true, // Wednesday
"TH" => days[4] = true, // Thursday
"FR" => days[5] = true, // Friday
"SA" => days[6] = true, // Saturday
_ => {} // Ignore unknown day codes
}
}
}
days
}

View File

@@ -0,0 +1,202 @@
use crate::models::ical::VEvent;
use web_sys::MouseEvent;
use yew::prelude::*;
#[derive(Clone, PartialEq, Debug)]
pub enum DeleteAction {
DeleteThis,
DeleteFollowing,
DeleteSeries,
}
#[derive(Clone, PartialEq, Debug)]
pub enum EditAction {
EditThis,
EditFuture,
EditAll,
}
#[derive(Properties, PartialEq)]
pub struct EventContextMenuProps {
pub is_open: bool,
pub x: i32,
pub y: i32,
pub event: Option<VEvent>,
pub on_edit: Callback<EditAction>,
pub on_delete: Callback<DeleteAction>,
pub on_view_details: Callback<VEvent>,
pub on_close: Callback<()>,
}
#[function_component(EventContextMenu)]
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
let menu_ref = use_node_ref();
if !props.is_open {
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;",
x, y
);
// Check if the event is recurring
let is_recurring = props
.event
.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();
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| {
on_edit.emit(action.clone());
on_close.emit(());
})
};
let create_delete_callback = |action: DeleteAction| {
let on_delete = props.on_delete.clone();
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| {
on_delete.emit(action.clone());
on_close.emit(());
})
};
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}
class="context-menu"
style={style}
>
{
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)}>
{"Edit This Event"}
</div>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditFuture)}>
{"Edit This and Future Events"}
</div>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditAll)}>
{"Edit All Events in Series"}
</div>
</>
}
} else {
// Regular single events - show edit option
html! {
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
{"Edit Event"}
</div>
}
}
}
{
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 Event"}
</div>
}
}
} else {
// 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,262 @@
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
Option<String>, // calendar_path
Option<u32>, // recurrence_count
Option<String>, // recurrence_until
) {
use chrono::{Local, TimeZone};
// Convert local date/time to UTC for backend
let (utc_start_date, utc_start_time, utc_end_date, utc_end_time) = if self.all_day {
// For all-day events, just use the dates as-is (no time conversion needed)
(
self.start_date.format("%Y-%m-%d").to_string(),
self.start_time.format("%H:%M").to_string(),
self.end_date.format("%Y-%m-%d").to_string(),
self.end_time.format("%H:%M").to_string(),
)
} else {
// Convert local date/time to UTC
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single();
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single();
if let (Some(start_dt), Some(end_dt)) = (start_local, end_local) {
let start_utc = start_dt.with_timezone(&chrono::Utc);
let end_utc = end_dt.with_timezone(&chrono::Utc);
(
start_utc.format("%Y-%m-%d").to_string(),
start_utc.format("%H:%M").to_string(),
end_utc.format("%Y-%m-%d").to_string(),
end_utc.format("%H:%M").to_string(),
)
} else {
// Fallback if timezone conversion fails - use local time as-is
web_sys::console::warn_1(&"⚠️ Failed to convert local time to UTC, using local time".into());
(
self.start_date.format("%Y-%m-%d").to_string(),
self.start_time.format("%H:%M").to_string(),
self.end_date.format("%Y-%m-%d").to_string(),
self.end_time.format("%H:%M").to_string(),
)
}
};
(
self.title.clone(),
self.description.clone(),
utc_start_date,
utc_start_time,
utc_end_date,
utc_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.selected_calendar.clone(),
self.recurrence_count,
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
)
}
}
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,10 +1,10 @@
use yew::prelude::*;
use crate::models::ical::VEvent;
use chrono::{DateTime, Utc};
use crate::services::{CalendarEvent, EventReminder, ReminderAction};
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct EventModalProps {
pub event: Option<CalendarEvent>,
pub event: Option<VEvent>,
pub on_close: Callback<()>,
}
@@ -16,7 +16,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
on_close.emit(());
})
};
let backdrop_click = {
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
@@ -39,7 +39,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
<strong>{"Title:"}</strong>
<span>{event.get_title()}</span>
</div>
{
if let Some(ref description) = event.description {
html! {
@@ -52,30 +52,30 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
<div class="event-detail">
<strong>{"Start:"}</strong>
<span>{format_datetime(&event.start, event.all_day)}</span>
<span>{format_datetime(&event.dtstart, event.all_day)}</span>
</div>
{
if let Some(ref end) = event.end {
if let Some(ref end) = event.dtend {
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 {
html! {}
}
}
<div class="event-detail">
<strong>{"All Day:"}</strong>
<span>{if event.all_day { "Yes" } else { "No" }}</span>
</div>
{
if let Some(ref location) = event.location {
html! {
@@ -88,48 +88,48 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
<div class="event-detail">
<strong>{"Status:"}</strong>
<span>{event.get_status_display()}</span>
</div>
<div class="event-detail">
<strong>{"Privacy:"}</strong>
<span>{event.get_class_display()}</span>
</div>
<div class="event-detail">
<strong>{"Priority:"}</strong>
<span>{event.get_priority_display()}</span>
</div>
{
if let Some(ref organizer) = event.organizer {
html! {
<div class="event-detail">
<strong>{"Organizer:"}</strong>
<span>{organizer}</span>
<span>{organizer.cal_address.clone()}</span>
</div>
}
} else {
html! {}
}
}
{
if !event.attendees.is_empty() {
html! {
<div class="event-detail">
<strong>{"Attendees:"}</strong>
<span>{event.attendees.join(", ")}</span>
<span>{event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(", ")}</span>
</div>
}
} else {
html! {}
}
}
{
if !event.categories.is_empty() {
html! {
@@ -142,9 +142,9 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
{
if let Some(ref recurrence) = event.recurrence_rule {
if let Some(ref recurrence) = event.rrule {
html! {
<div class="event-detail">
<strong>{"Repeats:"}</strong>
@@ -160,13 +160,13 @@ pub fn EventModal(props: &EventModalProps) -> Html {
}
}
}
{
if !event.reminders.is_empty() {
if !event.alarms.is_empty() {
html! {
<div class="event-detail">
<strong>{"Reminders:"}</strong>
<span>{format_reminders(&event.reminders)}</span>
<span>{"Alarms configured"}</span> /* TODO: Convert VAlarm to displayable format */
</div>
}
} else {
@@ -178,7 +178,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
}
}
}
{
if let Some(ref created) = event.created {
html! {
@@ -191,7 +191,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
html! {}
}
}
{
if let Some(ref modified) = event.last_modified {
html! {
@@ -221,6 +221,17 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
}
}
fn format_datetime_end(dt: &DateTime<Utc>, 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") {
@@ -236,54 +247,3 @@ fn format_recurrence_rule(rrule: &str) -> String {
format!("Custom ({})", rrule)
}
}
fn format_reminders(reminders: &[EventReminder]) -> String {
if reminders.is_empty() {
return "None".to_string();
}
let formatted_reminders: Vec<String> = reminders
.iter()
.map(|reminder| {
let time_text = if reminder.minutes_before == 0 {
"At event time".to_string()
} else if reminder.minutes_before < 60 {
format!("{} minutes before", reminder.minutes_before)
} else if reminder.minutes_before == 60 {
"1 hour before".to_string()
} else if reminder.minutes_before % 60 == 0 {
format!("{} hours before", reminder.minutes_before / 60)
} else if reminder.minutes_before < 1440 {
let hours = reminder.minutes_before / 60;
let minutes = reminder.minutes_before % 60;
format!("{}h {}m before", hours, minutes)
} else if reminder.minutes_before == 1440 {
"1 day before".to_string()
} else if reminder.minutes_before % 1440 == 0 {
format!("{} days before", reminder.minutes_before / 1440)
} else {
let days = reminder.minutes_before / 1440;
let remaining_minutes = reminder.minutes_before % 1440;
let hours = remaining_minutes / 60;
let minutes = remaining_minutes % 60;
if hours > 0 {
format!("{}d {}h before", days, hours)
} else if minutes > 0 {
format!("{}d {}m before", days, minutes)
} else {
format!("{} days before", days)
}
};
let action_text = match reminder.action {
ReminderAction::Display => "notification",
ReminderAction::Email => "email",
ReminderAction::Audio => "sound",
};
format!("{} ({})", time_text, action_text)
})
.collect();
formatted_reminders.join(", ")
}

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

@@ -1,6 +1,6 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use gloo_storage::{LocalStorage, Storage};
use web_sys::HtmlInputElement;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct LoginProps {
@@ -9,11 +9,20 @@ pub struct LoginProps {
#[function_component]
pub fn Login(props: &LoginProps) -> Html {
let server_url = use_state(String::new);
let username = use_state(String::new);
// Load remembered values from LocalStorage on mount
let server_url = use_state(|| {
LocalStorage::get::<String>("remembered_server_url").unwrap_or_default()
});
let username = use_state(|| {
LocalStorage::get::<String>("remembered_username").unwrap_or_default()
});
let password = use_state(String::new);
let error_message = use_state(|| Option::<String>::None);
let is_loading = use_state(|| false);
// Remember checkboxes state - default to checked
let remember_server = use_state(|| true);
let remember_username = use_state(|| true);
let server_url_ref = use_node_ref();
let username_ref = use_node_ref();
@@ -42,6 +51,38 @@ pub fn Login(props: &LoginProps) -> Html {
password.set(target.value());
})
};
let on_remember_server_change = {
let remember_server = remember_server.clone();
let server_url = server_url.clone();
Callback::from(move |e: Event| {
let target = e.target_unchecked_into::<HtmlInputElement>();
let checked = target.checked();
remember_server.set(checked);
if checked {
let _ = LocalStorage::set("remembered_server_url", (*server_url).clone());
} else {
let _ = LocalStorage::delete("remembered_server_url");
}
})
};
let on_remember_username_change = {
let remember_username = remember_username.clone();
let username = username.clone();
Callback::from(move |e: Event| {
let target = e.target_unchecked_into::<HtmlInputElement>();
let checked = target.checked();
remember_username.set(checked);
if checked {
let _ = LocalStorage::set("remembered_username", (*username).clone());
} else {
let _ = LocalStorage::delete("remembered_username");
}
})
};
let on_submit = {
let server_url = server_url.clone();
@@ -53,7 +94,7 @@ pub fn Login(props: &LoginProps) -> Html {
Callback::from(move |e: SubmitEvent| {
e.prevent_default();
let server_url = (*server_url).clone();
let username = (*username).clone();
let password = (*password).clone();
@@ -73,11 +114,18 @@ pub fn Login(props: &LoginProps) -> Html {
wasm_bindgen_futures::spawn_local(async move {
web_sys::console::log_1(&"🚀 Starting login process...".into());
match perform_login(server_url.clone(), username.clone(), password.clone()).await {
Ok((token, credentials)) => {
Ok((token, session_token, credentials, preferences)) => {
web_sys::console::log_1(&"✅ Login successful!".into());
// Store token and credentials in local storage
if let Err(_) = LocalStorage::set("auth_token", &token) {
error_message.set(Some("Failed to store authentication token".to_string()));
error_message
.set(Some("Failed to store authentication token".to_string()));
is_loading.set(false);
return;
}
if let Err(_) = LocalStorage::set("session_token", &session_token) {
error_message
.set(Some("Failed to store session token".to_string()));
is_loading.set(false);
return;
}
@@ -87,11 +135,20 @@ pub fn Login(props: &LoginProps) -> Html {
return;
}
// Store preferences from database
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
let _ = LocalStorage::set("user_preferences", &prefs_json);
}
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);
}
@@ -116,6 +173,15 @@ pub fn Login(props: &LoginProps) -> Html {
onchange={on_server_url_change}
disabled={*is_loading}
/>
<div class="remember-checkbox">
<input
type="checkbox"
id="remember_server"
checked={*remember_server}
onchange={on_remember_server_change}
/>
<label for="remember_server">{"Remember server"}</label>
</div>
</div>
<div class="form-group">
@@ -129,6 +195,15 @@ pub fn Login(props: &LoginProps) -> Html {
onchange={on_username_change}
disabled={*is_loading}
/>
<div class="remember-checkbox">
<input
type="checkbox"
id="remember_username"
checked={*remember_username}
onchange={on_remember_username_change}
/>
<label for="remember_username">{"Remember username"}</label>
</div>
</div>
<div class="form-group">
@@ -172,21 +247,25 @@ pub fn Login(props: &LoginProps) -> Html {
}
/// Perform login using the CalDAV auth service
async fn perform_login(server_url: String, username: String, password: String) -> Result<(String, String), String> {
async fn perform_login(
server_url: String,
username: String,
password: String,
) -> Result<(String, String, String, serde_json::Value), String> {
use crate::auth::{AuthService, CalDAVLoginRequest};
use serde_json;
web_sys::console::log_1(&format!("📡 Creating auth service and request...").into());
let auth_service = AuthService::new();
let request = CalDAVLoginRequest {
server_url: server_url.clone(),
username: username.clone(),
password: password.clone()
let request = CalDAVLoginRequest {
server_url: server_url.clone(),
username: username.clone(),
password: password.clone(),
};
web_sys::console::log_1(&format!("🚀 Sending login request to backend...").into());
match auth_service.login(request).await {
Ok(response) => {
web_sys::console::log_1(&format!("✅ Backend responded successfully").into());
@@ -196,11 +275,21 @@ async fn perform_login(server_url: String, username: String, password: String) -
"username": username,
"password": password
});
Ok((response.token, credentials.to_string()))
},
// Extract preferences as JSON
let preferences = serde_json::json!({
"calendar_selected_date": response.preferences.calendar_selected_date,
"calendar_time_increment": response.preferences.calendar_time_increment,
"calendar_view_mode": response.preferences.calendar_view_mode,
"calendar_theme": response.preferences.calendar_theme,
"calendar_colors": response.preferences.calendar_colors,
});
Ok((response.token, response.session_token, credentials.to_string(), preferences))
}
Err(err) => {
web_sys::console::log_1(&format!("❌ Backend error: {}", err).into());
Err(err)
},
}
}
}
}

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

@@ -0,0 +1,40 @@
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;
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_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};
pub use week_view::WeekView;

View File

@@ -0,0 +1,303 @@
use crate::models::ical::VEvent;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use chrono::{Datelike, NaiveDate, Weekday};
use std::collections::HashMap;
use wasm_bindgen::{prelude::*, JsCast};
use web_sys::window;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct MonthViewProps {
pub current_month: NaiveDate,
pub today: NaiveDate,
pub events: HashMap<NaiveDate, Vec<VEvent>>,
pub on_event_click: Callback<VEvent>,
#[prop_or_default]
pub refreshing_event_uid: Option<String>,
#[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)>>,
#[prop_or_default]
pub selected_date: Option<NaiveDate>,
#[prop_or_default]
pub on_day_select: Option<Callback<NaiveDate>>,
}
#[function_component(MonthView)]
pub fn month_view(props: &MonthViewProps) -> Html {
let max_events_per_day = use_state(|| 4); // Default to 4 events max
let first_day_of_month = props.current_month.with_day(1).unwrap();
let days_in_month = get_days_in_month(props.current_month);
let first_weekday = first_day_of_month.weekday();
let days_from_prev_month = get_days_from_previous_month(props.current_month, first_weekday);
// Calculate maximum events that can fit based on available height
let calculate_max_events = {
let max_events_per_day = max_events_per_day.clone();
move || {
// Since we're using CSS Grid with equal row heights,
// we can estimate based on typical calendar dimensions
// Typical calendar height is around 600-800px for 6 rows
// Each row gets ~100-133px, minus day number and padding leaves ~70-100px
// Each event is ~18px, so we can fit ~3-4 events + "+n more" indicator
max_events_per_day.set(3);
}
};
// Setup resize handler and initial calculation
{
let calculate_max_events = calculate_max_events.clone();
use_effect_with((), move |_| {
let calculate_max_events_clone = calculate_max_events.clone();
// Initial calculation with a slight delay to ensure DOM is ready
if let Some(window) = window() {
let timeout_closure = Closure::wrap(Box::new(move || {
calculate_max_events_clone();
}) as Box<dyn FnMut()>);
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
timeout_closure.as_ref().unchecked_ref(),
100,
);
timeout_closure.forget();
}
// Setup resize listener
let resize_closure = Closure::wrap(Box::new(move || {
calculate_max_events();
}) as Box<dyn Fn()>);
if let Some(window) = window() {
let _ = window.add_event_listener_with_callback(
"resize",
resize_closure.as_ref().unchecked_ref(),
);
resize_closure.forget(); // Keep the closure alive
}
|| {}
});
}
// Helper function to get calendar color for an event
let get_event_color = |event: &VEvent| -> String {
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()
.find(|cal| &cal.path == calendar_path)
{
return calendar.color.clone();
}
}
}
"#3B82F6".to_string()
};
html! {
<div class="calendar-grid">
// Weekday headers
<div class="weekday-header">{"Sun"}</div>
<div class="weekday-header">{"Mon"}</div>
<div class="weekday-header">{"Tue"}</div>
<div class="weekday-header">{"Wed"}</div>
<div class="weekday-header">{"Thu"}</div>
<div class="weekday-header">{"Fri"}</div>
<div class="weekday-header">{"Sat"}</div>
// Days from previous month (grayed out)
{
days_from_prev_month.iter().map(|day| {
html! {
<div class="calendar-day prev-month">{*day}</div>
}
}).collect::<Html>()
}
// Days of the current month
{
(1..=days_in_month).map(|day| {
let date = props.current_month.with_day(day).unwrap();
let is_today = date == props.today;
let is_selected = props.selected_date == Some(date);
let day_events = props.events.get(&date).cloned().unwrap_or_default();
// Calculate visible events and overflow
let max_events = *max_events_per_day as usize;
let visible_events: Vec<_> = day_events.iter().take(max_events).collect();
let hidden_count = day_events.len().saturating_sub(max_events);
html! {
<div
class={classes!(
"calendar-day",
if is_today { Some("today") } else { None },
if is_selected { Some("selected") } else { None }
)}
onclick={
if let Some(callback) = &props.on_day_select {
let callback = callback.clone();
Some(Callback::from(move |e: web_sys::MouseEvent| {
e.stop_propagation(); // Prevent other handlers
callback.emit(date);
}))
} else {
None
}
}
oncontextmenu={
if let Some(callback) = &props.on_calendar_context_menu {
let callback = callback.clone();
Some(Callback::from(move |e: web_sys::MouseEvent| {
e.prevent_default();
callback.emit((e, date));
}))
} else {
None
}
}
>
<div class="day-number">{day}</div>
<div class="day-events">
{
visible_events.iter().map(|event| {
let event_color = get_event_color(event);
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
let onclick = {
let on_event_click = props.on_event_click.clone();
let event = (*event).clone();
Callback::from(move |_: web_sys::MouseEvent| {
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();
callback.emit((e, event.clone()));
}))
} else {
None
}
};
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}
>
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
</div>
}
}).collect::<Html>()
}
{
if hidden_count > 0 {
html! {
<div class="more-events-indicator">
{format!("+{} more", hidden_count)}
</div>
}
} else {
html! {}
}
}
</div>
</div>
}
}).collect::<Html>()
}
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
</div>
}
}
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
let total_slots = 42; // 6 rows x 7 days
let used_slots = prev_days_count + current_days_count as usize;
let remaining_slots = if used_slots < total_slots {
total_slots - used_slots
} else {
0
};
(1..=remaining_slots)
.map(|day| {
html! {
<div class="calendar-day next-month">{day}</div>
}
})
.collect::<Html>()
}
fn get_days_in_month(date: NaiveDate) -> u32 {
NaiveDate::from_ymd_opt(
if date.month() == 12 {
date.year() + 1
} else {
date.year()
},
if date.month() == 12 {
1
} else {
date.month() + 1
},
1,
)
.unwrap()
.pred_opt()
.unwrap()
.day()
}
fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday) -> Vec<u32> {
let days_before = match first_weekday {
Weekday::Sun => 0,
Weekday::Mon => 1,
Weekday::Tue => 2,
Weekday::Wed => 3,
Weekday::Thu => 4,
Weekday::Fri => 5,
Weekday::Sat => 6,
};
if days_before == 0 {
vec![]
} else {
let prev_month = if current_month.month() == 1 {
NaiveDate::from_ymd_opt(current_month.year() - 1, 12, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
};
let prev_month_days = get_days_in_month(prev_month);
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
}
}

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

@@ -0,0 +1,98 @@
use crate::models::ical::VEvent;
use chrono::NaiveDateTime;
use yew::prelude::*;
#[derive(Clone, PartialEq)]
pub enum RecurringEditAction {
ThisEvent,
FutureEvents,
AllEvents,
}
#[derive(Properties, PartialEq)]
pub struct RecurringEditModalProps {
pub show: bool,
pub event: VEvent,
pub new_start: NaiveDateTime,
pub new_end: NaiveDateTime,
pub on_choice: Callback<RecurringEditAction>,
pub on_cancel: Callback<()>,
}
#[function_component(RecurringEditModal)]
pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
if !props.show {
return html! {};
}
let event_title = props
.event
.summary
.as_ref()
.map(|s| s.as_str())
.unwrap_or("Untitled Event");
let on_this_event = {
let on_choice = props.on_choice.clone();
Callback::from(move |_| {
on_choice.emit(RecurringEditAction::ThisEvent);
})
};
let on_future_events = {
let on_choice = props.on_choice.clone();
Callback::from(move |_| {
on_choice.emit(RecurringEditAction::FutureEvents);
})
};
let on_all_events = {
let on_choice = props.on_choice.clone();
Callback::from(move |_| {
on_choice.emit(RecurringEditAction::AllEvents);
})
};
let on_cancel = {
let on_cancel = props.on_cancel.clone();
Callback::from(move |_| {
on_cancel.emit(());
})
};
html! {
<div class="modal-backdrop">
<div class="modal-content recurring-edit-modal">
<div class="modal-header">
<h3>{"Edit Recurring Event"}</h3>
</div>
<div class="modal-body">
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
<p>{"How would you like to apply this change?"}</p>
<div class="recurring-edit-options">
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
<div class="option-title">{"This event only"}</div>
<div class="option-description">{"Change only this occurrence"}</div>
</button>
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
<div class="option-title">{"This and future events"}</div>
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
</button>
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
<div class="option-title">{"All events in series"}</div>
<div class="option-description">{"Change all occurrences in the series"}</div>
</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick={on_cancel}>
{"Cancel"}
</button>
</div>
</div>
</div>
}
}

View File

@@ -0,0 +1,167 @@
use crate::components::{Login, ViewMode};
use crate::models::ical::VEvent;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use yew::prelude::*;
use yew_router::prelude::*;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Home,
#[at("/login")]
Login,
#[at("/calendar")]
Calendar,
}
#[derive(Properties, PartialEq)]
pub struct RouteHandlerProps {
pub auth_token: Option<String>,
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)>>,
#[prop_or_default]
pub view: ViewMode,
#[prop_or_default]
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
#[prop_or_default]
pub on_event_update_request: Option<
Callback<(
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)>,
>,
#[prop_or_default]
pub context_menus_open: bool,
}
#[function_component(RouteHandler)]
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();
let on_create_event_request = props.on_create_event_request.clone();
let on_event_update_request = props.on_event_update_request.clone();
let context_menus_open = props.context_menus_open;
html! {
<Switch<Route> render={move |route| {
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();
let on_create_event_request = on_create_event_request.clone();
let on_event_update_request = on_event_update_request.clone();
let context_menus_open = context_menus_open;
match route {
Route::Home => {
if auth_token.is_some() {
html! { <Redirect<Route> to={Route::Calendar}/> }
} else {
html! { <Redirect<Route> to={Route::Login}/> }
}
}
Route::Login => {
if auth_token.is_some() {
html! { <Redirect<Route> to={Route::Calendar}/> }
} else {
html! { <Login {on_login} /> }
}
}
Route::Calendar => {
if auth_token.is_some() {
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}
on_create_event_request={on_create_event_request}
on_event_update_request={on_event_update_request}
context_menus_open={context_menus_open}
/>
}
} else {
html! { <Redirect<Route> to={Route::Login}/> }
}
}
}
}} />
}
}
#[derive(Properties, PartialEq)]
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)>>,
#[prop_or_default]
pub view: ViewMode,
#[prop_or_default]
pub on_create_event_request: Option<Callback<crate::components::EventCreationData>>,
#[prop_or_default]
pub on_event_update_request: Option<
Callback<(
VEvent,
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<String>,
Option<String>,
)>,
>,
#[prop_or_default]
pub context_menus_open: bool,
}
use crate::components::Calendar;
#[function_component(CalendarView)]
pub fn calendar_view(props: &CalendarViewProps) -> Html {
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()}
on_create_event_request={props.on_create_event_request.clone()}
on_event_update_request={props.on_event_update_request.clone()}
context_menus_open={props.context_menus_open}
/>
</div>
}
}

View File

@@ -0,0 +1,439 @@
use crate::components::CalendarListItem;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use web_sys::HtmlSelectElement;
use yew::prelude::*;
use yew_router::prelude::*;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Home,
#[at("/login")]
Login,
#[at("/calendar")]
Calendar,
}
#[derive(Clone, PartialEq)]
pub enum ViewMode {
Month,
Week,
}
#[derive(Clone, PartialEq)]
pub enum Theme {
Default,
Ocean,
Forest,
Sunset,
Purple,
Dark,
Rose,
Mint,
}
#[derive(Clone, PartialEq)]
pub enum Style {
Default,
Google,
}
impl Theme {
pub fn value(&self) -> &'static str {
match self {
Theme::Default => "default",
Theme::Ocean => "ocean",
Theme::Forest => "forest",
Theme::Sunset => "sunset",
Theme::Purple => "purple",
Theme::Dark => "dark",
Theme::Rose => "rose",
Theme::Mint => "mint",
}
}
pub fn from_value(value: &str) -> Self {
match value {
"ocean" => Theme::Ocean,
"forest" => Theme::Forest,
"sunset" => Theme::Sunset,
"purple" => Theme::Purple,
"dark" => Theme::Dark,
"rose" => Theme::Rose,
"mint" => Theme::Mint,
_ => Theme::Default,
}
}
}
impl Style {
pub fn value(&self) -> &'static str {
match self {
Style::Default => "default",
Style::Google => "google",
}
}
pub fn from_value(value: &str) -> Self {
match value {
"google" => Style::Google,
_ => Style::Default,
}
}
pub fn stylesheet_path(&self) -> Option<&'static str> {
match self {
Style::Default => None, // No additional stylesheet needed - uses base styles.css
Style::Google => Some("google.css"), // Trunk copies to root level
}
}
}
impl Default for ViewMode {
fn default() -> Self {
ViewMode::Month
}
}
#[derive(Properties, PartialEq)]
pub struct SidebarProps {
pub user_info: Option<UserInfo>,
pub on_logout: 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,
pub on_theme_change: Callback<Theme>,
pub current_style: Style,
pub on_style_change: Callback<Style>,
}
#[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| {
let target = e.target_dyn_into::<HtmlSelectElement>();
if let Some(select) = target {
let value = select.value();
let new_view = match value.as_str() {
"week" => ViewMode::Week,
_ => ViewMode::Month,
};
on_view_change.emit(new_view);
}
})
};
let on_theme_change = {
let on_theme_change = props.on_theme_change.clone();
Callback::from(move |e: Event| {
let target = e.target_dyn_into::<HtmlSelectElement>();
if let Some(select) = target {
let value = select.value();
let new_theme = Theme::from_value(&value);
on_theme_change.emit(new_theme);
}
})
};
let on_style_change = {
let on_style_change = props.on_style_change.clone();
Callback::from(move |e: Event| {
let target = e.target_dyn_into::<HtmlSelectElement>();
if let Some(select) = target {
let value = select.value();
let new_style = Style::from_value(&value);
on_style_change.emit(new_style);
}
})
};
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>{"Runway"}</h1>
{
if let Some(ref info) = props.user_info {
html! {
<div class="user-info">
<div class="username">{&info.username}</div>
<div class="server-url">{&info.server_url}</div>
</div>
}
} else {
html! { <div class="user-info loading">{"Loading..."}</div> }
}
}
</div>
{
if let Some(ref info) = props.user_info {
if !info.calendars.is_empty() {
html! {
<div class="calendar-list">
<h3>{"My Calendars"}</h3>
<ul>
{
info.calendars.iter().map(|cal| {
html! {
<CalendarListItem
calendar={cal.clone()}
color_picker_open={props.color_picker_open.as_ref() == Some(&cal.path)}
on_color_change={props.on_color_change.clone()}
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>()
}
</ul>
</div>
}
} else {
html! { <div class="no-calendars">{"No calendars found"}</div> }
}
} else {
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_add_calendar.reform(|_| ())} class="add-calendar-button">
{"+ Add Calendar"}
</button>
<div class="view-selector">
<select class="view-selector-dropdown" onchange={on_view_change}>
<option value="month" selected={matches!(props.current_view, ViewMode::Month)}>{"Month"}</option>
<option value="week" selected={matches!(props.current_view, ViewMode::Week)}>{"Week"}</option>
</select>
</div>
<div class="theme-selector">
<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>
<option value="forest" selected={matches!(props.current_theme, Theme::Forest)}>{"Forest"}</option>
<option value="sunset" selected={matches!(props.current_theme, Theme::Sunset)}>{"Sunset"}</option>
<option value="purple" selected={matches!(props.current_theme, Theme::Purple)}>{"Purple"}</option>
<option value="dark" selected={matches!(props.current_theme, Theme::Dark)}>{"Dark"}</option>
<option value="rose" selected={matches!(props.current_theme, Theme::Rose)}>{"Rose"}</option>
<option value="mint" selected={matches!(props.current_theme, Theme::Mint)}>{"Mint"}</option>
</select>
</div>
<div class="style-selector">
<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>
</select>
</div>
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
</div>
</aside>
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
mod app;
mod auth;
mod components;
mod models;
mod services;
use app::App;
fn main() {
yew::Renderer::<App>::new().render();
}
}

View File

@@ -0,0 +1,2 @@
// Re-export from shared calendar-models library for backward compatibility
pub use calendar_models::*;

View File

@@ -0,0 +1,5 @@
// RFC 5545 Compliant iCalendar Models
pub mod ical;
// Re-export commonly used types
// pub use ical::VEvent;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
pub mod calendar_service;
pub mod preferences;
pub use calendar_service::CalendarService;

View File

@@ -0,0 +1,206 @@
use gloo_storage::{LocalStorage, Storage};
use serde::{Deserialize, Serialize};
use serde_json;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserPreferences {
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
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)]
#[allow(dead_code)]
pub struct UpdatePreferencesRequest {
pub calendar_selected_date: Option<String>,
pub calendar_time_increment: Option<i32>,
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)]
pub struct PreferencesService {
base_url: String,
}
#[allow(dead_code)]
impl PreferencesService {
pub fn new() -> Self {
let base_url = option_env!("BACKEND_API_URL")
.unwrap_or("http://localhost:3000/api")
.to_string();
Self { base_url }
}
/// Load preferences from LocalStorage (cached from login)
pub fn load_cached() -> Option<UserPreferences> {
if let Ok(prefs_json) = LocalStorage::get::<String>("user_preferences") {
serde_json::from_str(&prefs_json).ok()
} else {
None
}
}
/// Update a single preference field and sync with backend
pub async fn update_preference(&self, field: &str, value: serde_json::Value) -> Result<(), String> {
// Get session token
let session_token = LocalStorage::get::<String>("session_token")
.map_err(|_| "No session token found".to_string())?;
// Load current preferences
let mut preferences = Self::load_cached().unwrap_or(UserPreferences {
calendar_selected_date: None,
calendar_time_increment: None,
calendar_view_mode: None,
calendar_theme: None,
calendar_colors: None,
last_used_calendar: None,
});
// Update the specific field
match field {
"calendar_selected_date" => {
preferences.calendar_selected_date = value.as_str().map(|s| s.to_string());
}
"calendar_time_increment" => {
preferences.calendar_time_increment = value.as_i64().map(|i| i as i32);
}
"calendar_view_mode" => {
preferences.calendar_view_mode = value.as_str().map(|s| s.to_string());
}
"calendar_theme" => {
preferences.calendar_theme = value.as_str().map(|s| s.to_string());
}
"calendar_colors" => {
preferences.calendar_colors = value.as_str().map(|s| s.to_string());
}
_ => return Err(format!("Unknown preference field: {}", field)),
}
// Save to LocalStorage cache
if let Ok(prefs_json) = serde_json::to_string(&preferences) {
let _ = LocalStorage::set("user_preferences", &prefs_json);
}
// Sync with backend
let request = UpdatePreferencesRequest {
calendar_selected_date: preferences.calendar_selected_date.clone(),
calendar_time_increment: preferences.calendar_time_increment,
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
}
/// Sync all preferences with backend
async fn sync_preferences(
&self,
session_token: &str,
request: &UpdatePreferencesRequest,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
let json_body = serde_json::to_string(request)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
opts.set_body(&wasm_bindgen::JsValue::from_str(&json_body));
let url = format!("{}/preferences", self.base_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request
.headers()
.set("Content-Type", "application/json")
.map_err(|e| format!("Header setting failed: {:?}", e))?;
request
.headers()
.set("X-Session-Token", session_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() {
Ok(())
} else {
Err(format!("Failed to update preferences: {}", resp.status()))
}
}
/// Migrate preferences from LocalStorage to backend (on first login after update)
pub async fn migrate_from_local_storage(&self) -> Result<(), String> {
let session_token = LocalStorage::get::<String>("session_token")
.map_err(|_| "No session token found".to_string())?;
let request = UpdatePreferencesRequest {
calendar_selected_date: LocalStorage::get::<String>("calendar_selected_date").ok(),
calendar_time_increment: LocalStorage::get::<u32>("calendar_time_increment").ok().map(|i| i as i32),
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
if request.calendar_selected_date.is_some()
|| request.calendar_time_increment.is_some()
|| 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?;
// Clear old LocalStorage entries after successful migration
let _ = LocalStorage::delete("calendar_selected_date");
let _ = LocalStorage::delete("calendar_time_increment");
let _ = LocalStorage::delete("calendar_view_mode");
let _ = LocalStorage::delete("calendar_theme");
let _ = LocalStorage::delete("calendar_colors");
}
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
}
}

4135
frontend/styles.css Normal file

File diff suppressed because it is too large Load Diff

645
frontend/styles/google.css Normal file
View File

@@ -0,0 +1,645 @@
/* Google Calendar-inspired styles */
/* Override CSS Variables for Google Calendar Style */
:root {
/* Google-style spacing */
--spacing-xs: 2px;
--spacing-sm: 4px;
--spacing-md: 8px;
--spacing-lg: 12px;
--spacing-xl: 16px;
/* Google-style borders and radius */
--border-radius-small: 2px;
--border-radius-medium: 4px;
--border-radius-large: 8px;
--border-light: 1px solid #dadce0;
--border-medium: 1px solid #dadce0;
/* Google-style shadows */
--shadow-sm: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
--shadow-md: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15);
--shadow-lg: 0 4px 6px 0 rgba(60,64,67,.3), 0 8px 25px 5px rgba(60,64,67,.15);
}
/* Google-style sidebar - override all theme variants */
body .app-sidebar,
[data-theme] .app-sidebar,
[data-theme="default"] .app-sidebar,
[data-theme="ocean"] .app-sidebar,
[data-theme="forest"] .app-sidebar,
[data-theme="sunset"] .app-sidebar,
[data-theme="purple"] .app-sidebar,
[data-theme="dark"] .app-sidebar,
[data-theme="rose"] .app-sidebar,
[data-theme="mint"] .app-sidebar {
background: #ffffff !important;
border-right: 1px solid #dadce0 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
box-shadow: 2px 0 8px rgba(60,64,67,.1) !important;
}
body .sidebar-header,
[data-theme] .sidebar-header {
background: transparent !important;
border-bottom: 1px solid #dadce0 !important;
}
body .sidebar-header h1,
[data-theme] .sidebar-header h1 {
font-size: 20px !important;
font-weight: 500 !important;
color: #3c4043 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
body .user-info,
[data-theme] .user-info {
color: #3c4043 !important;
}
body .user-info .username,
[data-theme] .user-info .username {
font-weight: 500 !important;
color: #3c4043 !important;
}
body .user-info .server-url,
[data-theme] .user-info .server-url {
color: #5f6368 !important;
}
/* Google-style buttons */
.create-calendar-button {
background: #1a73e8 !important;
color: white !important;
border: none !important;
border-radius: 4px !important;
padding: 8px 16px !important;
font-weight: 500 !important;
font-size: 14px !important;
cursor: pointer !important;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15) !important;
transition: box-shadow 0.2s ease !important;
}
.create-calendar-button:hover {
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
}
.logout-button {
background: transparent !important;
color: #1a73e8 !important;
border: 1px solid #dadce0 !important;
border-radius: 4px !important;
padding: 8px 16px !important;
font-weight: 500 !important;
font-size: 14px !important;
cursor: pointer !important;
transition: background-color 0.2s ease !important;
}
.logout-button:hover {
background: #f8f9fa !important;
}
/* Google-style navigation and sidebar text */
body .sidebar-nav .nav-link,
[data-theme] .sidebar-nav .nav-link {
color: #3c4043 !important;
text-decoration: none !important;
}
body .sidebar-nav .nav-link:hover,
[data-theme] .sidebar-nav .nav-link:hover {
color: #1a73e8 !important;
background: #f1f3f4 !important;
}
/* Calendar list styling */
body .calendar-list h3,
[data-theme] .calendar-list h3 {
color: #3c4043 !important;
}
body .calendar-list .calendar-name,
[data-theme] .calendar-list .calendar-name {
color: #3c4043 !important;
}
body .no-calendars,
[data-theme] .no-calendars {
color: #5f6368 !important;
}
/* Form labels and text */
body .sidebar-footer label,
[data-theme] .sidebar-footer label,
body .view-selector label,
[data-theme] .view-selector label,
body .theme-selector label,
[data-theme] .theme-selector label,
body .style-selector label,
[data-theme] .style-selector label {
color: #3c4043 !important;
}
/* Google-style selectors */
body .view-selector-dropdown,
body .theme-selector-dropdown,
body .style-selector-dropdown,
[data-theme] .view-selector-dropdown,
[data-theme] .theme-selector-dropdown,
[data-theme] .style-selector-dropdown {
border: 1px solid #dadce0 !important;
border-radius: 4px !important;
padding: 8px !important;
font-size: 14px !important;
color: #3c4043 !important;
background: white !important;
font-family: inherit !important;
}
.view-selector-dropdown:focus,
.theme-selector-dropdown:focus,
.style-selector-dropdown:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
}
/* Google-style calendar list items */
.calendar-list h3 {
font-size: 14px;
font-weight: 500;
color: #3c4043;
margin-bottom: 8px;
}
.calendar-list ul {
list-style: none;
}
.calendar-list .calendar-item {
padding: 4px 0;
border-radius: 4px;
transition: background-color 0.15s ease;
}
.calendar-list .calendar-item:hover {
background-color: #f1f3f4;
}
.calendar-list .calendar-name {
color: #3c4043;
font-size: 14px;
font-weight: 400;
}
/* Google-style main content area */
body .app-main,
[data-theme] .app-main {
background: #ffffff !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
color: #3c4043 !important;
}
/* Calendar header elements */
body .calendar-header,
[data-theme] .calendar-header {
background: #f8f9fa !important;
color: #3c4043 !important;
}
body .calendar-header h2,
body .calendar-header h3,
body .month-header,
body .week-header,
[data-theme] .calendar-header h2,
[data-theme] .calendar-header h3,
[data-theme] .month-header,
[data-theme] .week-header {
color: #3c4043 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Month name and title - aggressive override */
body h1,
body h2,
body h3,
body .month-title,
body .calendar-title,
body .current-month,
body .month-year,
body .header-title,
[data-theme] h1,
[data-theme] h2,
[data-theme] h3,
[data-theme] .month-title,
[data-theme] .calendar-title,
[data-theme] .current-month,
[data-theme] .month-year,
[data-theme] .header-title {
color: #3c4043 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Navigation arrows and buttons - aggressive override */
body button,
body .nav-button,
body .calendar-nav-button,
body .prev-button,
body .next-button,
body .arrow-button,
body .navigation-arrow,
body [class*="arrow"],
body [class*="nav"],
body [class*="button"],
[data-theme] button,
[data-theme] .nav-button,
[data-theme] .calendar-nav-button,
[data-theme] .prev-button,
[data-theme] .next-button,
[data-theme] .arrow-button,
[data-theme] .navigation-arrow,
[data-theme] [class*="arrow"],
[data-theme] [class*="nav"],
[data-theme] [class*="button"] {
color: #3c4043 !important;
background: #f8f9fa !important;
border: 1px solid #dadce0 !important;
border-radius: 4px !important;
}
body button:hover,
body .nav-button:hover,
body .calendar-nav-button:hover,
body .prev-button:hover,
body .next-button:hover,
body .arrow-button:hover,
[data-theme] button:hover,
[data-theme] .nav-button:hover,
[data-theme] .calendar-nav-button:hover,
[data-theme] .prev-button:hover,
[data-theme] .next-button:hover,
[data-theme] .arrow-button:hover {
background: #e8f0fe !important;
color: #1a73e8 !important;
border-color: #1a73e8 !important;
}
/* Calendar controls and date display */
body .calendar-controls,
body .current-date,
body .date-display,
[data-theme] .calendar-controls,
[data-theme] .current-date,
[data-theme] .date-display {
color: #3c4043 !important;
}
/* Ultimate nuclear approach - override EVERYTHING */
html body .app-main,
html body .app-main *,
html body .main-content,
html body .main-content *,
html body .calendar-container,
html body .calendar-container *,
html [data-theme] .app-main,
html [data-theme] .app-main *,
html [data-theme] .main-content,
html [data-theme] .main-content *,
html [data-theme] .calendar-container,
html [data-theme] .calendar-container *,
html [data-theme="default"] .app-main *,
html [data-theme="ocean"] .app-main *,
html [data-theme="forest"] .app-main *,
html [data-theme="sunset"] .app-main *,
html [data-theme="purple"] .app-main *,
html [data-theme="dark"] .app-main *,
html [data-theme="rose"] .app-main *,
html [data-theme="mint"] .app-main * {
color: #3c4043 !important;
text-shadow: none !important;
}
/* Force all text elements */
html body .app-main h1,
html body .app-main h2,
html body .app-main h3,
html body .app-main h4,
html body .app-main h5,
html body .app-main h6,
html body .app-main p,
html body .app-main span,
html body .app-main div,
html body .app-main button,
html [data-theme] .app-main h1,
html [data-theme] .app-main h2,
html [data-theme] .app-main h3,
html [data-theme] .app-main h4,
html [data-theme] .app-main h5,
html [data-theme] .app-main h6,
html [data-theme] .app-main p,
html [data-theme] .app-main span,
html [data-theme] .app-main div,
html [data-theme] .app-main button {
color: #3c4043 !important;
}
/* Exception for buttons - make them stand out */
body .app-main button,
body .main-content button,
[data-theme] .app-main button,
[data-theme] .main-content button {
color: #3c4043 !important;
background: #f8f9fa !important;
border: 1px solid #dadce0 !important;
}
/* Google-style calendar grid - more aggressive styling */
html body .calendar-grid,
html [data-theme] .calendar-grid,
body .calendar-container,
[data-theme] .calendar-container {
border: 1px solid #dadce0 !important;
border-radius: 8px !important;
overflow: hidden !important;
background: white !important;
box-shadow: 0 1px 3px 0 rgba(60,64,67,.3), 0 4px 8px 3px rgba(60,64,67,.15) !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
html body .calendar-header,
html [data-theme] .calendar-header {
background: #f8f9fa !important;
border-bottom: 1px solid #dadce0 !important;
padding: 16px !important;
}
html body .month-header,
html body .week-header,
html [data-theme] .month-header,
html [data-theme] .week-header {
font-size: 22px !important;
font-weight: 400 !important;
color: #3c4043 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Google-style calendar cells - complete overhaul */
html body .calendar-day,
html [data-theme] .calendar-day,
body .day-cell,
[data-theme] .day-cell {
border: 1px solid #e8eaed !important;
background: white !important;
transition: background-color 0.15s ease !important;
padding: 8px !important;
min-height: 120px !important;
position: relative !important;
}
html body .calendar-day:hover,
html [data-theme] .calendar-day:hover,
body .day-cell:hover,
[data-theme] .day-cell:hover {
background: #f8f9fa !important;
box-shadow: inset 0 0 0 1px #dadce0 !important;
}
html body .calendar-day.today,
html [data-theme] .calendar-day.today,
body .day-cell.today,
[data-theme] .day-cell.today {
background: #e8f0fe !important;
border-color: #1a73e8 !important;
}
html body .calendar-day.other-month,
html [data-theme] .calendar-day.other-month,
body .day-cell.other-month,
[data-theme] .day-cell.other-month {
background: #fafafa !important;
color: #9aa0a6 !important;
}
html body .day-number,
html [data-theme] .day-number,
body .date-number,
[data-theme] .date-number {
font-size: 14px !important;
font-weight: 500 !important;
color: #3c4043 !important;
margin-bottom: 4px !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Day headers (Mon, Tue, Wed, etc.) */
html body .day-header,
html [data-theme] .day-header,
body .weekday-header,
[data-theme] .weekday-header {
background: #f8f9fa !important;
color: #5f6368 !important;
font-size: 12px !important;
font-weight: 500 !important;
text-transform: uppercase !important;
letter-spacing: 0.8px !important;
padding: 8px !important;
border-bottom: 1px solid #dadce0 !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
}
/* Google Calendar-style events - complete redesign */
html body .app-main .event,
html [data-theme] .app-main .event,
html body .calendar-container .event,
html [data-theme] .calendar-container .event,
body .event,
[data-theme] .event {
border-radius: 4px !important;
padding: 2px 8px !important;
font-size: 11px !important;
font-weight: 400 !important;
margin: 1px 0 2px 0 !important;
cursor: pointer !important;
border: none !important;
color: white !important;
font-family: 'Google Sans', 'Roboto', sans-serif !important;
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
transition: transform 0.1s ease, box-shadow 0.1s ease !important;
display: block !important;
text-overflow: ellipsis !important;
overflow: hidden !important;
white-space: nowrap !important;
line-height: 1.3 !important;
}
html body .app-main .event *,
html [data-theme] .app-main .event *,
html body .calendar-container .event *,
html [data-theme] .calendar-container .event *,
body .event *,
[data-theme] .event * {
color: white !important;
font-family: inherit !important;
}
html body .app-main .event:hover,
html [data-theme] .app-main .event:hover,
body .event:hover,
[data-theme] .event:hover {
transform: translateY(-1px) !important;
box-shadow: 0 2px 8px rgba(60,64,67,.4) !important;
}
/* All-day events styling */
html body .event.all-day,
html [data-theme] .event.all-day {
border-radius: 12px !important;
padding: 4px 12px !important;
font-weight: 500 !important;
margin: 2px 0 !important;
}
/* Event time display */
html body .event-time,
html [data-theme] .event-time {
opacity: 0.9 !important;
font-size: 10px !important;
margin-right: 4px !important;
}
/* Week view events */
html body .week-view .event,
html [data-theme] .week-view .event {
border-left: 3px solid rgba(255,255,255,0.8) !important;
border-radius: 0 4px 4px 0 !important;
padding-left: 6px !important;
}
/* Calendar table structure */
html body .calendar-table,
html [data-theme] .calendar-table,
body table,
[data-theme] table {
border-collapse: separate !important;
border-spacing: 0 !important;
width: 100% !important;
background: white !important;
}
html body .calendar-table td,
html [data-theme] .calendar-table td,
body table td,
[data-theme] table td {
vertical-align: top !important;
border: 1px solid #e8eaed !important;
background: white !important;
}
/* Month/Week view toggle */
html body .view-toggle,
html [data-theme] .view-toggle {
display: flex !important;
gap: 4px !important;
background: #f1f3f4 !important;
border-radius: 6px !important;
padding: 2px !important;
}
html body .view-toggle button,
html [data-theme] .view-toggle button {
padding: 6px 12px !important;
border: none !important;
background: transparent !important;
color: #5f6368 !important;
border-radius: 4px !important;
font-size: 13px !important;
font-weight: 500 !important;
cursor: pointer !important;
transition: all 0.15s ease !important;
}
html body .view-toggle button.active,
html [data-theme] .view-toggle button.active {
background: white !important;
color: #1a73e8 !important;
box-shadow: 0 1px 3px rgba(60,64,67,.3) !important;
}
/* Today button */
html body .today-button,
html [data-theme] .today-button {
background: white !important;
border: 1px solid #dadce0 !important;
color: #1a73e8 !important;
padding: 8px 16px !important;
border-radius: 4px !important;
font-weight: 500 !important;
font-size: 14px !important;
cursor: pointer !important;
transition: all 0.15s ease !important;
}
html body .today-button:hover,
html [data-theme] .today-button:hover {
background: #f8f9fa !important;
border-color: #1a73e8 !important;
}
/* Google-style modals */
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
background: white;
border-radius: 8px;
box-shadow: var(--shadow-lg);
border: none;
}
.modal h2 {
font-size: 20px;
font-weight: 500;
color: #3c4043;
font-family: 'Google Sans', sans-serif;
}
/* Google-style form inputs */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="url"],
input[type="date"],
input[type="time"],
textarea,
select {
border: 1px solid #dadce0;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
color: #3c4043;
background: white;
font-family: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 2px rgba(26,115,232,.2);
}
/* Google-style labels */
label {
font-size: 14px;
font-weight: 500;
color: #3c4043;
margin-bottom: 4px;
display: block;
}

View File

@@ -1,10 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Calendar App</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link data-trunk rel="css" href="styles.css">
</head>
<body></body>
</html>

BIN
sample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,549 +0,0 @@
use yew::prelude::*;
use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use web_sys::MouseEvent;
use crate::components::{Sidebar, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType};
use crate::services::{CalendarService, calendar_service::{UserInfo, CalendarEvent}};
use chrono::NaiveDate;
#[function_component]
pub fn App() -> Html {
let auth_token = use_state(|| -> Option<String> {
LocalStorage::get("auth_token").ok()
});
let user_info = use_state(|| -> Option<UserInfo> { None });
let color_picker_open = use_state(|| -> Option<String> { None });
let create_modal_open = use_state(|| false);
let context_menu_open = use_state(|| false);
let context_menu_pos = use_state(|| (0i32, 0i32));
let context_menu_calendar_path = use_state(|| -> Option<String> { None });
let event_context_menu_open = use_state(|| false);
let event_context_menu_pos = use_state(|| (0i32, 0i32));
let event_context_menu_event = use_state(|| -> Option<CalendarEvent> { None });
let calendar_context_menu_open = use_state(|| false);
let calendar_context_menu_pos = use_state(|| (0i32, 0i32));
let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None });
let create_event_modal_open = use_state(|| false);
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
let available_colors = [
"#3B82F6", "#10B981", "#F59E0B", "#EF4444",
"#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
"#EC4899", "#6366F1", "#14B8A6", "#F3B806",
"#8B5A2B", "#6B7280", "#DC2626", "#7C3AED"
];
let on_login = {
let auth_token = auth_token.clone();
Callback::from(move |token: String| {
auth_token.set(Some(token));
})
};
let on_logout = {
let auth_token = auth_token.clone();
let user_info = user_info.clone();
Callback::from(move |_| {
let _ = LocalStorage::delete("auth_token");
auth_token.set(None);
user_info.set(None);
})
};
// Fetch user info when token is available
{
let user_info = user_info.clone();
let auth_token = auth_token.clone();
use_effect_with((*auth_token).clone(), move |token| {
if let Some(token) = token {
let user_info = user_info.clone();
let token = token.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
if !password.is_empty() {
match calendar_service.fetch_user_info(&token, &password).await {
Ok(mut info) => {
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
for saved_cal in &saved_info.calendars {
for cal in &mut info.calendars {
if cal.path == saved_cal.path {
cal.color = saved_cal.color.clone();
}
}
}
}
}
user_info.set(Some(info));
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to fetch user info: {}", err).into());
}
}
}
});
} else {
user_info.set(None);
}
|| ()
});
}
let on_outside_click = {
let color_picker_open = color_picker_open.clone();
let context_menu_open = context_menu_open.clone();
let event_context_menu_open = event_context_menu_open.clone();
let calendar_context_menu_open = calendar_context_menu_open.clone();
Callback::from(move |_: MouseEvent| {
color_picker_open.set(None);
context_menu_open.set(false);
event_context_menu_open.set(false);
calendar_context_menu_open.set(false);
})
};
let on_color_change = {
let user_info = user_info.clone();
let color_picker_open = color_picker_open.clone();
Callback::from(move |(calendar_path, color): (String, String)| {
if let Some(mut info) = (*user_info).clone() {
for calendar in &mut info.calendars {
if calendar.path == calendar_path {
calendar.color = color.clone();
break;
}
}
user_info.set(Some(info.clone()));
if let Ok(json) = serde_json::to_string(&info) {
let _ = LocalStorage::set("calendar_colors", json);
}
}
color_picker_open.set(None);
})
};
let on_color_picker_toggle = {
let color_picker_open = color_picker_open.clone();
Callback::from(move |calendar_path: String| {
if color_picker_open.as_ref() == Some(&calendar_path) {
color_picker_open.set(None);
} else {
color_picker_open.set(Some(calendar_path));
}
})
};
let on_calendar_context_menu = {
let context_menu_open = context_menu_open.clone();
let context_menu_pos = context_menu_pos.clone();
let context_menu_calendar_path = context_menu_calendar_path.clone();
Callback::from(move |(event, calendar_path): (MouseEvent, String)| {
context_menu_open.set(true);
context_menu_pos.set((event.client_x(), event.client_y()));
context_menu_calendar_path.set(Some(calendar_path));
})
};
let on_event_context_menu = {
let event_context_menu_open = event_context_menu_open.clone();
let event_context_menu_pos = event_context_menu_pos.clone();
let event_context_menu_event = event_context_menu_event.clone();
Callback::from(move |(event, calendar_event): (MouseEvent, CalendarEvent)| {
event_context_menu_open.set(true);
event_context_menu_pos.set((event.client_x(), event.client_y()));
event_context_menu_event.set(Some(calendar_event));
})
};
let on_calendar_date_context_menu = {
let calendar_context_menu_open = calendar_context_menu_open.clone();
let calendar_context_menu_pos = calendar_context_menu_pos.clone();
let calendar_context_menu_date = calendar_context_menu_date.clone();
Callback::from(move |(event, date): (MouseEvent, NaiveDate)| {
calendar_context_menu_open.set(true);
calendar_context_menu_pos.set((event.client_x(), event.client_y()));
calendar_context_menu_date.set(Some(date));
})
};
let on_create_event_click = {
let create_event_modal_open = create_event_modal_open.clone();
let selected_date_for_event = selected_date_for_event.clone();
let calendar_context_menu_date = calendar_context_menu_date.clone();
Callback::from(move |_: MouseEvent| {
create_event_modal_open.set(true);
selected_date_for_event.set((*calendar_context_menu_date).clone());
})
};
let on_event_create = {
let create_event_modal_open = create_event_modal_open.clone();
let auth_token = auth_token.clone();
Callback::from(move |event_data: EventCreationData| {
web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into());
create_event_modal_open.set(false);
if let Some(token) = (*auth_token).clone() {
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
// Get CalDAV password from storage
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
// Format date and time strings
let start_date = event_data.start_date.format("%Y-%m-%d").to_string();
let start_time = event_data.start_time.format("%H:%M").to_string();
let end_date = event_data.end_date.format("%Y-%m-%d").to_string();
let end_time = event_data.end_time.format("%H:%M").to_string();
// Convert enums to strings for backend
let status_str = match event_data.status {
EventStatus::Tentative => "tentative",
EventStatus::Cancelled => "cancelled",
_ => "confirmed",
}.to_string();
let class_str = match event_data.class {
EventClass::Private => "private",
EventClass::Confidential => "confidential",
_ => "public",
}.to_string();
let reminder_str = match event_data.reminder {
ReminderType::Minutes15 => "15min",
ReminderType::Minutes30 => "30min",
ReminderType::Hour1 => "1hour",
ReminderType::Hours2 => "2hours",
ReminderType::Day1 => "1day",
ReminderType::Days2 => "2days",
ReminderType::Week1 => "1week",
_ => "none",
}.to_string();
let recurrence_str = match event_data.recurrence {
RecurrenceType::Daily => "daily",
RecurrenceType::Weekly => "weekly",
RecurrenceType::Monthly => "monthly",
RecurrenceType::Yearly => "yearly",
_ => "none",
}.to_string();
match calendar_service.create_event(
&token,
&password,
event_data.title,
event_data.description,
start_date,
start_time,
end_date,
end_time,
event_data.location,
event_data.all_day,
status_str,
class_str,
event_data.priority,
event_data.organizer,
event_data.attendees,
event_data.categories,
reminder_str,
recurrence_str,
event_data.recurrence_days,
None // Let backend use first available calendar
).await {
Ok(_) => {
web_sys::console::log_1(&"Event created successfully".into());
// Refresh the page to show the new event
web_sys::window().unwrap().location().reload().unwrap();
}
Err(err) => {
web_sys::console::error_1(&format!("Failed to create event: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to create event: {}", err)).unwrap();
}
}
});
}
})
};
let refresh_calendars = {
let auth_token = auth_token.clone();
let user_info = user_info.clone();
Callback::from(move |_| {
if let Some(token) = (*auth_token).clone() {
let user_info = user_info.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.fetch_user_info(&token, &password).await {
Ok(mut info) => {
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
for saved_cal in &saved_info.calendars {
for cal in &mut info.calendars {
if cal.path == saved_cal.path {
cal.color = saved_cal.color.clone();
}
}
}
}
}
user_info.set(Some(info));
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to refresh calendars: {}", err).into());
}
}
});
}
})
};
html! {
<BrowserRouter>
<div class="app" onclick={on_outside_click}>
{
if auth_token.is_some() {
html! {
<>
<Sidebar
user_info={(*user_info).clone()}
on_logout={on_logout}
on_create_calendar={Callback::from({
let create_modal_open = create_modal_open.clone();
move |_| create_modal_open.set(true)
})}
color_picker_open={(*color_picker_open).clone()}
on_color_change={on_color_change}
on_color_picker_toggle={on_color_picker_toggle}
available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
on_calendar_context_menu={on_calendar_context_menu}
/>
<main class="app-main">
<RouteHandler
auth_token={(*auth_token).clone()}
user_info={(*user_info).clone()}
on_login={on_login.clone()}
on_event_context_menu={Some(on_event_context_menu.clone())}
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
/>
</main>
</>
}
} else {
html! {
<div class="login-layout">
<RouteHandler
auth_token={(*auth_token).clone()}
user_info={(*user_info).clone()}
on_login={on_login.clone()}
on_event_context_menu={Some(on_event_context_menu.clone())}
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
/>
</div>
}
}
}
<CreateCalendarModal
is_open={*create_modal_open}
on_close={Callback::from({
let create_modal_open = create_modal_open.clone();
move |_| create_modal_open.set(false)
})}
on_create={Callback::from({
let auth_token = auth_token.clone();
let refresh_calendars = refresh_calendars.clone();
let create_modal_open = create_modal_open.clone();
move |(name, description, color): (String, Option<String>, Option<String>)| {
if let Some(token) = (*auth_token).clone() {
let refresh_calendars = refresh_calendars.clone();
let create_modal_open = create_modal_open.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.create_calendar(&token, &password, name, description, color).await {
Ok(_) => {
web_sys::console::log_1(&"Calendar created successfully!".into());
refresh_calendars.emit(());
create_modal_open.set(false);
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to create calendar: {}", err).into());
create_modal_open.set(false);
}
}
});
}
}
})}
available_colors={available_colors.iter().map(|c| c.to_string()).collect::<Vec<_>>()}
/>
<ContextMenu
is_open={*context_menu_open}
x={context_menu_pos.0}
y={context_menu_pos.1}
on_close={Callback::from({
let context_menu_open = context_menu_open.clone();
move |_| context_menu_open.set(false)
})}
on_delete={Callback::from({
let auth_token = auth_token.clone();
let context_menu_calendar_path = context_menu_calendar_path.clone();
let refresh_calendars = refresh_calendars.clone();
move |_: MouseEvent| {
if let (Some(token), Some(calendar_path)) = ((*auth_token).clone(), (*context_menu_calendar_path).clone()) {
let refresh_calendars = refresh_calendars.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.delete_calendar(&token, &password, calendar_path).await {
Ok(_) => {
web_sys::console::log_1(&"Calendar deleted successfully!".into());
refresh_calendars.emit(());
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to delete calendar: {}", err).into());
}
}
});
}
}
})}
/>
<EventContextMenu
is_open={*event_context_menu_open}
x={event_context_menu_pos.0}
y={event_context_menu_pos.1}
on_close={Callback::from({
let event_context_menu_open = event_context_menu_open.clone();
move |_| event_context_menu_open.set(false)
})}
on_delete={Callback::from({
let auth_token = auth_token.clone();
let event_context_menu_event = event_context_menu_event.clone();
let event_context_menu_open = event_context_menu_open.clone();
let refresh_calendars = refresh_calendars.clone();
move |_: MouseEvent| {
if let (Some(token), Some(event)) = ((*auth_token).clone(), (*event_context_menu_event).clone()) {
let _refresh_calendars = refresh_calendars.clone();
let event_context_menu_open = event_context_menu_open.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
if let (Some(calendar_path), Some(event_href)) = (&event.calendar_path, &event.href) {
match calendar_service.delete_event(&token, &password, calendar_path.clone(), event_href.clone()).await {
Ok(_) => {
web_sys::console::log_1(&"Event deleted successfully!".into());
// Close the context menu
event_context_menu_open.set(false);
// Force a page reload to refresh the calendar events
web_sys::window().unwrap().location().reload().unwrap();
}
Err(err) => {
web_sys::console::log_1(&format!("Failed to delete event: {}", err).into());
}
}
} else {
web_sys::console::log_1(&"Missing calendar_path or href - cannot delete event".into());
}
});
}
}
})}
/>
<CalendarContextMenu
is_open={*calendar_context_menu_open}
x={calendar_context_menu_pos.0}
y={calendar_context_menu_pos.1}
on_close={Callback::from({
let calendar_context_menu_open = calendar_context_menu_open.clone();
move |_| calendar_context_menu_open.set(false)
})}
on_create_event={on_create_event_click}
/>
<CreateEventModal
is_open={*create_event_modal_open}
selected_date={(*selected_date_for_event).clone()}
on_close={Callback::from({
let create_event_modal_open = create_event_modal_open.clone();
move |_| create_event_modal_open.set(false)
})}
on_create={on_event_create}
/>
</div>
</BrowserRouter>
}
}

View File

@@ -1,302 +0,0 @@
use yew::prelude::*;
use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
use std::collections::HashMap;
use crate::services::calendar_service::{CalendarEvent, UserInfo};
use crate::components::EventModal;
use wasm_bindgen::JsCast;
#[derive(Properties, PartialEq)]
pub struct CalendarProps {
#[prop_or_default]
pub events: HashMap<NaiveDate, Vec<CalendarEvent>>,
pub on_event_click: Callback<CalendarEvent>,
#[prop_or_default]
pub refreshing_event_uid: Option<String>,
#[prop_or_default]
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
}
#[function_component]
pub fn Calendar(props: &CalendarProps) -> Html {
let today = Local::now().date_naive();
let current_month = use_state(|| today);
let selected_day = use_state(|| today);
let selected_event = use_state(|| None::<CalendarEvent>);
// Helper function to get calendar color for an event
let get_event_color = |event: &CalendarEvent| -> String {
if let Some(user_info) = &props.user_info {
if let Some(calendar_path) = &event.calendar_path {
// Find the calendar that matches this event's path
if let Some(calendar) = user_info.calendars.iter()
.find(|cal| &cal.path == calendar_path) {
return calendar.color.clone();
}
}
}
// Default color if no match found
"#3B82F6".to_string()
};
let first_day_of_month = current_month.with_day(1).unwrap();
let days_in_month = get_days_in_month(*current_month);
let first_weekday = first_day_of_month.weekday();
let days_from_prev_month = get_days_from_previous_month(*current_month, first_weekday);
let prev_month = {
let current_month = current_month.clone();
Callback::from(move |_| {
let prev = *current_month - Duration::days(1);
let first_of_prev = prev.with_day(1).unwrap();
current_month.set(first_of_prev);
})
};
let next_month = {
let current_month = current_month.clone();
Callback::from(move |_| {
let next = if current_month.month() == 12 {
NaiveDate::from_ymd_opt(current_month.year() + 1, 1, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() + 1, 1).unwrap()
};
current_month.set(next);
})
};
html! {
<div class="calendar">
<div class="calendar-header">
<button class="nav-button" onclick={prev_month}>{""}</button>
<h2 class="month-year">{format!("{} {}", get_month_name(current_month.month()), current_month.year())}</h2>
<button class="nav-button" onclick={next_month}>{""}</button>
</div>
<div class="calendar-grid">
// Weekday headers
<div class="weekday-header">{"Sun"}</div>
<div class="weekday-header">{"Mon"}</div>
<div class="weekday-header">{"Tue"}</div>
<div class="weekday-header">{"Wed"}</div>
<div class="weekday-header">{"Thu"}</div>
<div class="weekday-header">{"Fri"}</div>
<div class="weekday-header">{"Sat"}</div>
// Days from previous month (grayed out)
{
days_from_prev_month.iter().map(|day| {
html! {
<div class="calendar-day prev-month">{*day}</div>
}
}).collect::<Html>()
}
// Days of current month
{
(1..=days_in_month).map(|day| {
let date = current_month.with_day(day).unwrap();
let is_today = date == today;
let is_selected = date == *selected_day;
let events = props.events.get(&date).cloned().unwrap_or_default();
let mut classes = vec!["calendar-day", "current-month"];
if is_today {
classes.push("today");
}
if is_selected {
classes.push("selected");
}
if !events.is_empty() {
classes.push("has-events");
}
let selected_day_clone = selected_day.clone();
let on_click = Callback::from(move |_| {
selected_day_clone.set(date);
});
let on_context_menu = {
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
Callback::from(move |e: MouseEvent| {
// Only show context menu if we're not right-clicking on an event
if let Some(target) = e.target() {
if let Ok(element) = target.dyn_into::<web_sys::Element>() {
// Check if the click is on an event box or inside one
let mut current = Some(element);
while let Some(el) = current {
if el.class_name().contains("event-box") {
return; // Don't show calendar context menu on events
}
current = el.parent_element();
}
}
}
e.prevent_default();
e.stop_propagation();
if let Some(callback) = &on_calendar_context_menu {
callback.emit((e, date));
}
})
};
html! {
<div class={classes!(classes)} onclick={on_click} oncontextmenu={on_context_menu}>
<div class="day-number">{day}</div>
{
if !events.is_empty() {
html! {
<div class="event-indicators">
{
events.iter().take(2).map(|event| {
let event_clone = event.clone();
let selected_event_clone = selected_event.clone();
let on_event_click = props.on_event_click.clone();
let event_click = Callback::from(move |e: MouseEvent| {
e.stop_propagation(); // Prevent day selection
on_event_click.emit(event_clone.clone());
selected_event_clone.set(Some(event_clone.clone()));
});
let event_context_menu = {
let event_clone = event.clone();
let on_event_context_menu = props.on_event_context_menu.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
e.stop_propagation();
if let Some(callback) = &on_event_context_menu {
callback.emit((e, event_clone.clone()));
}
})
};
let title = event.get_title();
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" };
let event_color = get_event_color(&event);
html! {
<div class={class_name}
title={title.clone()}
onclick={event_click}
oncontextmenu={event_context_menu}
style={format!("background-color: {}", event_color)}>
{
if is_refreshing {
"🔄 Refreshing...".to_string()
} else if title.len() > 15 {
format!("{}...", &title[..12])
} else {
title
}
}
</div>
}
}).collect::<Html>()
}
{
if events.len() > 2 {
html! { <div class="more-events">{format!("+{} more", events.len() - 2)}</div> }
} else {
html! {}
}
}
</div>
}
} else {
html! {}
}
}
</div>
}
}).collect::<Html>()
}
{ render_next_month_days(days_from_prev_month.len(), days_in_month) }
</div>
// Event details modal
<EventModal
event={(*selected_event).clone()}
on_close={{
let selected_event_clone = selected_event.clone();
Callback::from(move |_| {
selected_event_clone.set(None);
})
}}
/>
</div>
}
}
fn render_next_month_days(prev_days_count: usize, current_days_count: u32) -> Html {
let total_slots = 42; // 6 rows x 7 days
let used_slots = prev_days_count + current_days_count as usize;
let remaining_slots = if used_slots < total_slots { total_slots - used_slots } else { 0 };
(1..=remaining_slots).map(|day| {
html! {
<div class="calendar-day next-month">{day}</div>
}
}).collect::<Html>()
}
fn get_days_in_month(date: NaiveDate) -> u32 {
NaiveDate::from_ymd_opt(
if date.month() == 12 { date.year() + 1 } else { date.year() },
if date.month() == 12 { 1 } else { date.month() + 1 },
1
)
.unwrap()
.pred_opt()
.unwrap()
.day()
}
fn get_days_from_previous_month(current_month: NaiveDate, first_weekday: Weekday) -> Vec<u32> {
let days_before = match first_weekday {
Weekday::Sun => 0,
Weekday::Mon => 1,
Weekday::Tue => 2,
Weekday::Wed => 3,
Weekday::Thu => 4,
Weekday::Fri => 5,
Weekday::Sat => 6,
};
if days_before == 0 {
vec![]
} else {
// Calculate the previous month
let prev_month = if current_month.month() == 1 {
NaiveDate::from_ymd_opt(current_month.year() - 1, 12, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(current_month.year(), current_month.month() - 1, 1).unwrap()
};
let prev_month_days = get_days_in_month(prev_month);
((prev_month_days - days_before as u32 + 1)..=prev_month_days).collect()
}
}
fn get_month_name(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "Invalid"
}
}

View File

@@ -1,47 +0,0 @@
use yew::prelude::*;
use web_sys::MouseEvent;
#[derive(Properties, PartialEq)]
pub struct CalendarContextMenuProps {
pub is_open: bool,
pub x: i32,
pub y: i32,
pub on_close: Callback<()>,
pub on_create_event: Callback<MouseEvent>,
}
#[function_component(CalendarContextMenu)]
pub fn calendar_context_menu(props: &CalendarContextMenuProps) -> Html {
let menu_ref = use_node_ref();
if !props.is_open {
return html! {};
}
let style = format!(
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
props.x, props.y
);
let on_create_event_click = {
let on_create_event = props.on_create_event.clone();
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
on_create_event.emit(e);
on_close.emit(());
})
};
html! {
<div
ref={menu_ref}
class="context-menu"
style={style}
>
<div class="context-menu-item context-menu-create" onclick={on_create_event_click}>
<span class="context-menu-icon">{"+"}</span>
{"Create Event"}
</div>
</div>
}
}

View File

@@ -1,75 +0,0 @@
use yew::prelude::*;
use web_sys::MouseEvent;
use crate::services::calendar_service::CalendarInfo;
#[derive(Properties, PartialEq)]
pub struct CalendarListItemProps {
pub calendar: CalendarInfo,
pub color_picker_open: bool,
pub on_color_change: Callback<(String, String)>, // (calendar_path, color)
pub on_color_picker_toggle: Callback<String>, // calendar_path
pub available_colors: Vec<String>,
pub on_context_menu: Callback<(MouseEvent, String)>, // (event, calendar_path)
}
#[function_component(CalendarListItem)]
pub fn calendar_list_item(props: &CalendarListItemProps) -> Html {
let on_color_click = {
let cal_path = props.calendar.path.clone();
let on_color_picker_toggle = props.on_color_picker_toggle.clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
on_color_picker_toggle.emit(cal_path.clone());
})
};
let on_context_menu = {
let cal_path = props.calendar.path.clone();
let on_context_menu = props.on_context_menu.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default();
on_context_menu.emit((e, 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();
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" };
html! {
<div class={class_name}
style={format!("background-color: {}", color)}
onclick={on_color_select}>
</div>
}
}).collect::<Html>()
}
</div>
}
} else {
html! {}
}
}
</span>
<span class="calendar-name">{&props.calendar.display_name}</span>
</li>
}
}

View File

@@ -1,49 +0,0 @@
use yew::prelude::*;
use web_sys::MouseEvent;
#[derive(Properties, PartialEq)]
pub struct ContextMenuProps {
pub is_open: bool,
pub x: i32,
pub y: i32,
pub on_delete: Callback<MouseEvent>,
pub on_close: Callback<()>,
}
#[function_component(ContextMenu)]
pub fn context_menu(props: &ContextMenuProps) -> Html {
let menu_ref = use_node_ref();
// Close menu when clicking outside (handled by parent component)
if !props.is_open {
return html! {};
}
let style = format!(
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
props.x, props.y
);
let on_delete_click = {
let on_delete = props.on_delete.clone();
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
on_delete.emit(e);
on_close.emit(());
})
};
html! {
<div
ref={menu_ref}
class="context-menu"
style={style}
>
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
<span class="context-menu-icon">{"🗑️"}</span>
{"Delete Calendar"}
</div>
</div>
}
}

View File

@@ -1,671 +0,0 @@
use yew::prelude::*;
use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement};
use chrono::{NaiveDate, NaiveTime};
#[derive(Properties, PartialEq)]
pub struct CreateEventModalProps {
pub is_open: bool,
pub selected_date: Option<NaiveDate>,
pub on_close: Callback<()>,
pub on_create: Callback<EventCreationData>,
}
#[derive(Clone, PartialEq, Debug)]
pub enum EventStatus {
Tentative,
Confirmed,
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,
Hours2,
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, Debug)]
pub struct EventCreationData {
pub title: String,
pub description: String,
pub start_date: NaiveDate,
pub start_time: NaiveTime,
pub end_date: NaiveDate,
pub end_time: NaiveTime,
pub location: String,
pub all_day: bool,
pub status: EventStatus,
pub class: EventClass,
pub priority: Option<u8>,
pub organizer: String,
pub attendees: String, // Comma-separated list
pub categories: String, // Comma-separated list
pub reminder: ReminderType,
pub recurrence: RecurrenceType,
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
}
impl Default for EventCreationData {
fn default() -> Self {
let now = chrono::Local::now().naive_local();
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(),
start_date: now.date(),
start_time,
end_date: now.date(),
end_time,
location: String::new(),
all_day: false,
status: EventStatus::default(),
class: EventClass::default(),
priority: None,
organizer: String::new(),
attendees: String::new(),
categories: String::new(),
reminder: ReminderType::default(),
recurrence: RecurrenceType::default(),
recurrence_days: vec![false; 7], // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] - all false by default
}
}
}
#[function_component(CreateEventModal)]
pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
let event_data = use_state(|| EventCreationData::default());
// Initialize with selected date if provided
use_effect_with((props.selected_date, props.is_open), {
let event_data = event_data.clone();
move |(selected_date, is_open)| {
if *is_open {
if let Some(date) = selected_date {
let mut data = (*event_data).clone();
data.start_date = *date;
data.end_date = *date;
event_data.set(data);
} else {
event_data.set(EventCreationData::default());
}
}
|| ()
}
});
if !props.is_open {
return html! {};
}
let on_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_title_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.title = input.value();
event_data.set(data);
}
})
};
let on_description_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() {
let mut data = (*event_data).clone();
data.description = textarea.value();
event_data.set(data);
}
})
};
let on_location_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.location = input.value();
event_data.set(data);
}
})
};
let on_organizer_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.organizer = input.value();
event_data.set(data);
}
})
};
let on_attendees_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(textarea) = e.target_dyn_into::<HtmlTextAreaElement>() {
let mut data = (*event_data).clone();
data.attendees = textarea.value();
event_data.set(data);
}
})
};
let on_categories_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.categories = input.value();
event_data.set(data);
}
})
};
let on_status_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone();
data.status = match select.value().as_str() {
"tentative" => EventStatus::Tentative,
"cancelled" => EventStatus::Cancelled,
_ => EventStatus::Confirmed,
};
event_data.set(data);
}
})
};
let on_class_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone();
data.class = match select.value().as_str() {
"private" => EventClass::Private,
"confidential" => EventClass::Confidential,
_ => EventClass::Public,
};
event_data.set(data);
}
})
};
let on_priority_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.priority = input.value().parse::<u8>().ok().filter(|&p| p <= 9);
event_data.set(data);
}
})
};
let on_reminder_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone();
data.reminder = match select.value().as_str() {
"15min" => ReminderType::Minutes15,
"30min" => ReminderType::Minutes30,
"1hour" => ReminderType::Hour1,
"2hours" => ReminderType::Hours2,
"1day" => ReminderType::Day1,
"2days" => ReminderType::Days2,
"1week" => ReminderType::Week1,
_ => ReminderType::None,
};
event_data.set(data);
}
})
};
let on_recurrence_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone();
data.recurrence = match select.value().as_str() {
"daily" => RecurrenceType::Daily,
"weekly" => RecurrenceType::Weekly,
"monthly" => RecurrenceType::Monthly,
"yearly" => RecurrenceType::Yearly,
_ => RecurrenceType::None,
};
// Reset recurrence days when changing recurrence type
data.recurrence_days = vec![false; 7];
event_data.set(data);
}
})
};
let on_weekday_change = {
let event_data = event_data.clone();
move |day_index: usize| {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
if day_index < data.recurrence_days.len() {
data.recurrence_days[day_index] = input.checked();
event_data.set(data);
}
}
})
}
};
let on_start_date_change = {
let event_data = event_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 data = (*event_data).clone();
data.start_date = date;
event_data.set(data);
}
}
})
};
let on_start_time_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") {
let mut data = (*event_data).clone();
data.start_time = time;
event_data.set(data);
}
}
})
};
let on_end_date_change = {
let event_data = event_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 data = (*event_data).clone();
data.end_date = date;
event_data.set(data);
}
}
})
};
let on_end_time_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
if let Ok(time) = NaiveTime::parse_from_str(&input.value(), "%H:%M") {
let mut data = (*event_data).clone();
data.end_time = time;
event_data.set(data);
}
}
})
};
let on_all_day_change = {
let event_data = event_data.clone();
Callback::from(move |e: Event| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.all_day = input.checked();
event_data.set(data);
}
})
};
let on_create_click = {
let event_data = event_data.clone();
let on_create = props.on_create.clone();
Callback::from(move |_: MouseEvent| {
on_create.emit((*event_data).clone());
})
};
let on_cancel_click = {
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| {
on_close.emit(());
})
};
let data = &*event_data;
html! {
<div class="modal-backdrop" onclick={on_backdrop_click}>
<div class="modal-content create-event-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
<div class="modal-header">
<h3>{"Create New Event"}</h3>
<button type="button" class="modal-close" onclick={Callback::from({
let on_close = props.on_close.clone();
move |_: MouseEvent| on_close.emit(())
})}>{"×"}</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="event-title">{"Title *"}</label>
<input
type="text"
id="event-title"
class="form-input"
value={data.title.clone()}
oninput={on_title_input}
placeholder="Enter event 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="Enter event description"
rows="3"
></textarea>
</div>
<div class="form-group">
<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="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 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 (0-9, optional)"}</label>
<input
type="number"
id="event-priority"
class="form-input"
value={data.priority.map(|p| p.to_string()).unwrap_or_default()}
oninput={on_priority_input}
placeholder="0-9 priority level"
min="0"
max="9"
/>
</div>
<div class="form-group">
<label for="event-organizer">{"Organizer Email"}</label>
<input
type="email"
id="event-organizer"
class="form-input"
value={data.organizer.clone()}
oninput={on_organizer_input}
placeholder="organizer@example.com"
/>
</div>
<div class="form-group">
<label for="event-attendees">{"Attendees (comma-separated emails)"}</label>
<textarea
id="event-attendees"
class="form-input"
value={data.attendees.clone()}
oninput={on_attendees_input}
placeholder="attendee1@example.com, attendee2@example.com"
rows="2"
></textarea>
</div>
<div class="form-group">
<label for="event-categories">{"Categories (comma-separated)"}</label>
<input
type="text"
id="event-categories"
class="form-input"
value={data.categories.clone()}
oninput={on_categories_input}
placeholder="work, meeting, personal"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="event-reminder">{"Reminder"}</label>
<select
id="event-reminder"
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"}</option>
<option value="30min" selected={matches!(data.reminder, ReminderType::Minutes30)}>{"30 minutes"}</option>
<option value="1hour" selected={matches!(data.reminder, ReminderType::Hour1)}>{"1 hour"}</option>
<option value="2hours" selected={matches!(data.reminder, ReminderType::Hours2)}>{"2 hours"}</option>
<option value="1day" selected={matches!(data.reminder, ReminderType::Day1)}>{"1 day"}</option>
<option value="2days" selected={matches!(data.reminder, ReminderType::Days2)}>{"2 days"}</option>
<option value="1week" selected={matches!(data.reminder, ReminderType::Week1)}>{"1 week"}</option>
</select>
</div>
<div class="form-group">
<label for="event-recurrence">{"Recurrence"}</label>
<select
id="event-recurrence"
class="form-input"
onchange={on_recurrence_change}
>
<option value="none" selected={matches!(data.recurrence, RecurrenceType::None)}>{"None"}</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>
// Show weekday selection only when weekly recurrence is selected
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>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick={on_cancel_click}>
{"Cancel"}
</button>
<button
type="button"
class="btn btn-primary"
onclick={on_create_click}
disabled={data.title.trim().is_empty()}
>
{"Create Event"}
</button>
</div>
</div>
</div>
}
}

View File

@@ -1,47 +0,0 @@
use yew::prelude::*;
use web_sys::MouseEvent;
#[derive(Properties, PartialEq)]
pub struct EventContextMenuProps {
pub is_open: bool,
pub x: i32,
pub y: i32,
pub on_delete: Callback<MouseEvent>,
pub on_close: Callback<()>,
}
#[function_component(EventContextMenu)]
pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
let menu_ref = use_node_ref();
if !props.is_open {
return html! {};
}
let style = format!(
"position: fixed; left: {}px; top: {}px; z-index: 1001;",
props.x, props.y
);
let on_delete_click = {
let on_delete = props.on_delete.clone();
let on_close = props.on_close.clone();
Callback::from(move |e: MouseEvent| {
on_delete.emit(e);
on_close.emit(());
})
};
html! {
<div
ref={menu_ref}
class="context-menu"
style={style}
>
<div class="context-menu-item context-menu-delete" onclick={on_delete_click}>
<span class="context-menu-icon">{"🗑️"}</span>
{"Delete Event"}
</div>
</div>
}
}

View File

@@ -1,23 +0,0 @@
pub mod login;
pub mod calendar;
pub mod event_modal;
pub mod create_calendar_modal;
pub mod context_menu;
pub mod event_context_menu;
pub mod calendar_context_menu;
pub mod create_event_modal;
pub mod sidebar;
pub mod calendar_list_item;
pub mod route_handler;
pub use login::Login;
pub use calendar::Calendar;
pub use event_modal::EventModal;
pub use create_calendar_modal::CreateCalendarModal;
pub use context_menu::ContextMenu;
pub use event_context_menu::EventContextMenu;
pub use calendar_context_menu::CalendarContextMenu;
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
pub use sidebar::Sidebar;
pub use calendar_list_item::CalendarListItem;
pub use route_handler::RouteHandler;

View File

@@ -1,259 +0,0 @@
use yew::prelude::*;
use yew_router::prelude::*;
use crate::components::Login;
use crate::services::calendar_service::{UserInfo, CalendarEvent};
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Home,
#[at("/login")]
Login,
#[at("/calendar")]
Calendar,
}
#[derive(Properties, PartialEq)]
pub struct RouteHandlerProps {
pub auth_token: Option<String>,
pub user_info: Option<UserInfo>,
pub on_login: Callback<String>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
}
#[function_component(RouteHandler)]
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 on_event_context_menu = props.on_event_context_menu.clone();
let on_calendar_context_menu = props.on_calendar_context_menu.clone();
html! {
<Switch<Route> render={move |route| {
let auth_token = auth_token.clone();
let user_info = user_info.clone();
let on_login = on_login.clone();
let on_event_context_menu = on_event_context_menu.clone();
let on_calendar_context_menu = on_calendar_context_menu.clone();
match route {
Route::Home => {
if auth_token.is_some() {
html! { <Redirect<Route> to={Route::Calendar}/> }
} else {
html! { <Redirect<Route> to={Route::Login}/> }
}
}
Route::Login => {
if auth_token.is_some() {
html! { <Redirect<Route> to={Route::Calendar}/> }
} else {
html! { <Login {on_login} /> }
}
}
Route::Calendar => {
if auth_token.is_some() {
html! {
<CalendarView
user_info={user_info}
on_event_context_menu={on_event_context_menu}
on_calendar_context_menu={on_calendar_context_menu}
/>
}
} else {
html! { <Redirect<Route> to={Route::Login}/> }
}
}
}
}} />
}
}
#[derive(Properties, PartialEq)]
pub struct CalendarViewProps {
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, chrono::NaiveDate)>>,
}
use gloo_storage::{LocalStorage, Storage};
use crate::services::CalendarService;
use crate::components::Calendar;
use std::collections::HashMap;
use chrono::{Local, NaiveDate, Datelike};
#[function_component(CalendarView)]
pub fn calendar_view(props: &CalendarViewProps) -> Html {
let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new());
let loading = use_state(|| true);
let error = use_state(|| None::<String>);
let refreshing_event = use_state(|| None::<String>);
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
let today = Local::now().date_naive();
let current_year = today.year();
let current_month = today.month();
let on_event_click = {
let events = events.clone();
let refreshing_event = refreshing_event.clone();
let auth_token = auth_token.clone();
Callback::from(move |event: CalendarEvent| {
if let Some(token) = auth_token.clone() {
let events = events.clone();
let refreshing_event = refreshing_event.clone();
let uid = event.uid.clone();
refreshing_event.set(Some(uid.clone()));
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.refresh_event(&token, &password, &uid).await {
Ok(Some(refreshed_event)) => {
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
if refreshed_event.recurrence_rule.is_some() {
let new_occurrences = CalendarService::expand_recurring_events(vec![refreshed_event.clone()]);
for occurrence in new_occurrences {
let date = occurrence.get_date();
updated_events.entry(date)
.or_insert_with(Vec::new)
.push(occurrence);
}
} else {
let date = refreshed_event.get_date();
updated_events.entry(date)
.or_insert_with(Vec::new)
.push(refreshed_event);
}
events.set(updated_events);
}
Ok(None) => {
let mut updated_events = (*events).clone();
for (_, day_events) in updated_events.iter_mut() {
day_events.retain(|e| e.uid != uid);
}
events.set(updated_events);
}
Err(_err) => {
}
}
refreshing_event.set(None);
});
}
})
};
{
let events = events.clone();
let loading = loading.clone();
let error = error.clone();
let auth_token = auth_token.clone();
use_effect_with((), move |_| {
if let Some(token) = auth_token {
let events = events.clone();
let loading = loading.clone();
let error = error.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
match calendar_service.fetch_events_for_month(&token, &password, current_year, current_month).await {
Ok(calendar_events) => {
let grouped_events = CalendarService::group_events_by_date(calendar_events);
events.set(grouped_events);
loading.set(false);
}
Err(err) => {
error.set(Some(format!("Failed to load events: {}", err)));
loading.set(false);
}
}
});
} else {
loading.set(false);
error.set(Some("No authentication token found".to_string()));
}
|| ()
});
}
html! {
<div class="calendar-view">
{
if *loading {
html! {
<div class="calendar-loading">
<p>{"Loading calendar events..."}</p>
</div>
}
} else if let Some(err) = (*error).clone() {
let dummy_callback = Callback::from(|_: CalendarEvent| {});
html! {
<div class="calendar-error">
<p>{format!("Error: {}", err)}</p>
<Calendar
events={HashMap::new()}
on_event_click={dummy_callback}
refreshing_event_uid={(*refreshing_event).clone()}
user_info={props.user_info.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
/>
</div>
}
} else {
html! {
<Calendar
events={(*events).clone()}
on_event_click={on_event_click}
refreshing_event_uid={(*refreshing_event).clone()}
user_info={props.user_info.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
/>
}
}
}
</div>
}
}

View File

@@ -1,89 +0,0 @@
use yew::prelude::*;
use yew_router::prelude::*;
use crate::services::calendar_service::UserInfo;
use crate::components::CalendarListItem;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Home,
#[at("/login")]
Login,
#[at("/calendar")]
Calendar,
}
#[derive(Properties, PartialEq)]
pub struct SidebarProps {
pub user_info: Option<UserInfo>,
pub on_logout: Callback<()>,
pub on_create_calendar: Callback<()>,
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 on_calendar_context_menu: Callback<(MouseEvent, String)>,
}
#[function_component(Sidebar)]
pub fn sidebar(props: &SidebarProps) -> Html {
html! {
<aside class="app-sidebar">
<div class="sidebar-header">
<h1>{"Calendar App"}</h1>
{
if let Some(ref info) = props.user_info {
html! {
<div class="user-info">
<div class="username">{&info.username}</div>
<div class="server-url">{&info.server_url}</div>
</div>
}
} else {
html! { <div class="user-info loading">{"Loading..."}</div> }
}
}
</div>
<nav class="sidebar-nav">
<Link<Route> to={Route::Calendar} classes="nav-link">{"Calendar"}</Link<Route>>
</nav>
{
if let Some(ref info) = props.user_info {
if !info.calendars.is_empty() {
html! {
<div class="calendar-list">
<h3>{"My Calendars"}</h3>
<ul>
{
info.calendars.iter().map(|cal| {
html! {
<CalendarListItem
calendar={cal.clone()}
color_picker_open={props.color_picker_open.as_ref() == Some(&cal.path)}
on_color_change={props.on_color_change.clone()}
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()}
/>
}
}).collect::<Html>()
}
</ul>
</div>
}
} else {
html! { <div class="no-calendars">{"No calendars found"}</div> }
}
} else {
html! {}
}
}
<div class="sidebar-footer">
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
{"+ Create Calendar"}
</button>
<button onclick={props.on_logout.reform(|_| ())} class="logout-button">{"Logout"}</button>
</div>
</aside>
}
}

View File

@@ -1,284 +0,0 @@
use serde::{Deserialize, Serialize};
use std::env;
use base64::prelude::*;
/// Configuration for CalDAV server connection and authentication.
///
/// This struct holds all the necessary information to connect to a CalDAV server,
/// including server URL, credentials, and optional collection paths.
///
/// # Security Note
///
/// The password field contains sensitive information and should be handled carefully.
/// This struct implements `Debug` but in production, consider implementing a custom
/// `Debug` that masks the password field.
///
/// # Example
///
/// ```rust
/// use crate::config::CalDAVConfig;
///
/// // Load configuration from environment variables
/// let config = CalDAVConfig::from_env()?;
///
/// // Use the configuration for HTTP requests
/// let auth_header = format!("Basic {}", config.get_basic_auth());
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalDAVConfig {
/// The base URL of the CalDAV server (e.g., "https://caldav.example.com/dav/")
pub server_url: String,
/// Username for authentication with the CalDAV server
pub username: String,
/// Password for authentication with the CalDAV server
///
/// **Security Note**: This contains sensitive information
pub password: String,
/// Optional path to the calendar collection on the server
///
/// If not provided, the client will need to discover available calendars
/// through CalDAV PROPFIND requests
pub calendar_path: Option<String>,
/// Optional path to the tasks/todo collection on the server
///
/// Some CalDAV servers store tasks separately from calendar events
pub tasks_path: Option<String>,
}
impl CalDAVConfig {
/// Creates a new CalDAVConfig by loading values from environment variables.
///
/// This method will attempt to load a `.env` file from the current directory
/// and then read the following required environment variables:
///
/// - `CALDAV_SERVER_URL`: The CalDAV server base URL
/// - `CALDAV_USERNAME`: Username for authentication
/// - `CALDAV_PASSWORD`: Password for authentication
///
/// Optional environment variables:
///
/// - `CALDAV_CALENDAR_PATH`: Path to calendar collection
/// - `CALDAV_TASKS_PATH`: Path to tasks collection
///
/// # Errors
///
/// Returns `ConfigError::MissingVar` if any required environment variable
/// is not set or cannot be read.
///
/// # Example
///
/// ```rust
/// use crate::config::CalDAVConfig;
///
/// match CalDAVConfig::from_env() {
/// Ok(config) => {
/// println!("Loaded config for server: {}", config.server_url);
/// }
/// Err(e) => {
/// eprintln!("Failed to load config: {}", e);
/// }
/// }
/// ```
pub fn from_env() -> Result<Self, ConfigError> {
// Attempt to load .env file, but don't fail if it doesn't exist
dotenvy::dotenv().ok();
let server_url = env::var("CALDAV_SERVER_URL")
.map_err(|_| ConfigError::MissingVar("CALDAV_SERVER_URL".to_string()))?;
let username = env::var("CALDAV_USERNAME")
.map_err(|_| ConfigError::MissingVar("CALDAV_USERNAME".to_string()))?;
let password = env::var("CALDAV_PASSWORD")
.map_err(|_| ConfigError::MissingVar("CALDAV_PASSWORD".to_string()))?;
// Optional paths - it's fine if these are not set
let calendar_path = env::var("CALDAV_CALENDAR_PATH").ok();
let tasks_path = env::var("CALDAV_TASKS_PATH").ok();
Ok(CalDAVConfig {
server_url,
username,
password,
calendar_path,
tasks_path,
})
}
/// Generates a Base64-encoded string for HTTP Basic Authentication.
///
/// This method combines the username and password in the format
/// `username:password` and encodes it using Base64, which is the
/// standard format for the `Authorization: Basic` HTTP header.
///
/// # Returns
///
/// A Base64-encoded string that can be used directly in the
/// `Authorization` header: `Authorization: Basic <returned_value>`
///
/// # Example
///
/// ```rust
/// use crate::config::CalDAVConfig;
///
/// let config = CalDAVConfig {
/// server_url: "https://example.com".to_string(),
/// username: "user".to_string(),
/// password: "pass".to_string(),
/// calendar_path: None,
/// tasks_path: None,
/// };
///
/// let auth_value = config.get_basic_auth();
/// let auth_header = format!("Basic {}", auth_value);
/// ```
pub fn get_basic_auth(&self) -> String {
let credentials = format!("{}:{}", self.username, self.password);
BASE64_STANDARD.encode(&credentials)
}
}
/// Errors that can occur when loading or using CalDAV configuration.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
/// A required environment variable is missing or cannot be read.
///
/// This error occurs when calling `CalDAVConfig::from_env()` and one of the
/// required environment variables (`CALDAV_SERVER_URL`, `CALDAV_USERNAME`,
/// or `CALDAV_PASSWORD`) is not set.
#[error("Missing environment variable: {0}")]
MissingVar(String),
/// The configuration contains invalid or malformed values.
///
/// This could include malformed URLs, invalid authentication credentials,
/// or other configuration issues that prevent proper CalDAV operation.
#[error("Invalid configuration: {0}")]
Invalid(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_auth_encoding() {
let config = CalDAVConfig {
server_url: "https://example.com".to_string(),
username: "testuser".to_string(),
password: "testpass".to_string(),
calendar_path: None,
tasks_path: None,
};
let auth = config.get_basic_auth();
let expected = BASE64_STANDARD.encode("testuser:testpass");
assert_eq!(auth, expected);
}
/// Integration test that authenticates with the actual Baikal CalDAV server
///
/// This test requires a valid .env file with:
/// - CALDAV_SERVER_URL
/// - CALDAV_USERNAME
/// - CALDAV_PASSWORD
///
/// Run with: `cargo test test_baikal_auth`
#[tokio::test]
async fn test_baikal_auth() {
// Load config from .env
let config = CalDAVConfig::from_env()
.expect("Failed to load CalDAV config from environment");
println!("Testing authentication to: {}", config.server_url);
// Create HTTP client
let client = reqwest::Client::new();
// Make a simple OPTIONS request to test authentication
let response = client
.request(reqwest::Method::OPTIONS, &config.server_url)
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
.header("User-Agent", "calendar-app/0.1.0")
.send()
.await
.expect("Failed to send request to CalDAV server");
println!("Response status: {}", response.status());
println!("Response headers: {:#?}", response.headers());
// Check if we got a successful response or at least not a 401 Unauthorized
assert!(
response.status().is_success() || response.status() != 401,
"Authentication failed with status: {}. Check your credentials in .env",
response.status()
);
// For Baikal/CalDAV servers, we should see DAV headers
assert!(
response.headers().contains_key("dav") ||
response.headers().contains_key("DAV") ||
response.status().is_success(),
"Server doesn't appear to be a CalDAV server - missing DAV headers"
);
println!("✓ Authentication test passed!");
}
/// Test making a PROPFIND request to discover calendars
///
/// This test requires a valid .env file and makes an actual CalDAV PROPFIND request
///
/// Run with: `cargo test test_propfind_calendars`
#[tokio::test]
async fn test_propfind_calendars() {
let config = CalDAVConfig::from_env()
.expect("Failed to load CalDAV config from environment");
let client = reqwest::Client::new();
// CalDAV PROPFIND request to discover calendars
let propfind_body = r#"<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:resourcetype />
<d:displayname />
<c:calendar-description />
<c:supported-calendar-component-set />
</d:prop>
</d:propfind>"#;
let response = client
.request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &config.server_url)
.header("Authorization", format!("Basic {}", config.get_basic_auth()))
.header("Content-Type", "application/xml")
.header("Depth", "1")
.header("User-Agent", "calendar-app/0.1.0")
.body(propfind_body)
.send()
.await
.expect("Failed to send PROPFIND request");
let status = response.status();
println!("PROPFIND Response status: {}", status);
let body = response.text().await.expect("Failed to read response body");
println!("PROPFIND Response body: {}", body);
// We should get a 207 Multi-Status for PROPFIND
assert_eq!(
status,
reqwest::StatusCode::from_u16(207).unwrap(),
"PROPFIND should return 207 Multi-Status"
);
// The response should contain XML with calendar information
assert!(body.contains("calendar"), "Response should contain calendar information");
println!("✓ PROPFIND calendars test passed!");
}
}

Some files were not shown because too many files have changed in this diff Show More