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

@@ -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<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())));
}
// 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<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)));
}
}