From ceae654a3962300510a7c8612be1a54aa07830e3 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Wed, 3 Sep 2025 15:35:50 -0400 Subject: [PATCH] Implement dynamic 15-minute time grid density and remove final boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scale time grid height dynamically based on time increment (1530px/2970px) - Add quarter-mode CSS classes for 15-minute blocks (30px each, same as 30-min blocks) - Update pixel-to-time conversion functions with 2px:1min scaling in 15-min mode - Generate correct number of time slots (4 per hour in 15-min mode) - Remove unnecessary final boundary time label and related CSS - Fix CSS grid layout by removing malformed CSS syntax - All time-related containers scale properly between 30-minute and 15-minute modes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- frontend/src/components/week_view.rs | 111 +++++++++++++++------------ frontend/styles.css | 53 +++++++++---- frontend/styles/default.css | 10 +++ 3 files changed, 113 insertions(+), 61 deletions(-) diff --git a/frontend/src/components/week_view.rs b/frontend/src/components/week_view.rs index e1d69f8..45c9c4d 100644 --- a/frontend/src/components/week_view.rs +++ b/frontend/src/components/week_view.rs @@ -95,8 +95,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { "#3B82F6".to_string() }; - // Generate time labels - 24 hours plus the final midnight boundary - let mut time_labels: Vec = (0..24) + // Generate time labels - 24 hours + let time_labels: Vec = (0..24) .map(|hour| { if hour == 0 { "12 AM".to_string() @@ -110,9 +110,6 @@ pub fn week_view(props: &WeekViewProps) -> Html { }) .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(); @@ -388,14 +385,17 @@ pub fn week_view(props: &WeekViewProps) -> Html { // Scrollable content area with time grid
-
+
// Time labels -
+
{ - time_labels.iter().enumerate().map(|(index, time)| { - let is_final = index == time_labels.len() - 1; + time_labels.iter().map(|time| { + let is_quarter_mode = props.time_increment == 15; html! { -
+
{time}
} @@ -404,12 +404,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Day columns -
+
{ 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); + let event_layouts = calculate_event_layout(&day_events, *date, props.time_increment); // Drag event handlers let drag_state_clone = drag_state.clone(); @@ -500,8 +500,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { 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); + 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 { @@ -529,7 +529,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { 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); + 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 { @@ -558,7 +558,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { }, DragType::ResizeEventStart(event) => { // Calculate new start time based on drag position - let new_start_time = pixels_to_time(current_drag.current_y); + 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 { @@ -594,7 +594,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { }, DragType::ResizeEventEnd(event) => { // Calculate new end time based on drag position - let new_end_time = pixels_to_time(current_drag.current_y); + 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(); @@ -643,7 +643,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { class={classes!( "week-day-column", if is_today { Some("today") } else { None }, - if is_creating_event { Some("creating-event") } else { None } + if is_creating_event { Some("creating-event") } else { None }, + if props.time_increment == 15 { Some("quarter-mode") } else { None } )} {onmousedown} {onmousemove} @@ -652,10 +653,21 @@ pub fn week_view(props: &WeekViewProps) -> Html { // Time slot backgrounds - 24 hour slots to represent full day { (0..24).map(|_hour| { + let slots_per_hour = 60 / props.time_increment; html! { -
-
-
+
+ { + (0..slots_per_hour).map(|_slot| { + let slot_class = if props.time_increment == 15 { + "time-slot-quarter" + } else { + "time-slot-half" + }; + html! { +
+ } + }).collect::() + }
} }).collect::() @@ -665,7 +677,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
{ day_events.iter().enumerate().filter_map(|(event_idx, event)| { - let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date); + 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 { @@ -693,7 +705,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { let drag_state = drag_state.clone(); let event_for_drag = event.clone(); let date_for_drag = *date; - let _time_increment = props.time_increment; + let time_increment = props.time_increment; Callback::from(move |e: MouseEvent| { e.stop_propagation(); // Prevent drag-to-create from starting on event clicks @@ -707,7 +719,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { 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, _, _) = 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 @@ -939,8 +951,8 @@ pub fn week_view(props: &WeekViewProps) -> Html { 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); + let start_time = pixels_to_time(start_y, props.time_increment); + let end_time = pixels_to_time(end_y, props.time_increment); html! {
Html { 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 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 { @@ -979,7 +991,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { }, DragType::ResizeEventStart(event) => { // Show the event being resized from the start - let new_start_time = pixels_to_time(drag.current_y); + 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 { @@ -987,7 +999,7 @@ pub fn week_view(props: &WeekViewProps) -> Html { }; // Calculate positions for the preview - let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date); + 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); @@ -1008,11 +1020,11 @@ pub fn week_view(props: &WeekViewProps) -> Html { }, DragType::ResizeEventEnd(event) => { // Show the event being resized from the end - let new_end_time = pixels_to_time(drag.current_y); + 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); + 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); @@ -1089,22 +1101,25 @@ fn get_weekday_name(weekday: Weekday) -> &'static str { } // 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) +// 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 increment_px = increment as f64; // Convert to pixels (1px = 1 minute) + 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) -> NaiveTime { - // Since 60px = 1 hour, pixels directly represent minutes - let total_minutes = pixels; // 1px = 1 minute +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 - if we're at exactly 1440 pixels (24:00), return midnight - if total_minutes >= 1440.0 { + // 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(); } @@ -1115,7 +1130,7 @@ fn pixels_to_time(pixels: f64) -> NaiveTime { 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) { +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(); @@ -1138,7 +1153,8 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) // 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 + 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 { @@ -1147,16 +1163,17 @@ fn calculate_event_position(event: &VEvent, date: NaiveDate) -> (f32, f32, bool) // 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 + // 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) * 60.0; + let end_pixels = (end_hour + end_minute / 60.0) * pixels_per_hour; (end_pixels - start_pixels).max(20.0) // Minimum 20px height } } else { - 60.0 // Default 1 hour if no end time + pixels_per_hour // Default 1 hour if no end time }; (start_pixels, duration_pixels, false) // is_all_day = false @@ -1183,13 +1200,13 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool { } // Calculate layout columns for overlapping events -fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usize)> { +fn calculate_event_layout(events: &[VEvent], date: NaiveDate, time_increment: u32) -> Vec<(usize, usize)> { // Filter and sort events that should appear on this date let mut day_events: Vec<_> = events.iter() .enumerate() .filter_map(|(idx, event)| { - let (_, _, _) = calculate_event_position(event, date); + 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 || diff --git a/frontend/styles.css b/frontend/styles.css index 9b4e63a..6cebb33 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -733,7 +733,11 @@ body { .time-grid { display: grid; grid-template-columns: 80px 1fr; - min-height: 1530px; + min-height: 1530px; /* 30-minute mode */ +} + +.time-grid.quarter-mode { + min-height: 2970px; /* 15-minute mode */ } /* Time Labels */ @@ -743,9 +747,15 @@ body { position: sticky; left: 0; z-index: 5; - min-height: 1440px; /* Match the time slots height */ + min-height: 1530px; /* 30-minute mode */ } +/* Scale time labels container for 15-minute mode */ +.time-labels.quarter-mode { + min-height: 2970px; /* 15-minute mode */ +} + +/* Default time label height for 30-minute mode */ .time-label { height: 60px; display: flex; @@ -758,24 +768,31 @@ body { font-weight: 500; } -.time-label.final-boundary { - height: 60px; /* Keep same height but this marks the end boundary */ - border-bottom: 2px solid #e9ecef; /* Stronger border to show day end */ - color: #999; /* Lighter color to indicate it's the boundary */ - font-size: 0.7rem; +/* Time label height for 15-minute mode - double height */ +.time-label.quarter-mode { + height: 120px; } + /* Week Days Grid */ .week-days-grid { display: grid; grid-template-columns: repeat(7, 1fr); - min-height: 1440px; /* Ensure grid is tall enough for 24 time slots */ + min-height: 1530px; /* 30-minute mode */ +} + +.week-days-grid.quarter-mode { + min-height: 2970px; /* 15-minute mode */ } .week-day-column { position: relative; border-right: 1px solid var(--time-label-border, #e9ecef); - min-height: 1440px; /* 24 time slots × 60px = 1440px total */ + min-height: 1530px; /* 30-minute mode */ +} + +.week-day-column.quarter-mode { + min-height: 2970px; /* 15-minute mode */ } .week-day-column:last-child { @@ -788,12 +805,16 @@ body { /* Time Slots */ .time-slot { - height: 60px; + height: 60px; /* 30-minute mode: 2 slots × 30px = 60px */ border-bottom: 1px solid var(--calendar-border, #f0f0f0); position: relative; pointer-events: none; /* Don't capture mouse events */ } +.time-slot.quarter-mode { + height: 120px; /* 15-minute mode: 4 slots × 30px = 120px */ +} + .time-slot-half { height: 30px; border-bottom: 1px dotted var(--calendar-border, #f5f5f5); @@ -804,13 +825,17 @@ body { border-bottom: none; } -.time-slot.boundary-slot { - height: 60px; /* Match the final time label height */ - border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */ - background: rgba(0,0,0,0.02); /* Slightly different background to indicate boundary */ +.time-slot-quarter { + height: 30px; + border-bottom: 1px dotted var(--calendar-border-light, #f8f8f8); pointer-events: none; /* Don't capture mouse events */ } +.time-slot-quarter:last-child { + border-bottom: none; +} + + /* Events Container */ .events-container { position: absolute; diff --git a/frontend/styles/default.css b/frontend/styles/default.css index c4f437a..00fea31 100644 --- a/frontend/styles/default.css +++ b/frontend/styles/default.css @@ -648,6 +648,16 @@ body { border-bottom: none; } +.time-slot-quarter { + height: 30px; + border-bottom: 1px dotted var(--calendar-border-light, #f8f8f8); + pointer-events: none; /* Don't capture mouse events */ +} + +.time-slot-quarter:last-child { + border-bottom: none; +} + .time-slot.boundary-slot { height: 60px; /* Match the final time label height */ border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */