- Add real-time current time indicator that updates every 5 seconds - Display horizontal line with dot and time label on current day only - Position indicator accurately based on time increment mode (15/30 min) - Use theme-aware colors with subdued gray styling for dark mode - Include subtle shadows and proper z-indexing for visibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1415 lines
87 KiB
Rust
1415 lines
87 KiB
Rust
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
|
use crate::models::ical::VEvent;
|
|
use crate::services::calendar_service::{UserInfo, ExternalCalendar};
|
|
use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Weekday};
|
|
use std::collections::HashMap;
|
|
use web_sys::MouseEvent;
|
|
use yew::prelude::*;
|
|
|
|
#[derive(Properties, PartialEq)]
|
|
pub struct WeekViewProps {
|
|
pub current_date: NaiveDate,
|
|
pub today: NaiveDate,
|
|
pub events: HashMap<NaiveDate, Vec<VEvent>>,
|
|
pub on_event_click: Callback<VEvent>,
|
|
#[prop_or_default]
|
|
pub refreshing_event_uid: Option<String>,
|
|
#[prop_or_default]
|
|
pub user_info: Option<UserInfo>,
|
|
#[prop_or_default]
|
|
pub external_calendars: Vec<ExternalCalendar>,
|
|
#[prop_or_default]
|
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, VEvent)>>,
|
|
#[prop_or_default]
|
|
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
|
#[prop_or_default]
|
|
pub on_create_event: Option<Callback<(NaiveDate, NaiveDateTime, NaiveDateTime)>>,
|
|
#[prop_or_default]
|
|
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
|
#[prop_or_default]
|
|
pub on_event_update: Option<
|
|
Callback<(
|
|
VEvent,
|
|
NaiveDateTime,
|
|
NaiveDateTime,
|
|
bool,
|
|
Option<chrono::DateTime<chrono::Utc>>,
|
|
Option<String>,
|
|
Option<String>,
|
|
)>,
|
|
>,
|
|
#[prop_or_default]
|
|
pub context_menus_open: bool,
|
|
#[prop_or_default]
|
|
pub time_increment: u32,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
enum DragType {
|
|
CreateEvent,
|
|
MoveEvent(VEvent),
|
|
ResizeEventStart(VEvent), // Resizing from the top edge (start time)
|
|
ResizeEventEnd(VEvent), // Resizing from the bottom edge (end time)
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
struct DragState {
|
|
is_dragging: bool,
|
|
drag_type: DragType,
|
|
start_date: NaiveDate,
|
|
start_y: f64,
|
|
current_y: f64,
|
|
offset_y: f64, // For event moves, this is the offset from the event's top
|
|
has_moved: bool, // Track if we've moved enough to constitute a real drag
|
|
}
|
|
|
|
#[function_component(WeekView)]
|
|
pub fn week_view(props: &WeekViewProps) -> Html {
|
|
let start_of_week = get_start_of_week(props.current_date);
|
|
let week_days: Vec<NaiveDate> = (0..7).map(|i| start_of_week + Duration::days(i)).collect();
|
|
|
|
// Drag state for event creation
|
|
let drag_state = use_state(|| None::<DragState>);
|
|
|
|
// State for recurring event edit modal
|
|
#[derive(Clone, PartialEq)]
|
|
struct PendingRecurringEdit {
|
|
event: VEvent,
|
|
new_start: NaiveDateTime,
|
|
new_end: NaiveDateTime,
|
|
}
|
|
|
|
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
|
|
|
// Current time state for time indicator
|
|
let current_time = use_state(|| Local::now());
|
|
|
|
// Update current time every 5 seconds
|
|
{
|
|
let current_time = current_time.clone();
|
|
use_effect_with((), move |_| {
|
|
let interval = gloo_timers::callback::Interval::new(5_000, move || {
|
|
current_time.set(Local::now());
|
|
});
|
|
|
|
// Return the interval to keep it alive
|
|
move || drop(interval)
|
|
});
|
|
}
|
|
|
|
// Helper function to calculate current time indicator position
|
|
let calculate_current_time_position = |time_increment: u32| -> f64 {
|
|
let now = current_time.time();
|
|
let hour = now.hour() as f64;
|
|
let minute = now.minute() as f64;
|
|
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
|
|
(hour + minute / 60.0) * pixels_per_hour
|
|
};
|
|
|
|
// Helper function to get calendar color for an event
|
|
let get_event_color = |event: &VEvent| -> String {
|
|
if let Some(calendar_path) = &event.calendar_path {
|
|
// Check external calendars first (path format: "external_{id}")
|
|
if calendar_path.starts_with("external_") {
|
|
if let Ok(id_str) = calendar_path.strip_prefix("external_").unwrap_or("").parse::<i32>() {
|
|
if let Some(external_calendar) = props.external_calendars
|
|
.iter()
|
|
.find(|cal| cal.id == id_str)
|
|
{
|
|
return external_calendar.color.clone();
|
|
}
|
|
}
|
|
}
|
|
// Check regular calendars
|
|
else if let Some(user_info) = &props.user_info {
|
|
if let Some(calendar) = user_info
|
|
.calendars
|
|
.iter()
|
|
.find(|cal| &cal.path == calendar_path)
|
|
{
|
|
return calendar.color.clone();
|
|
}
|
|
}
|
|
}
|
|
"#3B82F6".to_string()
|
|
};
|
|
|
|
// Generate time labels - 24 hours
|
|
let time_labels: Vec<String> = (0..24)
|
|
.map(|hour| {
|
|
if hour == 0 {
|
|
"12 AM".to_string()
|
|
} else if hour < 12 {
|
|
format!("{} AM", hour)
|
|
} else if hour == 12 {
|
|
"12 PM".to_string()
|
|
} else {
|
|
format!("{} PM", hour - 12)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Handlers for recurring event modification modal
|
|
let on_recurring_choice = {
|
|
let pending_recurring_edit = pending_recurring_edit.clone();
|
|
let on_event_update = props.on_event_update.clone();
|
|
let _on_create_event = props.on_create_event.clone();
|
|
let events = props.events.clone();
|
|
Callback::from(move |action: RecurringEditAction| {
|
|
if let Some(edit) = (*pending_recurring_edit).clone() {
|
|
match action {
|
|
RecurringEditAction::ThisEvent => {
|
|
// RFC 5545 Compliant Single Occurrence Modification: "This Event Only"
|
|
//
|
|
// When a user chooses to modify "this event only" for a recurring series,
|
|
// we implement an exception-based modification that:
|
|
//
|
|
// 1. **Add EXDATE to Original Series**: The original series is updated with
|
|
// an EXDATE entry to exclude this specific occurrence from generation
|
|
// 2. **Create Exception Event**: A new standalone event is created with
|
|
// RECURRENCE-ID pointing to the original occurrence, containing the modifications
|
|
//
|
|
// Example: User drags Aug 22 occurrence of "Daily 9AM meeting" to 2PM:
|
|
// - Original series: "Daily 9AM meeting" + EXDATE for Aug 22 (continues as normal except Aug 22)
|
|
// - Exception event: "Daily 2PM meeting" with RECURRENCE-ID=Aug22 (only affects Aug 22)
|
|
//
|
|
// This approach ensures:
|
|
// - All other occurrences remain unchanged (past and future)
|
|
// - Modified occurrence displays user's changes
|
|
// - RFC 5545 compliance through EXDATE and RECURRENCE-ID
|
|
// - CalDAV compatibility with standard calendar applications
|
|
//
|
|
// The backend handles both operations atomically within a single API call.
|
|
if let Some(update_callback) = &on_event_update {
|
|
// Extract occurrence date for backend processing
|
|
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
|
|
|
// Send single request to backend with "this_only" scope
|
|
// Backend will atomically:
|
|
// 1. Add EXDATE to original series (excludes this occurrence)
|
|
// 2. Create exception event with RECURRENCE-ID and user's modifications
|
|
update_callback.emit((
|
|
edit.event.clone(), // Original event (series to modify)
|
|
edit.new_start, // Dragged start time for exception
|
|
edit.new_end, // Dragged end time for exception
|
|
true, // preserve_rrule = true
|
|
None, // No until_date for this_only
|
|
Some("this_only".to_string()), // Update scope
|
|
Some(occurrence_date), // Date of occurrence being modified
|
|
));
|
|
}
|
|
}
|
|
RecurringEditAction::FutureEvents => {
|
|
// RFC 5545 Compliant Series Splitting: "This and Future Events"
|
|
//
|
|
// When a user chooses to modify "this and future events" for a recurring series,
|
|
// we implement a series split operation that:
|
|
//
|
|
// 1. **Terminates Original Series**: The existing series is updated with an UNTIL
|
|
// clause to stop before the occurrence being modified
|
|
// 2. **Creates New Series**: A new recurring series is created starting from the
|
|
// occurrence date with the user's modifications (new time, title, etc.)
|
|
//
|
|
// Example: User drags Aug 22 occurrence of "Daily 9AM meeting" to 2PM:
|
|
// - Original: "Daily 9AM meeting" → ends Aug 21 (UNTIL=Aug22T000000Z)
|
|
// - New: "Daily 2PM meeting" → starts Aug 22, continues indefinitely
|
|
//
|
|
// This approach ensures:
|
|
// - Past occurrences remain unchanged (preserves user's historical data)
|
|
// - Future occurrences reflect the new modifications
|
|
// - CalDAV compatibility through proper RRULE manipulation
|
|
// - No conflicts with existing calendar applications
|
|
//
|
|
// The backend handles both operations atomically within a single API call
|
|
// to prevent race conditions and ensure data consistency.
|
|
if let Some(update_callback) = &on_event_update {
|
|
// Find the original series event (not the occurrence)
|
|
// UIDs like "uuid-timestamp" need to split on the last hyphen, not the first
|
|
let base_uid = if let Some(last_hyphen_pos) = edit.event.uid.rfind('-')
|
|
{
|
|
let suffix = &edit.event.uid[last_hyphen_pos + 1..];
|
|
// Check if suffix is numeric (timestamp), if so remove it
|
|
if suffix.chars().all(|c| c.is_numeric()) {
|
|
edit.event.uid[..last_hyphen_pos].to_string()
|
|
} else {
|
|
edit.event.uid.clone()
|
|
}
|
|
} else {
|
|
edit.event.uid.clone()
|
|
};
|
|
|
|
web_sys::console::log_1(
|
|
&format!(
|
|
"🔍 Looking for original series: '{}' from occurrence: '{}'",
|
|
base_uid, edit.event.uid
|
|
)
|
|
.into(),
|
|
);
|
|
|
|
// Find the original series event by searching for the base UID
|
|
let mut original_series = None;
|
|
for events_list in events.values() {
|
|
for event in events_list {
|
|
if event.uid == base_uid {
|
|
original_series = Some(event.clone());
|
|
break;
|
|
}
|
|
}
|
|
if original_series.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let original_series = match original_series {
|
|
Some(series) => {
|
|
web_sys::console::log_1(
|
|
&format!("✅ Found original series: '{}'", series.uid)
|
|
.into(),
|
|
);
|
|
series
|
|
}
|
|
None => {
|
|
web_sys::console::log_1(&format!("⚠️ Could not find original series '{}', using occurrence but fixing UID", base_uid).into());
|
|
let mut fallback_event = edit.event.clone();
|
|
// Ensure the UID is the base UID, not the occurrence UID
|
|
fallback_event.uid = base_uid.clone();
|
|
fallback_event
|
|
}
|
|
};
|
|
|
|
// Calculate the day before this occurrence for UNTIL clause
|
|
let until_date =
|
|
edit.event.dtstart.date_naive() - 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,
|
|
);
|
|
|
|
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());
|
|
|
|
// 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
|
|
let new_start = edit.new_start; // The dragged start time
|
|
let new_end = edit.new_end; // The dragged end time
|
|
|
|
// Extract occurrence date from the dragged event for backend processing
|
|
// Format: YYYY-MM-DD (e.g., "2025-08-22")
|
|
// This tells the backend which specific occurrence is being modified
|
|
let occurrence_date = edit.event.dtstart.format("%Y-%m-%d").to_string();
|
|
|
|
// Send single request to backend with "this_and_future" scope
|
|
// Backend will atomically:
|
|
// 1. Add UNTIL clause to original series (stops before occurrence_date)
|
|
// 2. Create new series starting from occurrence_date with dragged times
|
|
update_callback.emit((
|
|
original_series, // Original event to terminate
|
|
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("this_and_future".to_string()), // Update scope
|
|
Some(occurrence_date), // Date of occurrence being modified
|
|
));
|
|
}
|
|
}
|
|
RecurringEditAction::AllEvents => {
|
|
// Modify the entire series
|
|
let series_event = edit.event.clone();
|
|
|
|
if let Some(callback) = &on_event_update {
|
|
callback.emit((
|
|
series_event,
|
|
edit.new_start,
|
|
edit.new_end,
|
|
true,
|
|
None,
|
|
Some("all_in_series".to_string()),
|
|
None,
|
|
)); // Regular drag operation - preserve RRULE, update_scope = all_in_series
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pending_recurring_edit.set(None);
|
|
})
|
|
};
|
|
|
|
let on_recurring_cancel = {
|
|
let pending_recurring_edit = pending_recurring_edit.clone();
|
|
Callback::from(move |_| {
|
|
pending_recurring_edit.set(None);
|
|
})
|
|
};
|
|
|
|
html! {
|
|
<div class="week-view-container">
|
|
// Header with weekday names and dates
|
|
<div class="week-header">
|
|
<div class="time-gutter"></div>
|
|
{
|
|
week_days.iter().map(|date| {
|
|
let is_today = *date == props.today;
|
|
let weekday_name = get_weekday_name(date.weekday());
|
|
|
|
// Collect all-day events that span this date (from any day in the week)
|
|
let mut all_day_events: Vec<&VEvent> = Vec::new();
|
|
for events_list in props.events.values() {
|
|
for event in events_list {
|
|
if event.all_day && event_spans_date(event, *date) {
|
|
all_day_events.push(event);
|
|
}
|
|
}
|
|
}
|
|
// Remove duplicates (same event might appear in multiple day buckets)
|
|
all_day_events.sort_by_key(|e| &e.uid);
|
|
all_day_events.dedup_by_key(|e| &e.uid);
|
|
|
|
html! {
|
|
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
|
<div class="day-header-content">
|
|
<div class="weekday-name">{weekday_name}</div>
|
|
<div class="day-number">{date.day()}</div>
|
|
</div>
|
|
|
|
// All-day events section
|
|
{if !all_day_events.is_empty() {
|
|
html! {
|
|
<div class="all-day-events">
|
|
{
|
|
all_day_events.iter().map(|event| {
|
|
let event_color = get_event_color(event);
|
|
let onclick = {
|
|
let on_event_click = props.on_event_click.clone();
|
|
let event = (*event).clone();
|
|
Callback::from(move |e: MouseEvent| {
|
|
e.stop_propagation();
|
|
on_event_click.emit(event.clone());
|
|
})
|
|
};
|
|
|
|
let oncontextmenu = {
|
|
if let Some(callback) = &props.on_event_context_menu {
|
|
let callback = callback.clone();
|
|
let event = (*event).clone();
|
|
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
|
e.prevent_default();
|
|
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
|
callback.emit((e, event.clone()));
|
|
}))
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
html! {
|
|
<div
|
|
class="all-day-event"
|
|
style={format!("background-color: {}", event_color)}
|
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
|
{onclick}
|
|
{oncontextmenu}
|
|
>
|
|
<span class="all-day-event-title">
|
|
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
|
|
</span>
|
|
</div>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</div>
|
|
}
|
|
} else {
|
|
html! {}
|
|
}}
|
|
</div>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</div>
|
|
|
|
// Scrollable content area with time grid
|
|
<div class="week-content">
|
|
<div class={classes!("time-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
|
// Time labels
|
|
<div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
|
{
|
|
time_labels.iter().map(|time| {
|
|
let is_quarter_mode = props.time_increment == 15;
|
|
html! {
|
|
<div class={classes!(
|
|
"time-label",
|
|
if is_quarter_mode { Some("quarter-mode") } else { None }
|
|
)}>
|
|
{time}
|
|
</div>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</div>
|
|
|
|
// Day columns
|
|
<div class={classes!("week-days-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
|
{
|
|
week_days.iter().enumerate().map(|(_column_index, date)| {
|
|
let is_today = *date == props.today;
|
|
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
|
let event_layouts = calculate_event_layout(&day_events, *date, props.time_increment);
|
|
|
|
// Drag event handlers
|
|
let drag_state_clone = drag_state.clone();
|
|
let date_for_drag = *date;
|
|
|
|
let onmousedown = {
|
|
let drag_state = drag_state_clone.clone();
|
|
let context_menus_open = props.context_menus_open;
|
|
let time_increment = props.time_increment;
|
|
Callback::from(move |e: MouseEvent| {
|
|
// Don't start drag if any context menu is open
|
|
if context_menus_open {
|
|
return;
|
|
}
|
|
|
|
// Only handle left-click (button 0)
|
|
if e.button() != 0 {
|
|
return;
|
|
}
|
|
|
|
// Calculate Y position relative to day column container
|
|
// Use layer_y which gives coordinates relative to positioned ancestor
|
|
let relative_y = e.layer_y() as f64;
|
|
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
|
|
|
// Snap to increment
|
|
let snapped_y = snap_to_increment(relative_y, time_increment);
|
|
|
|
drag_state.set(Some(DragState {
|
|
is_dragging: true,
|
|
drag_type: DragType::CreateEvent,
|
|
start_date: date_for_drag,
|
|
start_y: snapped_y,
|
|
current_y: snapped_y,
|
|
offset_y: 0.0,
|
|
has_moved: false,
|
|
}));
|
|
e.prevent_default();
|
|
})
|
|
};
|
|
|
|
let onmousemove = {
|
|
let drag_state = drag_state_clone.clone();
|
|
let time_increment = props.time_increment;
|
|
Callback::from(move |e: MouseEvent| {
|
|
// Only process mouse move if a button is still pressed
|
|
if e.buttons() == 0 {
|
|
// No mouse button pressed, clear drag state
|
|
drag_state.set(None);
|
|
return;
|
|
}
|
|
|
|
if let Some(mut current_drag) = (*drag_state).clone() {
|
|
if current_drag.is_dragging {
|
|
// Use layer_y for consistent coordinate calculation
|
|
let mouse_y = e.layer_y() as f64;
|
|
let mouse_y = if mouse_y > 0.0 { mouse_y } else { e.offset_y() as f64 };
|
|
|
|
// For move operations, we now follow the mouse directly since we start at click position
|
|
// For resize operations, we still use the mouse position directly
|
|
let adjusted_y = mouse_y;
|
|
|
|
// Snap to increment
|
|
let snapped_y = snap_to_increment(adjusted_y, time_increment);
|
|
|
|
// Check if we've moved enough to constitute a real drag (5 pixels minimum)
|
|
let movement_distance = (snapped_y - current_drag.start_y).abs();
|
|
if movement_distance > 5.0 {
|
|
current_drag.has_moved = true;
|
|
}
|
|
|
|
current_drag.current_y = snapped_y;
|
|
drag_state.set(Some(current_drag));
|
|
}
|
|
}
|
|
})
|
|
};
|
|
|
|
let onmouseup = {
|
|
let drag_state = drag_state_clone.clone();
|
|
let on_create_event = props.on_create_event.clone();
|
|
let on_event_update = props.on_event_update.clone();
|
|
let pending_recurring_edit = pending_recurring_edit.clone();
|
|
let time_increment = props.time_increment;
|
|
Callback::from(move |_e: MouseEvent| {
|
|
if let Some(current_drag) = (*drag_state).clone() {
|
|
if current_drag.is_dragging && current_drag.has_moved {
|
|
match ¤t_drag.drag_type {
|
|
DragType::CreateEvent => {
|
|
// Calculate start and end times
|
|
let start_time = pixels_to_time(current_drag.start_y, time_increment);
|
|
let end_time = pixels_to_time(current_drag.current_y, time_increment);
|
|
|
|
// Ensure start is before end
|
|
let (actual_start, actual_end) = if start_time <= end_time {
|
|
(start_time, end_time)
|
|
} else {
|
|
(end_time, start_time)
|
|
};
|
|
|
|
// Ensure minimum duration (15 minutes)
|
|
let actual_end = if actual_end.signed_duration_since(actual_start).num_minutes() < 15 {
|
|
actual_start + chrono::Duration::minutes(15)
|
|
} else {
|
|
actual_end
|
|
};
|
|
|
|
let start_datetime = NaiveDateTime::new(current_drag.start_date, actual_start);
|
|
let end_datetime = NaiveDateTime::new(current_drag.start_date, actual_end);
|
|
|
|
if let Some(callback) = &on_create_event {
|
|
callback.emit((current_drag.start_date, start_datetime, end_datetime));
|
|
}
|
|
},
|
|
DragType::MoveEvent(event) => {
|
|
// Calculate new start time based on drag position (accounting for click offset)
|
|
let unsnapped_position = current_drag.current_y - current_drag.offset_y;
|
|
// Snap the final position to maintain time increment alignment
|
|
let event_top_position = snap_to_increment(unsnapped_position, time_increment);
|
|
let new_start_time = pixels_to_time(event_top_position, time_increment);
|
|
|
|
// Calculate duration from original event
|
|
let original_duration = if let Some(end) = event.dtend {
|
|
end.signed_duration_since(event.dtstart)
|
|
} else {
|
|
chrono::Duration::hours(1) // Default 1 hour
|
|
};
|
|
|
|
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
|
let new_end_datetime = new_start_datetime + original_duration;
|
|
|
|
// Check if this is a recurring event
|
|
if event.rrule.is_some() {
|
|
// Show modal for recurring event modification
|
|
pending_recurring_edit.set(Some(PendingRecurringEdit {
|
|
event: event.clone(),
|
|
new_start: new_start_datetime,
|
|
new_end: new_end_datetime,
|
|
}));
|
|
} else {
|
|
// Regular event - proceed with update
|
|
if let Some(callback) = &on_event_update {
|
|
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None, None, None)); // Regular drag operation - preserve RRULE, no until_date
|
|
}
|
|
}
|
|
},
|
|
DragType::ResizeEventStart(event) => {
|
|
// Calculate new start time based on drag position
|
|
let new_start_time = pixels_to_time(current_drag.current_y, time_increment);
|
|
|
|
// Keep the original end time
|
|
let original_end = if let Some(end) = event.dtend {
|
|
end.with_timezone(&chrono::Local).naive_local()
|
|
} else {
|
|
// If no end time, use start time + 1 hour as default
|
|
event.dtstart.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
|
|
};
|
|
|
|
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
|
|
|
// Ensure start is before end (minimum 15 minutes)
|
|
let new_end_datetime = if new_start_datetime >= original_end {
|
|
new_start_datetime + chrono::Duration::minutes(15)
|
|
} else {
|
|
original_end
|
|
};
|
|
|
|
// Check if this is a recurring event
|
|
if event.rrule.is_some() {
|
|
// Show modal for recurring event modification
|
|
pending_recurring_edit.set(Some(PendingRecurringEdit {
|
|
event: event.clone(),
|
|
new_start: new_start_datetime,
|
|
new_end: new_end_datetime,
|
|
}));
|
|
} else {
|
|
// Regular event - proceed with update
|
|
if let Some(callback) = &on_event_update {
|
|
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None, None, None)); // Regular drag operation - preserve RRULE, no until_date
|
|
}
|
|
}
|
|
},
|
|
DragType::ResizeEventEnd(event) => {
|
|
// 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();
|
|
|
|
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
|
|
|
|
// Ensure end is after start (minimum 15 minutes)
|
|
let new_start_datetime = if new_end_datetime <= original_start {
|
|
new_end_datetime - chrono::Duration::minutes(15)
|
|
} else {
|
|
original_start
|
|
};
|
|
|
|
// Check if this is a recurring event
|
|
if event.rrule.is_some() {
|
|
// Show modal for recurring event modification
|
|
pending_recurring_edit.set(Some(PendingRecurringEdit {
|
|
event: event.clone(),
|
|
new_start: new_start_datetime,
|
|
new_end: new_end_datetime,
|
|
}));
|
|
} else {
|
|
// Regular event - proceed with update
|
|
if let Some(callback) = &on_event_update {
|
|
callback.emit((event.clone(), new_start_datetime, new_end_datetime, true, None, None, None)); // Regular drag operation - preserve RRULE, no until_date
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
drag_state.set(None);
|
|
}
|
|
}
|
|
})
|
|
};
|
|
|
|
// Check if currently dragging to create an event
|
|
let is_creating_event = if let Some(drag) = (*drag_state).clone() {
|
|
matches!(drag.drag_type, DragType::CreateEvent) && drag.is_dragging
|
|
} else {
|
|
false
|
|
};
|
|
|
|
html! {
|
|
<div
|
|
class={classes!(
|
|
"week-day-column",
|
|
if is_today { Some("today") } else { None },
|
|
if is_creating_event { Some("creating-event") } else { None },
|
|
if props.time_increment == 15 { Some("quarter-mode") } else { None }
|
|
)}
|
|
{onmousedown}
|
|
{onmousemove}
|
|
{onmouseup}
|
|
>
|
|
// Time slot backgrounds - 24 hour slots to represent full day
|
|
{
|
|
(0..24).map(|_hour| {
|
|
let slots_per_hour = 60 / props.time_increment;
|
|
html! {
|
|
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
|
|
{
|
|
(0..slots_per_hour).map(|_slot| {
|
|
let slot_class = if props.time_increment == 15 {
|
|
"time-slot-quarter"
|
|
} else {
|
|
"time-slot-half"
|
|
};
|
|
html! {
|
|
<div class={slot_class}></div>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</div>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
|
|
// Events positioned absolutely based on their actual times
|
|
<div class="events-container">
|
|
{
|
|
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
|
|
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date, props.time_increment);
|
|
|
|
// Skip all-day events (they're rendered in the header)
|
|
if is_all_day {
|
|
return None;
|
|
}
|
|
|
|
// Skip events that don't belong on this date or have invalid positioning
|
|
if start_pixels == 0.0 && duration_pixels == 0.0 {
|
|
return None;
|
|
}
|
|
|
|
let event_color = get_event_color(event);
|
|
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
|
|
|
|
let onclick = {
|
|
let on_event_click = props.on_event_click.clone();
|
|
let event = event.clone();
|
|
Callback::from(move |e: MouseEvent| {
|
|
e.stop_propagation(); // Prevent calendar click events from also triggering
|
|
on_event_click.emit(event.clone());
|
|
})
|
|
};
|
|
|
|
let onmousedown_event = {
|
|
let drag_state = drag_state.clone();
|
|
let event_for_drag = event.clone();
|
|
let date_for_drag = *date;
|
|
let time_increment = props.time_increment;
|
|
Callback::from(move |e: MouseEvent| {
|
|
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
|
|
|
|
// Only handle left-click (button 0) for moving
|
|
if e.button() != 0 {
|
|
return;
|
|
}
|
|
|
|
// Calculate click position relative to event element
|
|
let click_y_relative = e.layer_y() as f64;
|
|
let click_y_relative = if click_y_relative > 0.0 { click_y_relative } else { e.offset_y() as f64 };
|
|
|
|
// Get event's current position in day column coordinates
|
|
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag, time_increment);
|
|
let event_start_pixels = event_start_pixels as f64;
|
|
|
|
// Convert click position to day column coordinates
|
|
let click_y = event_start_pixels + click_y_relative;
|
|
|
|
// Store the offset from the event's top where the user clicked
|
|
// This will be used to maintain the relative click position
|
|
let offset_y = click_y_relative;
|
|
|
|
// Start drag tracking from where we clicked (in day column coordinates)
|
|
drag_state.set(Some(DragState {
|
|
is_dragging: true,
|
|
drag_type: DragType::MoveEvent(event_for_drag.clone()),
|
|
start_date: date_for_drag,
|
|
start_y: click_y,
|
|
current_y: click_y,
|
|
offset_y,
|
|
has_moved: false,
|
|
}));
|
|
e.prevent_default();
|
|
})
|
|
};
|
|
|
|
let oncontextmenu = {
|
|
if let Some(callback) = &props.on_event_context_menu {
|
|
let callback = callback.clone();
|
|
let event = event.clone();
|
|
let drag_state_for_context = drag_state.clone();
|
|
Some(Callback::from(move |e: web_sys::MouseEvent| {
|
|
// Check if we're currently dragging - if so, prevent context menu
|
|
if let Some(drag) = (*drag_state_for_context).clone() {
|
|
if drag.is_dragging {
|
|
e.prevent_default();
|
|
return;
|
|
}
|
|
}
|
|
|
|
e.prevent_default();
|
|
e.stop_propagation(); // Prevent calendar context menu from also triggering
|
|
callback.emit((e, event.clone()));
|
|
}))
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
// Format time display for the event
|
|
let time_display = if event.all_day {
|
|
"All Day".to_string()
|
|
} else {
|
|
let local_start = event.dtstart.with_timezone(&Local);
|
|
if let Some(end) = event.dtend {
|
|
let local_end = end.with_timezone(&Local);
|
|
|
|
// Check if both times are in same AM/PM period to avoid redundancy
|
|
let start_is_am = local_start.hour() < 12;
|
|
let end_is_am = local_end.hour() < 12;
|
|
|
|
if start_is_am == end_is_am {
|
|
// Same AM/PM period - show "9:00 - 10:30 AM"
|
|
format!("{} - {}",
|
|
local_start.format("%I:%M").to_string().trim_start_matches('0'),
|
|
local_end.format("%I:%M %p")
|
|
)
|
|
} else {
|
|
// Different AM/PM periods - show "9:00 AM - 2:30 PM"
|
|
format!("{} - {}",
|
|
local_start.format("%I:%M %p"),
|
|
local_end.format("%I:%M %p")
|
|
)
|
|
}
|
|
} else {
|
|
// No end time, just show start time
|
|
format!("{}", local_start.format("%I:%M %p"))
|
|
}
|
|
};
|
|
|
|
// Check if this event is currently being dragged or resized
|
|
let is_being_dragged = if let Some(drag) = (*drag_state).clone() {
|
|
match &drag.drag_type {
|
|
DragType::MoveEvent(dragged_event) =>
|
|
dragged_event.uid == event.uid && drag.is_dragging,
|
|
DragType::ResizeEventStart(dragged_event) =>
|
|
dragged_event.uid == event.uid && drag.is_dragging,
|
|
DragType::ResizeEventEnd(dragged_event) =>
|
|
dragged_event.uid == event.uid && drag.is_dragging,
|
|
_ => false,
|
|
}
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if is_being_dragged {
|
|
// Hide the original event while being dragged
|
|
Some(html! {})
|
|
} else {
|
|
// Create resize handles for left-click resize
|
|
let resize_start_handler = {
|
|
let drag_state = drag_state.clone();
|
|
let event_for_resize = event.clone();
|
|
let date_for_drag = *date;
|
|
let time_increment = props.time_increment;
|
|
Callback::from(move |e: web_sys::MouseEvent| {
|
|
e.stop_propagation();
|
|
|
|
let relative_y = e.layer_y() as f64;
|
|
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
|
let snapped_y = snap_to_increment(relative_y, time_increment);
|
|
|
|
drag_state.set(Some(DragState {
|
|
is_dragging: true,
|
|
drag_type: DragType::ResizeEventStart(event_for_resize.clone()),
|
|
start_date: date_for_drag,
|
|
start_y: snapped_y,
|
|
current_y: snapped_y,
|
|
offset_y: 0.0,
|
|
has_moved: false,
|
|
}));
|
|
e.prevent_default();
|
|
})
|
|
};
|
|
|
|
let resize_end_handler = {
|
|
let drag_state = drag_state.clone();
|
|
let event_for_resize = event.clone();
|
|
let date_for_drag = *date;
|
|
let time_increment = props.time_increment;
|
|
Callback::from(move |e: web_sys::MouseEvent| {
|
|
e.stop_propagation();
|
|
|
|
let relative_y = e.layer_y() as f64;
|
|
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
|
|
let snapped_y = snap_to_increment(relative_y, time_increment);
|
|
|
|
drag_state.set(Some(DragState {
|
|
is_dragging: true,
|
|
drag_type: DragType::ResizeEventEnd(event_for_resize.clone()),
|
|
start_date: date_for_drag,
|
|
start_y: snapped_y,
|
|
current_y: snapped_y,
|
|
offset_y: 0.0,
|
|
has_moved: false,
|
|
}));
|
|
e.prevent_default();
|
|
})
|
|
};
|
|
|
|
Some(html! {
|
|
<div
|
|
class={classes!(
|
|
"week-event",
|
|
if is_refreshing { Some("refreshing") } else { None },
|
|
if is_all_day { Some("all-day") } else { None }
|
|
)}
|
|
style={
|
|
let (column_idx, total_columns) = event_layouts[event_idx];
|
|
let column_width = if total_columns > 1 {
|
|
format!("calc((100% - 8px) / {})", total_columns) // Account for 4px margins on each side
|
|
} else {
|
|
"calc(100% - 8px)".to_string()
|
|
};
|
|
let left_offset = if total_columns > 1 {
|
|
format!("calc(4px + {} * (100% - 8px) / {})", column_idx, total_columns)
|
|
} else {
|
|
"4px".to_string()
|
|
};
|
|
|
|
format!(
|
|
"background-color: {}; top: {}px; height: {}px; left: {}; width: {}; right: auto;",
|
|
event_color,
|
|
start_pixels,
|
|
duration_pixels,
|
|
left_offset,
|
|
column_width
|
|
)
|
|
}
|
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
|
{onclick}
|
|
{oncontextmenu}
|
|
onmousedown={onmousedown_event}
|
|
>
|
|
// Top resize handle
|
|
{if !is_all_day {
|
|
html! {
|
|
<div
|
|
class="resize-handle resize-handle-top"
|
|
onmousedown={resize_start_handler}
|
|
/>
|
|
}
|
|
} else {
|
|
html! {}
|
|
}}
|
|
|
|
// Event content
|
|
<div class="event-content">
|
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
|
{if !is_all_day && duration_pixels > 30.0 {
|
|
html! { <div class="event-time">{time_display}</div> }
|
|
} else {
|
|
html! {}
|
|
}}
|
|
</div>
|
|
|
|
// Bottom resize handle
|
|
{if !is_all_day {
|
|
html! {
|
|
<div
|
|
class="resize-handle resize-handle-bottom"
|
|
onmousedown={resize_end_handler}
|
|
/>
|
|
}
|
|
} else {
|
|
html! {}
|
|
}}
|
|
</div>
|
|
})
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</div>
|
|
|
|
// Temporary event box during drag
|
|
{
|
|
if let Some(drag) = (*drag_state).clone() {
|
|
if drag.is_dragging && drag.has_moved && drag.start_date == *date {
|
|
match &drag.drag_type {
|
|
DragType::CreateEvent => {
|
|
let start_y = drag.start_y.min(drag.current_y);
|
|
let end_y = drag.start_y.max(drag.current_y);
|
|
let height = (drag.current_y - drag.start_y).abs().max(20.0);
|
|
|
|
// Convert pixels to times for display
|
|
let start_time = pixels_to_time(start_y, props.time_increment);
|
|
let end_time = pixels_to_time(end_y, props.time_increment);
|
|
|
|
html! {
|
|
<div
|
|
class="temp-event-box"
|
|
style={format!("top: {}px; height: {}px;", start_y, height)}
|
|
>
|
|
{format!("{} - {}", start_time.format("%I:%M %p"), end_time.format("%I:%M %p"))}
|
|
</div>
|
|
}
|
|
},
|
|
DragType::MoveEvent(event) => {
|
|
// Calculate the event's new position accounting for click offset
|
|
let unsnapped_position = drag.current_y - drag.offset_y;
|
|
// Snap the final position to maintain time increment alignment
|
|
let preview_position = snap_to_increment(unsnapped_position, props.time_increment);
|
|
let new_start_time = pixels_to_time(preview_position, props.time_increment);
|
|
let original_duration = if let Some(end) = event.dtend {
|
|
end.signed_duration_since(event.dtstart)
|
|
} else {
|
|
chrono::Duration::hours(1)
|
|
};
|
|
let duration_pixels = (original_duration.num_minutes() as f64).max(20.0);
|
|
let new_end_time = new_start_time + original_duration;
|
|
|
|
let event_color = get_event_color(event);
|
|
|
|
html! {
|
|
<div
|
|
class="temp-event-box moving-event"
|
|
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", preview_position, duration_pixels, event_color)}
|
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
|
>
|
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
|
{if duration_pixels > 30.0 {
|
|
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
|
|
} else {
|
|
html! {}
|
|
}}
|
|
</div>
|
|
}
|
|
},
|
|
DragType::ResizeEventStart(event) => {
|
|
// 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)
|
|
};
|
|
|
|
// Calculate positions for the preview
|
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
|
|
let original_duration = original_end.signed_duration_since(event.dtstart.with_timezone(&chrono::Local).naive_local());
|
|
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
|
|
|
|
let new_start_pixels = drag.current_y;
|
|
let new_height = (original_end_pixels as f64 - new_start_pixels).max(20.0);
|
|
|
|
let event_color = get_event_color(event);
|
|
|
|
html! {
|
|
<div
|
|
class="temp-event-box resizing-event"
|
|
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)}
|
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
|
>
|
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
|
{if new_height > 30.0 {
|
|
html! { <div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div> }
|
|
} else {
|
|
html! {}
|
|
}}
|
|
</div>
|
|
}
|
|
},
|
|
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();
|
|
|
|
// Calculate positions for the preview
|
|
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date, props.time_increment);
|
|
|
|
let new_end_pixels = drag.current_y;
|
|
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
|
|
|
|
let event_color = get_event_color(event);
|
|
|
|
html! {
|
|
<div
|
|
class="temp-event-box resizing-event"
|
|
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)}
|
|
data-external={event.categories.contains(&"__EXTERNAL_CALENDAR__".to_string()).to_string()}
|
|
>
|
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
|
{if new_height > 30.0 {
|
|
html! { <div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div> }
|
|
} else {
|
|
html! {}
|
|
}}
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
html! {}
|
|
}
|
|
} else {
|
|
html! {}
|
|
}
|
|
}
|
|
|
|
// Current time indicator - only show on today
|
|
{
|
|
if *date == props.today {
|
|
let current_time_position = calculate_current_time_position(props.time_increment);
|
|
let current_time_str = current_time.time().format("%I:%M %p").to_string();
|
|
|
|
html! {
|
|
<div class="current-time-indicator-container">
|
|
<div
|
|
class="current-time-indicator"
|
|
style={format!("top: {}px;", current_time_position)}
|
|
>
|
|
<div class="current-time-dot"></div>
|
|
<div class="current-time-line"></div>
|
|
<div class="current-time-label">{current_time_str}</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
} else {
|
|
html! {}
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
// Recurring event modification modal
|
|
if let Some(edit) = (*pending_recurring_edit).clone() {
|
|
<RecurringEditModal
|
|
show={true}
|
|
event={edit.event}
|
|
new_start={edit.new_start}
|
|
new_end={edit.new_end}
|
|
on_choice={on_recurring_choice}
|
|
on_cancel={on_recurring_cancel}
|
|
/>
|
|
} else {
|
|
<></>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
fn get_start_of_week(date: NaiveDate) -> NaiveDate {
|
|
let weekday = date.weekday();
|
|
let days_from_sunday = match weekday {
|
|
Weekday::Sun => 0,
|
|
Weekday::Mon => 1,
|
|
Weekday::Tue => 2,
|
|
Weekday::Wed => 3,
|
|
Weekday::Thu => 4,
|
|
Weekday::Fri => 5,
|
|
Weekday::Sat => 6,
|
|
};
|
|
date - Duration::days(days_from_sunday)
|
|
}
|
|
|
|
fn get_weekday_name(weekday: Weekday) -> &'static str {
|
|
match weekday {
|
|
Weekday::Sun => "Sun",
|
|
Weekday::Mon => "Mon",
|
|
Weekday::Tue => "Tue",
|
|
Weekday::Wed => "Wed",
|
|
Weekday::Thu => "Thu",
|
|
Weekday::Fri => "Fri",
|
|
Weekday::Sat => "Sat",
|
|
}
|
|
}
|
|
|
|
// Calculate the pixel position of an event based on its time
|
|
// Snap pixel position based on time increment and grid scaling
|
|
// In 30-minute mode: 60px per hour (1px = 1 minute)
|
|
// In 15-minute mode: 120px per hour (2px = 1 minute)
|
|
fn snap_to_increment(pixels: f64, increment: u32) -> f64 {
|
|
let pixels_per_minute = if increment == 15 { 2.0 } else { 1.0 };
|
|
let increment_px = increment as f64 * pixels_per_minute;
|
|
(pixels / increment_px).round() * increment_px
|
|
}
|
|
|
|
// Convert pixel position to time (inverse of time to pixels)
|
|
fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
|
|
let pixels_per_minute = if time_increment == 15 { 2.0 } else { 1.0 };
|
|
let total_minutes = pixels / pixels_per_minute;
|
|
let hours = (total_minutes / 60.0) as u32;
|
|
let minutes = (total_minutes % 60.0) as u32;
|
|
|
|
// Handle midnight boundary - check against scaled boundary
|
|
let max_pixels = 1440.0 * pixels_per_minute; // 24 hours in pixels
|
|
if pixels >= max_pixels {
|
|
return NaiveTime::from_hms_opt(0, 0, 0).unwrap();
|
|
}
|
|
|
|
// Clamp to valid time range for within-day times
|
|
let hours = hours.min(23);
|
|
let minutes = minutes.min(59);
|
|
|
|
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
|
|
}
|
|
|
|
fn calculate_event_position(event: &VEvent, date: NaiveDate, time_increment: u32) -> (f32, f32, bool) {
|
|
// Convert UTC times to local time for display
|
|
let local_start = event.dtstart.with_timezone(&Local);
|
|
let event_date = local_start.date_naive();
|
|
|
|
// Position events based on when they appear in local time, not their original date
|
|
// For timezone issues: an event created at 10 PM Sunday might be stored as Monday UTC
|
|
// but should still display on Sunday's column since that's when the user sees it
|
|
let should_display_here = event_date == date ||
|
|
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20);
|
|
|
|
if !should_display_here {
|
|
return (0.0, 0.0, false); // Event not on this date
|
|
}
|
|
|
|
// Handle all-day events - they appear at the top
|
|
if event.all_day {
|
|
return (0.0, 30.0, true); // Position at top, 30px height, is_all_day = true
|
|
}
|
|
|
|
// Calculate start position in pixels from midnight
|
|
let start_hour = local_start.hour() as f32;
|
|
let start_minute = local_start.minute() as f32;
|
|
let pixels_per_hour = if time_increment == 15 { 120.0 } else { 60.0 };
|
|
let start_pixels = (start_hour + start_minute / 60.0) * pixels_per_hour;
|
|
|
|
// 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();
|
|
|
|
// Handle events that span multiple days by capping at midnight
|
|
if end_date > date {
|
|
// Event continues past midnight, cap at 24:00
|
|
let max_pixels = 24.0 * pixels_per_hour;
|
|
max_pixels - start_pixels
|
|
} else {
|
|
let end_hour = local_end.hour() as f32;
|
|
let end_minute = local_end.minute() as f32;
|
|
let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour;
|
|
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
|
}
|
|
} else {
|
|
pixels_per_hour // Default 1 hour if no end time
|
|
};
|
|
|
|
(start_pixels, duration_pixels, false) // is_all_day = false
|
|
}
|
|
|
|
// Check if two events overlap in time
|
|
fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
|
|
// All-day events don't overlap with timed events for width calculation purposes
|
|
if event1.all_day || event2.all_day {
|
|
return false;
|
|
}
|
|
|
|
let start1 = event1.dtstart.with_timezone(&Local).naive_local();
|
|
let end1 = if let Some(end) = event1.dtend {
|
|
end.with_timezone(&Local).naive_local()
|
|
} else {
|
|
start1 + chrono::Duration::hours(1) // Default 1 hour duration
|
|
};
|
|
|
|
let start2 = event2.dtstart.with_timezone(&Local).naive_local();
|
|
let end2 = if let Some(end) = event2.dtend {
|
|
end.with_timezone(&Local).naive_local()
|
|
} else {
|
|
start2 + chrono::Duration::hours(1) // Default 1 hour duration
|
|
};
|
|
|
|
// Events overlap if one starts before the other ends
|
|
start1 < end2 && start2 < end1
|
|
}
|
|
|
|
// Calculate layout columns for overlapping events
|
|
fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u32) -> Vec<(usize, usize)> {
|
|
|
|
// Filter and sort events that should appear on this date (excluding all-day events)
|
|
let mut day_events: Vec<_> = events.iter()
|
|
.enumerate()
|
|
.filter_map(|(idx, event)| {
|
|
// Skip all-day events as they don't participate in timed event overlap calculations
|
|
if event.all_day {
|
|
return None;
|
|
}
|
|
|
|
let (_, _, _) = calculate_event_position(event, date, time_increment);
|
|
let local_start = event.dtstart.with_timezone(&Local);
|
|
let event_date = local_start.date_naive();
|
|
if event_date == date ||
|
|
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
|
|
Some((idx, event))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Sort by start time
|
|
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local());
|
|
|
|
// For each event, find all events it overlaps with
|
|
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
|
|
|
|
for i in 0..day_events.len() {
|
|
let (orig_idx_i, event_i) = day_events[i];
|
|
|
|
// Find all events that overlap with this event
|
|
let mut overlapping_events = vec![i];
|
|
for j in 0..day_events.len() {
|
|
if i != j {
|
|
let (_, event_j) = day_events[j];
|
|
if events_overlap(event_i, event_j) {
|
|
overlapping_events.push(j);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If this event doesn't overlap with anything, it gets full width
|
|
if overlapping_events.len() == 1 {
|
|
event_columns[orig_idx_i] = (0, 1);
|
|
} 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());
|
|
|
|
// Assign columns using a greedy algorithm
|
|
let mut columns: Vec<Vec<usize>> = Vec::new();
|
|
|
|
for &event_idx in &overlapping_events {
|
|
let (orig_idx, event) = day_events[event_idx];
|
|
|
|
// Find the first column where this event doesn't overlap with existing events
|
|
let mut placed = false;
|
|
for (col_idx, column) in columns.iter_mut().enumerate() {
|
|
let can_place = column.iter().all(|&existing_idx| {
|
|
let (_, existing_event) = day_events[existing_idx];
|
|
!events_overlap(event, existing_event)
|
|
});
|
|
|
|
if can_place {
|
|
column.push(event_idx);
|
|
event_columns[orig_idx] = (col_idx, columns.len());
|
|
placed = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if !placed {
|
|
// Create new column
|
|
columns.push(vec![event_idx]);
|
|
event_columns[orig_idx] = (columns.len() - 1, columns.len());
|
|
}
|
|
}
|
|
|
|
// Update total_columns for all events in this overlapping group
|
|
let total_columns = columns.len();
|
|
for &event_idx in &overlapping_events {
|
|
let (orig_idx, _) = day_events[event_idx];
|
|
event_columns[orig_idx].1 = total_columns;
|
|
}
|
|
}
|
|
}
|
|
|
|
event_columns
|
|
}
|
|
|
|
// Check if an all-day event spans the given date
|
|
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()
|
|
} else {
|
|
event.dtstart.with_timezone(&Local).date_naive()
|
|
};
|
|
|
|
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)
|
|
} else {
|
|
// For timed events, use timezone conversion
|
|
dtend.with_timezone(&Local).date_naive()
|
|
}
|
|
} else {
|
|
// Single day event
|
|
start_date
|
|
};
|
|
|
|
// Check if the given date falls within the event's date range
|
|
date >= start_date && date <= end_date
|
|
}
|