Files
calendar/frontend/src/components/week_view.rs
Connor Johnstone 15f2d0c6d9 Implement shared RFC 5545 VEvent library with workspace restructuring
- Created calendar-models/ shared library with RFC 5545-compliant VEvent structures
- Migrated backend to use shared VEvent with proper field mappings (dtstart/dtend, rrule, exdate, etc.)
- Converted CalDAV client to parse into VEvent structures with structured types
- Updated all CRUD handlers to use VEvent with CalendarUser, Attendee, VAlarm types
- Restructured project as Cargo workspace with frontend/, backend/, calendar-models/
- Updated Trunk configuration for new directory structure
- Fixed all compilation errors and field references throughout codebase
- Updated documentation and build instructions for workspace structure

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-30 11:45:58 -04:00

1047 lines
71 KiB
Rust

use yew::prelude::*;
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime};
use std::collections::HashMap;
use web_sys::MouseEvent;
use crate::services::calendar_service::UserInfo;
use crate::models::ical::VEvent;
use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
#[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>>)>>,
#[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 on_create_event_request = props.on_create_event_request.clone();
let events = props.events.clone();
Callback::from(move |action: RecurringEditAction| {
if let Some(edit) = (*pending_recurring_edit).clone() {
match action {
RecurringEditAction::ThisEvent => {
// Create exception for this occurrence only
// 1. First, add EXDATE to the original series to exclude this occurrence
if let Some(update_callback) = &on_event_update {
let mut updated_series = edit.event.clone();
updated_series.exdate.push(edit.event.dtstart);
// Keep the original series times unchanged - we're only adding EXDATE
let original_start = edit.event.dtstart.with_timezone(&chrono::Local).naive_local();
let original_end = edit.event.dtend.unwrap_or(edit.event.dtstart).with_timezone(&chrono::Local).naive_local();
web_sys::console::log_1(&format!("📅 Adding EXDATE {} to series '{}'",
edit.event.dtstart.format("%Y-%m-%d %H:%M:%S UTC"),
edit.event.summary.as_deref().unwrap_or("Untitled")
).into());
// Update the original series with the exception (times unchanged)
update_callback.emit((updated_series, original_start, original_end, true, None)); // preserve_rrule = true for EXDATE, no until_date
}
// 2. Then create the new single event using the create callback
if let Some(create_callback) = &on_create_event_request {
// Convert to EventCreationData for single event
let event_data = EventCreationData {
title: edit.event.summary.clone().unwrap_or_default(),
description: edit.event.description.clone().unwrap_or_default(),
start_date: edit.new_start.date(),
start_time: edit.new_start.time(),
end_date: edit.new_end.date(),
end_time: edit.new_end.time(),
location: edit.event.location.clone().unwrap_or_default(),
all_day: edit.event.all_day,
status: EventStatus::Confirmed,
class: EventClass::Public,
priority: edit.event.priority,
organizer: edit.event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
attendees: edit.event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
categories: edit.event.categories.join(","),
reminder: ReminderType::None,
recurrence: RecurrenceType::None, // Single event, no recurrence
recurrence_days: vec![false; 7],
selected_calendar: edit.event.calendar_path.clone(),
};
// Create the single event
create_callback.emit(event_data);
}
},
RecurringEditAction::FutureEvents => {
// Split series and modify future events
// 1. Update original series to set UNTIL to end before this occurrence
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());
// Use the original series start time (not the dragged occurrence time)
let original_start = original_series.dtstart.with_timezone(&chrono::Local).naive_local();
let original_end = original_series.dtend.unwrap_or(original_series.dtstart).with_timezone(&chrono::Local).naive_local();
// Send until_date to backend instead of modifying RRULE on frontend
update_callback.emit((original_series, original_start, original_end, true, Some(until_utc))); // preserve_rrule = true, backend will add UNTIL
}
// 2. Create new series starting from this occurrence with modified times
if let Some(create_callback) = &on_create_event_request {
// Convert the recurring event to EventCreationData for the create callback
let event_data = EventCreationData {
title: edit.event.summary.clone().unwrap_or_default(),
description: edit.event.description.clone().unwrap_or_default(),
start_date: edit.new_start.date(),
start_time: edit.new_start.time(),
end_date: edit.new_end.date(),
end_time: edit.new_end.time(),
location: edit.event.location.clone().unwrap_or_default(),
all_day: edit.event.all_day,
status: EventStatus::Confirmed, // Default status
class: EventClass::Public, // Default class
priority: edit.event.priority,
organizer: edit.event.organizer.as_ref().map(|o| o.cal_address.clone()).unwrap_or_default(),
attendees: edit.event.attendees.iter().map(|a| a.cal_address.clone()).collect::<Vec<_>>().join(","),
categories: edit.event.categories.join(","),
reminder: ReminderType::None, // Default reminder
recurrence: if let Some(rrule) = &edit.event.rrule {
if rrule.contains("FREQ=DAILY") {
RecurrenceType::Daily
} else if rrule.contains("FREQ=WEEKLY") {
RecurrenceType::Weekly
} else if rrule.contains("FREQ=MONTHLY") {
RecurrenceType::Monthly
} else if rrule.contains("FREQ=YEARLY") {
RecurrenceType::Yearly
} else {
RecurrenceType::None
}
} else {
RecurrenceType::None
},
recurrence_days: vec![false; 7], // Default days
selected_calendar: edit.event.calendar_path.clone(),
};
// Create the new series
create_callback.emit(event_data);
}
},
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)); // Regular drag operation - preserve RRULE, no until_date
}
},
}
}
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 &current_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)); // 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)); // 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)); // 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
}