Implement dynamic 15-minute time grid density and remove final boundary

- 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 <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-09-03 15:35:50 -04:00
parent fb28fa95c9
commit ceae654a39
3 changed files with 113 additions and 61 deletions

View File

@@ -95,8 +95,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
"#3B82F6".to_string() "#3B82F6".to_string()
}; };
// Generate time labels - 24 hours plus the final midnight boundary // Generate time labels - 24 hours
let mut time_labels: Vec<String> = (0..24) let time_labels: Vec<String> = (0..24)
.map(|hour| { .map(|hour| {
if hour == 0 { if hour == 0 {
"12 AM".to_string() "12 AM".to_string()
@@ -110,9 +110,6 @@ pub fn week_view(props: &WeekViewProps) -> Html {
}) })
.collect(); .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 // Handlers for recurring event modification modal
let on_recurring_choice = { let on_recurring_choice = {
let pending_recurring_edit = pending_recurring_edit.clone(); 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 // Scrollable content area with time grid
<div class="week-content"> <div class="week-content">
<div class="time-grid"> <div class={classes!("time-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
// Time labels // Time labels
<div class="time-labels"> <div class={classes!("time-labels", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{ {
time_labels.iter().enumerate().map(|(index, time)| { time_labels.iter().map(|time| {
let is_final = index == time_labels.len() - 1; let is_quarter_mode = props.time_increment == 15;
html! { html! {
<div class={classes!("time-label", if is_final { Some("final-boundary") } else { None })}> <div class={classes!(
"time-label",
if is_quarter_mode { Some("quarter-mode") } else { None }
)}>
{time} {time}
</div> </div>
} }
@@ -404,12 +404,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
</div> </div>
// Day columns // Day columns
<div class="week-days-grid"> <div class={classes!("week-days-grid", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{ {
week_days.iter().enumerate().map(|(_column_index, date)| { week_days.iter().enumerate().map(|(_column_index, date)| {
let is_today = *date == props.today; let is_today = *date == props.today;
let day_events = props.events.get(date).cloned().unwrap_or_default(); 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 // Drag event handlers
let drag_state_clone = drag_state.clone(); let drag_state_clone = drag_state.clone();
@@ -500,8 +500,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
match &current_drag.drag_type { match &current_drag.drag_type {
DragType::CreateEvent => { DragType::CreateEvent => {
// Calculate start and end times // Calculate start and end times
let start_time = pixels_to_time(current_drag.start_y); let start_time = pixels_to_time(current_drag.start_y, time_increment);
let end_time = pixels_to_time(current_drag.current_y); let end_time = pixels_to_time(current_drag.current_y, time_increment);
// Ensure start is before end // Ensure start is before end
let (actual_start, actual_end) = if start_time <= end_time { 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; let unsnapped_position = current_drag.current_y - current_drag.offset_y;
// Snap the final position to maintain time increment alignment // Snap the final position to maintain time increment alignment
let event_top_position = snap_to_increment(unsnapped_position, time_increment); 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 // Calculate duration from original event
let original_duration = if let Some(end) = event.dtend { let original_duration = if let Some(end) = event.dtend {
@@ -558,7 +558,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
}, },
DragType::ResizeEventStart(event) => { DragType::ResizeEventStart(event) => {
// Calculate new start time based on drag position // 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 // Keep the original end time
let original_end = if let Some(end) = event.dtend { let original_end = if let Some(end) = event.dtend {
@@ -594,7 +594,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
}, },
DragType::ResizeEventEnd(event) => { DragType::ResizeEventEnd(event) => {
// Calculate new end time based on drag position // 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 // Keep the original start time
let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local(); let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
@@ -643,7 +643,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
class={classes!( class={classes!(
"week-day-column", "week-day-column",
if is_today { Some("today") } else { None }, 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} {onmousedown}
{onmousemove} {onmousemove}
@@ -652,10 +653,21 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Time slot backgrounds - 24 hour slots to represent full day // Time slot backgrounds - 24 hour slots to represent full day
{ {
(0..24).map(|_hour| { (0..24).map(|_hour| {
let slots_per_hour = 60 / props.time_increment;
html! { html! {
<div class="time-slot"> <div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
<div class="time-slot-half"></div> {
<div class="time-slot-half"></div> (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> </div>
} }
}).collect::<Html>() }).collect::<Html>()
@@ -665,7 +677,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div class="events-container"> <div class="events-container">
{ {
day_events.iter().enumerate().filter_map(|(event_idx, event)| { 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) // Skip all-day events (they're rendered in the header)
if is_all_day { if is_all_day {
@@ -693,7 +705,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let drag_state = drag_state.clone(); let drag_state = drag_state.clone();
let event_for_drag = event.clone(); let event_for_drag = event.clone();
let date_for_drag = *date; let date_for_drag = *date;
let _time_increment = props.time_increment; let time_increment = props.time_increment;
Callback::from(move |e: MouseEvent| { Callback::from(move |e: MouseEvent| {
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks 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 }; 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 // 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; let event_start_pixels = event_start_pixels as f64;
// Convert click position to day column coordinates // 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); let height = (drag.current_y - drag.start_y).abs().max(20.0);
// Convert pixels to times for display // Convert pixels to times for display
let start_time = pixels_to_time(start_y); let start_time = pixels_to_time(start_y, props.time_increment);
let end_time = pixels_to_time(end_y); let end_time = pixels_to_time(end_y, props.time_increment);
html! { html! {
<div <div
@@ -956,7 +968,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let unsnapped_position = drag.current_y - drag.offset_y; let unsnapped_position = drag.current_y - drag.offset_y;
// Snap the final position to maintain time increment alignment // Snap the final position to maintain time increment alignment
let preview_position = snap_to_increment(unsnapped_position, props.time_increment); 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 { let original_duration = if let Some(end) = event.dtend {
end.signed_duration_since(event.dtstart) end.signed_duration_since(event.dtstart)
} else { } else {
@@ -979,7 +991,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
}, },
DragType::ResizeEventStart(event) => { DragType::ResizeEventStart(event) => {
// Show the event being resized from the start // 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 { let original_end = if let Some(end) = event.dtend {
end.with_timezone(&chrono::Local).naive_local() end.with_timezone(&chrono::Local).naive_local()
} else { } else {
@@ -987,7 +999,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
}; };
// Calculate positions for the preview // 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_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 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) => { DragType::ResizeEventEnd(event) => {
// Show the event being resized from the end // 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(); let original_start = event.dtstart.with_timezone(&chrono::Local).naive_local();
// Calculate positions for the preview // 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_end_pixels = drag.current_y;
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0); 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 // Calculate the pixel position of an event based on its time
// Each hour is 60px, so we convert time to pixels // Snap pixel position based on time increment and grid scaling
// Snap pixel position to 15-minute increments (15px = 15 minutes since 60px = 60 minutes) // 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 { 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 (pixels / increment_px).round() * increment_px
} }
// Convert pixel position to time (inverse of time to pixels) // Convert pixel position to time (inverse of time to pixels)
fn pixels_to_time(pixels: f64) -> NaiveTime { fn pixels_to_time(pixels: f64, time_increment: u32) -> NaiveTime {
// Since 60px = 1 hour, pixels directly represent minutes let pixels_per_minute = if time_increment == 15 { 2.0 } else { 1.0 };
let total_minutes = pixels; // 1px = 1 minute let total_minutes = pixels / pixels_per_minute;
let hours = (total_minutes / 60.0) as u32; let hours = (total_minutes / 60.0) as u32;
let minutes = (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 // Handle midnight boundary - check against scaled boundary
if total_minutes >= 1440.0 { 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(); 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()) 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 // Convert UTC times to local time for display
let local_start = event.dtstart.with_timezone(&Local); let local_start = event.dtstart.with_timezone(&Local);
let event_date = local_start.date_naive(); 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 // Calculate start position in pixels from midnight
let start_hour = local_start.hour() as f32; let start_hour = local_start.hour() as f32;
let start_minute = local_start.minute() 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 // Calculate duration and height
let duration_pixels = if let Some(end) = event.dtend { 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 // Handle events that span multiple days by capping at midnight
if end_date > date { if end_date > date {
// Event continues past midnight, cap at 24:00 (1440px) // Event continues past midnight, cap at 24:00
1440.0 - start_pixels let max_pixels = 24.0 * pixels_per_hour;
max_pixels - start_pixels
} else { } else {
let end_hour = local_end.hour() as f32; let end_hour = local_end.hour() as f32;
let end_minute = local_end.minute() 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 (end_pixels - start_pixels).max(20.0) // Minimum 20px height
} }
} else { } 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 (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 // 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 // Filter and sort events that should appear on this date
let mut day_events: Vec<_> = events.iter() let mut day_events: Vec<_> = events.iter()
.enumerate() .enumerate()
.filter_map(|(idx, event)| { .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 local_start = event.dtstart.with_timezone(&Local);
let event_date = local_start.date_naive(); let event_date = local_start.date_naive();
if event_date == date || if event_date == date ||

View File

@@ -733,7 +733,11 @@ body {
.time-grid { .time-grid {
display: grid; display: grid;
grid-template-columns: 80px 1fr; 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 */ /* Time Labels */
@@ -743,9 +747,15 @@ body {
position: sticky; position: sticky;
left: 0; left: 0;
z-index: 5; 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 { .time-label {
height: 60px; height: 60px;
display: flex; display: flex;
@@ -758,24 +768,31 @@ body {
font-weight: 500; font-weight: 500;
} }
.time-label.final-boundary { /* Time label height for 15-minute mode - double height */
height: 60px; /* Keep same height but this marks the end boundary */ .time-label.quarter-mode {
border-bottom: 2px solid #e9ecef; /* Stronger border to show day end */ height: 120px;
color: #999; /* Lighter color to indicate it's the boundary */
font-size: 0.7rem;
} }
/* Week Days Grid */ /* Week Days Grid */
.week-days-grid { .week-days-grid {
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); 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 { .week-day-column {
position: relative; position: relative;
border-right: 1px solid var(--time-label-border, #e9ecef); 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 { .week-day-column:last-child {
@@ -788,12 +805,16 @@ body {
/* Time Slots */ /* Time Slots */
.time-slot { .time-slot {
height: 60px; height: 60px; /* 30-minute mode: 2 slots × 30px = 60px */
border-bottom: 1px solid var(--calendar-border, #f0f0f0); border-bottom: 1px solid var(--calendar-border, #f0f0f0);
position: relative; position: relative;
pointer-events: none; /* Don't capture mouse events */ pointer-events: none; /* Don't capture mouse events */
} }
.time-slot.quarter-mode {
height: 120px; /* 15-minute mode: 4 slots × 30px = 120px */
}
.time-slot-half { .time-slot-half {
height: 30px; height: 30px;
border-bottom: 1px dotted var(--calendar-border, #f5f5f5); border-bottom: 1px dotted var(--calendar-border, #f5f5f5);
@@ -804,13 +825,17 @@ body {
border-bottom: none; border-bottom: none;
} }
.time-slot.boundary-slot { .time-slot-quarter {
height: 60px; /* Match the final time label height */ height: 30px;
border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */ border-bottom: 1px dotted var(--calendar-border-light, #f8f8f8);
background: rgba(0,0,0,0.02); /* Slightly different background to indicate boundary */
pointer-events: none; /* Don't capture mouse events */ pointer-events: none; /* Don't capture mouse events */
} }
.time-slot-quarter:last-child {
border-bottom: none;
}
/* Events Container */ /* Events Container */
.events-container { .events-container {
position: absolute; position: absolute;

View File

@@ -648,6 +648,16 @@ body {
border-bottom: none; 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 { .time-slot.boundary-slot {
height: 60px; /* Match the final time label height */ height: 60px; /* Match the final time label height */
border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */ border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */