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:
Connor Johnstone
2025-09-13 20:57:35 -04:00
parent acc5ced551
commit a6092d13ce
9 changed files with 158 additions and 177 deletions

View File

@@ -285,17 +285,25 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
let vevent = VEvent { let vevent = VEvent {
uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()), uid: uid.unwrap_or_else(|| Uuid::new_v4().to_string()),
dtstart, dtstart: dtstart.naive_utc(),
dtend, dtstart_tzid: None, // TODO: Parse timezone from ICS
dtend: dtend.map(|dt| dt.naive_utc()),
dtend_tzid: None, // TODO: Parse timezone from ICS
summary, summary,
description, description,
location, location,
all_day, all_day,
rrule, rrule,
rdate: Vec::new(),
rdate_tzid: None,
exdate: Vec::new(), // External calendars don't need exception handling exdate: Vec::new(), // External calendars don't need exception handling
exdate_tzid: None,
recurrence_id: None, recurrence_id: None,
recurrence_id_tzid: None,
created: None, created: None,
created_tzid: None,
last_modified: None, last_modified: None,
last_modified_tzid: None,
dtstamp: Utc::now(), dtstamp: Utc::now(),
sequence: Some(0), sequence: Some(0),
status: None, status: None,
@@ -313,7 +321,6 @@ fn convert_ical_to_vevent(ical_event: IcalEvent) -> Result<VEvent, Box<dyn std::
class: None, class: None,
contact: None, contact: None,
comment: None, comment: None,
rdate: Vec::new(),
alarms: Vec::new(), alarms: Vec::new(),
etag: None, etag: None,
href: 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") { if rrule.contains("FREQ=DAILY") {
// Daily recurrence // Daily recurrence
let interval = extract_interval_from_rrule(rrule).unwrap_or(1); 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 { if days_diff >= 0 && days_diff % interval as i64 == 0 {
// Check if times match (allowing for timezone differences within same day) // 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") { } else if rrule.contains("FREQ=WEEKLY") {
// Weekly recurrence // Weekly recurrence
let interval = extract_interval_from_rrule(rrule).unwrap_or(1); 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 // First check if it's the same day of week and time
let recurring_weekday = recurring_event.dtstart.weekday(); let recurring_weekday = recurring_event.dtstart.weekday();

View File

@@ -14,6 +14,33 @@ use calendar_models::{EventClass, EventStatus, VEvent};
use super::auth::{extract_bearer_token, extract_password_header}; 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 /// Create a new recurring event series
pub async fn create_event_series( pub async fn create_event_series(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
@@ -106,84 +133,29 @@ pub async fn create_event_series(
println!("📅 Using calendar path: {}", calendar_path); println!("📅 Using calendar path: {}", calendar_path);
// Parse datetime components // Parse dates and times as local times (no UTC conversion)
let start_date = let start_datetime = parse_event_datetime_local(&request.start_date, &request.start_time, request.all_day)
chrono::NaiveDate::parse_from_str(&request.start_date, "%Y-%m-%d").map_err(|_| { .map_err(|e| ApiError::BadRequest(format!("Invalid start date/time: {}", e)))?;
ApiError::BadRequest("Invalid start_date format. Expected YYYY-MM-DD".to_string())
})?;
let (start_datetime, end_datetime) = if request.all_day { let mut end_datetime = parse_event_datetime_local(&request.end_date, &request.end_time, request.all_day)
// For all-day events, use the dates as-is .map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
let start_dt = start_date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid start date".to_string()))?;
let end_date = if !request.end_date.is_empty() { // For all-day events, add one day to end date for RFC-5545 compliance
chrono::NaiveDate::parse_from_str(&request.end_date, "%Y-%m-%d").map_err(|_| { if request.all_day {
ApiError::BadRequest("Invalid end_date format. Expected YYYY-MM-DD".to_string()) end_datetime = end_datetime + chrono::Duration::days(1);
})? }
} 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),
)
};
// Generate a unique UID for the series // Generate a unique UID for the series
let uid = format!("series-{}", uuid::Uuid::new_v4().to_string()); 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); let mut event = VEvent::new(uid.clone(), start_datetime);
event.dtend = Some(end_datetime); event.dtend = Some(end_datetime);
event.all_day = request.all_day; // Set the all_day flag properly 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() { event.summary = if request.title.trim().is_empty() {
None None
} else { } else {
@@ -372,7 +344,7 @@ pub async fn update_event_series(
); );
// Parse datetime components for the update // 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 "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 // 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 // Calculate the duration from the original event
let original_duration_days = existing_event let original_duration_days = existing_event
.dtend .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); .unwrap_or(0);
start_date + chrono::Duration::days(original_duration_days) start_date + chrono::Duration::days(original_duration_days)
} else { } else {
@@ -410,11 +382,8 @@ pub async fn update_event_series(
.and_hms_opt(12, 0, 0) .and_hms_opt(12, 0, 0)
.ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?; .ok_or_else(|| ApiError::BadRequest("Invalid end date".to_string()))?;
// For all-day events, use UTC directly (no local conversion needed) // For all-day events, use local times directly
( (start_dt, end_dt)
chrono::Utc.from_utc_datetime(&start_dt),
chrono::Utc.from_utc_datetime(&end_dt),
)
} else { } else {
let start_time = if !request.start_time.is_empty() { let start_time = if !request.start_time.is_empty() {
chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| { chrono::NaiveTime::parse_from_str(&request.start_time, "%H:%M").map_err(|_| {
@@ -445,17 +414,11 @@ pub async fn update_event_series(
.dtend .dtend
.map(|end| end - existing_event.dtstart) .map(|end| end - existing_event.dtstart)
.unwrap_or_else(|| chrono::Duration::hours(1)); .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 // Frontend now sends local times, so use them directly
let start_local = chrono::Utc.from_utc_datetime(&start_dt); (start_dt, end_dt)
let end_local = chrono::Utc.from_utc_datetime(&end_dt);
(
start_local.with_timezone(&chrono::Utc),
end_local.with_timezone(&chrono::Utc),
)
}; };
// Handle different update scopes // Handle different update scopes
@@ -702,8 +665,8 @@ fn build_series_rrule_with_freq(
fn update_entire_series( fn update_entire_series(
existing_event: &mut VEvent, existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest, request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>, start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::DateTime<chrono::Utc>, end_datetime: chrono::NaiveDateTime,
) -> Result<(VEvent, u32), ApiError> { ) -> Result<(VEvent, u32), ApiError> {
// Clone the existing event to preserve all metadata // Clone the existing event to preserve all metadata
let mut updated_event = existing_event.clone(); let mut updated_event = existing_event.clone();
@@ -711,6 +674,8 @@ fn update_entire_series(
// Update only the modified properties from the request // Update only the modified properties from the request
updated_event.dtstart = start_datetime; updated_event.dtstart = start_datetime;
updated_event.dtend = Some(end_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() { updated_event.summary = if request.title.trim().is_empty() {
existing_event.summary.clone() // Keep original if empty existing_event.summary.clone() // Keep original if empty
} else { } else {
@@ -743,8 +708,9 @@ fn update_entire_series(
// Update timestamps // Update timestamps
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let now_naive = now.naive_utc();
updated_event.dtstamp = now; 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 // Keep original created timestamp to preserve event history
// Update RRULE if recurrence parameters are provided // Update RRULE if recurrence parameters are provided
@@ -832,8 +798,8 @@ fn update_entire_series(
async fn update_this_and_future( async fn update_this_and_future(
existing_event: &mut VEvent, existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest, request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>, start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::DateTime<chrono::Utc>, end_datetime: chrono::NaiveDateTime,
client: &CalDAVClient, client: &CalDAVClient,
calendar_path: &str, calendar_path: &str,
) -> Result<(VEvent, u32), ApiError> { ) -> Result<(VEvent, u32), ApiError> {
@@ -881,6 +847,8 @@ async fn update_this_and_future(
new_series.uid = new_series_uid.clone(); new_series.uid = new_series_uid.clone();
new_series.dtstart = start_datetime; new_series.dtstart = start_datetime;
new_series.dtend = Some(end_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() { new_series.summary = if request.title.trim().is_empty() {
None None
} else { } else {
@@ -913,9 +881,10 @@ async fn update_this_and_future(
// Update timestamps // Update timestamps
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let now_naive = now.naive_utc();
new_series.dtstamp = now; new_series.dtstamp = now;
new_series.created = Some(now); new_series.created = Some(now_naive);
new_series.last_modified = Some(now); new_series.last_modified = Some(now_naive);
new_series.href = None; // Will be set when created new_series.href = None; // Will be set when created
println!( println!(
@@ -943,8 +912,8 @@ async fn update_this_and_future(
async fn update_single_occurrence( async fn update_single_occurrence(
existing_event: &mut VEvent, existing_event: &mut VEvent,
request: &UpdateEventSeriesRequest, request: &UpdateEventSeriesRequest,
start_datetime: chrono::DateTime<chrono::Utc>, start_datetime: chrono::NaiveDateTime,
end_datetime: chrono::DateTime<chrono::Utc>, end_datetime: chrono::NaiveDateTime,
client: &CalDAVClient, client: &CalDAVClient,
calendar_path: &str, calendar_path: &str,
_original_event_href: &str, _original_event_href: &str,
@@ -969,21 +938,20 @@ async fn update_single_occurrence(
// Create the EXDATE datetime using the original event's time // Create the EXDATE datetime using the original event's time
let original_time = existing_event.dtstart.time(); let original_time = existing_event.dtstart.time();
let exception_datetime = exception_date.and_time(original_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 // Add the exception date to the original series
println!( println!(
"📝 BEFORE adding EXDATE: existing_event.exdate = {:?}", "📝 BEFORE adding EXDATE: existing_event.exdate = {:?}",
existing_event.exdate existing_event.exdate
); );
existing_event.exdate.push(exception_utc); existing_event.exdate.push(exception_datetime);
println!( println!(
"📝 AFTER adding EXDATE: existing_event.exdate = {:?}", "📝 AFTER adding EXDATE: existing_event.exdate = {:?}",
existing_event.exdate existing_event.exdate
); );
println!( println!(
"🚫 Added EXDATE for single occurrence modification: {}", "🚫 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 // 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 // Update timestamps for the exception event
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let now_naive = now.naive_utc();
exception_event.dtstamp = now; 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 // Keep original created timestamp to preserve event history
// Set RECURRENCE-ID to point to the original occurrence // Set RECURRENCE-ID to point to the original occurrence
@@ -1044,7 +1013,7 @@ async fn update_single_occurrence(
println!( println!(
"✨ Created exception event with RECURRENCE-ID: {}", "✨ 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) // 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) // Create the EXDATE datetime (use the same time as the original event)
let original_time = existing_event.dtstart.time(); let original_time = existing_event.dtstart.time();
let exception_datetime = exception_date.and_time(original_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 // Add the exception date to the event's EXDATE list
let mut updated_event = existing_event; let mut updated_event = existing_event;
updated_event.exdate.push(exception_utc); updated_event.exdate.push(exception_datetime);
println!( println!(
"🗑️ Added EXDATE for single occurrence deletion: {}", "🗑️ 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 // Update the event on the CalDAV server

View File

@@ -1,7 +1,7 @@
//! VEvent - RFC 5545 compliant calendar event structure //! VEvent - RFC 5545 compliant calendar event structure
use crate::common::*; use crate::common::*;
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, NaiveDateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ==================== VEVENT COMPONENT ==================== // ==================== VEVENT COMPONENT ====================
@@ -9,12 +9,14 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VEvent { pub struct VEvent {
// Required properties // Required properties
pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED pub dtstamp: DateTime<Utc>, // Date-time stamp (DTSTAMP) - REQUIRED (always UTC)
pub uid: String, // Unique identifier (UID) - REQUIRED pub uid: String, // Unique identifier (UID) - REQUIRED
pub dtstart: DateTime<Utc>, // Start date-time (DTSTART) - REQUIRED pub dtstart: NaiveDateTime, // Start date-time (DTSTART) - REQUIRED (local time)
pub dtstart_tzid: Option<String>, // Timezone ID for DTSTART (TZID parameter)
// Optional properties (commonly used) // Optional properties (commonly used)
pub dtend: Option<DateTime<Utc>>, // End date-time (DTEND) pub dtend: Option<NaiveDateTime>, // End date-time (DTEND) (local time)
pub dtend_tzid: Option<String>, // Timezone ID for DTEND (TZID parameter)
pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND pub duration: Option<Duration>, // Duration (DURATION) - alternative to DTEND
pub summary: Option<String>, // Summary/title (SUMMARY) pub summary: Option<String>, // Summary/title (SUMMARY)
pub description: Option<String>, // Description (DESCRIPTION) pub description: Option<String>, // Description (DESCRIPTION)
@@ -43,14 +45,19 @@ pub struct VEvent {
// Versioning and modification // Versioning and modification
pub sequence: Option<u32>, // Sequence number (SEQUENCE) pub sequence: Option<u32>, // Sequence number (SEQUENCE)
pub created: Option<DateTime<Utc>>, // Creation time (CREATED) pub created: Option<NaiveDateTime>, // Creation time (CREATED) (local time)
pub last_modified: Option<DateTime<Utc>>, // Last modified (LAST-MODIFIED) pub created_tzid: Option<String>, // Timezone ID for CREATED
pub last_modified: Option<NaiveDateTime>, // Last modified (LAST-MODIFIED) (local time)
pub last_modified_tzid: Option<String>, // Timezone ID for LAST-MODIFIED
// Recurrence // Recurrence
pub rrule: Option<String>, // Recurrence rule (RRULE) pub rrule: Option<String>, // Recurrence rule (RRULE)
pub rdate: Vec<DateTime<Utc>>, // Recurrence dates (RDATE) pub rdate: Vec<NaiveDateTime>, // Recurrence dates (RDATE) (local time)
pub exdate: Vec<DateTime<Utc>>, // Exception dates (EXDATE) pub rdate_tzid: Option<String>, // Timezone ID for RDATE
pub recurrence_id: Option<DateTime<Utc>>, // Recurrence ID (RECURRENCE-ID) pub exdate: Vec<NaiveDateTime>, // Exception dates (EXDATE) (local time)
pub exdate_tzid: Option<String>, // Timezone ID for EXDATE
pub recurrence_id: Option<NaiveDateTime>, // Recurrence ID (RECURRENCE-ID) (local time)
pub recurrence_id_tzid: Option<String>, // Timezone ID for RECURRENCE-ID
// Alarms and attachments // Alarms and attachments
pub alarms: Vec<VAlarm>, // VALARM components pub alarms: Vec<VAlarm>, // VALARM components
@@ -64,13 +71,15 @@ pub struct VEvent {
} }
impl VEvent { impl VEvent {
/// Create a new VEvent with required fields /// Create a new VEvent with required fields (local time)
pub fn new(uid: String, dtstart: DateTime<Utc>) -> Self { pub fn new(uid: String, dtstart: NaiveDateTime) -> Self {
Self { Self {
dtstamp: Utc::now(), dtstamp: Utc::now(),
uid, uid,
dtstart, dtstart,
dtstart_tzid: None,
dtend: None, dtend: None,
dtend_tzid: None,
duration: None, duration: None,
summary: None, summary: None,
description: None, description: None,
@@ -89,12 +98,17 @@ impl VEvent {
url: None, url: None,
geo: None, geo: None,
sequence: None, sequence: None,
created: Some(Utc::now()), created: Some(chrono::Local::now().naive_local()),
last_modified: Some(Utc::now()), created_tzid: None,
last_modified: Some(chrono::Local::now().naive_local()),
last_modified_tzid: None,
rrule: None, rrule: None,
rdate: Vec::new(), rdate: Vec::new(),
rdate_tzid: None,
exdate: Vec::new(), exdate: Vec::new(),
exdate_tzid: None,
recurrence_id: None, recurrence_id: None,
recurrence_id_tzid: None,
alarms: Vec::new(), alarms: Vec::new(),
attachments: Vec::new(), attachments: Vec::new(),
etag: None, etag: None,
@@ -105,7 +119,7 @@ impl VEvent {
} }
/// Helper method to get effective end time (dtend or dtstart + duration) /// Helper method to get effective end time (dtend or dtstart + duration)
pub fn get_end_time(&self) -> DateTime<Utc> { pub fn get_end_time(&self) -> NaiveDateTime {
if let Some(dtend) = self.dtend { if let Some(dtend) = self.dtend {
dtend dtend
} else if let Some(duration) = self.duration { } else if let Some(duration) = self.duration {
@@ -136,7 +150,7 @@ impl VEvent {
/// Helper method to get start date for UI compatibility /// Helper method to get start date for UI compatibility
pub fn get_date(&self) -> chrono::NaiveDate { pub fn get_date(&self) -> chrono::NaiveDate {
self.dtstart.date_naive() self.dtstart.date()
} }
/// Check if event is recurring /// Check if event is recurring

View File

@@ -32,7 +32,7 @@ pub struct CalendarProps {
chrono::NaiveDateTime, chrono::NaiveDateTime,
chrono::NaiveDateTime, chrono::NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)>, )>,
@@ -437,7 +437,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
chrono::NaiveDateTime, chrono::NaiveDateTime,
chrono::NaiveDateTime, chrono::NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)| { )| {

View File

@@ -195,7 +195,7 @@ pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
let on_external_success = on_external_success.clone(); let on_external_success = on_external_success.clone();
wasm_bindgen_futures::spawn_local(async move { wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new(); let _calendar_service = CalendarService::new();
match CalendarService::create_external_calendar(&name, &url, &color).await { match CalendarService::create_external_calendar(&name, &url, &color).await {
Ok(calendar) => { Ok(calendar) => {

View File

@@ -238,12 +238,11 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
// Convert VEvent to EventCreationData for editing // Convert VEvent to EventCreationData for editing
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData { fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
use chrono::Local;
// Convert start datetime from UTC to local // VEvent fields are already local time (NaiveDateTime)
let start_local = event.dtstart.with_timezone(&Local).naive_local(); let start_local = event.dtstart;
let end_local = if let Some(dtend) = event.dtend { let end_local = if let Some(dtend) = event.dtend {
dtend.with_timezone(&Local).naive_local() dtend
} else { } else {
// Default to 1 hour after start if no end time // Default to 1 hour after start if no end time
start_local + chrono::Duration::hours(1) start_local + chrono::Duration::hours(1)

View File

@@ -1,5 +1,4 @@
use crate::models::ical::VEvent; use crate::models::ical::VEvent;
use chrono::{DateTime, Utc};
use yew::prelude::*; use yew::prelude::*;
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
@@ -213,7 +212,7 @@ pub fn EventModal(props: &EventModalProps) -> Html {
} }
} }
fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String { fn format_datetime(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
if all_day { if all_day {
dt.format("%B %d, %Y").to_string() dt.format("%B %d, %Y").to_string()
} else { } else {
@@ -221,7 +220,7 @@ fn format_datetime(dt: &DateTime<Utc>, all_day: bool) -> String {
} }
} }
fn format_datetime_end(dt: &DateTime<Utc>, all_day: bool) -> String { fn format_datetime_end(dt: &chrono::NaiveDateTime, all_day: bool) -> String {
if all_day { if all_day {
// For all-day events, subtract one day from end date for display // For all-day events, subtract one day from end date for display
// RFC-5545 uses exclusive end dates, but users expect inclusive display // RFC-5545 uses exclusive end dates, but users expect inclusive display

View File

@@ -38,7 +38,7 @@ pub struct RouteHandlerProps {
chrono::NaiveDateTime, chrono::NaiveDateTime,
chrono::NaiveDateTime, chrono::NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)>, )>,
@@ -136,7 +136,7 @@ pub struct CalendarViewProps {
chrono::NaiveDateTime, chrono::NaiveDateTime,
chrono::NaiveDateTime, chrono::NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)>, )>,

View File

@@ -33,7 +33,7 @@ pub struct WeekViewProps {
NaiveDateTime, NaiveDateTime,
NaiveDateTime, NaiveDateTime,
bool, bool,
Option<chrono::DateTime<chrono::Utc>>, Option<chrono::NaiveDateTime>,
Option<String>, Option<String>,
Option<String>, Option<String>,
)>, )>,
@@ -285,18 +285,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Calculate the day before this occurrence for UNTIL clause // Calculate the day before this occurrence for UNTIL clause
let until_date = let until_date =
edit.event.dtstart.date_naive() - chrono::Duration::days(1); edit.event.dtstart.date() - chrono::Duration::days(1);
let until_datetime = until_date let until_datetime = until_date
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap()); .and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
let until_utc = let until_naive = until_datetime; // Use local time directly
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
until_datetime,
chrono::Utc,
);
web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}", web_sys::console::log_1(&format!("🔄 Will set UNTIL {} for original series to end before occurrence {}",
until_utc.format("%Y-%m-%d %H:%M:%S UTC"), until_naive.format("%Y-%m-%d %H:%M:%S"),
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into()); edit.event.dtstart.format("%Y-%m-%d %H:%M:%S")).into());
// Critical: Use the dragged times (new_start/new_end) not the original series times // Critical: Use the dragged times (new_start/new_end) not the original series times
// This ensures the new series reflects the user's drag operation // This ensures the new series reflects the user's drag operation
@@ -317,7 +313,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
new_start, // Dragged start time for new series new_start, // Dragged start time for new series
new_end, // Dragged end time for new series new_end, // Dragged end time for new series
true, // preserve_rrule = true true, // preserve_rrule = true
Some(until_utc), // UNTIL date for original series Some(until_naive), // UNTIL date for original series
Some("this_and_future".to_string()), // Update scope Some("this_and_future".to_string()), // Update scope
Some(occurrence_date), // Date of occurrence being modified Some(occurrence_date), // Date of occurrence being modified
)); ));
@@ -617,10 +613,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Keep the original end time // Keep the original end time
let original_end = if let Some(end) = event.dtend { let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local() end } else {
} else {
// If no end time, use start time + 1 hour as default // If no end time, use start time + 1 hour as default
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1) event.dtstart + chrono::Duration::hours(1)
}; };
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time); let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
@@ -651,8 +646,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Calculate new end time based on drag position // Calculate new end time based on drag position
let new_end_time = pixels_to_time(current_drag.current_y, time_increment); let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
// Keep the original start time // Keep the original start time (already local)
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); let original_start = event.dtstart;
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time); let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
@@ -827,9 +822,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let time_display = if event.all_day { let time_display = if event.all_day {
"All Day".to_string() "All Day".to_string()
} else { } else {
let local_start = event.dtstart.with_timezone(&Local); let local_start = event.dtstart;
if let Some(end) = event.dtend { if let Some(end) = event.dtend {
let local_end = end.with_timezone(&Local); let local_end = end;
// Check if both times are in same AM/PM period to avoid redundancy // Check if both times are in same AM/PM period to avoid redundancy
let start_is_am = local_start.hour() < 12; let start_is_am = local_start.hour() < 12;
@@ -1056,14 +1051,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Show the event being resized from the start // Show the event being resized from the start
let new_start_time = pixels_to_time(drag.current_y, props.time_increment); let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
let original_end = if let Some(end) = event.dtend { let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local() end } else {
} else { event.dtstart + chrono::Duration::hours(1)
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
}; };
// Calculate positions for the preview // Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour); let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local()); let original_duration = original_end.signed_duration_since(event.dtstart);
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32); let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
let new_start_pixels = drag.current_y; let new_start_pixels = drag.current_y;
@@ -1089,7 +1083,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
DragType::ResizeEventEnd(event) => { DragType::ResizeEventEnd(event) => {
// Show the event being resized from the end // Show the event being resized from the end
let new_end_time = pixels_to_time(drag.current_y, props.time_increment); let new_end_time = pixels_to_time(drag.current_y, props.time_increment);
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); let original_start = event.dtstart;
// Calculate positions for the preview // Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour); let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment, props.print_pixels_per_hour, props.print_start_hour);
@@ -1227,12 +1221,12 @@ fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
} }
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) { fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32, print_pixels_per_hour: Option<f64>, print_start_hour: Option<u32>) -> (f32, f32, bool) {
// Convert UTC times to local time for display // Events are already in local time
let local_start = event.dtstart.with_timezone(&Local); let local_start = event.dtstart;
// Events should display based on their stored date (which now preserves the original local date) // Events should display based on their local date, since we now store proper UTC times
// not the calculated local date from UTC conversion, since we fixed the creation logic // Convert the UTC stored time back to local time to determine display date
let event_date = event.dtstart.date_naive(); // Use the stored date, not the converted local date let event_date = local_start.date();
if event_date != date { if event_date != date {
return (0.0, 0.0, false); // Event not on this date return (0.0, 0.0, false); // Event not on this date
@@ -1263,8 +1257,8 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32
// Calculate duration and height // Calculate duration and height
let duration_pixels = if let Some(end) = event.dtend { let duration_pixels = if let Some(end) = event.dtend {
let local_end = end.with_timezone(&Local); let local_end = end;
let end_date = local_end.date_naive(); let end_date = local_end.date();
// Handle events that span multiple days by capping at midnight // Handle events that span multiple days by capping at midnight
if end_date > date { if end_date > date {
@@ -1291,16 +1285,16 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
return false; return false;
} }
let start1 = event1.dtstart.with_timezone(&Local).naive_local(); let start1 = event1.dtstart;
let end1 = if let Some(end) = event1.dtend { let end1 = if let Some(end) = event1.dtend {
end.with_timezone(&Local).naive_local() end
} else { } else {
start1 + chrono::Duration::hours(1) // Default 1 hour duration start1 + chrono::Duration::hours(1) // Default 1 hour duration
}; };
let start2 = event2.dtstart.with_timezone(&Local).naive_local(); let start2 = event2.dtstart;
let end2 = if let Some(end) = event2.dtend { let end2 = if let Some(end) = event2.dtend {
end.with_timezone(&Local).naive_local() end
} else { } else {
start2 + chrono::Duration::hours(1) // Default 1 hour duration start2 + chrono::Duration::hours(1) // Default 1 hour duration
}; };
@@ -1322,8 +1316,8 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
} }
let (_, _, _) = calculate_event_position(event, date, time_increment, None, None); let (_, _, _) = calculate_event_position(event, date, time_increment, None, None);
let local_start = event.dtstart.with_timezone(&Local); let local_start = event.dtstart;
let event_date = local_start.date_naive(); let event_date = local_start.date();
if event_date == date || if event_date == date ||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) { (event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
Some((idx, event)) Some((idx, event))
@@ -1334,7 +1328,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
.collect(); .collect();
// Sort by start time // Sort by start time
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local()); day_events.sort_by_key(|(_, event)| event.dtstart);
// For each event, find all events it overlaps with // For each event, find all events it overlaps with
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns) let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
@@ -1359,7 +1353,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
} else { } else {
// This event overlaps - we need to calculate column layout // This event overlaps - we need to calculate column layout
// Sort the overlapping group by start time // Sort the overlapping group by start time
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart.with_timezone(&Local).naive_local()); overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart);
// Assign columns using a greedy algorithm // Assign columns using a greedy algorithm
let mut columns: Vec<Vec<usize>> = Vec::new(); let mut columns: Vec<Vec<usize>> = Vec::new();
@@ -1407,19 +1401,19 @@ fn event_spans_date(event: &VEvent, date: NaiveDate) -> bool {
let start_date = if event.all_day { let start_date = if event.all_day {
// For all-day events, extract date directly from UTC without timezone conversion // For all-day events, extract date directly from UTC without timezone conversion
// since all-day events are stored at noon UTC to avoid timezone boundary issues // since all-day events are stored at noon UTC to avoid timezone boundary issues
event.dtstart.date_naive() event.dtstart.date()
} else { } else {
event.dtstart.with_timezone(&Local).date_naive() event.dtstart.date()
}; };
let end_date = if let Some(dtend) = event.dtend { let end_date = if let Some(dtend) = event.dtend {
if event.all_day { if event.all_day {
// For all-day events, dtend is set to the day after the last day (RFC 5545) // For all-day events, dtend is set to the day after the last day (RFC 5545)
// Extract date directly from UTC and subtract a day to get actual last day // Extract date directly from UTC and subtract a day to get actual last day
dtend.date_naive() - chrono::Duration::days(1) dtend.date() - chrono::Duration::days(1)
} else { } else {
// For timed events, use timezone conversion // For timed events, use timezone conversion
dtend.with_timezone(&Local).date_naive() dtend.date()
} }
} else { } else {
// Single day event // Single day event