24 Commits

Author SHA1 Message Date
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
36 changed files with 3117 additions and 2617 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -845,17 +845,18 @@ fn parse_event_datetime(
time_str: &str,
all_day: bool,
) -> Result<chrono::DateTime<chrono::Utc>, String> {
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
use chrono::{Local, 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 midnight UTC
// 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(0, 0, 0)
.ok_or_else(|| "Failed to create midnight datetime".to_string())?;
.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
@@ -865,7 +866,11 @@ fn parse_event_datetime(
// 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))
// Treat the datetime as local time and convert to UTC
let local_datetime = Local.from_local_datetime(&datetime)
.single()
.ok_or_else(|| "Ambiguous local datetime".to_string())?;
Ok(local_datetime.with_timezone(&Utc))
}
}

View File

@@ -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,410 @@
use axum::{
extract::{Path, State},
response::Json,
};
use chrono::{DateTime, Utc};
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 {
let client = Client::new();
let response = client
.get(&calendar.url)
.send()
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?;
if !response.status().is_success() {
return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status())));
}
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;
}
}
}
}
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
}

View File

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

View File

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

View File

@@ -130,9 +130,17 @@ pub async fn create_event_series(
.and_hms_opt(23, 59, 59)
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
// Convert from local time to UTC
let start_local = chrono::Local.from_local_datetime(&start_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
let end_local = chrono::Local.from_local_datetime(&end_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
(
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
start_local.with_timezone(&chrono::Utc),
end_local.with_timezone(&chrono::Utc),
)
} else {
// Parse times for timed events
@@ -163,9 +171,17 @@ pub async fn create_event_series(
start_date.and_time(end_time)
};
// Convert from local time to UTC
let start_local = chrono::Local.from_local_datetime(&start_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
let end_local = chrono::Local.from_local_datetime(&end_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
(
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
start_local.with_timezone(&chrono::Utc),
end_local.with_timezone(&chrono::Utc),
)
};
@@ -246,8 +262,8 @@ pub async fn update_event_series(
Json(request): Json<UpdateEventSeriesRequest>,
) -> Result<Json<UpdateEventSeriesResponse>, ApiError> {
println!(
"🔄 Update event series request received: series_uid='{}', update_scope='{}'",
request.series_uid, request.update_scope
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date
);
// Extract and verify token
@@ -381,8 +397,9 @@ pub async fn update_event_series(
};
let (start_datetime, end_datetime) = if request.all_day {
// For all-day events, use noon UTC to avoid timezone boundary issues
let start_dt = start_date
.and_hms_opt(0, 0, 0)
.and_hms_opt(12, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
// For all-day events, also preserve the original date pattern
@@ -398,9 +415,10 @@ pub async fn update_event_series(
};
let end_dt = end_date
.and_hms_opt(23, 59, 59)
.and_hms_opt(12, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
// For all-day events, use UTC directly (no local conversion needed)
(
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
@@ -438,9 +456,17 @@ pub async fn update_event_series(
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
};
// Convert from local time to UTC
let start_local = chrono::Local.from_local_datetime(&start_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?;
let end_local = chrono::Local.from_local_datetime(&end_dt)
.single()
.ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?;
(
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
start_local.with_timezone(&chrono::Utc),
end_local.with_timezone(&chrono::Utc),
)
};
@@ -733,9 +759,36 @@ fn update_entire_series(
updated_event.last_modified = Some(now);
// Keep original created timestamp to preserve event history
// For simple updates (like drag operations), preserve the existing RRULE
// For more complex updates, we might need to regenerate it, but for now keep it simple
// updated_event.rrule remains unchanged from the clone
// Update RRULE if recurrence parameters are provided
if let Some(ref existing_rrule) = updated_event.rrule {
let mut new_rrule = existing_rrule.clone();
println!("🔄 Original RRULE: {}", existing_rrule);
// Update COUNT if provided
if let Some(count) = request.recurrence_count {
println!("🔄 Updating RRULE with new COUNT: {}", count);
// Remove old COUNT or UNTIL parameters
new_rrule = new_rrule.split(';')
.filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL="))
.collect::<Vec<_>>()
.join(";");
// Add new COUNT
new_rrule = format!("{};COUNT={}", new_rrule, count);
} else if let Some(ref end_date) = request.recurrence_end_date {
println!("🔄 Updating RRULE with new UNTIL: {}", end_date);
// Remove old COUNT or UNTIL parameters
new_rrule = new_rrule.split(';')
.filter(|part| !part.starts_with("COUNT=") && !part.starts_with("UNTIL="))
.collect::<Vec<_>>()
.join(";");
// Add new UNTIL (convert YYYY-MM-DD to YYYYMMDD format)
let until_date = end_date.replace("-", "");
new_rrule = format!("{};UNTIL={}", new_rrule, until_date);
}
println!("🔄 Updated RRULE: {}", new_rrule);
updated_event.rrule = Some(new_rrule);
}
// Copy the updated event back to existing_event for the main handler
*existing_event = updated_event.clone();

View File

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

View File

@@ -30,6 +30,7 @@ pub struct UserPreferencesResponse {
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>,
pub last_used_calendar: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -40,6 +41,7 @@ pub struct UpdatePreferencesRequest {
pub calendar_theme: Option<String>,
pub calendar_style: Option<String>,
pub calendar_colors: Option<String>,
pub last_used_calendar: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -54,6 +56,7 @@ pub struct CalendarInfo {
pub path: String,
pub display_name: String,
pub color: String,
pub is_visible: bool,
}
#[derive(Debug, Deserialize)]

View File

@@ -37,6 +37,7 @@ reqwest = { version = "0.11", features = ["json"] }
ical = "0.7"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde-wasm-bindgen = "0.6"
# Date and time handling
chrono = { version = "0.4", features = ["serde", "wasm-bindgen"] }

View File

@@ -1,13 +1,14 @@
use crate::components::{
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModalV2, DeleteAction,
EditAction, EventClass, EventContextMenu, EventCreationData, EventStatus, RecurrenceType,
ReminderType, RouteHandler, Sidebar, Theme, ViewMode,
CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction,
EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler,
Sidebar, Theme, ViewMode,
};
use crate::components::sidebar::{Style};
use crate::models::ical::VEvent;
use crate::services::{calendar_service::UserInfo, CalendarService};
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
use chrono::NaiveDate;
use gloo_storage::{LocalStorage, Storage};
use gloo_timers::callback::Interval;
use wasm_bindgen::JsCast;
use web_sys::MouseEvent;
use yew::prelude::*;
@@ -74,6 +75,12 @@ pub fn App() -> Html {
let _recurring_edit_modal_open = use_state(|| false);
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
// External calendar state
let external_calendars = use_state(|| -> Vec<ExternalCalendar> { Vec::new() });
let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() });
let external_calendar_modal_open = use_state(|| false);
let refresh_interval = use_state(|| -> Option<Interval> { None });
// Calendar view state - load from localStorage if available
let current_view = use_state(|| {
@@ -302,6 +309,80 @@ pub fn App() -> Html {
});
}
// Function to refresh external calendars
let refresh_external_calendars = {
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
Callback::from(move |_| {
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
wasm_bindgen_futures::spawn_local(async move {
// Load external calendars
match CalendarService::get_external_calendars().await {
Ok(calendars) => {
external_calendars.set(calendars.clone());
// Load events for visible external calendars
let mut all_events = Vec::new();
for calendar in calendars {
if calendar.is_visible {
if let Ok(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await {
// Set calendar_path for color matching
for event in &mut events {
event.calendar_path = Some(format!("external_{}", calendar.id));
}
all_events.extend(events);
}
}
}
external_calendar_events.set(all_events);
}
Err(err) => {
web_sys::console::log_1(
&format!("Failed to load external calendars: {}", err).into(),
);
}
}
});
})
};
// Load external calendars when auth token is available and set up auto-refresh
{
let auth_token = auth_token.clone();
let refresh_external_calendars = refresh_external_calendars.clone();
let refresh_interval = refresh_interval.clone();
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
use_effect_with((*auth_token).clone(), move |token| {
if let Some(_) = token {
// Initial load
refresh_external_calendars.emit(());
// Set up 5-minute refresh interval
let refresh_external_calendars = refresh_external_calendars.clone();
let interval = Interval::new(5 * 60 * 1000, move || {
refresh_external_calendars.emit(());
});
refresh_interval.set(Some(interval));
} else {
// Clear data and interval when logged out
external_calendars.set(Vec::new());
external_calendar_events.set(Vec::new());
refresh_interval.set(None);
}
// Cleanup function
let refresh_interval = refresh_interval.clone();
move || {
// Clear interval on cleanup
refresh_interval.set(None);
}
});
}
let on_outside_click = {
let color_picker_open = color_picker_open.clone();
let context_menu_open = context_menu_open.clone();
@@ -413,7 +494,151 @@ pub fn App() -> Html {
let create_event_modal_open = create_event_modal_open.clone();
let auth_token = auth_token.clone();
Callback::from(move |event_data: EventCreationData| {
// Check if this is an update operation (has original_uid) or a create operation
if let Some(original_uid) = event_data.original_uid.clone() {
web_sys::console::log_1(&format!("Updating event via modal: {:?}", event_data).into());
create_event_modal_open.set(false);
// Handle the update operation using the existing backend update logic
if let Some(token) = (*auth_token).clone() {
let event_data_for_update = event_data.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()
};
// Convert EventCreationData to update parameters
let params = event_data_for_update.to_create_event_params();
// Determine if this is a recurring event update
let is_recurring = matches!(event_data_for_update.recurrence, crate::components::event_form::RecurrenceType::Daily |
crate::components::event_form::RecurrenceType::Weekly |
crate::components::event_form::RecurrenceType::Monthly |
crate::components::event_form::RecurrenceType::Yearly);
let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() {
// Use series update endpoint for recurring events
let edit_action = event_data_for_update.edit_scope.unwrap();
let scope = match edit_action {
crate::components::EditAction::EditAll => "all_in_series".to_string(),
crate::components::EditAction::EditFuture => "this_and_future".to_string(),
crate::components::EditAction::EditThis => "this_only".to_string(),
};
calendar_service
.update_series(
&token,
&password,
original_uid.clone(),
params.0, // title
params.1, // description
params.2, // start_date
params.3, // start_time
params.4, // end_date
params.5, // end_time
params.6, // location
params.7, // all_day
params.8, // status
params.9, // class
params.10, // priority
params.11, // organizer
params.12, // attendees
params.13, // categories
params.14, // reminder
params.15, // recurrence
params.16, // recurrence_days
params.18, // recurrence_count
params.19, // recurrence_until
params.17, // calendar_path
scope,
event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date
)
.await
} else {
// Use regular update endpoint for single events
calendar_service
.update_event(
&token,
&password,
original_uid.clone(),
params.0, // title
params.1, // description
params.2, // start_date
params.3, // start_time
params.4, // end_date
params.5, // end_time
params.6, // location
params.7, // all_day
params.8, // status
params.9, // class
params.10, // priority
params.11, // organizer
params.12, // attendees
params.13, // categories
params.14, // reminder
params.15, // recurrence
params.16, // recurrence_days
params.17, // calendar_path
vec![], // exception_dates - empty for simple updates
None, // update_action - None for regular updates
None, // until_date - None for regular updates
)
.await
};
match update_result {
Ok(_) => {
web_sys::console::log_1(&"Event updated successfully via modal".into());
// Trigger a page reload to refresh events from all calendars
if let Some(window) = web_sys::window() {
let _ = window.location().reload();
}
}
Err(err) => {
web_sys::console::error_1(
&format!("Failed to update event: {}", err).into(),
);
web_sys::window()
.unwrap()
.alert_with_message(&format!("Failed to update event: {}", err))
.unwrap();
}
}
});
}
return;
}
web_sys::console::log_1(&format!("Creating event: {:?}", event_data).into());
// Save the selected calendar as the last used calendar
if let Some(ref calendar_path) = event_data.selected_calendar {
let _ = LocalStorage::set("last_used_calendar", calendar_path);
// Also sync to backend asynchronously
let calendar_path_for_sync = calendar_path.clone();
wasm_bindgen_futures::spawn_local(async move {
let preferences_service = crate::services::preferences::PreferencesService::new();
if let Err(e) = preferences_service.update_last_used_calendar(&calendar_path_for_sync).await {
web_sys::console::warn_1(&format!("Failed to sync last used calendar to backend: {}", e).into());
}
});
}
create_event_modal_open.set(false);
if let Some(_token) = (*auth_token).clone() {
@@ -455,6 +680,8 @@ pub fn App() -> Html {
params.14, // reminder
params.15, // recurrence
params.16, // recurrence_days
params.18, // recurrence_count
params.19, // recurrence_until
params.17, // calendar_path
)
.await;
@@ -534,18 +761,11 @@ pub fn App() -> Html {
String::new()
};
// Convert local times to UTC for backend storage
let start_utc = new_start
.and_local_timezone(chrono::Local)
.unwrap()
.to_utc();
let end_utc = new_end.and_local_timezone(chrono::Local).unwrap().to_utc();
// Format UTC date and time strings for backend
let start_date = start_utc.format("%Y-%m-%d").to_string();
let start_time = start_utc.format("%H:%M").to_string();
let end_date = end_utc.format("%Y-%m-%d").to_string();
let end_time = end_utc.format("%H:%M").to_string();
// Send local time directly to backend (backend will handle UTC conversion)
let start_date = new_start.format("%Y-%m-%d").to_string();
let start_time = new_start.format("%H:%M").to_string();
let end_date = new_end.format("%Y-%m-%d").to_string();
let end_time = new_end.format("%H:%M").to_string();
// Convert existing event data to string formats for the API
let status_str = match original_event.status {
@@ -624,6 +844,9 @@ pub fn App() -> Html {
original_event.categories.join(","),
reminder_str.clone(),
recurrence_str.clone(),
vec![false; 7],
None,
None,
original_event.calendar_path.clone(),
scope.clone(),
occurrence_date,
@@ -783,11 +1006,146 @@ pub fn App() -> Html {
let create_modal_open = create_modal_open.clone();
move |_| create_modal_open.set(true)
})}
on_create_external_calendar={Callback::from({
let external_calendar_modal_open = external_calendar_modal_open.clone();
move |_| external_calendar_modal_open.set(true)
})}
external_calendars={(*external_calendars).clone()}
on_external_calendar_toggle={Callback::from({
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
move |id: i32| {
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
wasm_bindgen_futures::spawn_local(async move {
// Find the calendar and toggle its visibility
let mut calendars = (*external_calendars).clone();
if let Some(calendar) = calendars.iter_mut().find(|c| c.id == id) {
calendar.is_visible = !calendar.is_visible;
// Update on server
if let Err(err) = CalendarService::update_external_calendar(
calendar.id,
&calendar.name,
&calendar.url,
&calendar.color,
calendar.is_visible,
).await {
web_sys::console::log_1(
&format!("Failed to update external calendar: {}", err).into(),
);
return;
}
external_calendars.set(calendars.clone());
// Reload events for all visible external calendars
let mut all_events = Vec::new();
for cal in calendars {
if cal.is_visible {
if let Ok(mut events) = CalendarService::fetch_external_calendar_events(cal.id).await {
// Set calendar_path for color matching
for event in &mut events {
event.calendar_path = Some(format!("external_{}", cal.id));
}
all_events.extend(events);
}
}
}
external_calendar_events.set(all_events);
}
});
}
})}
on_external_calendar_delete={Callback::from({
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
move |id: i32| {
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
wasm_bindgen_futures::spawn_local(async move {
// Delete the external calendar from the server
if let Err(err) = CalendarService::delete_external_calendar(id).await {
web_sys::console::log_1(
&format!("Failed to delete external calendar: {}", err).into(),
);
return;
}
// Remove calendar from local state
let mut calendars = (*external_calendars).clone();
calendars.retain(|c| c.id != id);
external_calendars.set(calendars.clone());
// Remove events from this calendar
let mut events = (*external_calendar_events).clone();
events.retain(|e| {
if let Some(ref calendar_path) = e.calendar_path {
calendar_path != &format!("external_{}", id)
} else {
true
}
});
external_calendar_events.set(events);
});
}
})}
on_external_calendar_refresh={Callback::from({
let external_calendar_events = external_calendar_events.clone();
let external_calendars = external_calendars.clone();
move |id: i32| {
let external_calendar_events = external_calendar_events.clone();
let external_calendars = external_calendars.clone();
wasm_bindgen_futures::spawn_local(async move {
// Force refresh of this specific calendar
if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await {
// Set calendar_path for color matching
for event in &mut events {
event.calendar_path = Some(format!("external_{}", id));
}
// Update events for this calendar
let mut all_events = (*external_calendar_events).clone();
// Remove old events from this calendar
all_events.retain(|e| {
if let Some(ref calendar_path) = e.calendar_path {
calendar_path != &format!("external_{}", id)
} else {
true
}
});
// Add new events
all_events.extend(events);
external_calendar_events.set(all_events);
// Update the last_fetched timestamp in calendars list
if let Ok(calendars) = CalendarService::get_external_calendars().await {
external_calendars.set(calendars);
}
}
});
}
})}
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).clone()}
on_calendar_context_menu={on_calendar_context_menu}
on_calendar_visibility_toggle={Callback::from({
let user_info = user_info.clone();
move |calendar_path: String| {
let user_info = user_info.clone();
wasm_bindgen_futures::spawn_local(async move {
if let Some(mut info) = (*user_info).clone() {
// Toggle the visibility
if let Some(calendar) = info.calendars.iter_mut().find(|c| c.path == calendar_path) {
calendar.is_visible = !calendar.is_visible;
user_info.set(Some(info));
}
}
});
}
})}
current_view={(*current_view).clone()}
on_view_change={on_view_change}
current_theme={(*current_theme).clone()}
@@ -800,6 +1158,8 @@ pub fn App() -> Html {
auth_token={(*auth_token).clone()}
user_info={(*user_info).clone()}
on_login={on_login.clone()}
external_calendar_events={(*external_calendar_events).clone()}
external_calendars={(*external_calendars).clone()}
on_event_context_menu={Some(on_event_context_menu.clone())}
on_calendar_context_menu={Some(on_calendar_date_context_menu.clone())}
view={(*current_view).clone()}
@@ -1031,7 +1391,7 @@ pub fn App() -> Html {
on_create_event={on_create_event_click}
/>
<CreateEventModalV2
<CreateEventModal
is_open={*create_event_modal_open}
selected_date={(*selected_date_for_event).clone()}
initial_start_time={None}
@@ -1052,6 +1412,59 @@ pub fn App() -> Html {
on_create={on_event_create}
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
/>
<ExternalCalendarModal
is_open={*external_calendar_modal_open}
on_close={Callback::from({
let external_calendar_modal_open = external_calendar_modal_open.clone();
move |_| external_calendar_modal_open.set(false)
})}
on_success={Callback::from({
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
move |new_calendar_id: i32| {
let external_calendars = external_calendars.clone();
let external_calendar_events = external_calendar_events.clone();
wasm_bindgen_futures::spawn_local(async move {
// First, refresh the calendar list to get the new calendar
match CalendarService::get_external_calendars().await {
Ok(calendars) => {
external_calendars.set(calendars.clone());
// Then immediately fetch events for the new calendar if it's visible
if let Some(new_calendar) = calendars.iter().find(|c| c.id == new_calendar_id) {
if new_calendar.is_visible {
match CalendarService::fetch_external_calendar_events(new_calendar_id).await {
Ok(mut events) => {
// Set calendar_path for color matching
for event in &mut events {
event.calendar_path = Some(format!("external_{}", new_calendar_id));
}
// Add the new calendar's events to existing events
let mut all_events = (*external_calendar_events).clone();
all_events.extend(events);
external_calendar_events.set(all_events);
}
Err(e) => {
web_sys::console::log_1(
&format!("Failed to fetch events for new calendar {}: {}", new_calendar_id, e).into(),
);
}
}
}
}
}
Err(err) => {
web_sys::console::log_1(
&format!("Failed to refresh calendars after creation: {}", err).into(),
);
}
}
});
}
})}
/>
</div>
</BrowserRouter>
}

View File

@@ -1,8 +1,8 @@
use crate::components::{
CalendarHeader, CreateEventModalV2, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
};
use crate::models::ical::VEvent;
use crate::services::{calendar_service::UserInfo, CalendarService};
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
use chrono::{Datelike, Duration, Local, NaiveDate};
use gloo_storage::{LocalStorage, Storage};
use std::collections::HashMap;
@@ -14,6 +14,10 @@ pub struct CalendarProps {
#[prop_or_default]
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub external_calendar_events: Vec<VEvent>,
#[prop_or_default]
pub external_calendars: Vec<ExternalCalendar>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
@@ -101,10 +105,14 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let loading = loading.clone();
let error = error.clone();
let current_date = current_date.clone();
let external_events = props.external_calendar_events.clone(); // Clone before the effect
let view = props.view.clone(); // Clone before the effect
use_effect_with((*current_date, props.view.clone()), move |(date, _view)| {
use_effect_with((*current_date, view.clone(), external_events.len(), props.user_info.clone()), move |(date, _view, _external_len, user_info)| {
let auth_token: Option<String> = LocalStorage::get("auth_token").ok();
let date = *date; // Clone the date to avoid lifetime issues
let external_events = external_events.clone(); // Clone external events to avoid lifetime issues
let user_info = user_info.clone(); // Clone user_info to avoid lifetime issues
if let Some(token) = auth_token {
let events = events.clone();
@@ -141,7 +149,38 @@ pub fn Calendar(props: &CalendarProps) -> Html {
.await
{
Ok(vevents) => {
let grouped_events = CalendarService::group_events_by_date(vevents);
// Filter CalDAV events based on calendar visibility
let mut filtered_events = if let Some(user_info) = user_info.as_ref() {
vevents.into_iter()
.filter(|event| {
if let Some(calendar_path) = event.calendar_path.as_ref() {
// Find the calendar info for this event
user_info.calendars.iter()
.find(|cal| &cal.path == calendar_path)
.map(|cal| cal.is_visible)
.unwrap_or(true) // Default to visible if not found
} else {
true // Show events without calendar path
}
})
.collect()
} else {
vevents // Show all events if no user info
};
// Mark external events as external by adding a special category
let marked_external_events: Vec<VEvent> = external_events
.into_iter()
.map(|mut event| {
// Add a special category to identify external events
event.categories.push("__EXTERNAL_CALENDAR__".to_string());
event
})
.collect();
filtered_events.extend(marked_external_events);
let grouped_events = CalendarService::group_events_by_date(filtered_events);
events.set(grouped_events);
loading.set(false);
}
@@ -452,6 +491,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_event_click={on_event_click.clone()}
refreshing_event_uid={(*refreshing_event_uid).clone()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
selected_date={Some(*selected_date)}
@@ -467,6 +507,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_event_click={on_event_click.clone()}
refreshing_event_uid={(*refreshing_event_uid).clone()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
on_create_event={Some(on_create_event)}
@@ -492,7 +533,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
/>
// Create event modal
<CreateEventModalV2
<CreateEventModal
is_open={*show_create_modal}
selected_date={create_event_data.as_ref().map(|(date, _, _)| *date)}
event_to_edit={None}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,210 +0,0 @@
use crate::components::event_form::*;
use crate::components::create_event_modal::{EventCreationData}; // Use the existing types
use crate::components::{EditAction};
use crate::models::ical::VEvent;
use crate::services::calendar_service::CalendarInfo;
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(CreateEventModalV2)]
pub fn create_event_modal_v2(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 {
// TODO: Convert VEvent to EventCreationData
EventCreationData::default()
} 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() {
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();
Callback::from(move |_: MouseEvent| {
on_create.emit((*event_data).clone());
})
};
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>
}
}

View File

@@ -1,7 +1,7 @@
use super::types::*;
use crate::components::create_event_modal::{EventStatus, EventClass};
// Types are already imported from super::types::*
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, HtmlSelectElement};
use web_sys::HtmlSelectElement;
use yew::prelude::*;
#[function_component(AdvancedTab)]

View File

@@ -1,5 +1,5 @@
use super::types::*;
use crate::components::create_event_modal::{EventStatus, EventClass, RecurrenceType, ReminderType};
// Types are already imported from super::types::*
use chrono::{Datelike, NaiveDate};
use wasm_bindgen::JsCast;
use web_sys::{HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};

View File

@@ -1,5 +1,5 @@
use super::types::*;
use crate::components::create_event_modal::ReminderType;
// Types are already imported from super::types::*
use wasm_bindgen::JsCast;
use web_sys::HtmlSelectElement;
use yew::prelude::*;

View File

@@ -1,7 +1,5 @@
use crate::models::ical::VEvent;
use crate::services::calendar_service::CalendarInfo;
use chrono::{Datelike, Local, NaiveDate, NaiveTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use chrono::{Local, NaiveDate, NaiveTime};
use yew::prelude::*;
#[derive(Clone, PartialEq, Debug)]
@@ -78,12 +76,7 @@ impl Default for ModalTab {
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum EditAction {
ThisOnly,
ThisAndFuture,
AllInSeries,
}
// EditAction is now imported from event_context_menu - this duplicate removed
#[derive(Clone, PartialEq, Debug)]
pub struct EventCreationData {
@@ -130,8 +123,58 @@ pub struct EventCreationData {
pub selected_calendar: Option<String>,
// Edit tracking (for recurring events)
pub edit_scope: Option<EditAction>,
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
) {
(
self.title.clone(),
self.description.clone(),
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.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 {
@@ -168,6 +211,8 @@ impl Default for EventCreationData {
selected_calendar: None,
edit_scope: None,
changed_fields: vec![],
original_uid: None,
occurrence_date: None,
}
}
}
@@ -175,6 +220,6 @@ impl Default for EventCreationData {
// Common props for all tab components
#[derive(Properties, PartialEq)]
pub struct TabProps {
pub data: UseStateHandle<crate::components::create_event_modal::EventCreationData>,
pub data: UseStateHandle<EventCreationData>,
pub available_calendars: Vec<CalendarInfo>,
}

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

@@ -5,10 +5,10 @@ pub mod calendar_list_item;
pub mod context_menu;
pub mod create_calendar_modal;
pub mod create_event_modal;
pub mod create_event_modal_v2;
pub mod event_context_menu;
pub mod event_form;
pub mod event_modal;
pub mod external_calendar_modal;
pub mod login;
pub mod month_view;
pub mod recurring_edit_modal;
@@ -22,16 +22,12 @@ pub use calendar_header::CalendarHeader;
pub use calendar_list_item::CalendarListItem;
pub use context_menu::ContextMenu;
pub use create_calendar_modal::CreateCalendarModal;
pub use create_event_modal::{
CreateEventModal, EventClass, EventCreationData, EventStatus, RecurrenceType, ReminderType,
};
pub use create_event_modal_v2::CreateEventModalV2;
pub use event_form::{
EventClass as EventFormClass, EventCreationData as EventFormData, EventStatus as EventFormStatus,
RecurrenceType as EventFormRecurrenceType, ReminderType as EventFormReminderType,
};
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 external_calendar_modal::ExternalCalendarModal;
pub use login::Login;
pub use month_view::MonthView;
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
use crate::components::CalendarListItem;
use crate::services::calendar_service::UserInfo;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use web_sys::HtmlSelectElement;
use yew::prelude::*;
use yew_router::prelude::*;
@@ -101,11 +101,17 @@ pub struct SidebarProps {
pub user_info: Option<UserInfo>,
pub on_logout: Callback<()>,
pub on_create_calendar: Callback<()>,
pub on_create_external_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 on_calendar_context_menu: Callback<(MouseEvent, String)>,
pub on_calendar_visibility_toggle: Callback<String>,
pub current_view: ViewMode,
pub on_view_change: Callback<ViewMode>,
pub current_theme: Theme,
@@ -116,6 +122,7 @@ pub struct SidebarProps {
#[function_component(Sidebar)]
pub fn sidebar(props: &SidebarProps) -> Html {
let external_context_menu_open = use_state(|| None::<i32>);
let on_view_change = {
let on_view_change = props.on_view_change.clone();
Callback::from(move |e: Event| {
@@ -155,6 +162,30 @@ pub fn sidebar(props: &SidebarProps) -> Html {
})
};
let on_external_calendar_context_menu = {
let external_context_menu_open = external_context_menu_open.clone();
Callback::from(move |(e, cal_id): (MouseEvent, i32)| {
e.prevent_default();
external_context_menu_open.set(Some(cal_id));
})
};
let on_external_calendar_delete = {
let on_external_calendar_delete = props.on_external_calendar_delete.clone();
let external_context_menu_open = external_context_menu_open.clone();
Callback::from(move |cal_id: i32| {
on_external_calendar_delete.emit(cal_id);
external_context_menu_open.set(None);
})
};
let close_external_context_menu = {
let external_context_menu_open = external_context_menu_open.clone();
Callback::from(move |_| {
external_context_menu_open.set(None);
})
};
html! {
<aside class="app-sidebar">
<div class="sidebar-header">
@@ -192,6 +223,7 @@ pub fn sidebar(props: &SidebarProps) -> Html {
on_color_picker_toggle={props.on_color_picker_toggle.clone()}
available_colors={props.available_colors.clone()}
on_context_menu={props.on_calendar_context_menu.clone()}
on_visibility_toggle={props.on_calendar_visibility_toggle.clone()}
/>
}
}).collect::<Html>()
@@ -206,10 +238,127 @@ pub fn sidebar(props: &SidebarProps) -> Html {
html! {}
}
}
// External calendars section
<div class="external-calendar-list">
<h3>{"External Calendars"}</h3>
{
if !props.external_calendars.is_empty() {
html! {
<ul class="external-calendar-items">
{
props.external_calendars.iter().map(|cal| {
let on_toggle = {
let on_external_calendar_toggle = props.on_external_calendar_toggle.clone();
let cal_id = cal.id;
Callback::from(move |_| {
on_external_calendar_toggle.emit(cal_id);
})
};
html! {
<li class="external-calendar-item" style="position: relative;">
<div
class="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)}
/>
<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);
})
}}
>
{"🔄"}
</button>
</div>
</div>
{
if *external_context_menu_open == Some(cal.id) {
html! {
<>
<div
class="context-menu-overlay"
onclick={close_external_context_menu.clone()}
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 999;"
/>
<div class="context-menu" style="position: absolute; top: 0; right: 0; background: white; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 1000; min-width: 120px;">
<div
class="context-menu-item"
style="padding: 8px 12px; cursor: pointer; color: #d73a49;"
onclick={{
let on_delete = on_external_calendar_delete.clone();
let cal_id = cal.id;
Callback::from(move |_| {
on_delete.emit(cal_id);
})
}}
>
{"Delete Calendar"}
</div>
</div>
</>
}
} else {
html! {}
}
}
</li>
}
}).collect::<Html>()
}
</ul>
}
} else {
html! {}
}
}
</div>
<div class="sidebar-footer">
<button onclick={props.on_create_calendar.reform(|_| ())} class="create-calendar-button">
{"+ Create Calendar"}
</button>
<button onclick={props.on_create_external_calendar.reform(|_| ())} class="create-external-calendar-button">
{"+ Add External Calendar"}
</button>
<div class="view-selector">
<select class="view-selector-dropdown" onchange={on_view_change}>

View File

@@ -1,6 +1,6 @@
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
use crate::models::ical::VEvent;
use crate::services::calendar_service::UserInfo;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
use std::collections::HashMap;
use web_sys::MouseEvent;
@@ -17,6 +17,8 @@ pub struct WeekViewProps {
#[prop_or_default]
pub user_info: Option<UserInfo>,
#[prop_or_default]
pub external_calendars: Vec<ExternalCalendar>,
#[prop_or_default]
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
@@ -81,8 +83,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Helper function to get calendar color for an event
let get_event_color = |event: &VEvent| -> String {
if let Some(user_info) = &props.user_info {
if let Some(calendar_path) = &event.calendar_path {
if let Some(calendar_path) = &event.calendar_path {
// Check external calendars first (path format: "external_{id}")
if calendar_path.starts_with("external_") {
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
if let Some(external_calendar) = props.external_calendars
.iter()
.find(|cal| cal.id == id_str)
{
return external_calendar.color.clone();
}
}
}
// Check regular calendars
else if let Some(user_info) = &props.user_info {
if let Some(calendar) = user_info
.calendars
.iter()
@@ -95,8 +109,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
"#3B82F6".to_string()
};
// Generate time labels - 24 hours plus the final midnight boundary
let mut time_labels: Vec<String> = (0..24)
// Generate time labels - 24 hours
let time_labels: Vec<String> = (0..24)
.map(|hour| {
if hour == 0 {
"12 AM".to_string()
@@ -110,9 +124,6 @@ pub fn week_view(props: &WeekViewProps) -> Html {
})
.collect();
// Add the final midnight boundary to show where the day ends
time_labels.push("12 AM".to_string());
// Handlers for recurring event modification modal
let on_recurring_choice = {
let pending_recurring_edit = pending_recurring_edit.clone();
@@ -319,10 +330,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
week_days.iter().map(|date| {
let is_today = *date == props.today;
let weekday_name = get_weekday_name(date.weekday());
let day_events = props.events.get(date).cloned().unwrap_or_default();
// Filter for all-day events only
let all_day_events: Vec<_> = day_events.iter().filter(|event| event.all_day).collect();
// Collect all-day events that span this date (from any day in the week)
let mut all_day_events: Vec<&VEvent> = Vec::new();
for events_list in props.events.values() {
for event in events_list {
if event.all_day && event_spans_date(event, *date) {
all_day_events.push(event);
}
}
}
// Remove duplicates (same event might appear in multiple day buckets)
all_day_events.sort_by_key(|e| &e.uid);
all_day_events.dedup_by_key(|e| &e.uid);
html! {
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
@@ -365,6 +385,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div
class="all-day-event"
style={format!("background-color: {}", event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
{onclick}
{oncontextmenu}
>
@@ -388,14 +409,17 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Scrollable content area with time grid
<div class="week-content">
<div class="time-grid">
<div class={classes!("time-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
// Time labels
<div class="time-labels">
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{
time_labels.iter().enumerate().map(|(index, time)| {
let is_final = index == time_labels.len() - 1;
time_labels.iter().map(|time| {
let is_quarter_mode = props.time_increment == 15;
html! {
<div class={classes!("time-label", if is_final { Some("final-boundary") } else { None })}>
<div class={classes!(
"time-label",
if is_quarter_mode { Some("quarter-mode") } else { None }
)}>
{time}
</div>
}
@@ -404,12 +428,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
</div>
// Day columns
<div class="week-days-grid">
<div class={classes!("week-days-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{
week_days.iter().enumerate().map(|(_column_index, date)| {
let is_today = *date == props.today;
let day_events = props.events.get(date).cloned().unwrap_or_default();
let event_layouts = calculate_event_layout(&day_events, *date);
let event_layouts = calculate_event_layout(&day_events, *date, props.time_increment);
// Drag event handlers
let drag_state_clone = drag_state.clone();
@@ -500,8 +524,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
match &current_drag.drag_type {
DragType::CreateEvent => {
// Calculate start and end times
let start_time = pixels_to_time(current_drag.start_y);
let end_time = pixels_to_time(current_drag.current_y);
let start_time = pixels_to_time(current_drag.start_y, time_increment);
let end_time = pixels_to_time(current_drag.current_y, time_increment);
// Ensure start is before end
let (actual_start, actual_end) = if start_time <= end_time {
@@ -529,7 +553,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let unsnapped_position = current_drag.current_y - current_drag.offset_y;
// Snap the final position to maintain time increment alignment
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
let new_start_time = pixels_to_time(event_top_position);
let new_start_time = pixels_to_time(event_top_position, time_increment);
// Calculate duration from original event
let original_duration = if let Some(end) = event.dtend {
@@ -558,7 +582,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
},
DragType::ResizeEventStart(event) => {
// Calculate new start time based on drag position
let new_start_time = pixels_to_time(current_drag.current_y);
let new_start_time = pixels_to_time(current_drag.current_y, time_increment);
// Keep the original end time
let original_end = if let Some(end) = event.dtend {
@@ -594,7 +618,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
},
DragType::ResizeEventEnd(event) => {
// Calculate new end time based on drag position
let new_end_time = pixels_to_time(current_drag.current_y);
let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
// Keep the original start time
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
@@ -643,7 +667,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
class={classes!(
"week-day-column",
if is_today { Some("today") } else { None },
if is_creating_event { Some("creating-event") } else { None }
if is_creating_event { Some("creating-event") } else { None },
if props.time_increment == 15 { Some("quarter-mode") } else { None }
)}
{onmousedown}
{onmousemove}
@@ -652,10 +677,21 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Time slot backgrounds - 24 hour slots to represent full day
{
(0..24).map(|_hour| {
let slots_per_hour = 60 / props.time_increment;
html! {
<div class="time-slot">
<div class="time-slot-half"></div>
<div class="time-slot-half"></div>
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{
(0..slots_per_hour).map(|_slot| {
let slot_class = if props.time_increment == 15 {
"time-slot-quarter"
} else {
"time-slot-half"
};
html! {
<div class={slot_class}></div>
}
}).collect::<Html>()
}
</div>
}
}).collect::<Html>()
@@ -665,7 +701,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div class="events-container">
{
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment);
// Skip all-day events (they're rendered in the header)
if is_all_day {
@@ -693,7 +729,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let drag_state = drag_state.clone();
let event_for_drag = event.clone();
let date_for_drag = *date;
let _time_increment = props.time_increment;
let time_increment = props.time_increment;
Callback::from(move |e: MouseEvent| {
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
@@ -707,7 +743,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
// Get event's current position in day column coordinates
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment);
let event_start_pixels = event_start_pixels as f64;
// Convert click position to day column coordinates
@@ -884,6 +920,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
column_width
)
}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
{onclick}
{oncontextmenu}
onmousedown={onmousedown_event}
@@ -903,7 +940,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Event content
<div class="event-content">
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
{if !is_all_day {
{if !is_all_day && duration_pixels > 30.0 {
html! { <div class="event-time">{time_display}</div> }
} else {
html! {}
@@ -939,8 +976,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let height = (drag.current_y - drag.start_y).abs().max(20.0);
// Convert pixels to times for display
let start_time = pixels_to_time(start_y);
let end_time = pixels_to_time(end_y);
let start_time = pixels_to_time(start_y, props.time_increment);
let end_time = pixels_to_time(end_y, props.time_increment);
html! {
<div
@@ -956,7 +993,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let unsnapped_position = drag.current_y - drag.offset_y;
// Snap the final position to maintain time increment alignment
let preview_position = snap_to_increment(unsnapped_position, props.time_increment);
let new_start_time = pixels_to_time(preview_position);
let new_start_time = pixels_to_time(preview_position, props.time_increment);
let original_duration = if let Some(end) = event.dtend {
end.signed_duration_since(event.dtstart)
} else {
@@ -971,15 +1008,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div
class="temp-event-box moving-event"
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
>
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
{if duration_pixels > 30.0 {
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
} else {
html! {}
}}
</div>
}
},
DragType::ResizeEventStart(event) => {
// Show the event being resized from the start
let new_start_time = pixels_to_time(drag.current_y);
let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local()
} else {
@@ -987,7 +1029,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
};
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
@@ -1000,19 +1042,24 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div
class="temp-event-box resizing-event"
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
>
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div>
{if new_height > 30.0 {
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div> }
} else {
html! {}
}}
</div>
}
},
DragType::ResizeEventEnd(event) => {
// Show the event being resized from the end
let new_end_time = pixels_to_time(drag.current_y);
let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
let new_end_pixels = drag.current_y;
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
@@ -1023,9 +1070,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div
class="temp-event-box resizing-event"
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)}
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
>
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
<div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
{if new_height > 30.0 {
html! { <div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
} else {
html! {}
}}
</div>
}
}
@@ -1089,22 +1141,25 @@ fn get_weekday_name(weekday: Weekday) -> &'static str {
}
// Calculate the pixel position of an event based on its time
// Each hour is 60px, so we convert time to pixels
// Snap pixel position to 15-minute increments (15px = 15 minutes since 60px = 60 minutes)
// Snap pixel position based on time increment and grid scaling
// In 30-minute mode: 60px per hour (1px = 1 minute)
// In 15-minute mode: 120px per hour (2px = 1 minute)
fn snap_to_increment(pixels: f64, increment: u32) -> f64 {
let increment_px = increment as f64; // Convert to pixels (1px = 1 minute)
let pixels_per_minute = if increment == 15 { 2.0 } else { 1.0 };
let increment_px = increment as f64 * pixels_per_minute;
(pixels / increment_px).round() * increment_px
}
// Convert pixel position to time (inverse of time to pixels)
fn pixels_to_time(pixels: f64) -> NaiveTime {
// Since 60px = 1 hour, pixels directly represent minutes
let total_minutes = pixels; // 1px = 1 minute
fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
let pixels_per_minute = if time_increment == 15 { 2.0 } else { 1.0 };
let total_minutes = pixels / pixels_per_minute;
let hours = (total_minutes / 60.0) as u32;
let minutes = (total_minutes % 60.0) as u32;
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
if total_minutes >= 1440.0 {
// Handle midnight boundary - check against scaled boundary
let max_pixels = 1440.0 * pixels_per_minute; // 24 hours in pixels
if pixels >= max_pixels {
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
}
@@ -1115,7 +1170,7 @@ fn pixels_to_time(pixels: f64) -> NaiveTime {
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
}
fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) {
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32) -> (f32, f32, bool) {
// Convert UTC times to local time for display
let local_start = event.dtstart.with_timezone(&Local);
let event_date = local_start.date_naive();
@@ -1138,7 +1193,8 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
// Calculate start position in pixels from midnight
let start_hour = local_start.hour() as f32;
let start_minute = local_start.minute() as f32;
let start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px per hour
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour;
// Calculate duration and height
let duration_pixels = if let Some(end) = event.dtend {
@@ -1147,16 +1203,17 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
// Handle events that span multiple days by capping at midnight
if end_date > date {
// Event continues past midnight, cap at 24:00 (1440px)
1440.0 - start_pixels
// Event continues past midnight, cap at 24:00
let max_pixels = 24.0 * pixels_per_hour;
max_pixels - start_pixels
} else {
let end_hour = local_end.hour() as f32;
let end_minute = local_end.minute() as f32;
let end_pixels = (end_hour + end_minute / 60.0) * 60.0;
let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour;
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
}
} else {
60.0 // Default 1 hour if no end time
pixels_per_hour // Default 1 hour if no end time
};
(start_pixels, duration_pixels, false) // is_all_day = false
@@ -1164,6 +1221,11 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool)
// Check if two events overlap in time
fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
// All-day events don't overlap with timed events for width calculation purposes
if event1.all_day || event2.all_day {
return false;
}
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
let end1 = if let Some(end) = event1.dtend {
end.with_timezone(&Local).naive_local()
@@ -1183,13 +1245,18 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
}
// Calculate layout columns for overlapping events
fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usize)> {
fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u32) -> Vec<(usize, usize)> {
// Filter and sort events that should appear on this date
// Filter and sort events that should appear on this date (excluding all-day events)
let mut day_events: Vec<_> = events.iter()
.enumerate()
.filter_map(|(idx, event)| {
let (_, _, _) = calculate_event_position(event, date);
// Skip all-day events as they don't participate in timed event overlap calculations
if event.all_day {
return None;
}
let (_, _, _) = calculate_event_position(event, date, time_increment);
let local_start = event.dtstart.with_timezone(&Local);
let event_date = local_start.date_naive();
if event_date == date ||
@@ -1269,3 +1336,31 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usi
event_columns
}
// Check if an all-day event spans the given date
fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool {
let start_date = if event.all_day {
// For all-day events, extract date directly from UTC without timezone conversion
// since all-day events are stored at noon UTC to avoid timezone boundary issues
event.dtstart.date_naive()
} else {
event.dtstart.with_timezone(&Local).date_naive()
};
let end_date = if let Some(dtend) = event.dtend {
if event.all_day {
// For all-day events, dtend is set to the day after the last day (RFC 5545)
// Extract date directly from UTC and subtract a day to get actual last day
dtend.date_naive() - chrono::Duration::days(1)
} else {
// For timed events, use timezone conversion
dtend.with_timezone(&Local).date_naive()
}
} else {
// Single day event
start_date
};
// Check if the given date falls within the event's date range
date >= start_date && date <= end_date
}

View File

@@ -1,4 +1,5 @@
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
use gloo_storage::{LocalStorage, Storage};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::JsCast;
@@ -43,6 +44,7 @@ pub struct CalendarInfo {
pub path: String,
pub display_name: String,
pub color: String,
pub is_visible: bool,
}
// CalendarEvent, EventStatus, and EventClass are now imported from shared library
@@ -271,8 +273,8 @@ impl CalendarService {
pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
let mut expanded_events = Vec::new();
let today = chrono::Utc::now().date_naive();
let start_range = today - Duration::days(30); // Show past 30 days
let end_range = today + Duration::days(365); // Show next 365 days
let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events)
let end_range = today + Duration::days(36500); // Show next 100 years
for event in events {
if let Some(ref rrule) = event.rrule {
@@ -1249,6 +1251,8 @@ impl CalendarService {
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -1281,8 +1285,8 @@ impl CalendarService {
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": 1_u32, // Default interval
"recurrence_end_date": None as Option<String>, // No end date by default
"recurrence_count": None as Option<u32>, // No count limit by default
"recurrence_end_date": recurrence_until,
"recurrence_count": recurrence_count,
"calendar_path": calendar_path
});
let url = format!("{}/calendar/events/series/create", self.base_url);
@@ -1676,6 +1680,9 @@ impl CalendarService {
categories: String,
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
update_scope: String,
occurrence_date: Option<String>,
@@ -1704,10 +1711,10 @@ impl CalendarService {
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": vec![false; 7], // Default - could be enhanced
"recurrence_interval": 1_u32, // Default interval
"recurrence_end_date": None as Option<String>, // No end date by default
"recurrence_count": None as Option<u32>, // No count limit by default
"recurrence_days": recurrence_days,
"recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter
"recurrence_end_date": recurrence_until,
"recurrence_count": recurrence_count,
"calendar_path": calendar_path,
"update_scope": update_scope,
"occurrence_date": occurrence_date
@@ -1849,4 +1856,257 @@ impl CalendarService {
None
}
// ==================== EXTERNAL CALENDAR METHODS ====================
pub async fn get_external_calendars() -> Result<Vec<ExternalCalendar>, String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_string())?;
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("GET");
opts.set_mode(RequestMode::Cors);
let service = Self::new();
let url = format!("{}/external-calendars", service.base_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request
.headers()
.set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
let json = JsFuture::from(resp.json().unwrap())
.await
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
let external_calendars: Vec<ExternalCalendar> = serde_wasm_bindgen::from_value(json)
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
Ok(external_calendars)
}
pub async fn create_external_calendar(name: &str, url: &str, color: &str) -> Result<ExternalCalendar, String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_string())?;
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
let body = serde_json::json!({
"name": name,
"url": url,
"color": color
});
let service = Self::new();
let body_string = serde_json::to_string(&body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
opts.set_body(&body_string.into());
let url = format!("{}/external-calendars", service.base_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request
.headers()
.set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
request
.headers()
.set("Content-Type", "application/json")
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
let json = JsFuture::from(resp.json().unwrap())
.await
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
let external_calendar: ExternalCalendar = serde_wasm_bindgen::from_value(json)
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
Ok(external_calendar)
}
pub async fn update_external_calendar(
id: i32,
name: &str,
url: &str,
color: &str,
is_visible: bool,
) -> Result<(), String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_string())?;
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
let body = serde_json::json!({
"name": name,
"url": url,
"color": color,
"is_visible": is_visible
});
let service = Self::new();
let body_string = serde_json::to_string(&body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
opts.set_body(&body_string.into());
let url = format!("{}/external-calendars/{}", service.base_url, id);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request
.headers()
.set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
request
.headers()
.set("Content-Type", "application/json")
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
Ok(())
}
pub async fn delete_external_calendar(id: i32) -> Result<(), String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_string())?;
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("DELETE");
opts.set_mode(RequestMode::Cors);
let service = Self::new();
let url = format!("{}/external-calendars/{}", service.base_url, id);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request
.headers()
.set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
Ok(())
}
pub async fn fetch_external_calendar_events(id: i32) -> Result<Vec<VEvent>, String> {
let token = LocalStorage::get::<String>("auth_token")
.map_err(|_| "No authentication token found".to_string())?;
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("GET");
opts.set_mode(RequestMode::Cors);
let service = Self::new();
let url = format!("{}/external-calendars/{}/events", service.base_url, id);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request
.headers()
.set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|e| format!("Response casting failed: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {}", resp.status()));
}
let json = JsFuture::from(resp.json().unwrap())
.await
.map_err(|e| format!("JSON parsing failed: {:?}", e))?;
#[derive(Deserialize)]
struct ExternalCalendarEventsResponse {
events: Vec<VEvent>,
last_fetched: chrono::DateTime<chrono::Utc>,
}
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)
.map_err(|e| format!("Deserialization failed: {:?}", e))?;
Ok(response.events)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExternalCalendar {
pub id: i32,
pub name: String,
pub url: String,
pub color: String,
pub is_visible: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub last_fetched: Option<chrono::DateTime<chrono::Utc>>,
}

View File

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

View File

@@ -733,7 +733,11 @@ body {
.time-grid {
display: grid;
grid-template-columns: 80px 1fr;
min-height: 1530px;
min-height: 1530px; /* 30-minute mode */
}
.time-grid.quarter-mode {
min-height: 2970px; /* 15-minute mode */
}
/* Time Labels */
@@ -743,9 +747,15 @@ body {
position: sticky;
left: 0;
z-index: 5;
min-height: 1440px; /* Match the time slots height */
min-height: 1530px; /* 30-minute mode */
}
/* Scale time labels container for 15-minute mode */
.time-labels.quarter-mode {
min-height: 2970px; /* 15-minute mode */
}
/* Default time label height for 30-minute mode */
.time-label {
height: 60px;
display: flex;
@@ -758,24 +768,31 @@ body {
font-weight: 500;
}
.time-label.final-boundary {
height: 60px; /* Keep same height but this marks the end boundary */
border-bottom: 2px solid #e9ecef; /* Stronger border to show day end */
color: #999; /* Lighter color to indicate it's the boundary */
font-size: 0.7rem;
/* Time label height for 15-minute mode - double height */
.time-label.quarter-mode {
height: 120px;
}
/* Week Days Grid */
.week-days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
min-height: 1440px; /* Ensure grid is tall enough for 24 time slots */
min-height: 1530px; /* 30-minute mode */
}
.week-days-grid.quarter-mode {
min-height: 2970px; /* 15-minute mode */
}
.week-day-column {
position: relative;
border-right: 1px solid var(--time-label-border, #e9ecef);
min-height: 1440px; /* 24 time slots × 60px = 1440px total */
min-height: 1530px; /* 30-minute mode */
}
.week-day-column.quarter-mode {
min-height: 2970px; /* 15-minute mode */
}
.week-day-column:last-child {
@@ -788,12 +805,16 @@ body {
/* Time Slots */
.time-slot {
height: 60px;
height: 60px; /* 30-minute mode: 2 slots × 30px = 60px */
border-bottom: 1px solid var(--calendar-border, #f0f0f0);
position: relative;
pointer-events: none; /* Don't capture mouse events */
}
.time-slot.quarter-mode {
height: 120px; /* 15-minute mode: 4 slots × 30px = 120px */
}
.time-slot-half {
height: 30px;
border-bottom: 1px dotted var(--calendar-border, #f5f5f5);
@@ -804,13 +825,17 @@ body {
border-bottom: none;
}
.time-slot.boundary-slot {
height: 60px; /* Match the final time label height */
border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */
background: rgba(0,0,0,0.02); /* Slightly different background to indicate boundary */
.time-slot-quarter {
height: 30px;
border-bottom: 1px dotted var(--calendar-border-light, #f8f8f8);
pointer-events: none; /* Don't capture mouse events */
}
.time-slot-quarter:last-child {
border-bottom: none;
}
/* Events Container */
.events-container {
position: absolute;
@@ -3242,6 +3267,7 @@ body {
--accent-color: #667eea;
--calendar-bg: white;
--calendar-border: #f0f0f0;
--calendar-border-light: #f8f8f8;
--calendar-day-bg: white;
--calendar-day-hover: #f8f9ff;
--calendar-day-prev-next: #fafafa;
@@ -3275,6 +3301,7 @@ body {
--accent-color: #2196F3;
--calendar-bg: #ffffff;
--calendar-border: #bbdefb;
--calendar-border-light: #e3f2fd;
--calendar-day-bg: #ffffff;
--calendar-day-hover: #e1f5fe;
--calendar-day-prev-next: #f3f8ff;
@@ -3317,6 +3344,7 @@ body {
--accent-color: #4CAF50;
--calendar-bg: #ffffff;
--calendar-border: #c8e6c9;
--calendar-border-light: #e8f5e8;
--calendar-day-bg: #ffffff;
--calendar-day-hover: #f1f8e9;
--calendar-day-prev-next: #f9fbe7;
@@ -3359,6 +3387,7 @@ body {
--accent-color: #FF9800;
--calendar-bg: #ffffff;
--calendar-border: #ffe0b2;
--calendar-border-light: #fff3e0;
--calendar-day-bg: #ffffff;
--calendar-day-hover: #fff8e1;
--calendar-day-prev-next: #fffde7;
@@ -3401,6 +3430,7 @@ body {
--accent-color: #9C27B0;
--calendar-bg: #ffffff;
--calendar-border: #ce93d8;
--calendar-border-light: #f3e5f5;
--calendar-day-bg: #ffffff;
--calendar-day-hover: #f8e9fc;
--calendar-day-prev-next: #fce4ec;
@@ -3443,6 +3473,7 @@ body {
--accent-color: #666666;
--calendar-bg: #1f1f1f;
--calendar-border: #333333;
--calendar-border-light: #2a2a2a;
--calendar-day-bg: #1f1f1f;
--calendar-day-hover: #2a2a2a;
--calendar-day-prev-next: #1a1a1a;
@@ -3495,6 +3526,7 @@ body {
--accent-color: #E91E63;
--calendar-bg: #ffffff;
--calendar-border: #f8bbd9;
--calendar-border-light: #fce4ec;
--calendar-day-bg: #ffffff;
--calendar-day-hover: #fdf2f8;
--calendar-day-prev-next: #fef7ff;
@@ -3537,6 +3569,7 @@ body {
--accent-color: #26A69A;
--calendar-bg: #ffffff;
--calendar-border: #b2dfdb;
--calendar-border-light: #e0f2f1;
--calendar-day-bg: #ffffff;
--calendar-day-hover: #f0fdfc;
--calendar-day-prev-next: #f7ffff;
@@ -3712,3 +3745,383 @@ body {
grid-template-columns: repeat(2, 1fr);
}
}
/* ==================== EXTERNAL CALENDARS STYLES ==================== */
/* External Calendar Section in Sidebar */
.external-calendar-list {
margin-bottom: 2rem;
}
.external-calendar-list h3 {
color: rgba(255, 255, 255, 0.9);
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding-left: 0.5rem;
}
.external-calendar-items {
list-style: none;
margin: 0;
padding: 0;
}
.external-calendar-item {
margin-bottom: 0.5rem;
}
.external-calendar-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
transition: all 0.2s ease;
cursor: pointer;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.external-calendar-info:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
transform: translateX(2px);
}
.external-calendar-info input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: rgba(255, 255, 255, 0.8);
cursor: pointer;
}
.external-calendar-color {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.3);
flex-shrink: 0;
}
.external-calendar-name {
color: rgba(255, 255, 255, 0.9);
font-size: 0.85rem;
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.external-calendar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.last-updated {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.6);
opacity: 0.8;
}
.external-calendar-refresh-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 0.8rem;
padding: 2px 4px;
border-radius: 3px;
transition: all 0.2s ease;
line-height: 1;
}
.external-calendar-refresh-btn:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.1);
transform: rotate(180deg);
}
.external-calendar-indicator {
font-size: 0.8rem;
opacity: 0.7;
flex-shrink: 0;
}
/* CalDAV Calendar Styles */
.calendar-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
transition: all 0.2s ease;
cursor: pointer;
}
.calendar-info:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateX(2px);
}
.calendar-info input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: rgba(255, 255, 255, 0.8);
cursor: pointer;
}
/* Create External Calendar Button */
.create-external-calendar-button {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
color: rgba(255, 255, 255, 0.9);
padding: 0.75rem 1rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 1rem;
font-size: 0.85rem;
font-weight: 500;
backdrop-filter: blur(10px);
width: 100%;
position: relative;
}
.create-external-calendar-button::before {
content: "";
position: absolute;
left: 1rem;
font-size: 0.8rem;
opacity: 0.8;
}
.create-external-calendar-button {
padding-left: 2.5rem;
}
.create-external-calendar-button:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-1px);
}
.create-external-calendar-button:active {
transform: translateY(0);
}
/* External Calendar Modal */
.external-calendar-modal {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
animation: modalSlideIn 0.3s ease;
}
.external-calendar-modal .modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2rem 2rem 1rem;
border-bottom: 1px solid #e9ecef;
}
.external-calendar-modal .modal-header h3 {
margin: 0;
color: #495057;
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.external-calendar-modal .modal-header h3::before {
content: "📅";
font-size: 1.2rem;
opacity: 0.8;
}
.external-calendar-modal .modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6c757d;
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.external-calendar-modal .modal-close:hover {
background: #f8f9fa;
color: #495057;
}
.external-calendar-modal .modal-body {
padding: 1.5rem 2rem 2rem;
}
.external-calendar-modal .form-group {
margin-bottom: 1.5rem;
}
.external-calendar-modal .form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #495057;
font-size: 0.9rem;
}
.external-calendar-modal .form-group input[type="text"],
.external-calendar-modal .form-group input[type="url"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 8px;
font-size: 0.9rem;
transition: all 0.2s ease;
background: white;
}
.external-calendar-modal .form-group input[type="text"]:focus,
.external-calendar-modal .form-group input[type="url"]:focus {
outline: none;
border-color: var(--primary-color, #667eea);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.external-calendar-modal .form-group input[type="color"] {
width: 80px;
height: 40px;
padding: 0;
border: 1px solid #ced4da;
border-radius: 8px;
cursor: pointer;
background: none;
}
.external-calendar-modal .form-help {
display: block;
margin-top: 0.5rem;
font-size: 0.8rem;
color: #6c757d;
font-style: italic;
}
.external-calendar-modal .modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding: 1.5rem 2rem;
border-top: 1px solid #e9ecef;
background: #f8f9fa;
border-radius: 0 0 12px 12px;
}
.external-calendar-modal .btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: none;
min-width: 100px;
}
.external-calendar-modal .btn-secondary {
background: #6c757d;
color: white;
}
.external-calendar-modal .btn-secondary:hover:not(:disabled) {
background: #5a6268;
transform: translateY(-1px);
}
.external-calendar-modal .btn-primary {
background: var(--primary-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
color: white;
}
.external-calendar-modal .btn-primary:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
.external-calendar-modal .btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.external-calendar-modal .error-message {
background: #f8d7da;
color: #721c24;
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
border: 1px solid #f5c6cb;
}
/* External Calendar Events (Visual Distinction) */
.event[data-external="true"] {
position: relative;
border-style: dashed !important;
opacity: 0.85;
}
.event[data-external="true"]::before {
content: "📅";
position: absolute;
top: 2px;
right: 2px;
font-size: 0.7rem;
opacity: 0.7;
z-index: 1;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.external-calendar-modal {
max-height: 95vh;
margin: 1rem;
width: calc(100% - 2rem);
}
.external-calendar-modal .modal-header,
.external-calendar-modal .modal-body,
.external-calendar-modal .modal-actions {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.external-calendar-info {
padding: 0.5rem;
}
.external-calendar-name {
font-size: 0.8rem;
}
.create-external-calendar-button {
font-size: 0.8rem;
padding: 0.5rem 1rem 0.5rem 2rem;
}
}

View File

@@ -648,6 +648,16 @@ body {
border-bottom: none;
}
.time-slot-quarter {
height: 30px;
border-bottom: 1px dotted var(--calendar-border-light, #f8f8f8);
pointer-events: none; /* Don't capture mouse events */
}
.time-slot-quarter:last-child {
border-bottom: none;
}
.time-slot.boundary-slot {
height: 60px; /* Match the final time label height */
border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */