From acc5ced551d5d7d5644f252af4bc788e3b6e5c81 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Sat, 13 Sep 2025 20:56:18 -0400 Subject: [PATCH] Fix timezone handling for drag-and-drop and recurring event updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/calendar.rs | 241 +++++++++++++++++--- backend/src/handlers/events.rs | 58 ++--- backend/src/models.rs | 4 + frontend/src/app.rs | 50 ++-- frontend/src/components/event_form/types.rs | 68 +++--- frontend/src/services/calendar_service.rs | 124 +++++++--- 6 files changed, 387 insertions(+), 158 deletions(-) diff --git a/backend/src/calendar.rs b/backend/src/calendar.rs index f8abe76..2637c7f 100644 --- a/backend/src/calendar.rs +++ b/backend/src/calendar.rs @@ -359,19 +359,20 @@ impl CalDAVClient { .clone(); // Parse start time (required) - let start = properties + let start_prop = properties .get("DTSTART") .ok_or_else(|| CalDAVError::ParseError("Missing DTSTART field".to_string()))?; - let start = self.parse_datetime(start, 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 @@ -411,23 +412,35 @@ impl CalDAVClient { .map(|s| s.split(',').map(|c| c.trim().to_string()).collect()) .unwrap_or_default(); - // Parse dates - let created = properties - .get("CREATED") - .and_then(|s| self.parse_datetime(s, None).ok()); - - let last_modified = properties - .get("LAST-MODIFIED") - .and_then(|s| self.parse_datetime(s, None).ok()); + // Parse dates with timezone information + let (created_naive, created_tzid) = if let Some(created_str) = properties.get("CREATED") { + match self.parse_datetime_with_tz(created_str, None) { + Ok((dt, tz)) => (Some(dt), tz), + Err(_) => (None, None) + } + } else { + (None, None) + }; + + let (last_modified_naive, last_modified_tzid) = if let Some(modified_str) = properties.get("LAST-MODIFIED") { + match self.parse_datetime_with_tz(modified_str, None) { + Ok((dt, tz)) => (Some(dt), tz), + Err(_) => (None, None) + } + } else { + (None, None) + }; // Parse exception dates (EXDATE) let exdate = self.parse_exdate(&event); - // Create VEvent with required fields - let mut vevent = VEvent::new(uid, start); + // Create VEvent with parsed naive datetime and timezone info + let mut vevent = VEvent::new(uid, start_naive); - // Set optional fields - vevent.dtend = end; + // Set optional fields with timezone information + vevent.dtend = end_naive; + vevent.dtstart_tzid = start_tzid; + vevent.dtend_tzid = end_tzid; vevent.summary = properties.get("SUMMARY").cloned(); vevent.description = properties.get("DESCRIPTION").cloned(); vevent.location = properties.get("LOCATION").cloned(); @@ -450,10 +463,13 @@ impl CalDAVClient { vevent.attendees = Vec::new(); vevent.categories = categories; - vevent.created = created; - vevent.last_modified = last_modified; + vevent.created = created_naive; + vevent.created_tzid = created_tzid; + vevent.last_modified = last_modified_naive; + vevent.last_modified_tzid = last_modified_tzid; vevent.rrule = properties.get("RRULE").cloned(); - vevent.exdate = exdate; + vevent.exdate = exdate.into_iter().map(|dt| dt.naive_utc()).collect(); + vevent.exdate_tzid = None; // TODO: Parse timezone info for EXDATE vevent.all_day = all_day; // Parse alarms @@ -704,6 +720,89 @@ 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), CalDAVError> { + // Extract timezone information from the original property if available + let mut timezone_id: Option = 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()))); + } + + // Try parsing as naive datetime + if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(datetime_part, format) { + // Per RFC 5545: if no TZID parameter is provided, treat as UTC + let tz = timezone_id.unwrap_or_else(|| "UTC".to_string()); + + // If it's UTC, the naive time is already correct + // If it's a local timezone, we store the naive time and the timezone ID + return Ok((naive_dt, Some(tz))); + } + } + + Err(CalDAVError::ParseError(format!( + "Could not parse datetime: {}", + datetime_str + ))) + } + /// Parse iCal datetime format with timezone support fn parse_datetime( &self, @@ -1207,8 +1306,19 @@ impl CalDAVClient { // Format datetime for iCal (YYYYMMDDTHHMMSSZ format) let format_datetime = |dt: &DateTime| -> 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| -> String { dt.format("%Y%m%d").to_string() }; + let _format_date = |dt: &DateTime| -> 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::() { + let offset_minutes = start_tzid[4..6].parse::().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::() { + let offset_minutes = end_tzid[4..6].parse::().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))); } } diff --git a/backend/src/handlers/events.rs b/backend/src/handlers/events.rs index 8257131..c40fa32 100644 --- a/backend/src/handlers/events.rs +++ b/backend/src/handlers/events.rs @@ -85,7 +85,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() { @@ -234,26 +234,26 @@ pub async fn delete_event( if event.rrule.is_some() && !event.rrule.as_ref().unwrap().is_empty() { // Recurring event - add EXDATE for this occurrence if let Some(occurrence_date) = &request.occurrence_date { - let exception_utc = if let Ok(date) = + let exception_datetime = if let Ok(date) = chrono::DateTime::parse_from_rfc3339(occurrence_date) { - // RFC3339 format (with time and timezone) - date.with_timezone(&chrono::Utc) + // RFC3339 format (with time and timezone) - convert to naive + date.naive_utc() } else if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(occurrence_date, "%Y-%m-%d") { // Simple date format (YYYY-MM-DD) - naive_date.and_hms_opt(0, 0, 0).unwrap().and_utc() + naive_date.and_hms_opt(0, 0, 0).unwrap() } else { return Err(ApiError::BadRequest(format!("Invalid occurrence date format: {}. Expected RFC3339 or YYYY-MM-DD", occurrence_date))); }; let mut updated_event = event; - updated_event.exdate.push(exception_utc); + updated_event.exdate.push(exception_datetime); println!( "🔄 Adding EXDATE {} to recurring event {}", - exception_utc.format("%Y%m%dT%H%M%SZ"), + exception_datetime.format("%Y%m%dT%H%M%S"), updated_event.uid ); @@ -453,12 +453,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 +594,13 @@ pub async fn create_event( } }; - // Create the VEvent struct (RFC 5545 compliant) + // Create the VEvent struct (RFC 5545 compliant) with local times let mut event = VEvent::new(uid, start_datetime); event.dtend = Some(end_datetime); + + // Set timezone information from client + event.dtstart_tzid = Some(request.timezone.clone()); + event.dtend_tzid = Some(request.timezone.clone()); event.summary = if request.title.trim().is_empty() { None } else { @@ -757,12 +761,12 @@ 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) 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 { @@ -840,33 +846,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, String> { - use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; +) -> Result { + 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)) } } diff --git a/backend/src/models.rs b/backend/src/models.rs index 2b77fde..c0faa4c 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -117,6 +117,7 @@ pub struct CreateEventRequest { pub recurrence: String, // recurrence type pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub calendar_path: Option, // Optional - use first calendar if not specified + pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00") } #[derive(Debug, Serialize)] @@ -148,6 +149,7 @@ pub struct UpdateEventRequest { pub recurrence_days: Vec, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence pub calendar_path: Option, // Optional - search all calendars if not specified pub update_action: Option, // "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, // ISO datetime string for RRULE UNTIL clause } @@ -185,6 +187,7 @@ pub struct CreateEventSeriesRequest { pub recurrence_end_date: Option, // When the series ends (YYYY-MM-DD) pub recurrence_count: Option, // Number of occurrences pub calendar_path: Option, // Optional - search all calendars if not specified + pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00") } #[derive(Debug, Serialize)] @@ -227,6 +230,7 @@ pub struct UpdateEventSeriesRequest { pub update_scope: String, // "this_only", "this_and_future", "all_in_series" pub occurrence_date: Option, // ISO date string for specific occurrence being updated pub changed_fields: Option>, // List of field names that were changed (for optimization) + pub timezone: String, // Client timezone (e.g., "+05:00", "-04:00") } #[derive(Debug, Serialize)] diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 2f20503..f10e9f4 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -759,6 +759,7 @@ pub fn App() -> Html { params.17, // calendar_path scope, event_data_for_update.occurrence_date.map(|d| d.format("%Y-%m-%d").to_string()), // occurrence_date + params.20, // timezone ) .await } else { @@ -789,6 +790,7 @@ pub fn App() -> Html { vec![], // exception_dates - empty for simple updates None, // update_action - None for regular updates None, // until_date - None for regular updates + params.20, // timezone ) .await }; @@ -875,6 +877,7 @@ pub fn App() -> Html { params.18, // recurrence_count params.19, // recurrence_until params.17, // calendar_path + params.20, // timezone ) .await; match create_result { @@ -915,7 +918,7 @@ pub fn App() -> Html { chrono::NaiveDateTime, chrono::NaiveDateTime, bool, - Option>, + Option, Option, Option, )| { @@ -954,30 +957,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 { @@ -1062,6 +1048,14 @@ pub fn App() -> Html { original_event.calendar_path.clone(), scope.clone(), 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 + }, ) .await, ) @@ -1113,6 +1107,14 @@ pub fn App() -> Html { Some("this_and_future".to_string()) }, until_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 + }, ) .await }; @@ -1597,7 +1599,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()); diff --git a/frontend/src/components/event_form/types.rs b/frontend/src/components/event_form/types.rs index 1e81c94..3b7dff6 100644 --- a/frontend/src/components/event_form/types.rs +++ b/frontend/src/components/event_form/types.rs @@ -151,54 +151,41 @@ impl EventCreationData { Option, // calendar_path Option, // recurrence_count Option, // recurrence_until + 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, but preserve original local dates - 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); - - // IMPORTANT: Use original local dates, not UTC dates! - // This ensures events display on the correct day regardless of timezone conversion - ( - self.start_date.format("%Y-%m-%d").to_string(), - start_utc.format("%H:%M").to_string(), - self.end_date.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(), @@ -213,6 +200,7 @@ impl EventCreationData { self.selected_calendar.clone(), self.recurrence_count, self.recurrence_until.map(|d| d.format("%Y-%m-%d").to_string()), + timezone, ) } } diff --git a/frontend/src/services/calendar_service.rs b/frontend/src/services/calendar_service.rs index a67e2a7..4dac139 100644 --- a/frontend/src/services/calendar_service.rs +++ b/frontend/src/services/calendar_service.rs @@ -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; @@ -275,14 +275,60 @@ impl CalendarService { grouped } + /// Convert UTC events to local timezone for display + fn convert_utc_to_local(mut event: VEvent) -> VEvent { + // Check if event times are in UTC (legacy events from before timezone migration) + let is_utc_event = event.dtstart_tzid.as_ref().map_or(true, |tz| tz == "UTC"); + + if is_utc_event { + web_sys::console::log_1(&format!( + "🕐 Converting UTC event '{}' to local time", + event.summary.as_deref().unwrap_or("Untitled") + ).into()); + + // Get current timezone offset (convert from UTC to local) + let date = js_sys::Date::new_0(); + let timezone_offset_minutes = date.get_timezone_offset() as i32; + + // Convert start time from UTC to local + // getTimezoneOffset() returns minutes UTC is ahead of local time + // To convert UTC to local, we subtract the offset (add negative offset) + let local_start = event.dtstart + chrono::Duration::minutes(-timezone_offset_minutes as i64); + event.dtstart = local_start; + event.dtstart_tzid = None; // Clear UTC timezone indicator + + // Convert end time if present + if let Some(end_utc) = event.dtend { + let local_end = end_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64); + event.dtend = Some(local_end); + event.dtend_tzid = None; // Clear UTC timezone indicator + } + + // Convert created/modified times if present + if let Some(created_utc) = event.created { + event.created = Some(created_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64)); + event.created_tzid = None; + } + + if let Some(modified_utc) = event.last_modified { + event.last_modified = Some(modified_utc + chrono::Duration::minutes(-timezone_offset_minutes as i64)); + event.last_modified_tzid = None; + } + } + + event + } + /// Expand recurring events using VEvent (RFC 5545 compliant) pub fn expand_recurring_events(events: Vec) -> Vec { 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!( @@ -372,17 +418,18 @@ impl CalendarService { // Get UNTIL date if specified let until_date = components.get("UNTIL").and_then(|until_str| { - // Parse UNTIL date in YYYYMMDDTHHMMSSZ format + // Parse UNTIL date in YYYYMMDDTHHMMSSZ format (treat as local time) if let Ok(dt) = chrono::NaiveDateTime::parse_from_str( until_str.trim_end_matches('Z'), "%Y%m%dT%H%M%S", ) { - Some(chrono::Utc.from_utc_datetime(&dt)) + Some(dt) } else if let Ok(dt) = chrono::DateTime::parse_from_str(until_str, "%Y%m%dT%H%M%SZ") { - Some(dt.with_timezone(&chrono::Utc)) + // Convert UTC to local (naive) time for consistency + Some(dt.naive_utc()) } else if let Ok(date) = chrono::NaiveDate::parse_from_str(until_str, "%Y%m%d") { // Handle date-only UNTIL - Some(chrono::Utc.from_utc_datetime(&date.and_hms_opt(23, 59, 59).unwrap())) + Some(date.and_hms_opt(23, 59, 59).unwrap()) } else { web_sys::console::log_1( &format!("⚠️ Failed to parse UNTIL date: {}", until_str).into(), @@ -395,7 +442,7 @@ impl CalendarService { web_sys::console::log_1(&format!("📅 RRULE has UNTIL: {}", until).into()); } - let start_date = base_event.dtstart.date_naive(); + let start_date = base_event.dtstart.date(); let mut current_date = start_date; let mut occurrence_count = 0; @@ -422,8 +469,8 @@ impl CalendarService { // Check if this occurrence is in the exception dates (EXDATE) let is_exception = base_event.exdate.iter().any(|exception_date| { // Compare dates ignoring sub-second precision - let exception_naive = exception_date.naive_utc(); - let occurrence_naive = occurrence_datetime.naive_utc(); + let exception_naive = exception_date.and_utc(); + let occurrence_naive = occurrence_datetime.and_utc(); // Check if dates match (within a minute to handle minor time differences) let diff = occurrence_naive - exception_naive; @@ -555,7 +602,7 @@ impl CalendarService { interval: i32, start_range: NaiveDate, end_range: NaiveDate, - until_date: Option>, + until_date: Option, count: usize, ) -> Vec { let mut occurrences = Vec::new(); @@ -565,7 +612,7 @@ impl CalendarService { return occurrences; } - let start_date = base_event.dtstart.date_naive(); + let start_date = base_event.dtstart.date(); // Find the Monday of the week containing the start_date (reference week) let reference_week_start = @@ -623,8 +670,8 @@ impl CalendarService { // Check if this occurrence is in the exception dates (EXDATE) let is_exception = base_event.exdate.iter().any(|exception_date| { - let exception_naive = exception_date.naive_utc(); - let occurrence_naive = occurrence_datetime.naive_utc(); + let exception_naive = exception_date.and_utc(); + let occurrence_naive = occurrence_datetime.and_utc(); let diff = occurrence_naive - exception_naive; let matches = diff.num_seconds().abs() < 60; @@ -675,7 +722,7 @@ impl CalendarService { interval: i32, start_range: NaiveDate, end_range: NaiveDate, - until_date: Option>, + until_date: Option, count: usize, ) -> Vec { let mut occurrences = Vec::new(); @@ -691,7 +738,7 @@ impl CalendarService { return occurrences; } - let start_date = base_event.dtstart.date_naive(); + let start_date = base_event.dtstart.date(); let mut current_month_start = NaiveDate::from_ymd_opt(start_date.year(), start_date.month(), 1).unwrap(); let mut total_occurrences = 0; @@ -749,9 +796,7 @@ impl CalendarService { // Check if this occurrence is in the exception dates (EXDATE) let is_exception = base_event.exdate.iter().any(|exception_date| { - let exception_naive = exception_date.naive_utc(); - let occurrence_naive = occurrence_datetime.naive_utc(); - let diff = occurrence_naive - exception_naive; + let diff = occurrence_datetime - *exception_date; diff.num_seconds().abs() < 60 }); @@ -792,14 +837,14 @@ impl CalendarService { interval: i32, start_range: NaiveDate, end_range: NaiveDate, - until_date: Option>, + until_date: Option, count: usize, ) -> Vec { 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 +875,7 @@ impl CalendarService { // Check EXDATE let is_exception = base_event.exdate.iter().any(|exception_date| { - let exception_naive = exception_date.naive_utc(); - let occurrence_naive = occurrence_datetime.naive_utc(); - let diff = occurrence_naive - exception_naive; + let diff = occurrence_datetime - *exception_date; diff.num_seconds().abs() < 60 }); @@ -871,7 +914,7 @@ impl CalendarService { interval: i32, start_range: NaiveDate, end_range: NaiveDate, - until_date: Option>, + until_date: Option, count: usize, ) -> Vec { let mut occurrences = Vec::new(); @@ -887,7 +930,7 @@ impl CalendarService { return occurrences; } - let start_date = base_event.dtstart.date_naive(); + let start_date = base_event.dtstart.date(); let mut current_year = start_date.year(); let mut total_occurrences = 0; @@ -930,9 +973,7 @@ impl CalendarService { // Check EXDATE let is_exception = base_event.exdate.iter().any(|exception_date| { - let exception_naive = exception_date.naive_utc(); - let occurrence_naive = occurrence_datetime.naive_utc(); - let diff = occurrence_naive - exception_naive; + let diff = occurrence_datetime - *exception_date; diff.num_seconds().abs() < 60 }); @@ -1260,6 +1301,7 @@ impl CalendarService { recurrence_count: Option, recurrence_until: Option, calendar_path: Option, + timezone: String, ) -> Result<(), String> { let window = web_sys::window().ok_or("No global window exists")?; @@ -1293,7 +1335,8 @@ impl CalendarService { "recurrence_interval": 1_u32, // Default 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 +1360,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) @@ -1395,9 +1439,10 @@ impl CalendarService { recurrence: String, recurrence_days: Vec, calendar_path: Option, - exception_dates: Vec>, + exception_dates: Vec, update_action: Option, - until_date: Option>, + until_date: Option, + timezone: String, ) -> Result<(), String> { // Forward to update_event_with_scope with default scope self.update_event_with_scope( @@ -1425,6 +1470,7 @@ impl CalendarService { exception_dates, update_action, until_date, + timezone, ) .await } @@ -1452,9 +1498,10 @@ impl CalendarService { recurrence: String, recurrence_days: Vec, calendar_path: Option, - exception_dates: Vec>, + exception_dates: Vec, update_action: Option, - until_date: Option>, + until_date: Option, + timezone: String, ) -> Result<(), String> { let window = web_sys::window().ok_or("No global window exists")?; @@ -1485,8 +1532,9 @@ impl CalendarService { "calendar_path": calendar_path, "update_action": update_action, "occurrence_date": null, - "exception_dates": exception_dates.iter().map(|dt| dt.to_rfc3339()).collect::>(), - "until_date": until_date.as_ref().map(|dt| dt.to_rfc3339()) + "exception_dates": exception_dates.iter().map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()).collect::>(), + "until_date": until_date.as_ref().map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()), + "timezone": timezone }); let url = format!("{}/calendar/events/update", self.base_url); @@ -1692,6 +1740,7 @@ impl CalendarService { calendar_path: Option, update_scope: String, occurrence_date: Option, + timezone: String, ) -> Result<(), String> { let window = web_sys::window().ok_or("No global window exists")?; @@ -1723,7 +1772,8 @@ impl CalendarService { "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);