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()
};
// Generate time labels - 24 hours plus the final midnight boundary
let mut time_labels: Vec<String> = (0..24)
// Generate time labels - 24 hours
let time_labels: Vec<String> = (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
<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
<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)| {
let is_final = index == time_labels.len() - 1;
time_labels.iter().map(|time| {
let is_quarter_mode = props.time_increment == 15;
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}
</div>
}
@@ -404,12 +404,12 @@ pub fn week_view(props: &WeekViewProps) -> Html {
</div>
// 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)| {
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 &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);
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! {
<div class="time-slot">
<div class="time-slot-half"></div>
<div class="time-slot-half"></div>
<div class={classes!("time-slot", if props.time_increment == 15 { Some("quarter-mode") } else { None })}>
{
(0..slots_per_hour).map(|_slot| {
let slot_class = if props.time_increment == 15 {
"time-slot-quarter"
} else {
"time-slot-half"
};
html! {
<div class={slot_class}></div>
}
}).collect::<Html>()
}
</div>
}
}).collect::<Html>()
@@ -665,7 +677,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div class="events-container">
{
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
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! {
<div
@@ -956,7 +968,7 @@ pub fn week_view(props: &WeekViewProps) -> 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 ||

View File

@@ -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;

View File

@@ -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 */