Some checks failed
Build and Push Docker Image / docker (push) Failing after 1m7s
Moved event fetching logic from CalendarView to Calendar component to properly use the visible date range instead of hardcoded current month. The Calendar component already tracks the current visible date through navigation, so events now load correctly for August and other months when navigating. Changes: - Calendar component now manages its own events state and fetching - Event fetching responds to current_date changes from navigation - CalendarView simplified to just render Calendar component - Fixed cargo fmt/clippy formatting across codebase 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1068 lines
68 KiB
Rust
1068 lines
68 KiB
Rust
use crate::components::{EventCreationData, RecurringEditAction, RecurringEditModal};
|
|
use crate::models::ical::VEvent;
|
|
use crate::services::calendar_service::UserInfo;
|
|
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 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>);
|
|
|
|
// Helper function to get calendar color for an event
|
|
let get_event_color = |event: &VEvent| -> String {
|
|
if let Some(user_info) = &props.user_info {
|
|
if let Some(calendar_path) = &event.calendar_path {
|
|
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 plus the final midnight boundary
|
|
let mut 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();
|
|
|
|
// Add the final midnight boundary to show where the day ends
|
|
time_labels.push("12 AM".to_string());
|
|
|
|
// 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());
|
|
|
|
html! {
|
|
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
|
|
<div class="weekday-name">{weekday_name}</div>
|
|
<div class="day-number">{date.day()}</div>
|
|
</div>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</div>
|
|
|
|
// Scrollable content area with time grid
|
|
<div class="week-content">
|
|
<div class="time-grid">
|
|
// Time labels
|
|
<div class="time-labels">
|
|
{
|
|
time_labels.iter().enumerate().map(|(index, time)| {
|
|
let is_final = index == time_labels.len() - 1;
|
|
html! {
|
|
<div class={classes!("time-label", if is_final { Some("final-boundary") } else { None })}>
|
|
{time}
|
|
</div>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
</div>
|
|
|
|
// Day columns
|
|
<div class="week-days-grid">
|
|
{
|
|
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();
|
|
|
|
// 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| {
|
|
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);
|
|
let end_time = pixels_to_time(current_drag.current_y);
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
})
|
|
};
|
|
|
|
html! {
|
|
<div
|
|
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
|
{onmousedown}
|
|
{onmousemove}
|
|
{onmouseup}
|
|
>
|
|
// Time slot backgrounds - 24 hour slots to represent full day
|
|
{
|
|
(0..24).map(|_hour| {
|
|
html! {
|
|
<div class="time-slot">
|
|
<div class="time-slot-half"></div>
|
|
<div class="time-slot-half"></div>
|
|
</div>
|
|
}
|
|
}).collect::<Html>()
|
|
}
|
|
// Final boundary slot to complete the 24-hour visual grid - make it interactive like other slots
|
|
<div class="time-slot boundary-slot">
|
|
<div class="time-slot-half"></div>
|
|
<div class="time-slot-half"></div>
|
|
</div>
|
|
|
|
// Events positioned absolutely based on their actual times
|
|
<div class="events-container">
|
|
{
|
|
day_events.iter().filter_map(|event| {
|
|
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
|
|
|
|
// Skip events that don't belong on this date or have invalid positioning
|
|
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
|
|
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);
|
|
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={format!(
|
|
"background-color: {}; top: {}px; height: {}px;",
|
|
event_color,
|
|
start_pixels,
|
|
duration_pixels
|
|
)}
|
|
{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 {
|
|
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.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);
|
|
let end_time = pixels_to_time(end_y);
|
|
|
|
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);
|
|
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)}
|
|
>
|
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
|
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
|
|
</div>
|
|
}
|
|
},
|
|
DragType::ResizeEventStart(event) => {
|
|
// Show the event being resized from the start
|
|
let new_start_time = pixels_to_time(drag.current_y);
|
|
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);
|
|
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)}
|
|
>
|
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
|
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div>
|
|
</div>
|
|
}
|
|
},
|
|
DragType::ResizeEventEnd(event) => {
|
|
// Show the event being resized from the end
|
|
let new_end_time = pixels_to_time(drag.current_y);
|
|
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);
|
|
|
|
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)}
|
|
>
|
|
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
|
|
<div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
html! {}
|
|
}
|
|
} 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
|
|
// Each hour is 60px, so we convert time to pixels
|
|
// Snap pixel position to 15-minute increments (15px = 15 minutes since 60px = 60 minutes)
|
|
fn snap_to_increment(pixels: f64, increment: u32) -> f64 {
|
|
let increment_px = increment as f64; // Convert to pixels (1px = 1 minute)
|
|
(pixels / increment_px).round() * increment_px
|
|
}
|
|
|
|
// Convert pixel position to time (inverse of time to pixels)
|
|
fn pixels_to_time(pixels: f64) -> NaiveTime {
|
|
// Since 60px = 1 hour, pixels directly represent minutes
|
|
let total_minutes = pixels; // 1px = 1 minute
|
|
let hours = (total_minutes / 60.0) as u32;
|
|
let minutes = (total_minutes % 60.0) as u32;
|
|
|
|
// Handle midnight boundary - if we're at exactly 1440 pixels (24:00), return midnight
|
|
if total_minutes >= 1440.0 {
|
|
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) -> (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();
|
|
|
|
// Only position events that are on this specific date
|
|
if event_date != date {
|
|
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 start_pixels = (start_hour + start_minute / 60.0) * 60.0; // 60px 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 (1440px)
|
|
1440.0 - 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) * 60.0;
|
|
(end_pixels - start_pixels).max(20.0) // Minimum 20px height
|
|
}
|
|
} else {
|
|
60.0 // Default 1 hour if no end time
|
|
};
|
|
|
|
(start_pixels, duration_pixels, false) // is_all_day = false
|
|
}
|