Complete calendar model refactor to use NaiveDateTime for local time handling
- Refactor VEvent to use NaiveDateTime for all date/time fields (dtstart, dtend, created, etc.) - Add separate timezone ID fields (_tzid) for proper RFC 5545 compliance - Update all handlers and services to work with naive local times - Fix external calendar event conversion to handle new model structure - Remove UTC conversions from frontend - backend now handles timezone conversion - Update series operations to work with local time throughout the system - Maintain compatibility with existing CalDAV servers and RFC 5545 specification This major refactor simplifies timezone handling by treating all event times as local until the final CalDAV conversion step, eliminating multiple conversion points that caused timing inconsistencies. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -285,17 +285,25 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
|
||||
|
||||
let vevent = VEvent {
|
||||
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
dtstart,
|
||||
dtend,
|
||||
dtstart: dtstart.naive_utc(),
|
||||
dtstart_tzid: None, // TODO: Parse timezone from ICS
|
||||
dtend: dtend.map(|dt| dt.naive_utc()),
|
||||
dtend_tzid: None, // TODO: Parse timezone from ICS
|
||||
summary,
|
||||
description,
|
||||
location,
|
||||
all_day,
|
||||
rrule,
|
||||
rdate: Vec::new(),
|
||||
rdate_tzid: None,
|
||||
exdate: Vec::new(), // External calendars don't need exception handling
|
||||
exdate_tzid: None,
|
||||
recurrence_id: None,
|
||||
recurrence_id_tzid: None,
|
||||
created: None,
|
||||
created_tzid: None,
|
||||
last_modified: None,
|
||||
last_modified_tzid: None,
|
||||
dtstamp: Utc::now(),
|
||||
sequence: Some(0),
|
||||
status: None,
|
||||
@@ -313,7 +321,6 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
|
||||
class: None,
|
||||
contact: None,
|
||||
comment: None,
|
||||
rdate: Vec::new(),
|
||||
alarms: Vec::new(),
|
||||
etag: None,
|
||||
href: None,
|
||||
@@ -834,7 +841,7 @@ fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VE
|
||||
if rrule.contains("FREQ=DAILY") {
|
||||
// Daily recurrence
|
||||
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
|
||||
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||
|
||||
if days_diff >= 0 && days_diff % interval as i64 == 0 {
|
||||
// Check if times match (allowing for timezone differences within same day)
|
||||
@@ -845,7 +852,7 @@ fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VE
|
||||
} else if rrule.contains("FREQ=WEEKLY") {
|
||||
// Weekly recurrence
|
||||
let interval = extract_interval_from_rrule(rrule).unwrap_or(1);
|
||||
let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days();
|
||||
let days_diff = (single_event.dtstart.date() - recurring_event.dtstart.date()).num_days();
|
||||
|
||||
// First check if it's the same day of week and time
|
||||
let recurring_weekday = recurring_event.dtstart.weekday();
|
||||
|
||||
@@ -14,6 +14,33 @@ use calendar_models::{EventClass, EventStatus, VEvent};
|
||||
|
||||
use super::auth::{extract_bearer_token, extract_password_header};
|
||||
|
||||
fn parse_event_datetime_local(
|
||||
date_str: &str,
|
||||
time_str: &str,
|
||||
all_day: bool,
|
||||
) -> 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 start of day
|
||||
let datetime = date
|
||||
.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 - now keeping as local time
|
||||
Ok(NaiveDateTime::new(date, time))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new recurring event series
|
||||
pub async fn create_event_series(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -106,84 +133,29 @@ pub async fn create_event_series(
|
||||
|
||||
println!("📅 Using calendar path: {}", calendar_path);
|
||||
|
||||
// Parse datetime components
|
||||
let start_date =
|
||||
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
|
||||
})?;
|
||||
// Parse dates and times as local times (no UTC conversion)
|
||||
let start_datetime = 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 (start_datetime, end_datetime) = if request.all_day {
|
||||
// For all-day events, use the dates as-is
|
||||
let start_dt = start_date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
|
||||
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)))?;
|
||||
|
||||
let end_date = if !request.end_date.is_empty() {
|
||||
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
||||
})?
|
||||
} else {
|
||||
start_date
|
||||
};
|
||||
|
||||
let end_dt = end_date
|
||||
.and_hms_opt(23, 59, 59)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".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),
|
||||
end_local.with_timezone(&chrono::Utc),
|
||||
)
|
||||
} else {
|
||||
// Parse times for timed events
|
||||
let start_time = if !request.start_time.is_empty() {
|
||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid start_time format. Expected HH:MM".to_string())
|
||||
})?
|
||||
} else {
|
||||
chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9 AM
|
||||
};
|
||||
|
||||
let end_time = if !request.end_time.is_empty() {
|
||||
chrono::NaiveTime::parse_from_str(&request.end_time, "%H:%M").map_err(|_| {
|
||||
ApiError::BadRequest("Invalid end_time format. Expected HH:MM".to_string())
|
||||
})?
|
||||
} else {
|
||||
chrono::NaiveTime::from_hms_opt(10, 0, 0).unwrap() // Default to 1 hour duration
|
||||
};
|
||||
|
||||
let start_dt = start_date.and_time(start_time);
|
||||
let end_dt = if !request.end_date.is_empty() {
|
||||
let end_date = chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d")
|
||||
.map_err(|_| {
|
||||
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string())
|
||||
})?;
|
||||
end_date.and_time(end_time)
|
||||
} else {
|
||||
start_date.and_time(end_time)
|
||||
};
|
||||
|
||||
// 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),
|
||||
end_local.with_timezone(&chrono::Utc),
|
||||
)
|
||||
};
|
||||
// For all-day events, add one day to end date for RFC-5545 compliance
|
||||
if request.all_day {
|
||||
end_datetime = end_datetime + chrono::Duration::days(1);
|
||||
}
|
||||
|
||||
// Generate a unique UID for the series
|
||||
let uid = format!("series-{}", uuid::Uuid::new_v4().to_string());
|
||||
|
||||
// Create the VEvent for the series
|
||||
// Create the VEvent for the series with local times
|
||||
let mut event = VEvent::new(uid.clone(), start_datetime);
|
||||
event.dtend = Some(end_datetime);
|
||||
event.all_day = request.all_day; // Set the all_day flag properly
|
||||
|
||||
// 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 {
|
||||
@@ -372,7 +344,7 @@ pub async fn update_event_series(
|
||||
);
|
||||
|
||||
// Parse datetime components for the update
|
||||
let original_start_date = existing_event.dtstart.date_naive();
|
||||
let original_start_date = existing_event.dtstart.date();
|
||||
|
||||
// For "this_and_future" and "this_only" updates, use the occurrence date for the modified event
|
||||
// For "all_in_series" updates, preserve the original series start date
|
||||
@@ -399,7 +371,7 @@ pub async fn update_event_series(
|
||||
// Calculate the duration from the original event
|
||||
let original_duration_days = existing_event
|
||||
.dtend
|
||||
.map(|end| (end.date_naive() - existing_event.dtstart.date_naive()).num_days())
|
||||
.map(|end| (end.date() - existing_event.dtstart.date()).num_days())
|
||||
.unwrap_or(0);
|
||||
start_date + chrono::Duration::days(original_duration_days)
|
||||
} else {
|
||||
@@ -410,11 +382,8 @@ pub async fn update_event_series(
|
||||
.and_hms_opt(12, 0, 0)
|
||||
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
|
||||
|
||||
// For all-day events, use UTC directly (no local conversion needed)
|
||||
(
|
||||
chrono::Utc.from_utc_datetime(&start_dt),
|
||||
chrono::Utc.from_utc_datetime(&end_dt),
|
||||
)
|
||||
// For all-day events, use local times directly
|
||||
(start_dt, end_dt)
|
||||
} else {
|
||||
let start_time = if !request.start_time.is_empty() {
|
||||
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
|
||||
@@ -445,17 +414,11 @@ pub async fn update_event_series(
|
||||
.dtend
|
||||
.map(|end| end - existing_event.dtstart)
|
||||
.unwrap_or_else(|| chrono::Duration::hours(1));
|
||||
(chrono::Utc.from_utc_datetime(&start_dt) + original_duration).naive_utc()
|
||||
start_dt + original_duration
|
||||
};
|
||||
|
||||
// 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),
|
||||
end_local.with_timezone(&chrono::Utc),
|
||||
)
|
||||
// Frontend now sends local times, so use them directly
|
||||
(start_dt, end_dt)
|
||||
};
|
||||
|
||||
// Handle different update scopes
|
||||
@@ -702,8 +665,8 @@ fn build_series_rrule_with_freq(
|
||||
fn update_entire_series(
|
||||
existing_event: &mut VEvent,
|
||||
request: &UpdateEventSeriesRequest,
|
||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
||||
start_datetime: chrono::NaiveDateTime,
|
||||
end_datetime: chrono::NaiveDateTime,
|
||||
) -> Result<(VEvent, u32), ApiError> {
|
||||
// Clone the existing event to preserve all metadata
|
||||
let mut updated_event = existing_event.clone();
|
||||
@@ -711,6 +674,8 @@ fn update_entire_series(
|
||||
// Update only the modified properties from the request
|
||||
updated_event.dtstart = start_datetime;
|
||||
updated_event.dtend = Some(end_datetime);
|
||||
updated_event.dtstart_tzid = Some(request.timezone.clone());
|
||||
updated_event.dtend_tzid = Some(request.timezone.clone());
|
||||
updated_event.summary = if request.title.trim().is_empty() {
|
||||
existing_event.summary.clone() // Keep original if empty
|
||||
} else {
|
||||
@@ -743,8 +708,9 @@ fn update_entire_series(
|
||||
|
||||
// Update timestamps
|
||||
let now = chrono::Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
updated_event.dtstamp = now;
|
||||
updated_event.last_modified = Some(now);
|
||||
updated_event.last_modified = Some(now_naive);
|
||||
// Keep original created timestamp to preserve event history
|
||||
|
||||
// Update RRULE if recurrence parameters are provided
|
||||
@@ -832,8 +798,8 @@ fn update_entire_series(
|
||||
async fn update_this_and_future(
|
||||
existing_event: &mut VEvent,
|
||||
request: &UpdateEventSeriesRequest,
|
||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
||||
start_datetime: chrono::NaiveDateTime,
|
||||
end_datetime: chrono::NaiveDateTime,
|
||||
client: &CalDAVClient,
|
||||
calendar_path: &str,
|
||||
) -> Result<(VEvent, u32), ApiError> {
|
||||
@@ -881,6 +847,8 @@ async fn update_this_and_future(
|
||||
new_series.uid = new_series_uid.clone();
|
||||
new_series.dtstart = start_datetime;
|
||||
new_series.dtend = Some(end_datetime);
|
||||
new_series.dtstart_tzid = Some(request.timezone.clone());
|
||||
new_series.dtend_tzid = Some(request.timezone.clone());
|
||||
new_series.summary = if request.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -913,9 +881,10 @@ async fn update_this_and_future(
|
||||
|
||||
// Update timestamps
|
||||
let now = chrono::Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
new_series.dtstamp = now;
|
||||
new_series.created = Some(now);
|
||||
new_series.last_modified = Some(now);
|
||||
new_series.created = Some(now_naive);
|
||||
new_series.last_modified = Some(now_naive);
|
||||
new_series.href = None; // Will be set when created
|
||||
|
||||
println!(
|
||||
@@ -943,8 +912,8 @@ async fn update_this_and_future(
|
||||
async fn update_single_occurrence(
|
||||
existing_event: &mut VEvent,
|
||||
request: &UpdateEventSeriesRequest,
|
||||
start_datetime: chrono::DateTime<chrono::Utc>,
|
||||
end_datetime: chrono::DateTime<chrono::Utc>,
|
||||
start_datetime: chrono::NaiveDateTime,
|
||||
end_datetime: chrono::NaiveDateTime,
|
||||
client: &CalDAVClient,
|
||||
calendar_path: &str,
|
||||
_original_event_href: &str,
|
||||
@@ -969,21 +938,20 @@ async fn update_single_occurrence(
|
||||
// Create the EXDATE datetime using the original event's time
|
||||
let original_time = existing_event.dtstart.time();
|
||||
let exception_datetime = exception_date.and_time(original_time);
|
||||
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
||||
|
||||
// Add the exception date to the original series
|
||||
println!(
|
||||
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
|
||||
existing_event.exdate
|
||||
);
|
||||
existing_event.exdate.push(exception_utc);
|
||||
existing_event.exdate.push(exception_datetime);
|
||||
println!(
|
||||
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
|
||||
existing_event.exdate
|
||||
);
|
||||
println!(
|
||||
"🚫 Added EXDATE for single occurrence modification: {}",
|
||||
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
||||
exception_datetime.format("%Y-%m-%d %H:%M:%S")
|
||||
);
|
||||
|
||||
// Create exception event by cloning the existing event to preserve all metadata
|
||||
@@ -1027,8 +995,9 @@ async fn update_single_occurrence(
|
||||
|
||||
// Update timestamps for the exception event
|
||||
let now = chrono::Utc::now();
|
||||
let now_naive = now.naive_utc();
|
||||
exception_event.dtstamp = now;
|
||||
exception_event.last_modified = Some(now);
|
||||
exception_event.last_modified = Some(now_naive);
|
||||
// Keep original created timestamp to preserve event history
|
||||
|
||||
// Set RECURRENCE-ID to point to the original occurrence
|
||||
@@ -1044,7 +1013,7 @@ async fn update_single_occurrence(
|
||||
|
||||
println!(
|
||||
"✨ Created exception event with RECURRENCE-ID: {}",
|
||||
exception_utc.format("%Y-%m-%d %H:%M:%S")
|
||||
exception_datetime.format("%Y-%m-%d %H:%M:%S")
|
||||
);
|
||||
|
||||
// Create the exception event as a new event (original series will be updated by main handler)
|
||||
@@ -1172,15 +1141,14 @@ async fn delete_single_occurrence(
|
||||
// Create the EXDATE datetime (use the same time as the original event)
|
||||
let original_time = existing_event.dtstart.time();
|
||||
let exception_datetime = exception_date.and_time(original_time);
|
||||
let exception_utc = chrono::Utc.from_utc_datetime(&exception_datetime);
|
||||
|
||||
// Add the exception date to the event's EXDATE list
|
||||
let mut updated_event = existing_event;
|
||||
updated_event.exdate.push(exception_utc);
|
||||
updated_event.exdate.push(exception_datetime);
|
||||
|
||||
println!(
|
||||
"🗑️ Added EXDATE for single occurrence deletion: {}",
|
||||
exception_utc.format("%Y%m%dT%H%M%SZ")
|
||||
exception_datetime.format("%Y%m%dT%H%M%S")
|
||||
);
|
||||
|
||||
// Update the event on the CalDAV server
|
||||
|
||||
Reference in New Issue
Block a user