From c6eea880029754828855e3c285ab0d7ac949eb21 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Thu, 4 Sep 2025 14:07:33 -0400 Subject: [PATCH] Fix drag-and-drop timezone bug between dev and production environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root cause was that drag operations sent naive local time to the backend, which the backend interpreted using the SERVER's local timezone rather than the USER's timezone. This caused different behavior between development and production servers in different timezones. **Frontend Changes:** - Convert naive datetime from drag operations to UTC before sending to backend - Use client-side Local timezone to properly convert user's intended times - Handle DST transition edge cases with fallback logic **Backend Changes:** - Update parse_event_datetime to treat incoming times as UTC (no server timezone conversion) - Update series handlers to expect UTC times from frontend - Remove server-side Local timezone dependency for event parsing **Result:** - Consistent behavior across all server environments regardless of server timezone - Drag operations now correctly preserve user's intended local times - Fixes "4 hours too early" issue in production drag-and-drop operations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/handlers/events.rs | 10 +++------- backend/src/handlers/series.rs | 30 +++++++++--------------------- frontend/src/app.rs | 29 ++++++++++++++++++++++++----- 3 files changed, 36 insertions(+), 33 deletions(-) diff --git a/backend/src/handlers/events.rs b/backend/src/handlers/events.rs index b80c91f..8257131 100644 --- a/backend/src/handlers/events.rs +++ b/backend/src/handlers/events.rs @@ -845,7 +845,7 @@ fn parse_event_datetime( time_str: &str, all_day: bool, ) -> Result, String> { - use chrono::{Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; + use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; // Parse the date let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") @@ -866,11 +866,7 @@ fn parse_event_datetime( // Combine date and time let datetime = NaiveDateTime::new(date, time); - // Treat the datetime as local time and convert to UTC - let local_datetime = Local.from_local_datetime(&datetime) - .single() - .ok_or_else(|| "Ambiguous local datetime".to_string())?; - - Ok(local_datetime.with_timezone(&Utc)) + // Frontend now sends UTC times, so treat as UTC directly + Ok(Utc.from_utc_datetime(&datetime)) } } diff --git a/backend/src/handlers/series.rs b/backend/src/handlers/series.rs index ed1d0a8..f1b8584 100644 --- a/backend/src/handlers/series.rs +++ b/backend/src/handlers/series.rs @@ -130,13 +130,9 @@ pub async fn create_event_series( .and_hms_opt(23, 59, 59) .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; - // Convert from local time to UTC - let start_local = chrono::Local.from_local_datetime(&start_dt) - .single() - .ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?; - let end_local = chrono::Local.from_local_datetime(&end_dt) - .single() - .ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?; + // 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), @@ -171,13 +167,9 @@ pub async fn create_event_series( start_date.and_time(end_time) }; - // Convert from local time to UTC - let start_local = chrono::Local.from_local_datetime(&start_dt) - .single() - .ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?; - let end_local = chrono::Local.from_local_datetime(&end_dt) - .single() - .ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?; + // 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), @@ -456,13 +448,9 @@ pub async fn update_event_series( (chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc() }; - // Convert from local time to UTC - let start_local = chrono::Local.from_local_datetime(&start_dt) - .single() - .ok_or_else(|| ApiError::BadRequest("Ambiguous start datetime".to_string()))?; - let end_local = chrono::Local.from_local_datetime(&end_dt) - .single() - .ok_or_else(|| ApiError::BadRequest("Ambiguous end datetime".to_string()))?; + // 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), diff --git a/frontend/src/app.rs b/frontend/src/app.rs index e003453..edbf1f2 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -761,11 +761,30 @@ pub fn App() -> Html { String::new() }; - // Send local time directly to backend (backend will handle UTC conversion) - let start_date = new_start.format("%Y-%m-%d").to_string(); - let start_time = new_start.format("%H:%M").to_string(); - let end_date = new_end.format("%Y-%m-%d").to_string(); - let end_time = new_end.format("%H:%M").to_string(); + // Convert 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(); // Convert existing event data to string formats for the API let status_str = match original_event.status {