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:
@@ -32,7 +32,7 @@ pub struct CalendarProps {
|
||||
chrono::NaiveDateTime,
|
||||
chrono::NaiveDateTime,
|
||||
bool,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<chrono::NaiveDateTime>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)>,
|
||||
@@ -437,7 +437,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
chrono::NaiveDateTime,
|
||||
chrono::NaiveDateTime,
|
||||
bool,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<chrono::NaiveDateTime>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)| {
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn calendar_management_modal(props: &CalendarManagementModalProps) -> Html {
|
||||
let on_external_success = on_external_success.clone();
|
||||
|
||||
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 {
|
||||
Ok(calendar) => {
|
||||
|
||||
@@ -238,12 +238,11 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
|
||||
|
||||
// Convert VEvent to EventCreationData for editing
|
||||
fn vevent_to_creation_data(event: &crate::models::ical::VEvent, available_calendars: &[CalendarInfo]) -> EventCreationData {
|
||||
use chrono::Local;
|
||||
|
||||
// Convert start datetime from UTC to local
|
||||
let start_local = event.dtstart.with_timezone(&Local).naive_local();
|
||||
// VEvent fields are already local time (NaiveDateTime)
|
||||
let start_local = event.dtstart;
|
||||
let end_local = if let Some(dtend) = event.dtend {
|
||||
dtend.with_timezone(&Local).naive_local()
|
||||
dtend
|
||||
} else {
|
||||
// Default to 1 hour after start if no end time
|
||||
start_local + chrono::Duration::hours(1)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::models::ical::VEvent;
|
||||
use chrono::{DateTime, Utc};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[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 {
|
||||
dt.format("%B %d, %Y").to_string()
|
||||
} 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 {
|
||||
// For all-day events, subtract one day from end date for display
|
||||
// RFC-5545 uses exclusive end dates, but users expect inclusive display
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct RouteHandlerProps {
|
||||
chrono::NaiveDateTime,
|
||||
chrono::NaiveDateTime,
|
||||
bool,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<chrono::NaiveDateTime>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)>,
|
||||
@@ -136,7 +136,7 @@ pub struct CalendarViewProps {
|
||||
chrono::NaiveDateTime,
|
||||
chrono::NaiveDateTime,
|
||||
bool,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<chrono::NaiveDateTime>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)>,
|
||||
|
||||
@@ -33,7 +33,7 @@ pub struct WeekViewProps {
|
||||
NaiveDateTime,
|
||||
NaiveDateTime,
|
||||
bool,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<chrono::NaiveDateTime>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)>,
|
||||
@@ -285,18 +285,14 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
|
||||
// Calculate the day before this occurrence for UNTIL clause
|
||||
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
|
||||
.and_time(chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap());
|
||||
let until_utc =
|
||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||
until_datetime,
|
||||
chrono::Utc,
|
||||
);
|
||||
let until_naive = until_datetime; // Use local time directly
|
||||
|
||||
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"),
|
||||
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC")).into());
|
||||
until_naive.format("%Y-%m-%d %H:%M:%S"),
|
||||
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
|
||||
// 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_end, // Dragged end time for new series
|
||||
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(occurrence_date), // Date of occurrence being modified
|
||||
));
|
||||
@@ -617,10 +613,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
|
||||
// Keep the original end time
|
||||
let original_end = if let Some(end) = event.dtend {
|
||||
end.with_timezone(&chrono::Local).naive_local()
|
||||
} else {
|
||||
end } else {
|
||||
// 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);
|
||||
@@ -651,8 +646,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
// Calculate new end time based on drag position
|
||||
let new_end_time = pixels_to_time(current_drag.current_y, time_increment);
|
||||
|
||||
// Keep the original start time
|
||||
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
|
||||
// Keep the original start time (already local)
|
||||
let original_start = event.dtstart;
|
||||
|
||||
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 {
|
||||
"All Day".to_string()
|
||||
} else {
|
||||
let local_start = event.dtstart.with_timezone(&Local);
|
||||
let local_start = event.dtstart;
|
||||
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
|
||||
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
|
||||
let new_start_time = pixels_to_time(drag.current_y, props.time_increment);
|
||||
let original_end = if let Some(end) = event.dtend {
|
||||
end.with_timezone(&chrono::Local).naive_local()
|
||||
} else {
|
||||
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
||||
end } else {
|
||||
event.dtstart + chrono::Duration::hours(1)
|
||||
};
|
||||
|
||||
// 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_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 new_start_pixels = drag.current_y;
|
||||
@@ -1089,7 +1083,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
DragType::ResizeEventEnd(event) => {
|
||||
// Show the event being resized from the end
|
||||
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
|
||||
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) {
|
||||
// Convert UTC times to local time for display
|
||||
let local_start = event.dtstart.with_timezone(&Local);
|
||||
// Events are already in local time
|
||||
let local_start = event.dtstart;
|
||||
|
||||
// Events should display based on their stored date (which now preserves the original local date)
|
||||
// not the calculated local date from UTC conversion, since we fixed the creation logic
|
||||
let event_date = event.dtstart.date_naive(); // Use the stored date, not the converted local date
|
||||
// Events should display based on their local date, since we now store proper UTC times
|
||||
// Convert the UTC stored time back to local time to determine display date
|
||||
let event_date = local_start.date();
|
||||
|
||||
if event_date != 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
|
||||
let duration_pixels = if let Some(end) = event.dtend {
|
||||
let local_end = end.with_timezone(&Local);
|
||||
let end_date = local_end.date_naive();
|
||||
let local_end = end;
|
||||
let end_date = local_end.date();
|
||||
|
||||
// Handle events that span multiple days by capping at midnight
|
||||
if end_date > date {
|
||||
@@ -1291,16 +1285,16 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
|
||||
let start1 = event1.dtstart;
|
||||
let end1 = if let Some(end) = event1.dtend {
|
||||
end.with_timezone(&Local).naive_local()
|
||||
end
|
||||
} else {
|
||||
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 {
|
||||
end.with_timezone(&Local).naive_local()
|
||||
end
|
||||
} else {
|
||||
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 local_start = event.dtstart.with_timezone(&Local);
|
||||
let event_date = local_start.date_naive();
|
||||
let local_start = event.dtstart;
|
||||
let event_date = local_start.date();
|
||||
if event_date == date ||
|
||||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
|
||||
Some((idx, event))
|
||||
@@ -1334,7 +1328,7 @@ fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u3
|
||||
.collect();
|
||||
|
||||
// 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
|
||||
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 {
|
||||
// This event overlaps - we need to calculate column layout
|
||||
// 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
|
||||
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 {
|
||||
// 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
|
||||
event.dtstart.date_naive()
|
||||
event.dtstart.date()
|
||||
} else {
|
||||
event.dtstart.with_timezone(&Local).date_naive()
|
||||
event.dtstart.date()
|
||||
};
|
||||
|
||||
let end_date = if let Some(dtend) = event.dtend {
|
||||
if event.all_day {
|
||||
// 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
|
||||
dtend.date_naive() - chrono::Duration::days(1)
|
||||
dtend.date() - chrono::Duration::days(1)
|
||||
} else {
|
||||
// For timed events, use timezone conversion
|
||||
dtend.with_timezone(&Local).date_naive()
|
||||
dtend.date()
|
||||
}
|
||||
} else {
|
||||
// Single day event
|
||||
|
||||
Reference in New Issue
Block a user