Add configurable time increment toggle for event creation

- Add toggle button (15/30 minutes) in calendar header next to navigation arrows
- Implement circular frosted styling consistent with nav buttons
- Add configurable snapping for drag-to-create events in week view
- Persist time increment setting across browser sessions using localStorage
- Update snap function to accept configurable increment instead of hardcoded 15 minutes

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-29 12:17:31 -04:00
parent 4fbef8a5dc
commit edd209238f
4 changed files with 95 additions and 12 deletions

View File

@@ -66,6 +66,20 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let show_create_modal = use_state(|| false);
let create_event_data = use_state(|| None::<(chrono::NaiveDate, chrono::NaiveTime, chrono::NaiveTime)>);
// State for time increment snapping (15 or 30 minutes)
let time_increment = use_state(|| {
// Try to load saved time increment from localStorage
if let Ok(saved_increment) = LocalStorage::get::<u32>("calendar_time_increment") {
if saved_increment == 15 || saved_increment == 30 {
saved_increment
} else {
15
}
} else {
15
}
});
// Handle view mode changes - adjust current_date format when switching between month/week
{
let current_date = current_date.clone();
@@ -152,6 +166,17 @@ pub fn Calendar(props: &CalendarProps) -> Html {
})
};
// Handle time increment toggle
let on_time_increment_toggle = {
let time_increment = time_increment.clone();
Callback::from(move |_: MouseEvent| {
let current = *time_increment;
let next = if current == 15 { 30 } else { 15 };
time_increment.set(next);
let _ = LocalStorage::set("calendar_time_increment", next);
})
};
// Handle drag-to-create event
let on_create_event = {
let show_create_modal = show_create_modal.clone();
@@ -172,6 +197,8 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_prev={on_prev}
on_next={on_next}
on_today={on_today}
time_increment={Some(*time_increment)}
on_time_increment_toggle={Some(on_time_increment_toggle)}
/>
{
@@ -212,6 +239,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
on_create_event={Some(on_create_event)}
context_menus_open={props.context_menus_open}
time_increment={*time_increment}
/>
},
}

View File

@@ -10,6 +10,10 @@ pub struct CalendarHeaderProps {
pub on_prev: Callback<MouseEvent>,
pub on_next: Callback<MouseEvent>,
pub on_today: Callback<MouseEvent>,
#[prop_or_default]
pub time_increment: Option<u32>,
#[prop_or_default]
pub on_time_increment_toggle: Option<Callback<MouseEvent>>,
}
#[function_component(CalendarHeader)]
@@ -18,7 +22,20 @@ pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
html! {
<div class="calendar-header">
<button class="nav-button" onclick={props.on_prev.clone()}>{""}</button>
<div class="header-left">
<button class="nav-button" onclick={props.on_prev.clone()}>{""}</button>
{
if let (Some(increment), Some(callback)) = (props.time_increment, &props.on_time_increment_toggle) {
html! {
<button class="time-increment-button" onclick={callback.clone()}>
{format!("{}", increment)}
</button>
}
} else {
html! {}
}
}
</div>
<h2 class="month-year">{title}</h2>
<div class="header-right">
<button class="today-button" onclick={props.on_today.clone()}>{"Today"}</button>

View File

@@ -22,6 +22,8 @@ pub struct WeekViewProps {
pub on_create_event: Option<Callback<(NaiveDate, NaiveDateTime, NaiveDateTime)>>,
#[prop_or_default]
pub context_menus_open: bool,
#[prop_or_default]
pub time_increment: u32,
}
#[derive(Clone, PartialEq)]
@@ -122,6 +124,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
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 {
@@ -138,8 +141,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
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 15-minute increments
let snapped_y = snap_to_15_minutes(relative_y);
// Snap to increment
let snapped_y = snap_to_increment(relative_y, time_increment);
drag_state.set(Some(DragState {
is_dragging: true,
@@ -153,6 +156,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
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 {
@@ -160,8 +164,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
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 15-minute increments
let snapped_y = snap_to_15_minutes(relative_y);
// Snap to increment
let snapped_y = snap_to_increment(relative_y, time_increment);
current_drag.current_y = snapped_y;
drag_state.set(Some(current_drag));
@@ -214,7 +218,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
{onmousemove}
{onmouseup}
>
// Time slot backgrounds - 24 full hour slots + 1 boundary slot
// Time slot backgrounds - 24 hour slots to represent full day
{
(0..24).map(|_hour| {
html! {
@@ -225,8 +229,11 @@ pub fn week_view(props: &WeekViewProps) -> Html {
}
}).collect::<Html>()
}
// Final boundary slot to match the final time label
<div class="time-slot boundary-slot"></div>
// 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">
@@ -398,9 +405,9 @@ 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)
fn snap_to_15_minutes(pixels: f64) -> f64 {
let increment = 15.0; // 15px = 15 minutes
(pixels / increment).round() * increment
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)
@@ -410,7 +417,12 @@ fn pixels_to_time(pixels: f64) -> NaiveTime {
let hours = (total_minutes / 60.0) as u32;
let minutes = (total_minutes % 60.0) as u32;
// Clamp to valid time range
// 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);

View File

@@ -410,12 +410,38 @@ body {
transform: translateX(-50%);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.time-increment-button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
font-size: 14px;
font-weight: bold;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.time-increment-button:hover {
background: rgba(255,255,255,0.3);
}
.nav-button {
background: rgba(255,255,255,0.2);
border: none;