Implement comprehensive calendar UX improvements

- Add time range display to week view events showing "start - end" format
- Optimize time display with smart AM/PM formatting to reduce redundancy
- Fix context menu overlap by adding stop_propagation to event handlers
- Implement persistent view mode (Month/Week) across page refreshes using localStorage
- Replace month-based tracking with intelligent selected date tracking
- Add day selection in month view with visual feedback and click handlers
- Fix view switching to navigate to week containing selected day, not first week of month
- Separate selected_date from display_date for proper context switching
- Simplify week view header to show "Month Year" instead of "Week of Month Day"
- Add backward compatibility for existing localStorage keys

Greatly improves calendar navigation and user experience with persistent state

🤖 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 10:44:44 -04:00
parent 5d0628878b
commit a8bb2c8164
5 changed files with 169 additions and 46 deletions

View File

@@ -28,8 +28,18 @@ pub fn App() -> Html {
let create_event_modal_open = use_state(|| false);
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
// Calendar view state
let current_view = use_state(|| ViewMode::Month);
// Calendar view state - load from localStorage if available
let current_view = use_state(|| {
// Try to load saved view mode from localStorage
if let Ok(saved_view) = LocalStorage::get::<String>("calendar_view_mode") {
match saved_view.as_str() {
"week" => ViewMode::Week,
_ => ViewMode::Month,
}
} else {
ViewMode::Month // Default to month view
}
});
let available_colors = [
"#3B82F6", "#10B981", "#F59E0B", "#EF4444",
@@ -58,6 +68,14 @@ pub fn App() -> Html {
let on_view_change = {
let current_view = current_view.clone();
Callback::from(move |new_view: ViewMode| {
// Save view mode to localStorage
let view_string = match new_view {
ViewMode::Month => "month",
ViewMode::Week => "week",
};
let _ = LocalStorage::set("calendar_view_mode", view_string);
// Update state
current_view.set(new_view);
})
};

View File

@@ -26,66 +26,121 @@ pub struct CalendarProps {
#[function_component]
pub fn Calendar(props: &CalendarProps) -> Html {
let today = Local::now().date_naive();
let current_date = use_state(|| {
// Try to load saved date from localStorage
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_current_month") {
// Track the currently selected date (the actual day the user has selected)
let selected_date = use_state(|| {
// Try to load saved selected date from localStorage
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_selected_date") {
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
saved_date.with_day(1).unwrap_or(today)
saved_date
} else {
today
}
} else {
today
// Check for old key for backward compatibility
if let Ok(saved_date_str) = LocalStorage::get::<String>("calendar_current_month") {
if let Ok(saved_date) = NaiveDate::parse_from_str(&saved_date_str, "%Y-%m-%d") {
saved_date
} else {
today
}
} else {
today
}
}
});
// Track the display date (what to show in the view)
let current_date = use_state(|| {
match props.view {
ViewMode::Month => selected_date.with_day(1).unwrap_or(*selected_date),
ViewMode::Week => *selected_date,
}
});
let selected_event = use_state(|| None::<CalendarEvent>);
// Handle view mode changes - adjust current_date format when switching between month/week
{
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
use_effect_with(view, move |view_mode| {
let selected = *selected_date;
let new_display_date = match view_mode {
ViewMode::Month => selected.with_day(1).unwrap_or(selected),
ViewMode::Week => selected, // Show the week containing the selected date
};
current_date.set(new_display_date);
|| {}
});
}
let on_prev = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
Callback::from(move |_: MouseEvent| {
let new_date = match view {
let (new_selected, new_display) = match view {
ViewMode::Month => {
let prev = *current_date - Duration::days(1);
prev.with_day(1).unwrap()
// Go to previous month, select the 1st day
let prev_month = *current_date - Duration::days(1);
let first_of_prev = prev_month.with_day(1).unwrap();
(first_of_prev, first_of_prev)
},
ViewMode::Week => {
// Go to previous week
let prev_week = *selected_date - Duration::weeks(1);
(prev_week, prev_week)
},
ViewMode::Week => *current_date - Duration::weeks(1),
};
current_date.set(new_date);
let _ = LocalStorage::set("calendar_current_month", new_date.format("%Y-%m-%d").to_string());
selected_date.set(new_selected);
current_date.set(new_display);
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
})
};
let on_next = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
Callback::from(move |_: MouseEvent| {
let new_date = match view {
let (new_selected, new_display) = match view {
ViewMode::Month => {
if current_date.month() == 12 {
// Go to next month, select the 1st day
let next_month = if current_date.month() == 12 {
NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap()
}
};
(next_month, next_month)
},
ViewMode::Week => {
// Go to next week
let next_week = *selected_date + Duration::weeks(1);
(next_week, next_week)
},
ViewMode::Week => *current_date + Duration::weeks(1),
};
current_date.set(new_date);
let _ = LocalStorage::set("calendar_current_month", new_date.format("%Y-%m-%d").to_string());
selected_date.set(new_selected);
current_date.set(new_display);
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
})
};
let on_today = {
let current_date = current_date.clone();
let selected_date = selected_date.clone();
let view = props.view.clone();
Callback::from(move |_| {
let today = Local::now().date_naive();
let new_date = match view {
ViewMode::Month => today.with_day(1).unwrap(),
ViewMode::Week => today,
let (new_selected, new_display) = match view {
ViewMode::Month => {
let first_of_today = today.with_day(1).unwrap();
(today, first_of_today) // Select today, but display the month
},
ViewMode::Week => (today, today), // Select and display today
};
current_date.set(new_date);
let _ = LocalStorage::set("calendar_current_month", new_date.format("%Y-%m-%d").to_string());
selected_date.set(new_selected);
current_date.set(new_display);
let _ = LocalStorage::set("calendar_selected_date", new_selected.format("%Y-%m-%d").to_string());
})
};
@@ -101,17 +156,29 @@ pub fn Calendar(props: &CalendarProps) -> Html {
{
match props.view {
ViewMode::Month => html! {
<MonthView
current_month={*current_date}
today={today}
events={props.events.clone()}
on_event_click={props.on_event_click.clone()}
refreshing_event_uid={props.refreshing_event_uid.clone()}
user_info={props.user_info.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
/>
ViewMode::Month => {
let on_day_select = {
let selected_date = selected_date.clone();
Callback::from(move |date: NaiveDate| {
selected_date.set(date);
let _ = LocalStorage::set("calendar_selected_date", date.format("%Y-%m-%d").to_string());
})
};
html! {
<MonthView
current_month={*current_date}
today={today}
events={props.events.clone()}
on_event_click={props.on_event_click.clone()}
refreshing_event_uid={props.refreshing_event_uid.clone()}
user_info={props.user_info.clone()}
on_event_context_menu={props.on_event_context_menu.clone()}
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
selected_date={Some(*selected_date)}
on_day_select={Some(on_day_select)}
/>
}
},
ViewMode::Week => html! {
<WeekView

View File

@@ -14,14 +14,7 @@ pub struct CalendarHeaderProps {
#[function_component(CalendarHeader)]
pub fn calendar_header(props: &CalendarHeaderProps) -> Html {
let title = match props.view_mode {
ViewMode::Month => {
format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year())
},
ViewMode::Week => {
format!("Week of {} {}", get_month_name(props.current_date.month()), props.current_date.day())
},
};
let title = format!("{} {}", get_month_name(props.current_date.month()), props.current_date.year());
html! {
<div class="calendar-header">

View File

@@ -18,6 +18,10 @@ pub struct MonthViewProps {
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
#[prop_or_default]
pub selected_date: Option<NaiveDate>,
#[prop_or_default]
pub on_day_select: Option<Callback<NaiveDate>>,
}
#[function_component(MonthView)]
@@ -65,11 +69,27 @@ pub fn month_view(props: &MonthViewProps) -> Html {
(1..=days_in_month).map(|day| {
let date = props.current_month.with_day(day).unwrap();
let is_today = date == props.today;
let is_selected = props.selected_date == Some(date);
let day_events = props.events.get(&date).cloned().unwrap_or_default();
html! {
<div
class={classes!("calendar-day", if is_today { Some("today") } else { None })}
class={classes!(
"calendar-day",
if is_today { Some("today") } else { None },
if is_selected { Some("selected") } else { None }
)}
onclick={
if let Some(callback) = &props.on_day_select {
let callback = callback.clone();
Some(Callback::from(move |e: web_sys::MouseEvent| {
e.stop_propagation(); // Prevent other handlers
callback.emit(date);
}))
} else {
None
}
}
oncontextmenu={
if let Some(callback) = &props.on_calendar_context_menu {
let callback = callback.clone();

View File

@@ -147,7 +147,8 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let onclick = {
let on_event_click = props.on_event_click.clone();
let event = event.clone();
Callback::from(move |_: MouseEvent| {
Callback::from(move |e: MouseEvent| {
e.stop_propagation(); // Prevent calendar click events from also triggering
on_event_click.emit(event.clone());
})
};
@@ -158,6 +159,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let event = event.clone();
Some(Callback::from(move |e: web_sys::MouseEvent| {
e.prevent_default();
e.stop_propagation(); // Prevent calendar context menu from also triggering
callback.emit((e, event.clone()));
}))
} else {
@@ -170,7 +172,30 @@ pub fn week_view(props: &WeekViewProps) -> Html {
"All Day".to_string()
} else {
let local_start = event.start.with_timezone(&Local);
format!("{}", local_start.format("%I:%M %p"))
if let Some(end) = event.end {
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"))
}
};
Some(html! {