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)));
}
}

View File

@@ -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<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

@@ -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)]
@@ -148,6 +149,7 @@ pub struct UpdateEventRequest {
pub recurrence_days: Vec<bool>, // [Sun, Mon, Tue, Wed, Thu, Fri, Sat] for weekly recurrence
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 +187,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 +230,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

@@ -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);