24 Commits

Author SHA1 Message Date
Connor Johnstone
fd80624429 Fix frontend compilation warnings by removing dead code
All checks were successful
Build and Push Docker Image / docker (push) Successful in 4m5s
- Remove unused Route enum and yew_router import from sidebar.rs
- Remove unused last_fetched field from ExternalCalendarEventsResponse
- Prefix unused variables with underscore (_preserve_rrule, _until_date, etc.)

Frontend now compiles cleanly with no warnings.

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

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

Frontend now runs with minimal essential logging.

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

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

Backend now runs with minimal essential logging only.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 18:53:40 -04:00
Connor Johnstone
73d191c5ca Merge branch 'main' of git.rcjohnstone.com:connor/calendar into print-preview-feature 2025-09-11 18:01:56 -04:00
d930468748 Merge pull request 'Small bugfixes on the external calendar handling' (#19) from bugfix/external_cal_misc into main
All checks were successful
Build and Push Docker Image / docker (push) Successful in 4m3s
Reviewed-on: #19
2025-09-11 18:01:00 -04:00
Connor Johnstone
4cbc495c48 Add print preview modal with hour range selection
Implements comprehensive print functionality for calendar views:
- Print preview modal with live preview and zoom controls
- Hour range selection for week view (start/end hours)
- Print-specific CSS to hide UI elements and optimize layout
- Event repositioning to align with visible time labels
- Support for both 30-minute and 15-minute time increments
- Mouse wheel zoom functionality in preview

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 14:55:18 -04:00
33 changed files with 2744 additions and 7633 deletions

View File

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

View File

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

View File

@@ -167,8 +167,6 @@ impl CalDAVClient {
};
let basic_auth = self.config.get_basic_auth();
println!("🔑 REPORT Basic Auth: Basic {}", basic_auth);
println!("🌐 REPORT URL: {}", url);
let response = self
.http_client
@@ -349,6 +347,8 @@ impl CalDAVClient {
}
}
full_prop.push_str(&format!(":{}", prop_value));
full_properties.insert(prop_name, full_prop);
}
@@ -358,27 +358,30 @@ impl CalDAVClient {
.ok_or_else(|| CalDAVError::ParseError("Missing UID field".to_string()))?
.clone();
// Determine if it's an all-day event FIRST by checking for VALUE=DATE parameter per RFC 5545
let empty_string = String::new();
let dtstart_raw = full_properties.get("DTSTART").unwrap_or(&empty_string);
let dtstart_value = properties.get("DTSTART").unwrap_or(&empty_string);
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_value.contains("T") && dtstart_value.len() == 8);
// Parse start time (required)
let start = properties
let start_prop = properties
.get("DTSTART")
.ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?;
let start = self.parse_datetime(start, full_properties.get("DTSTART"))?;
let (start_naive, start_tzid) = self.parse_datetime_with_tz(start_prop, full_properties.get("DTSTART"))?;
// Parse end time (optional - use start time if not present)
let end = if let Some(dtend) = properties.get("DTEND") {
Some(self.parse_datetime(dtend, full_properties.get("DTEND"))?)
let (end_naive, end_tzid) = if let Some(dtend) = properties.get("DTEND") {
let (end_dt, end_tz) = self.parse_datetime_with_tz(dtend, full_properties.get("DTEND"))?;
(Some(end_dt), end_tz)
} else if let Some(_duration) = properties.get("DURATION") {
// TODO: Parse duration and add to start time
Some(start)
(Some(start_naive), start_tzid.clone())
} else {
None
(None, None)
};
// Determine if it's an all-day event by checking for VALUE=DATE parameter
let empty_string = String::new();
let dtstart_raw = properties.get("DTSTART").unwrap_or(&empty_string);
let all_day = dtstart_raw.contains("VALUE=DATE") || (!dtstart_raw.contains("T") && dtstart_raw.len() == 8);
// Parse status
let status = properties
.get("STATUS")
@@ -411,23 +414,35 @@ impl CalDAVClient {
.map(|s| s.split(',').map(|c| c.trim().to_string()).collect())
.unwrap_or_default();
// Parse dates
let created = properties
.get("CREATED")
.and_then(|s| self.parse_datetime(s, None).ok());
let last_modified = properties
.get("LAST-MODIFIED")
.and_then(|s| self.parse_datetime(s, None).ok());
// Parse dates with timezone information
let (created_naive, created_tzid) = if let Some(created_str) = properties.get("CREATED") {
match self.parse_datetime_with_tz(created_str, None) {
Ok((dt, tz)) => (Some(dt), tz),
Err(_) => (None, None)
}
} else {
(None, None)
};
let (last_modified_naive, last_modified_tzid) = if let Some(modified_str) = properties.get("LAST-MODIFIED") {
match self.parse_datetime_with_tz(modified_str, None) {
Ok((dt, tz)) => (Some(dt), tz),
Err(_) => (None, None)
}
} else {
(None, None)
};
// Parse exception dates (EXDATE)
let exdate = self.parse_exdate(&event);
// Create VEvent with required fields
let mut vevent = VEvent::new(uid, start);
// Create VEvent with parsed naive datetime and timezone info
let mut vevent = VEvent::new(uid, start_naive);
// Set optional fields
vevent.dtend = end;
// Set optional fields with timezone information
vevent.dtend = end_naive;
vevent.dtstart_tzid = start_tzid;
vevent.dtend_tzid = end_tzid;
vevent.summary = properties.get("SUMMARY").cloned();
vevent.description = properties.get("DESCRIPTION").cloned();
vevent.location = properties.get("LOCATION").cloned();
@@ -450,10 +465,13 @@ impl CalDAVClient {
vevent.attendees = Vec::new();
vevent.categories = categories;
vevent.created = created;
vevent.last_modified = last_modified;
vevent.created = created_naive;
vevent.created_tzid = created_tzid;
vevent.last_modified = last_modified_naive;
vevent.last_modified_tzid = last_modified_tzid;
vevent.rrule = properties.get("RRULE").cloned();
vevent.exdate = exdate;
vevent.exdate = exdate.into_iter().map(|dt| dt.naive_utc()).collect();
vevent.exdate_tzid = None; // TODO: Parse timezone info for EXDATE
vevent.all_day = all_day;
// Parse alarms
@@ -566,11 +584,9 @@ impl CalDAVClient {
pub async fn discover_calendars(&self) -> Result<Vec<String>, CalDAVError> {
// First, try to discover user calendars if we have a calendar path in config
if let Some(calendar_path) = &self.config.calendar_path {
println!("Using configured calendar path: {}", calendar_path);
return Ok(vec![calendar_path.clone()]);
}
println!("No calendar path configured, discovering calendars...");
// Try different common CalDAV discovery paths
// Note: paths should be relative to the server URL base
@@ -583,20 +599,16 @@ impl CalDAVClient {
let mut has_valid_caldav_response = false;
for path in discovery_paths {
println!("Trying discovery path: {}", path);
match self.discover_calendars_at_path(&path).await {
Ok(calendars) => {
println!("Found {} calendar(s) at {}", calendars.len(), path);
has_valid_caldav_response = true;
all_calendars.extend(calendars);
}
Err(CalDAVError::ServerError(status)) => {
Err(CalDAVError::ServerError(_status)) => {
// HTTP error - this might be expected for some paths, continue trying
println!("Discovery path {} returned HTTP {}, trying next path", path, status);
}
Err(e) => {
// Network or other error - this suggests the server isn't reachable or isn't CalDAV
println!("Discovery failed for path {}: {:?}", path, e);
return Err(e);
}
}
@@ -653,7 +665,6 @@ impl CalDAVClient {
}
let body = response.text().await.map_err(CalDAVError::RequestError)?;
println!("Discovery response for {}: {}", path, body);
let mut calendar_paths = Vec::new();
@@ -664,7 +675,6 @@ impl CalDAVClient {
// Extract href first
if let Some(href) = self.extract_xml_content(response_content, "href") {
println!("🔍 Checking resource: {}", href);
// Check if this is a calendar collection by looking for supported-calendar-component-set
// This indicates it's an actual calendar that can contain events
@@ -688,14 +698,10 @@ impl CalDAVClient {
&& !href.ends_with("/calendars/")
&& href.ends_with('/')
{
println!("📅 Found calendar collection: {}", href);
calendar_paths.push(href);
} else {
println!("❌ Skipping system/root directory: {}", href);
}
} else {
println!(" Not a calendar collection: {} (is_calendar: {}, has_collection: {})",
href, is_calendar, has_collection);
}
}
}
@@ -704,6 +710,99 @@ impl CalDAVClient {
Ok(calendar_paths)
}
/// Parse iCal datetime format and return NaiveDateTime + timezone info
/// According to RFC 5545: if no TZID parameter is provided, treat as UTC
fn parse_datetime_with_tz(
&self,
datetime_str: &str,
original_property: Option<&String>,
) -> Result<(chrono::NaiveDateTime, Option<String>), CalDAVError> {
// Extract timezone information from the original property if available
let mut timezone_id: Option<String> = None;
if let Some(prop) = original_property {
// Look for TZID parameter in the property
// Format: DTSTART;TZID=America/Denver:20231225T090000
if let Some(tzid_start) = prop.find("TZID=") {
let tzid_part = &prop[tzid_start + 5..];
if let Some(tzid_end) = tzid_part.find(':') {
timezone_id = Some(tzid_part[..tzid_end].to_string());
} else if let Some(tzid_end) = tzid_part.find(';') {
timezone_id = Some(tzid_part[..tzid_end].to_string());
}
}
}
// Clean the datetime string - remove any TZID prefix if present
let cleaned = datetime_str.replace("TZID=", "").trim().to_string();
// Split on colon to separate TZID from datetime if format is "TZID=America/Denver:20231225T090000"
let datetime_part = if let Some(colon_pos) = cleaned.find(':') {
&cleaned[colon_pos + 1..]
} else {
&cleaned
};
// Try different parsing formats
let formats = [
"%Y%m%dT%H%M%SZ", // UTC format: 20231225T120000Z
"%Y%m%dT%H%M%S", // Local format: 20231225T120000
"%Y%m%d", // Date only: 20231225
];
for format in &formats {
// Try parsing as UTC format (with Z suffix)
if datetime_part.ends_with('Z') {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&datetime_part[..datetime_part.len()-1], "%Y%m%dT%H%M%S") {
// Z suffix means UTC, ignore any TZID parameter
return Ok((dt, Some("UTC".to_string())));
}
}
// Try parsing with timezone offset (e.g., 20231225T120000-0500)
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y%m%dT%H%M%S%z") {
// Convert to naive UTC time and return UTC timezone
return Ok((dt.naive_utc(), Some("UTC".to_string())));
}
// Try ISO format with timezone offset (e.g., 2023-12-25T12:00:00-05:00)
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%S%z") {
// Convert to naive UTC time and return UTC timezone
return Ok((dt.naive_utc(), Some("UTC".to_string())));
}
// Try ISO format with Z suffix (e.g., 2023-12-25T12:00:00Z)
if let Ok(dt) = chrono::DateTime::parse_from_str(datetime_part, "%Y-%m-%dT%H:%M:%SZ") {
// Z suffix means UTC
return Ok((dt.naive_utc(), Some("UTC".to_string())));
}
// Special handling for date-only format (all-day events)
if *format == "%Y%m%d" {
if let Ok(date) = chrono::NaiveDate::parse_from_str(datetime_part, format) {
// Convert date to midnight datetime for all-day events
let naive_dt = date.and_hms_opt(0, 0, 0).unwrap();
let tz = timezone_id.unwrap_or_else(|| "UTC".to_string());
return Ok((naive_dt, Some(tz)));
}
} else {
// Try parsing as naive datetime for time-based formats
if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) {
// Per RFC 5545: if no TZID parameter is provided, treat as UTC
let tz = timezone_id.unwrap_or_else(|| "UTC".to_string());
// If it's UTC, the naive time is already correct
// If it's a local timezone, we store the naive time and the timezone ID
return Ok((naive_dt, Some(tz)));
}
}
}
Err(CalDAVError::ParseError(format!(
"Could not parse datetime: {}",
datetime_str
)))
}
/// Parse iCal datetime format with timezone support
fn parse_datetime(
&self,
@@ -1207,8 +1306,19 @@ impl CalDAVClient {
// Format datetime for iCal (YYYYMMDDTHHMMSSZ format)
let format_datetime =
|dt: &DateTime<Utc>| -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() };
let format_datetime_naive =
|dt: &chrono::NaiveDateTime| -> String { dt.format("%Y%m%dT%H%M%S").to_string() };
let format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
let _format_date = |dt: &DateTime<Utc>| -> String { dt.format("%Y%m%d").to_string() };
// Format NaiveDateTime for iCal (local time without Z suffix)
let format_naive_datetime = |dt: &chrono::NaiveDateTime| -> String {
dt.format("%Y%m%dT%H%M%S").to_string()
};
let format_naive_date = |dt: &chrono::NaiveDateTime| -> String {
dt.format("%Y%m%d").to_string()
};
// Start building the iCal event
let mut ical = String::new();
@@ -1225,15 +1335,77 @@ impl CalDAVClient {
if event.all_day {
ical.push_str(&format!(
"DTSTART;VALUE=DATE:{}\r\n",
format_date(&event.dtstart)
format_naive_date(&event.dtstart)
));
if let Some(end) = &event.dtend {
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_date(end)));
ical.push_str(&format!("DTEND;VALUE=DATE:{}\r\n", format_naive_date(end)));
}
} else {
ical.push_str(&format!("DTSTART:{}\r\n", format_datetime(&event.dtstart)));
// Include timezone information for non-all-day events per RFC 5545
if let Some(ref start_tzid) = event.dtstart_tzid {
if start_tzid == "UTC" {
// UTC events should use Z suffix format
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&event.dtstart)));
} else if start_tzid.starts_with('+') || start_tzid.starts_with('-') {
// Timezone offset format (e.g., "+05:00", "-04:00")
// Convert local time to UTC using the offset and use Z format
if let Ok(offset_hours) = start_tzid[1..3].parse::<i32>() {
let offset_minutes = start_tzid[4..6].parse::<i32>().unwrap_or(0);
let total_offset_minutes = if start_tzid.starts_with('+') {
offset_hours * 60 + offset_minutes
} else {
-(offset_hours * 60 + offset_minutes)
};
// Convert local time to UTC by applying the inverse offset
// If timezone is +04:00 (local ahead of UTC), subtract to get UTC
// If timezone is -04:00 (local behind UTC), add to get UTC
let utc_time = event.dtstart - chrono::Duration::minutes(total_offset_minutes as i64);
ical.push_str(&format!("DTSTART:{}Z\r\n", format_naive_datetime(&utc_time)));
} else {
// Fallback to floating time if offset parsing fails
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
}
} else {
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
ical.push_str(&format!("DTSTART;TZID={}:{}\r\n", start_tzid, format_naive_datetime(&event.dtstart)));
}
} else {
// No timezone info - treat as floating local time per RFC 5545
ical.push_str(&format!("DTSTART:{}\r\n", format_naive_datetime(&event.dtstart)));
}
if let Some(end) = &event.dtend {
ical.push_str(&format!("DTEND:{}\r\n", format_datetime(end)));
if let Some(ref end_tzid) = event.dtend_tzid {
if end_tzid == "UTC" {
// UTC events should use Z suffix format
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(end)));
} else if end_tzid.starts_with('+') || end_tzid.starts_with('-') {
// Timezone offset format (e.g., "+05:00", "-04:00")
// Convert local time to UTC using the offset and use Z format
if let Ok(offset_hours) = end_tzid[1..3].parse::<i32>() {
let offset_minutes = end_tzid[4..6].parse::<i32>().unwrap_or(0);
let total_offset_minutes = if end_tzid.starts_with('+') {
offset_hours * 60 + offset_minutes
} else {
-(offset_hours * 60 + offset_minutes)
};
// Convert local time to UTC by subtracting the offset
let utc_time = *end - chrono::Duration::minutes(total_offset_minutes as i64);
ical.push_str(&format!("DTEND:{}Z\r\n", format_naive_datetime(&utc_time)));
} else {
// Fallback to floating time if offset parsing fails
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
}
} else {
// Named timezone (e.g., "America/New_York") - use TZID parameter per RFC 5545
ical.push_str(&format!("DTEND;TZID={}:{}\r\n", end_tzid, format_naive_datetime(end)));
}
} else {
// No timezone info - treat as floating local time per RFC 5545
ical.push_str(&format!("DTEND:{}\r\n", format_naive_datetime(end)));
}
}
}
@@ -1289,7 +1461,18 @@ impl CalDAVClient {
// Creation and modification times
if let Some(created) = &event.created {
ical.push_str(&format!("CREATED:{}\r\n", format_datetime(created)));
if let Some(ref created_tzid) = event.created_tzid {
if created_tzid == "UTC" {
ical.push_str(&format!("CREATED:{}Z\r\n", format_datetime_naive(created)));
} else {
// Per RFC 5545, CREATED typically should be in UTC or floating time
// Treat non-UTC as floating time
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
}
} else {
// No timezone info - output as floating time per RFC 5545
ical.push_str(&format!("CREATED:{}\r\n", format_datetime_naive(created)));
}
}
ical.push_str(&format!("LAST-MODIFIED:{}\r\n", format_datetime(&now)));
@@ -1346,10 +1529,10 @@ impl CalDAVClient {
if event.all_day {
ical.push_str(&format!(
"EXDATE;VALUE=DATE:{}\r\n",
format_date(exception_date)
format_naive_date(exception_date)
));
} else {
ical.push_str(&format!("EXDATE:{}\r\n", format_datetime(exception_date)));
ical.push_str(&format!("EXDATE:{}\r\n", format_naive_datetime(exception_date)));
}
}

View File

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

View File

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

View File

@@ -285,17 +285,25 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
let vevent = VEvent {
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
dtstart,
dtend,
dtstart: dtstart.naive_utc(),
dtstart_tzid: None, // TODO: Parse timezone from ICS
dtend: dtend.map(|dt| dt.naive_utc()),
dtend_tzid: None, // TODO: Parse timezone from ICS
summary,
description,
location,
all_day,
rrule,
rdate: Vec::new(),
rdate_tzid: None,
exdate: Vec::new(), // External calendars don't need exception handling
exdate_tzid: None,
recurrence_id: None,
recurrence_id_tzid: None,
created: None,
created_tzid: None,
last_modified: None,
last_modified_tzid: None,
dtstamp: Utc::now(),
sequence: Some(0),
status: None,
@@ -313,7 +321,6 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
class: None,
contact: None,
comment: None,
rdate: Vec::new(),
alarms: Vec::new(),
etag: None,
href: None,
@@ -484,26 +491,18 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
let mut uid_groups: HashMap<String, Vec<VEvent>> = HashMap::new();
for event in events.drain(..) {
// Debug logging to understand what's happening
println!("🔍 Event: '{}' at {} (RRULE: {}) - UID: {}",
event.summary.as_ref().unwrap_or(&"No Title".to_string()),
event.dtstart.format("%Y-%m-%d %H:%M"),
if event.rrule.is_some() { "Yes" } else { "No" },
event.uid
);
uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event);
}
let mut uid_deduplicated_events = Vec::new();
for (uid, mut events_with_uid) in uid_groups.drain() {
for (_uid, mut events_with_uid) in uid_groups.drain() {
if events_with_uid.len() == 1 {
// Only one event with this UID, keep it
uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap());
} else {
// Multiple events with same UID - prefer recurring over non-recurring
println!("🔍 Found {} events with UID '{}'", events_with_uid.len(), uid);
// Sort by preference: recurring events first, then by completeness
events_with_uid.sort_by(|a, b| {
@@ -522,10 +521,6 @@ fn deduplicate_events(mut events: Vec<VEvent>) -> Vec<VEvent> {
// Keep the first (preferred) event
let preferred_event = events_with_uid.into_iter().next().unwrap();
println!("🔄 UID dedup: Keeping '{}' (RRULE: {})",
preferred_event.summary.as_ref().unwrap_or(&"No Title".to_string()),
if preferred_event.rrule.is_some() { "Yes" } else { "No" }
);
uid_deduplicated_events.push(preferred_event);
}
}
@@ -834,7 +829,7 @@ fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VE
if rrule.contains("FREQ=DAILY") {
// Daily recurrence
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
if days_diff >= 0 && days_diff % interval as i64 == 0 {
// Check if times match (allowing for timezone differences within same day)
@@ -845,7 +840,7 @@ fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VE
} else if rrule.contains("FREQ=WEEKLY") {
// Weekly recurrence
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
// First check if it's the same day of week and time
let recurring_weekday = recurring_event.dtstart.weekday();

View File

@@ -14,6 +14,33 @@ use calendar_models::{EventClass, EventStatus, VEvent};
use super::auth::{extract_bearer_token, extract_password_header};
fn parse_event_datetime_local(
date_str: &str,
time_str: &str,
all_day: bool,
) -> Result<chrono::NaiveDateTime, String> {
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
// Parse the date
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|_| format!("Invalid date format: {}. Expected YYYY-MM-DD", date_str))?;
if all_day {
// For all-day events, use start of day
let datetime = date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| "Failed to create start-of-day datetime".to_string())?;
Ok(datetime)
} else {
// Parse the time
let time = NaiveTime::parse_from_str(time_str, "%H:%M")
.map_err(|_| format!("Invalid time format: {}. Expected HH:MM", time_str))?;
// Combine date and time - now keeping as local time
Ok(NaiveDateTime::new(date, time))
}
}
/// Create a new recurring event series
pub async fn create_event_series(
State(state): State<Arc<AppState>>,
@@ -106,84 +133,29 @@ pub async fn create_event_series(
println!("📅 Using calendar path: {}", calendar_path);
// Parse datetime components
let start_date =
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| {
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
})?;
// Parse dates and times as local times (no UTC conversion)
let start_datetime = parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
let (start_datetime, end_datetime) = if request.all_day {
// For all-day events, use the dates as-is
let start_dt = start_date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
let end_date = if !request.end_date.is_empty() {
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| {
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
})?
} else {
start_date
};
let end_dt = end_date
.and_hms_opt(23, 59, 59)
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
// Frontend now sends UTC times, so treat as UTC directly
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
let end_local = 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
let start_time = if !request.start_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
})?
} else {
chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM
};
let end_time = if !request.end_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
})?
} else {
chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration
};
let start_dt = start_date.and_time(start_time);
let end_dt = if !request.end_date.is_empty() {
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
.map_err(|_| {
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
})?;
end_date.and_time(end_time)
} else {
start_date.and_time(end_time)
};
// Frontend now sends UTC times, so treat as UTC directly
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
(
start_local.with_timezone(&chrono::Utc),
end_local.with_timezone(&chrono::Utc),
)
};
// For all-day events, add one day to end date for RFC-5545 compliance
if request.all_day {
end_datetime = end_datetime + chrono::Duration::days(1);
}
// Generate a unique UID for the series
let uid = format!("series-{}", uuid::Uuid::new_v4().to_string());
// Create the VEvent for the series
// Create the VEvent for the series with local times
let mut event = VEvent::new(uid.clone(), start_datetime);
event.dtend = Some(end_datetime);
event.all_day = request.all_day; // Set the all_day flag properly
// Set timezone information from client
event.dtstart_tzid = Some(request.timezone.clone());
event.dtend_tzid = Some(request.timezone.clone());
event.summary = if request.title.trim().is_empty() {
None
} else {
@@ -257,6 +229,8 @@ pub async fn update_event_series(
"🔄 Update event series request received: series_uid='{}', update_scope='{}', recurrence_count={:?}, recurrence_end_date={:?}",
request.series_uid, request.update_scope, request.recurrence_count, request.recurrence_end_date
);
println!("🕐 SERIES: Received start_date: '{}', start_time: '{}', timezone: '{}'",
request.start_date, request.start_time, request.timezone);
// Extract and verify token
let token = extract_bearer_token(&headers)?;
@@ -372,7 +346,7 @@ pub async fn update_event_series(
);
// Parse datetime components for the update
let original_start_date = existing_event.dtstart.date_naive();
let original_start_date = existing_event.dtstart.date();
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
// For "all_in_series" updates, preserve the original series start date
@@ -399,7 +373,7 @@ pub async fn update_event_series(
// Calculate the duration from the original event
let original_duration_days = existing_event
.dtend
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
.map(|end| (end.date() - existing_event.dtstart.date()).num_days())
.unwrap_or(0);
start_date + chrono::Duration::days(original_duration_days)
} else {
@@ -410,11 +384,8 @@ pub async fn update_event_series(
.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),
)
// For all-day events, use local times directly
(start_dt, end_dt)
} else {
let start_time = if !request.start_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
@@ -445,17 +416,11 @@ pub async fn update_event_series(
.dtend
.map(|end| end - existing_event.dtstart)
.unwrap_or_else(|| chrono::Duration::hours(1));
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
start_dt + original_duration
};
// Frontend now sends UTC times, so treat as UTC directly
let start_local = chrono::Utc.from_utc_datetime(&start_dt);
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
(
start_local.with_timezone(&chrono::Utc),
end_local.with_timezone(&chrono::Utc),
)
// Frontend now sends local times, so use them directly
(start_dt, end_dt)
};
// Handle different update scopes
@@ -702,8 +667,8 @@ fn build_series_rrule_with_freq(
fn update_entire_series(
existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>,
end_datetime: chrono::DateTime<chrono::Utc>,
start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::NaiveDateTime,
) -> Result<(VEvent, u32), ApiError> {
// Clone the existing event to preserve all metadata
let mut updated_event = existing_event.clone();
@@ -711,6 +676,8 @@ fn update_entire_series(
// Update only the modified properties from the request
updated_event.dtstart = start_datetime;
updated_event.dtend = Some(end_datetime);
updated_event.dtstart_tzid = Some(request.timezone.clone());
updated_event.dtend_tzid = Some(request.timezone.clone());
updated_event.summary = if request.title.trim().is_empty() {
existing_event.summary.clone() // Keep original if empty
} else {
@@ -743,8 +710,9 @@ fn update_entire_series(
// Update timestamps
let now = chrono::Utc::now();
let now_naive = now.naive_utc();
updated_event.dtstamp = now;
updated_event.last_modified = Some(now);
updated_event.last_modified = Some(now_naive);
// Keep original created timestamp to preserve event history
// Update RRULE if recurrence parameters are provided
@@ -832,8 +800,8 @@ fn update_entire_series(
async fn update_this_and_future(
existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>,
end_datetime: chrono::DateTime<chrono::Utc>,
start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::NaiveDateTime,
client: &CalDAVClient,
calendar_path: &str,
) -> Result<(VEvent, u32), ApiError> {
@@ -881,6 +849,8 @@ async fn update_this_and_future(
new_series.uid = new_series_uid.clone();
new_series.dtstart = start_datetime;
new_series.dtend = Some(end_datetime);
new_series.dtstart_tzid = Some(request.timezone.clone());
new_series.dtend_tzid = Some(request.timezone.clone());
new_series.summary = if request.title.trim().is_empty() {
None
} else {
@@ -913,9 +883,10 @@ async fn update_this_and_future(
// Update timestamps
let now = chrono::Utc::now();
let now_naive = now.naive_utc();
new_series.dtstamp = now;
new_series.created = Some(now);
new_series.last_modified = Some(now);
new_series.created = Some(now_naive);
new_series.last_modified = Some(now_naive);
new_series.href = None; // Will be set when created
println!(
@@ -943,8 +914,8 @@ async fn update_this_and_future(
async fn update_single_occurrence(
existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>,
end_datetime: chrono::DateTime<chrono::Utc>,
start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::NaiveDateTime,
client: &CalDAVClient,
calendar_path: &str,
_original_event_href: &str,
@@ -969,21 +940,20 @@ async fn update_single_occurrence(
// Create the EXDATE datetime using the original event's time
let original_time = existing_event.dtstart.time();
let exception_datetime = exception_date.and_time(original_time);
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
// Add the exception date to the original series
println!(
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
existing_event.exdate
);
existing_event.exdate.push(exception_utc);
existing_event.exdate.push(exception_datetime);
println!(
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
existing_event.exdate
);
println!(
"🚫 Added EXDATE for single occurrence modification: {}",
exception_utc.format("%Y-%m-%d %H:%M:%S")
exception_datetime.format("%Y-%m-%d %H:%M:%S")
);
// Create exception event by cloning the existing event to preserve all metadata
@@ -995,6 +965,8 @@ async fn update_single_occurrence(
// Update the modified properties from the request
exception_event.dtstart = start_datetime;
exception_event.dtend = Some(end_datetime);
exception_event.dtstart_tzid = Some(request.timezone.clone());
exception_event.dtend_tzid = Some(request.timezone.clone());
exception_event.summary = if request.title.trim().is_empty() {
existing_event.summary.clone() // Keep original if empty
} else {
@@ -1027,8 +999,9 @@ async fn update_single_occurrence(
// Update timestamps for the exception event
let now = chrono::Utc::now();
let now_naive = now.naive_utc();
exception_event.dtstamp = now;
exception_event.last_modified = Some(now);
exception_event.last_modified = Some(now_naive);
// Keep original created timestamp to preserve event history
// Set RECURRENCE-ID to point to the original occurrence
@@ -1044,7 +1017,7 @@ async fn update_single_occurrence(
println!(
"✨ Created exception event with RECURRENCE-ID: {}",
exception_utc.format("%Y-%m-%d %H:%M:%S")
exception_datetime.format("%Y-%m-%d %H:%M:%S")
);
// Create the exception event as a new event (original series will be updated by main handler)
@@ -1172,15 +1145,14 @@ async fn delete_single_occurrence(
// Create the EXDATE datetime (use the same time as the original event)
let original_time = existing_event.dtstart.time();
let exception_datetime = exception_date.and_time(original_time);
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
// Add the exception date to the event's EXDATE list
let mut updated_event = existing_event;
updated_event.exdate.push(exception_utc);
updated_event.exdate.push(exception_datetime);
println!(
"🗑️ Added EXDATE for single occurrence deletion: {}",
exception_utc.format("%Y%m%dT%H%M%SZ")
exception_datetime.format("%Y%m%dT%H%M%S")
);
// Update the event on the CalDAV server

View File

@@ -117,6 +117,7 @@ pub struct CreateEventRequest {
pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub calendar_path: Option<String>, // Optional - use first calendar if not specified
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
}
#[derive(Debug, Serialize)]
@@ -146,8 +147,12 @@ pub struct UpdateEventRequest {
pub reminder: String, // reminder type
pub recurrence: String, // recurrence type
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
pub recurrence_interval: Option<u32>, // Every N days/weeks/months/years
pub recurrence_count: Option<u32>, // Number of occurrences
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
pub update_action: Option<String>, // "update_series" for recurring events
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
#[serde(skip_serializing_if = "Option::is_none")]
pub until_date: Option<String>, // ISO datetime string for RRULE UNTIL clause
}
@@ -185,6 +190,7 @@ pub struct CreateEventSeriesRequest {
pub recurrence_end_date: Option<String>, // When the series ends (YYYY-MM-DD)
pub recurrence_count: Option<u32>, // Number of occurrences
pub calendar_path: Option<String>, // Optional - search all calendars if not specified
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
}
#[derive(Debug, Serialize)]
@@ -227,6 +233,7 @@ pub struct UpdateEventSeriesRequest {
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00")
}
#[derive(Debug, Serialize)]

View File

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

View File

@@ -30,6 +30,8 @@ web-sys = { version = "0.3", features = [
"RequestMode",
"Response",
"CssStyleDeclaration",
"MediaQueryList",
"MediaQueryListEvent",
] }
wasm-bindgen = "0.2"
js-sys = "0.3"

View File

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

View File

@@ -6,8 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base data-trunk-public-url />
<link data-trunk rel="css" href="styles.css">
<link data-trunk rel="css" href="print-preview.css">
<link data-trunk rel="copy-file" href="styles/google.css">
<link data-trunk rel="icon" href="favicon.ico">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>
<script>

1215
frontend/print-preview.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -71,7 +71,6 @@ pub fn App() -> Html {
match auth_service.verify_token(&stored_token).await {
Ok(true) => {
// Token is valid, set it
web_sys::console::log_1(&"✅ Stored auth token is valid".into());
auth_token.set(Some(stored_token));
}
_ => {
@@ -723,8 +722,12 @@ pub fn App() -> Html {
crate::components::event_form::RecurrenceType::Monthly |
crate::components::event_form::RecurrenceType::Yearly);
web_sys::console::log_1(&format!("🐛 FRONTEND DEBUG: is_recurring={}, edit_scope={:?}, original_uid={:?}",
is_recurring, event_data_for_update.edit_scope, event_data_for_update.original_uid).into());
let update_result = if is_recurring && event_data_for_update.edit_scope.is_some() {
// Use series update endpoint for recurring events
// Only use series endpoint for existing recurring events being edited
// Singleton→series conversion should use regular update_event endpoint
let edit_action = event_data_for_update.edit_scope.unwrap();
let scope = match edit_action {
crate::components::EditAction::EditAll => "all_in_series".to_string(),
@@ -754,11 +757,13 @@ pub fn App() -> Html {
params.14, // reminder
params.15, // recurrence
params.16, // recurrence_days
params.17, // recurrence_interval
params.18, // recurrence_count
params.19, // recurrence_until
params.17, // calendar_path
params.20, // calendar_path
scope,
event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date
params.21, // timezone
)
.await
} else {
@@ -785,10 +790,11 @@ pub fn App() -> Html {
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
params.17, // recurrence_interval
params.18, // recurrence_count
params.19, // recurrence_until
params.20, // calendar_path
params.21, // timezone
)
.await
};
@@ -872,9 +878,11 @@ pub fn App() -> Html {
params.14, // reminder
params.15, // recurrence
params.16, // recurrence_days
params.17, // recurrence_interval
params.18, // recurrence_count
params.19, // recurrence_until
params.17, // calendar_path
params.20, // calendar_path
params.21, // timezone
)
.await;
match create_result {
@@ -906,8 +914,8 @@ pub fn App() -> Html {
original_event,
new_start,
new_end,
preserve_rrule,
until_date,
_preserve_rrule,
_until_date,
update_scope,
occurrence_date,
): (
@@ -915,7 +923,7 @@ pub fn App() -> Html {
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)| {
@@ -954,30 +962,13 @@ pub fn App() -> Html {
String::new()
};
// Convert local naive datetime to UTC before sending to backend
use chrono::TimeZone;
let local_tz = chrono::Local;
let start_utc = local_tz.from_local_datetime(&new_start)
.single()
.unwrap_or_else(|| {
// Fallback for ambiguous times (DST transitions)
local_tz.from_local_datetime(&new_start).earliest().unwrap()
})
.with_timezone(&chrono::Utc);
let end_utc = local_tz.from_local_datetime(&new_end)
.single()
.unwrap_or_else(|| {
// Fallback for ambiguous times (DST transitions)
local_tz.from_local_datetime(&new_end).earliest().unwrap()
})
.with_timezone(&chrono::Utc);
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 times to backend, which will handle timezone 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 {
@@ -1056,12 +1047,21 @@ 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,
vec![false; 7], // recurrence_days
1, // recurrence_interval - default for drag-and-drop
None, // recurrence_count
None, // recurrence_until
original_event.calendar_path.clone(), // calendar_path
scope.clone(), // update_scope
occurrence_date, // occurrence_date
{
// Get timezone offset
let date = js_sys::Date::new_0();
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
let minutes = (timezone_offset as i32).abs() % 60;
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
}, // timezone
)
.await,
)
@@ -1105,14 +1105,18 @@ pub fn App() -> Html {
reminder_str,
recurrence_str,
recurrence_days,
1, // recurrence_interval - default to 1 for drag-and-drop
None, // recurrence_count - preserve existing
None, // recurrence_until - preserve existing
original_event.calendar_path,
original_event.exdate.clone(),
if preserve_rrule {
Some("update_series".to_string())
} else {
Some("this_and_future".to_string())
{
// Get timezone offset
let date = js_sys::Date::new_0();
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
let minutes = (timezone_offset as i32).abs() % 60;
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
},
until_date,
)
.await
};
@@ -1193,10 +1197,6 @@ pub fn App() -> Html {
})
};
// Debug logging
web_sys::console::log_1(
&format!("App rendering: auth_token = {:?}", auth_token.is_some()).into(),
);
html! {
<BrowserRouter>
@@ -1474,7 +1474,7 @@ pub fn App() -> Html {
let calendar_management_modal_open = calendar_management_modal_open.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let _calendar_service = CalendarService::new();
match CalendarService::get_external_calendars().await {
Ok(calendars) => {
external_calendars.set(calendars);
@@ -1597,7 +1597,7 @@ pub fn App() -> Html {
};
// Get the occurrence date from the clicked event
let occurrence_date = Some(event.dtstart.date_naive().format("%Y-%m-%d").to_string());
let occurrence_date = Some(event.dtstart.date().format("%Y-%m-%d").to_string());
web_sys::console::log_1(&format!("🔄 Delete action: {}", action_str).into());
web_sys::console::log_1(&format!("🔄 Event UID: {}", event.uid).into());
@@ -1637,6 +1637,19 @@ pub fn App() -> Html {
}
}
})}
on_edit_singleton={Callback::from({
let event_context_menu_event = event_context_menu_event.clone();
let event_context_menu_open = event_context_menu_open.clone();
let create_event_modal_open = create_event_modal_open.clone();
let event_edit_scope = event_edit_scope.clone();
move |event: VEvent| {
// For singleton events, open edit modal WITHOUT setting edit_scope
event_context_menu_event.set(Some(event));
event_edit_scope.set(None); // Explicitly set to None for singleton edits
event_context_menu_open.set(false);
create_event_modal_open.set(true);
}
})}
on_view_details={Callback::from({
let event_context_menu_open = event_context_menu_open.clone();
let view_event_modal_open = view_event_modal_open.clone();
@@ -1702,6 +1715,9 @@ pub fn App() -> Html {
on_close={on_mobile_warning_close}
/>
</div>
// Hidden print copy that gets shown only during printing
<div id="print-preview-copy" class="print-preview-paper" style="display: none;"></div>
</BrowserRouter>
}
}

View File

@@ -1,5 +1,5 @@
use crate::components::{
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, ViewMode, WeekView,
CalendarHeader, CreateEventModal, EventCreationData, EventModal, MonthView, PrintPreviewModal, ViewMode, WeekView,
};
use crate::models::ical::VEvent;
use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService};
@@ -32,7 +32,7 @@ pub struct CalendarProps {
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)>,
@@ -389,6 +389,15 @@ pub fn Calendar(props: &CalendarProps) -> Html {
})
};
// Handle print calendar preview
let show_print_preview = use_state(|| false);
let on_print = {
let show_print_preview = show_print_preview.clone();
Callback::from(move |_: MouseEvent| {
show_print_preview.set(true);
})
};
// Handle drag-to-create event
let on_create_event = {
let show_create_modal = show_create_modal.clone();
@@ -428,7 +437,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
chrono::NaiveDateTime,
chrono::NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)| {
@@ -457,6 +466,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_today={on_today}
time_increment={Some(*time_increment)}
on_time_increment_toggle={Some(on_time_increment_toggle)}
on_print={Some(on_print)}
/>
{
@@ -563,6 +573,32 @@ pub fn Calendar(props: &CalendarProps) -> Html {
})
}}
/>
// Print preview modal
{
if *show_print_preview {
html! {
<PrintPreviewModal
on_close={{
let show_print_preview = show_print_preview.clone();
Callback::from(move |_| {
show_print_preview.set(false);
})
}}
view_mode={props.view.clone()}
current_date={*current_date}
selected_date={*selected_date}
events={(*events).clone()}
user_info={props.user_info.clone()}
external_calendars={props.external_calendars.clone()}
time_increment={*time_increment}
today={today}
/>
}
} else {
html! {}
}
}
</div>
}
}

View File

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

View File

@@ -195,7 +195,7 @@ pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
let on_external_success = on_external_success.clone();
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
let _calendar_service = CalendarService::new();
match CalendarService::create_external_calendar(&name, &url, &color).await {
Ok(calendar) => {

View File

@@ -238,12 +238,11 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
// Convert VEvent to EventCreationData for editing
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
use chrono::Local;
// Convert start datetime from UTC to local
let start_local = event.dtstart.with_timezone(&Local).naive_local();
// VEvent fields are already local time (NaiveDateTime)
let start_local = event.dtstart;
let end_local = if let Some(dtend) = event.dtend {
dtend.with_timezone(&Local).naive_local()
dtend
} else {
// Default to 1 hour after start if no end time
start_local + chrono::Duration::hours(1)
@@ -292,8 +291,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
// Recurrence - Parse RRULE if present
recurrence: if let Some(ref rrule_str) = event.rrule {
web_sys::console::log_1(&format!("🐛 MODAL DEBUG: Event has RRULE: {}", rrule_str).into());
parse_rrule_frequency(rrule_str)
} else {
web_sys::console::log_1(&"🐛 MODAL DEBUG: Event has no RRULE (singleton)".into());
RecurrenceType::None
},
recurrence_interval: if let Some(ref rrule_str) = event.rrule {
@@ -338,7 +339,10 @@ fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calend
},
// Edit tracking
edit_scope: None, // Will be set by the modal after creation
edit_scope: {
web_sys::console::log_1(&"🐛 MODAL DEBUG: Setting edit_scope to None for vevent_to_creation_data".into());
None // Will be set by the modal after creation
},
changed_fields: vec![],
original_uid: Some(event.uid.clone()), // Preserve original UID for editing
occurrence_date: Some(start_local.date()), // The occurrence date being edited

View File

@@ -26,6 +26,7 @@ pub struct EventContextMenuProps {
pub on_delete: Callback<DeleteAction>,
pub on_view_details: Callback<VEvent>,
pub on_close: Callback<()>,
pub on_edit_singleton: Callback<VEvent>, // New callback for editing singleton events without scope
}
#[function_component(EventContextMenu)]
@@ -109,6 +110,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
})
};
let create_singleton_edit_callback = {
let on_edit_singleton = props.on_edit_singleton.clone();
let on_close = props.on_close.clone();
let event = props.event.clone();
Callback::from(move |_: MouseEvent| {
if let Some(event) = &event {
on_edit_singleton.emit(event.clone());
}
on_close.emit(());
})
};
let create_delete_callback = |action: DeleteAction| {
let on_delete = props.on_delete.clone();
let on_close = props.on_close.clone();
@@ -160,9 +173,9 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
</>
}
} else {
// Regular single events - show edit option
// Regular single events - show edit option without setting edit scope
html! {
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
<div class="context-menu-item" onclick={create_singleton_edit_callback}>
{"Edit Event"}
</div>
}

View File

@@ -148,54 +148,45 @@ impl EventCreationData {
String, // reminder
String, // recurrence
Vec<bool>, // recurrence_days
Option<String>, // calendar_path
u32, // recurrence_interval
Option<u32>, // recurrence_count
Option<String>, // recurrence_until
Option<String>, // calendar_path
String, // timezone
) {
use chrono::{Local, TimeZone};
// Convert local date/time to UTC for backend
let (utc_start_date, utc_start_time, utc_end_date, utc_end_time) = if self.all_day {
// For all-day events, just use the dates as-is (no time conversion needed)
(
self.start_date.format("%Y-%m-%d").to_string(),
self.start_time.format("%H:%M").to_string(),
self.end_date.format("%Y-%m-%d").to_string(),
self.end_time.format("%H:%M").to_string(),
)
// Use local date/times and timezone - no UTC conversion
let effective_end_date = if self.end_time == NaiveTime::from_hms_opt(0, 0, 0).unwrap() {
// If end time is midnight (00:00), treat it as beginning of next day
self.end_date + chrono::Duration::days(1)
} else {
// Convert local date/time to UTC
let start_local = Local.from_local_datetime(&self.start_date.and_time(self.start_time)).single();
let end_local = Local.from_local_datetime(&self.end_date.and_time(self.end_time)).single();
if let (Some(start_dt), Some(end_dt)) = (start_local, end_local) {
let start_utc = start_dt.with_timezone(&chrono::Utc);
let end_utc = end_dt.with_timezone(&chrono::Utc);
(
start_utc.format("%Y-%m-%d").to_string(),
start_utc.format("%H:%M").to_string(),
end_utc.format("%Y-%m-%d").to_string(),
end_utc.format("%H:%M").to_string(),
)
} else {
// Fallback if timezone conversion fails - use local time as-is
web_sys::console::warn_1(&"⚠️ Failed to convert local time to UTC, using local time".into());
(
self.start_date.format("%Y-%m-%d").to_string(),
self.start_time.format("%H:%M").to_string(),
self.end_date.format("%Y-%m-%d").to_string(),
self.end_time.format("%H:%M").to_string(),
)
}
self.end_date
};
// Get the local timezone
let timezone = {
use js_sys::Date;
let date = Date::new_0();
let timezone_offset = date.get_timezone_offset(); // Minutes from UTC
let hours = -(timezone_offset as i32) / 60; // Convert to hours, negate for proper sign
let minutes = (timezone_offset as i32).abs() % 60;
format!("{:+03}:{:02}", hours, minutes) // Format as +05:00 or -04:00
};
let (start_date, start_time, end_date, end_time) = (
self.start_date.format("%Y-%m-%d").to_string(),
self.start_time.format("%H:%M").to_string(),
effective_end_date.format("%Y-%m-%d").to_string(),
self.end_time.format("%H:%M").to_string(),
);
(
self.title.clone(),
self.description.clone(),
utc_start_date,
utc_start_time,
utc_end_date,
utc_end_time,
start_date,
start_time,
end_date,
end_time,
self.location.clone(),
self.all_day,
format!("{:?}", self.status).to_uppercase(),
@@ -207,9 +198,11 @@ impl EventCreationData {
format!("{:?}", self.reminder),
format!("{:?}", self.recurrence),
self.recurrence_days.clone(),
self.selected_calendar.clone(),
self.recurrence_interval,
self.recurrence_count,
self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()),
self.selected_calendar.clone(),
timezone,
)
}
}

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ pub mod external_calendar_modal;
pub mod login;
pub mod mobile_warning_modal;
pub mod month_view;
pub mod print_preview_modal;
pub mod recurring_edit_modal;
pub mod route_handler;
pub mod sidebar;
@@ -32,6 +33,7 @@ pub use event_modal::EventModal;
pub use login::Login;
pub use mobile_warning_modal::MobileWarningModal;
pub use month_view::MonthView;
pub use print_preview_modal::PrintPreviewModal;
pub use recurring_edit_modal::{RecurringEditAction, RecurringEditModal};
pub use route_handler::RouteHandler;
pub use sidebar::{Sidebar, Theme, ViewMode};

View File

@@ -113,6 +113,7 @@ pub fn month_view(props: &MonthViewProps) -> Html {
"#3B82F6".to_string()
};
html! {
<div class="calendar-grid">
// Weekday headers

View File

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

View File

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

View File

@@ -2,17 +2,7 @@ use crate::components::CalendarListItem;
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
use web_sys::HtmlSelectElement;
use yew::prelude::*;
use yew_router::prelude::*;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Home,
#[at("/login")]
Login,
#[at("/calendar")]
Calendar,
}
#[derive(Clone, PartialEq)]
pub enum ViewMode {
@@ -350,9 +340,9 @@ pub fn sidebar(props: &SidebarProps) -> Html {
>
{
if props.refreshing_calendar_id == Some(cal.id) {
"" // Loading spinner
html! { <i class="fas fa-spinner fa-spin"></i> }
} else {
"🔄" // Normal refresh icon
html! { <i class="fas fa-sync-alt"></i> }
}
}
</button>

View File

@@ -33,7 +33,7 @@ pub struct WeekViewProps {
NaiveDateTime,
NaiveDateTime,
bool,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)>,
@@ -42,6 +42,12 @@ pub struct WeekViewProps {
pub context_menus_open: bool,
#[prop_or_default]
pub time_increment: u32,
#[prop_or_default]
pub print_mode: bool,
#[prop_or_default]
pub print_pixels_per_hour: Option<f64>,
#[prop_or_default]
pub print_start_hour: Option<u32>,
}
#[derive(Clone, PartialEq)]
@@ -279,18 +285,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Calculate the day before this occurrence for UNTIL clause
let until_date =
edit.event.dtstart.date_naive() - chrono::Duration::days(1);
edit.event.dtstart.date() - chrono::Duration::days(1);
let until_datetime = until_date
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
let until_utc =
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
until_datetime,
chrono::Utc,
);
let until_naive = until_datetime; // Use local time directly
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
until_utc.format("%Y-%m-%d %H:%M:%S UTC"),
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
until_naive.format("%Y-%m-%d %H:%M:%S"),
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S")).into());
// Critical: Use the dragged times (new_start/new_end) not the original series times
// This ensures the new series reflects the user's drag operation
@@ -311,7 +313,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
new_start, // Dragged start time for new series
new_end, // Dragged end time for new series
true, // preserve_rrule = true
Some(until_utc), // UNTIL date for original series
Some(until_naive), // UNTIL date for original series
Some("this_and_future".to_string()), // Update scope
Some(occurrence_date), // Date of occurrence being modified
));
@@ -346,6 +348,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
})
};
html! {
<div class="week-view-container">
// Header with weekday names and dates
@@ -438,13 +441,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Time labels
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{
time_labels.iter().map(|time| {
time_labels.iter().enumerate().map(|(hour, time)| {
let is_quarter_mode = props.time_increment == 15;
html! {
<div class={classes!(
"time-label",
if is_quarter_mode { Some("quarter-mode") } else { None }
)}>
)} data-hour={hour.to_string()}>
{time}
</div>
}
@@ -611,10 +614,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Keep the original end time
let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local()
} else {
end } else {
// If no end time, use start time + 1 hour as default
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
event.dtstart + chrono::Duration::hours(1)
};
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
@@ -645,8 +647,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Calculate new end time based on drag position
let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
// Keep the original start time
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
// Keep the original start time (already local)
let original_start = event.dtstart;
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
@@ -701,10 +703,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
>
// Time slot backgrounds - 24 hour slots to represent full day
{
(0..24).map(|_hour| {
(0..24).map(|hour| {
let slots_per_hour = 60 / props.time_increment;
html! {
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })} data-hour={hour.to_string()}>
{
(0..slots_per_hour).map(|_slot| {
let slot_class = if props.time_increment == 15 {
@@ -726,7 +728,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, props.time_increment);
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
// Skip all-day events (they're rendered in the header)
if is_all_day {
@@ -755,6 +757,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let event_for_drag = event.clone();
let date_for_drag = *date;
let time_increment = props.time_increment;
let print_pixels_per_hour = props.print_pixels_per_hour;
let print_start_hour = props.print_start_hour;
Callback::from(move |e: MouseEvent| {
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
@@ -768,7 +772,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
// Get event's current position in day column coordinates
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment);
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment, print_pixels_per_hour, print_start_hour);
let event_start_pixels = event_start_pixels as f64;
// Convert click position to day column coordinates
@@ -819,9 +823,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let time_display = if event.all_day {
"All Day".to_string()
} else {
let local_start = event.dtstart.with_timezone(&Local);
let local_start = event.dtstart;
if let Some(end) = event.dtend {
let local_end = end.with_timezone(&Local);
let local_end = end;
// Check if both times are in same AM/PM period to avoid redundancy
let start_is_am = local_start.hour() < 12;
@@ -1048,14 +1052,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Show the event being resized from the start
let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local()
} else {
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
end } else {
event.dtstart + chrono::Duration::hours(1)
};
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
let original_duration = original_end.signed_duration_since(event.dtstart);
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
let new_start_pixels = drag.current_y;
@@ -1081,10 +1084,10 @@ pub fn week_view(props: &WeekViewProps) -> Html {
DragType::ResizeEventEnd(event) => {
// Show the event being resized from the end
let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
let original_start = event.dtstart;
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
let new_end_pixels = drag.current_y;
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
@@ -1218,18 +1221,15 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
}
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();
// Position events based on when they appear in local time, not their original date
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
// but should still display on Sunday's column since that's when the user sees it
let should_display_here = event_date == date ||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
// Events are already in local time
let local_start = event.dtstart;
if !should_display_here {
// Events should display based on their local date, since we now store proper UTC times
// Convert the UTC stored time back to local time to determine display date
let event_date = local_start.date();
if event_date != date {
return (0.0, 0.0, false); // Event not on this date
}
@@ -1238,32 +1238,44 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
}
// Calculate start position in pixels from midnight
// Calculate start position in pixels
let start_hour = local_start.hour() as f32;
let start_minute = local_start.minute() as f32;
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour;
let pixels_per_hour = if let Some(print_pph) = print_pixels_per_hour {
print_pph as f32 // Use the dynamic print mode calculation
} else {
if time_increment == 15 { 120.0 } else { 60.0 } // Default values
};
// In print mode, offset by the start hour to show relative position within visible range
let hour_offset = if let Some(print_start) = print_start_hour {
print_start as f32
} else {
0.0 // No offset for normal view (starts at midnight)
};
let start_pixels = ((start_hour + start_minute / 60.0) - hour_offset) * pixels_per_hour;
// Calculate duration and height
let duration_pixels = if let Some(end) = event.dtend {
let local_end = end.with_timezone(&Local);
let end_date = local_end.date_naive();
let local_end = end;
let end_date = local_end.date();
// Handle events that span multiple days by capping at midnight
if end_date > date {
// Event continues past midnight, cap at 24:00
let max_pixels = 24.0 * pixels_per_hour;
max_pixels - start_pixels
// Event continues past midnight, cap at end of visible range
let max_hour = if let Some(_print_start) = print_start_hour { 24.0 } else { 24.0 };
let max_pixels = (max_hour - hour_offset) * pixels_per_hour;
(max_pixels - start_pixels).max(20.0)
} else {
let end_hour = local_end.hour() as f32;
let end_minute = local_end.minute() as f32;
let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour;
let end_pixels = ((end_hour + end_minute / 60.0) - hour_offset) * pixels_per_hour;
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
}
} else {
pixels_per_hour // Default 1 hour if no end time
};
(start_pixels, duration_pixels, false) // is_all_day = false
}
@@ -1274,16 +1286,16 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
return false;
}
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
let start1 = event1.dtstart;
let end1 = if let Some(end) = event1.dtend {
end.with_timezone(&Local).naive_local()
end
} else {
start1 + chrono::Duration::hours(1) // Default 1 hour duration
};
let start2 = event2.dtstart.with_timezone(&Local).naive_local();
let start2 = event2.dtstart;
let end2 = if let Some(end) = event2.dtend {
end.with_timezone(&Local).naive_local()
end
} else {
start2 + chrono::Duration::hours(1) // Default 1 hour duration
};
@@ -1304,9 +1316,9 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
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();
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
let local_start = event.dtstart;
let event_date = local_start.date();
if event_date == date ||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
Some((idx, event))
@@ -1317,7 +1329,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
.collect();
// Sort by start time
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local());
day_events.sort_by_key(|(_, event)| event.dtstart);
// For each event, find all events it overlaps with
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
@@ -1342,7 +1354,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
} else {
// This event overlaps - we need to calculate column layout
// Sort the overlapping group by start time
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart.with_timezone(&Local).naive_local());
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart);
// Assign columns using a greedy algorithm
let mut columns: Vec<Vec<usize>> = Vec::new();
@@ -1390,19 +1402,19 @@ 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()
event.dtstart.date()
} else {
event.dtstart.with_timezone(&Local).date_naive()
event.dtstart.date()
};
let end_date = if let Some(dtend) = event.dtend {
if event.all_day {
// For all-day events, dtend is set to the day after the last day (RFC 5545)
// Extract date directly from UTC and subtract a day to get actual last day
dtend.date_naive() - chrono::Duration::days(1)
dtend.date() - chrono::Duration::days(1)
} else {
// For timed events, use timezone conversion
dtend.with_timezone(&Local).date_naive()
dtend.date()
}
} else {
// Single day event

View File

@@ -1,4 +1,4 @@
use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Utc, Weekday};
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
use gloo_storage::{LocalStorage, Storage};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -247,6 +247,8 @@ impl CalendarService {
if resp.ok() {
let events: Vec<CalendarEvent> = serde_json::from_str(&text_string)
.map_err(|e| format!("JSON parsing failed: {}", e))?;
Ok(events)
} else {
Err(format!(
@@ -275,47 +277,66 @@ impl CalendarService {
grouped
}
/// Convert UTC events to local timezone for display
fn convert_utc_to_local(mut event: VEvent) -> VEvent {
// All-day events should not have timezone conversions applied
if event.all_day {
return event;
}
// Check if event times are in UTC (legacy events from before timezone migration)
let is_utc_event = event.dtstart_tzid.as_ref().map_or(true, |tz| tz == "UTC");
if is_utc_event {
// Get current timezone offset (convert from UTC to local)
let date = js_sys::Date::new_0();
let timezone_offset_minutes = date.get_timezone_offset() as i32;
// Convert start time from UTC to local
// getTimezoneOffset() returns minutes UTC is ahead of local time
// To convert UTC to local, we subtract the offset (add negative offset)
let local_start = event.dtstart + chrono::Duration::minutes(-timezone_offset_minutes as i64);
event.dtstart = local_start;
event.dtstart_tzid = None; // Clear UTC timezone indicator
// Convert end time if present
if let Some(end_utc) = event.dtend {
let local_end = end_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64);
event.dtend = Some(local_end);
event.dtend_tzid = None; // Clear UTC timezone indicator
}
// Convert created/modified times if present
if let Some(created_utc) = event.created {
event.created = Some(created_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64));
event.created_tzid = None;
}
if let Some(modified_utc) = event.last_modified {
event.last_modified = Some(modified_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64));
event.last_modified_tzid = None;
}
}
event
}
/// Expand recurring events using VEvent (RFC 5545 compliant)
pub fn expand_recurring_events(events: Vec<VEvent>) -> Vec<VEvent> {
let mut expanded_events = Vec::new();
let today = chrono::Utc::now().date_naive();
let today = chrono::Local::now().date_naive();
let start_range = today - Duration::days(36500); // Show past 100 years (to catch any historical yearly events)
let end_range = today + Duration::days(36500); // Show next 100 years
for event in events {
// Convert UTC events to local time for proper display
let event = Self::convert_utc_to_local(event);
if let Some(ref rrule) = event.rrule {
web_sys::console::log_1(
&format!(
"📅 Processing recurring VEvent '{}' with RRULE: {}",
event.summary.as_deref().unwrap_or("Untitled"),
rrule
)
.into(),
);
// Log if event has exception dates
if !event.exdate.is_empty() {
web_sys::console::log_1(
&format!(
"📅 VEvent '{}' has {} exception dates: {:?}",
event.summary.as_deref().unwrap_or("Untitled"),
event.exdate.len(),
event.exdate
)
.into(),
);
}
// Generate occurrences for recurring events using VEvent
let occurrences = Self::generate_occurrences(&event, rrule, start_range, end_range);
web_sys::console::log_1(
&format!(
"📅 Generated {} occurrences for VEvent '{}'",
occurrences.len(),
event.summary.as_deref().unwrap_or("Untitled")
)
.into(),
);
expanded_events.extend(occurrences);
} else {
// Non-recurring event - add as-is
@@ -337,7 +358,6 @@ impl CalendarService {
// Parse RRULE components
let rrule_upper = rrule.to_uppercase();
web_sys::console::log_1(&format!("🔄 Parsing RRULE: {}", rrule_upper).into());
let components: HashMap<String, String> = rrule_upper
.split(';')
@@ -372,17 +392,18 @@ impl CalendarService {
// Get UNTIL date if specified
let until_date = components.get("UNTIL").and_then(|until_str| {
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format
// Parse UNTIL date in YYYYMMDDTHHMMSSZ format (treat as local time)
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(
until_str.trim_end_matches('Z'),
"%Y%m%dT%H%M%S",
) {
Some(chrono::Utc.from_utc_datetime(&dt))
Some(dt)
} else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") {
Some(dt.with_timezone(&chrono::Utc))
// Convert UTC to local (naive) time for consistency
Some(dt.naive_utc())
} else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") {
// Handle date-only UNTIL
Some(chrono::Utc.from_utc_datetime(&date.and_hms_opt(23, 59, 59).unwrap()))
Some(date.and_hms_opt(23, 59, 59).unwrap())
} else {
web_sys::console::log_1(
&format!("⚠️ Failed to parse UNTIL date: {}", until_str).into(),
@@ -391,11 +412,10 @@ impl CalendarService {
}
});
if let Some(until) = until_date {
web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into());
if let Some(_until) = until_date {
}
let start_date = base_event.dtstart.date_naive();
let start_date = base_event.dtstart.date();
let mut current_date = start_date;
let mut occurrence_count = 0;
@@ -406,10 +426,6 @@ impl CalendarService {
let current_datetime = base_event.dtstart
+ Duration::days(current_date.signed_duration_since(start_date).num_days());
if current_datetime > until {
web_sys::console::log_1(
&format!("🛑 Stopping at {} due to UNTIL {}", current_datetime, until)
.into(),
);
break;
}
}
@@ -422,8 +438,8 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| {
// Compare dates ignoring sub-second precision
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let exception_naive = exception_date.and_utc();
let occurrence_naive = occurrence_datetime.and_utc();
// Check if dates match (within a minute to handle minor time differences)
let diff = occurrence_naive - exception_naive;
@@ -555,7 +571,7 @@ impl CalendarService {
interval: i32,
start_range: NaiveDate,
end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
@@ -565,7 +581,7 @@ impl CalendarService {
return occurrences;
}
let start_date = base_event.dtstart.date_naive();
let start_date = base_event.dtstart.date();
// Find the Monday of the week containing the start_date (reference week)
let reference_week_start =
@@ -606,13 +622,6 @@ impl CalendarService {
let days_diff = occurrence_date.signed_duration_since(start_date).num_days();
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
if occurrence_datetime > until {
web_sys::console::log_1(
&format!(
"🛑 Stopping at {} due to UNTIL {}",
occurrence_datetime, until
)
.into(),
);
return occurrences;
}
}
@@ -623,8 +632,8 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let exception_naive = exception_date.and_utc();
let occurrence_naive = occurrence_datetime.and_utc();
let diff = occurrence_naive - exception_naive;
let matches = diff.num_seconds().abs() < 60;
@@ -675,7 +684,7 @@ impl CalendarService {
interval: i32,
start_range: NaiveDate,
end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
@@ -691,7 +700,7 @@ impl CalendarService {
return occurrences;
}
let start_date = base_event.dtstart.date_naive();
let start_date = base_event.dtstart.date();
let mut current_month_start =
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
let mut total_occurrences = 0;
@@ -732,13 +741,6 @@ impl CalendarService {
occurrence_date.signed_duration_since(start_date).num_days();
let occurrence_datetime = base_event.dtstart + Duration::days(days_diff);
if occurrence_datetime > until {
web_sys::console::log_1(
&format!(
"🛑 Stopping at {} due to UNTIL {}",
occurrence_datetime, until
)
.into(),
);
return occurrences;
}
}
@@ -749,9 +751,7 @@ impl CalendarService {
// Check if this occurrence is in the exception dates (EXDATE)
let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let diff = occurrence_naive - exception_naive;
let diff = occurrence_datetime - *exception_date;
diff.num_seconds().abs() < 60
});
@@ -792,14 +792,14 @@ impl CalendarService {
interval: i32,
start_range: NaiveDate,
end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
// Parse BYDAY for monthly (e.g., "1MO" = first Monday, "-1FR" = last Friday)
if let Some((position, weekday)) = Self::parse_monthly_byday(byday) {
let start_date = base_event.dtstart.date_naive();
let start_date = base_event.dtstart.date();
let mut current_month_start =
NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap();
let mut total_occurrences = 0;
@@ -830,9 +830,7 @@ impl CalendarService {
// Check EXDATE
let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let diff = occurrence_naive - exception_naive;
let diff = occurrence_datetime - *exception_date;
diff.num_seconds().abs() < 60
});
@@ -871,7 +869,7 @@ impl CalendarService {
interval: i32,
start_range: NaiveDate,
end_range: NaiveDate,
until_date: Option<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
let mut occurrences = Vec::new();
@@ -887,7 +885,7 @@ impl CalendarService {
return occurrences;
}
let start_date = base_event.dtstart.date_naive();
let start_date = base_event.dtstart.date();
let mut current_year = start_date.year();
let mut total_occurrences = 0;
@@ -930,9 +928,7 @@ impl CalendarService {
// Check EXDATE
let is_exception = base_event.exdate.iter().any(|exception_date| {
let exception_naive = exception_date.naive_utc();
let occurrence_naive = occurrence_datetime.naive_utc();
let diff = occurrence_naive - exception_naive;
let diff = occurrence_datetime - *exception_date;
diff.num_seconds().abs() < 60
});
@@ -1257,9 +1253,11 @@ impl CalendarService {
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
timezone: String,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -1290,10 +1288,11 @@ impl CalendarService {
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": 1_u32, // Default interval
"recurrence_interval": recurrence_interval,
"recurrence_end_date": recurrence_until,
"recurrence_count": recurrence_count,
"calendar_path": calendar_path
"calendar_path": calendar_path,
"timezone": timezone
});
let url = format!("{}/calendar/events/series/create", self.base_url);
(body, url)
@@ -1317,7 +1316,8 @@ impl CalendarService {
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"calendar_path": calendar_path
"calendar_path": calendar_path,
"timezone": timezone
});
let url = format!("{}/calendar/events/create", self.base_url);
(body, url)
@@ -1394,10 +1394,11 @@ impl CalendarService {
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
exception_dates: Vec<DateTime<Utc>>,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>,
timezone: String,
) -> Result<(), String> {
// Forward to update_event_with_scope with default scope
self.update_event_with_scope(
@@ -1421,10 +1422,11 @@ impl CalendarService {
reminder,
recurrence,
recurrence_days,
recurrence_interval,
recurrence_count,
recurrence_until,
calendar_path,
exception_dates,
update_action,
until_date,
timezone,
)
.await
}
@@ -1451,10 +1453,11 @@ impl CalendarService {
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
exception_dates: Vec<DateTime<Utc>>,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>,
timezone: String,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -1482,11 +1485,11 @@ impl CalendarService {
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": recurrence_interval,
"recurrence_count": recurrence_count,
"recurrence_end_date": recurrence_until,
"calendar_path": calendar_path,
"update_action": update_action,
"occurrence_date": null,
"exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::<Vec<String>>(),
"until_date": until_date.as_ref().map(|dt| dt.to_rfc3339())
"timezone": timezone
});
let url = format!("{}/calendar/events/update", self.base_url);
@@ -1687,11 +1690,13 @@ impl CalendarService {
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
recurrence_interval: u32,
recurrence_count: Option<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
update_scope: String,
occurrence_date: Option<String>,
timezone: String,
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
@@ -1718,12 +1723,13 @@ impl CalendarService {
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"recurrence_interval": 1_u32, // Default interval - could be enhanced to be a parameter
"recurrence_interval": recurrence_interval,
"recurrence_end_date": recurrence_until,
"recurrence_count": recurrence_count,
"calendar_path": calendar_path,
"update_scope": update_scope,
"occurrence_date": occurrence_date
"occurrence_date": occurrence_date,
"timezone": timezone
});
let url = format!("{}/calendar/events/series/update", self.base_url);
@@ -2095,7 +2101,6 @@ impl CalendarService {
#[derive(Deserialize)]
struct ExternalCalendarEventsResponse {
events: Vec<VEvent>,
last_fetched: chrono::DateTime<chrono::Utc>,
}
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)

View File

@@ -18,19 +18,10 @@
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 8px 25px rgba(0,0,0,0.15);
--border-light: 1px solid #e9ecef;
--border-medium: 1px solid #dee2e6;
--transition-fast: 0.15s ease;
--transition-normal: 0.2s ease;
--transition-slow: 0.3s ease;
/* Common Glass/Glassmorphism Effects */
--glass-bg: var(--glass-bg);
--glass-bg-light: var(--glass-bg-light);
--glass-bg-lighter: var(--glass-bg-lighter);
--glass-border: 1px solid var(--glass-bg-light);
--glass-border-light: 1px solid var(--glass-bg-lighter);
/* Standard Control Dimensions */
--control-height: 40px;
--control-padding: 0.875rem;
@@ -39,12 +30,54 @@
/* Common Transition */
--standard-transition: all 0.2s ease;
/* Default Light Theme Colors */
--background-primary: #f8f9fa;
--background-secondary: #ffffff;
--background-tertiary: #f1f3f4;
--text-primary: #333333;
--text-secondary: #6c757d;
--text-inverse: #ffffff;
--border-primary: #e9ecef;
--border-secondary: #dee2e6;
--border-light: #f8f9fa;
--error-color: #dc3545;
--success-color: #28a745;
--warning-color: #ffc107;
--info-color: #17a2b8;
/* Modal Colors */
--modal-background: #ffffff;
--modal-text: #333333;
--modal-header-background: #ffffff;
--modal-header-border: #e5e7eb;
/* Button Colors */
--button-primary-bg: #667eea;
--button-primary-text: #ffffff;
--button-secondary-bg: #6c757d;
--button-secondary-text: #ffffff;
--button-danger-bg: #dc3545;
--button-danger-text: #ffffff;
/* Input Colors */
--input-background: #ffffff;
--input-border: #ced4da;
--input-border-focus: #80bdff;
--input-text: #495057;
/* Glass/Glassmorphism Effects */
--glass-bg: rgba(255, 255, 255, 0.1);
--glass-bg-light: rgba(255, 255, 255, 0.2);
--glass-bg-lighter: rgba(255, 255, 255, 0.3);
--glass-border: 1px solid rgba(255, 255, 255, 0.2);
--glass-border-light: 1px solid rgba(255, 255, 255, 0.3);
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
color: #333;
background-color: var(--background-primary);
color: var(--text-primary);
line-height: 1.6;
}
@@ -115,6 +148,44 @@ input, select, textarea, button {
[data-theme="dark"] {
--primary-color: #374151;
--primary-gradient: linear-gradient(135deg, #374151 0%, #1f2937 100%);
/* Dark Theme Overrides */
--background-primary: #111827;
--background-secondary: #1f2937;
--background-tertiary: #374151;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-inverse: #111827;
--border-primary: #374151;
--border-secondary: #4b5563;
--border-light: #6b7280;
/* Modal Colors - Dark */
--modal-background: #1f2937;
--modal-text: #f3f4f6;
--modal-header-background: #1f2937;
--modal-header-border: #374151;
/* Button Colors - Dark */
--button-primary-bg: #4f46e5;
--button-primary-text: #ffffff;
--button-secondary-bg: #4b5563;
--button-secondary-text: #ffffff;
--button-danger-bg: #dc2626;
--button-danger-text: #ffffff;
/* Input Colors - Dark */
--input-background: #374151;
--input-border: #4b5563;
--input-border-focus: #6366f1;
--input-text: #f9fafb;
/* Glass Effects - Dark */
--glass-bg: rgba(0, 0, 0, 0.2);
--glass-bg-light: rgba(0, 0, 0, 0.3);
--glass-bg-lighter: rgba(0, 0, 0, 0.4);
--glass-border: 1px solid rgba(255, 255, 255, 0.1);
--glass-border-light: 1px solid rgba(255, 255, 255, 0.2);
}
[data-theme="rose"] {
@@ -133,8 +204,8 @@ input, select, textarea, button {
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
color: #333;
background-color: var(--background-primary);
color: var(--text-primary);
line-height: 1.6;
}
@@ -381,7 +452,7 @@ body {
border-radius: var(--border-radius-medium);
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
max-width: 500px;
}
.login-form h2, .register-form h2 {
@@ -421,30 +492,83 @@ body {
cursor: not-allowed;
}
.remember-checkbox {
.input-with-checkbox {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.375rem;
gap: 1rem;
}
.input-with-checkbox input[type="text"],
.input-with-checkbox input[type="password"] {
flex: 1;
min-width: 0;
}
.form-group input {
width: 100%;
}
.remember-checkbox {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
opacity: 0.7;
white-space: nowrap;
min-width: 80px;
}
.remember-checkbox input[type="checkbox"] {
width: auto;
margin: 0;
cursor: pointer;
transform: scale(0.85);
transform: scale(1.1);
}
.remember-checkbox label {
margin: 0;
font-size: 0.75rem;
font-size: 0.55rem;
color: #888;
cursor: pointer;
user-select: none;
font-weight: 400;
}
.password-input-container {
position: relative;
display: flex;
align-items: center;
}
.password-input-container input {
padding-right: 3rem !important;
}
.password-toggle-btn {
position: absolute;
right: 0.75rem;
background: transparent;
border: none;
color: #888;
cursor: pointer;
padding: 0.25rem;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
z-index: 1;
}
.password-toggle-btn:hover {
color: #555;
}
.password-toggle-btn:focus {
outline: none;
color: #667eea;
}
.login-button, .register-button {
width: 100%;
padding: var(--control-padding);
@@ -637,6 +761,25 @@ body {
background: rgba(255,255,255,0.3);
}
.print-button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
font-size: 1rem;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.print-button:hover {
background: rgba(255,255,255,0.3);
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
@@ -1736,7 +1879,7 @@ body {
.form-group label {
display: block;
margin-bottom: 0.75rem;
margin-bottom: 0.4rem;
color: #374151;
font-weight: 600;
font-size: 0.95rem;
@@ -3684,10 +3827,37 @@ body {
}
.external-calendar-info input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 16px;
height: 16px;
accent-color: rgba(255, 255, 255, 0.8);
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
cursor: pointer;
position: relative;
transition: all 0.2s ease;
}
.external-calendar-info input[type="checkbox"]:hover {
border-color: rgba(255, 255, 255, 0.5);
}
.external-calendar-info input[type="checkbox"]:checked {
border-color: rgba(255, 255, 255, 0.6);
}
.external-calendar-info input[type="checkbox"]:checked::after {
content: "✓";
position: absolute;
top: 45%;
left: 50%;
transform: translate(-50%, -50%);
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
font-weight: bold;
line-height: 1;
}
.external-calendar-color {
@@ -3762,10 +3932,37 @@ body {
}
.calendar-info input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 16px;
height: 16px;
accent-color: rgba(255, 255, 255, 0.8);
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
cursor: pointer;
position: relative;
transition: all 0.2s ease;
}
.calendar-info input[type="checkbox"]:hover {
border-color: rgba(255, 255, 255, 0.5);
}
.calendar-info input[type="checkbox"]:checked {
border-color: rgba(255, 255, 255, 0.6);
}
.calendar-info input[type="checkbox"]:checked::after {
content: "✓";
position: absolute;
top: 45%;
left: 50%;
transform: translate(-50%, -50%);
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
font-weight: bold;
line-height: 1;
}
/* Create External Calendar Button */

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff