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>
This commit is contained in:
Connor Johnstone
2025-09-13 20:56:18 -04:00
parent 890940fe31
commit acc5ced551
6 changed files with 387 additions and 158 deletions

View File

@@ -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<chrono::DateTime<chrono::Utc>>,
Option<chrono::NaiveDateTime>,
Option<String>,
Option<String>,
)| {
@@ -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());

View File

@@ -151,54 +151,41 @@ impl EventCreationData {
Option<String>, // calendar_path
Option<u32>, // recurrence_count
Option<String>, // 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,
)
}
}

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;
@@ -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<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!(
@@ -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<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
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<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
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<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 +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<chrono::DateTime<chrono::Utc>>,
until_date: Option<chrono::NaiveDateTime>,
count: usize,
) -> Vec<VEvent> {
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<u32>,
recurrence_until: Option<String>,
calendar_path: Option<String>,
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<bool>,
calendar_path: Option<String>,
exception_dates: Vec<DateTime<Utc>>,
exception_dates: Vec<chrono::NaiveDateTime>,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>,
until_date: Option<chrono::NaiveDateTime>,
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<bool>,
calendar_path: Option<String>,
exception_dates: Vec<DateTime<Utc>>,
exception_dates: Vec<chrono::NaiveDateTime>,
update_action: Option<String>,
until_date: Option<DateTime<Utc>>,
until_date: Option<chrono::NaiveDateTime>,
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::<Vec<String>>(),
"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::<Vec<String>>(),
"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<String>,
update_scope: String,
occurrence_date: Option<String>,
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);