Implement interactive calendar color picker

Backend enhancements:
- Add calendar_path field to CalendarEvent for color mapping
- Generate consistent colors for calendars using path-based hashing
- Update CalDAV parsing to associate events with their calendar paths
- Add 16-color palette with hash-based assignment algorithm

Frontend features:
- Interactive color picker with 4x4 grid of selectable colors
- Click color swatches to open dropdown with all available colors
- Instant color changes for both sidebar and calendar events
- Persistent color preferences using local storage
- Enhanced UX with hover effects and visual feedback

Styling improvements:
- Larger 16px color swatches for better clickability
- Professional color picker dropdown with smooth animations
- Dynamic event coloring based on calendar assignment
- Improved contrast with text shadows and borders
- Click-outside-to-close functionality for better UX

Users can now personalize their calendar organization with custom colors
that persist across sessions and immediately update throughout the app.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-28 20:14:56 -04:00
parent 5d519fd875
commit f94d057f81
7 changed files with 255 additions and 16 deletions

View File

@@ -61,6 +61,9 @@ pub struct CalendarEvent {
/// URL/href of this event on the CalDAV server /// URL/href of this event on the CalDAV server
pub href: Option<String>, pub href: Option<String>,
/// Calendar path this event belongs to
pub calendar_path: Option<String>,
} }
/// Event status enumeration /// Event status enumeration
@@ -182,11 +185,11 @@ impl CalDAVClient {
} }
let body = response.text().await.map_err(CalDAVError::RequestError)?; let body = response.text().await.map_err(CalDAVError::RequestError)?;
self.parse_calendar_response(&body) self.parse_calendar_response(&body, calendar_path)
} }
/// Parse CalDAV XML response containing calendar data /// Parse CalDAV XML response containing calendar data
fn parse_calendar_response(&self, xml_response: &str) -> Result<Vec<CalendarEvent>, CalDAVError> { fn parse_calendar_response(&self, xml_response: &str, calendar_path: &str) -> Result<Vec<CalendarEvent>, CalDAVError> {
let mut events = Vec::new(); let mut events = Vec::new();
// Extract calendar data from XML response // Extract calendar data from XML response
@@ -198,6 +201,7 @@ impl CalDAVClient {
for mut event in parsed_events { for mut event in parsed_events {
event.etag = calendar_data.etag.clone(); event.etag = calendar_data.etag.clone();
event.href = calendar_data.href.clone(); event.href = calendar_data.href.clone();
event.calendar_path = Some(calendar_path.to_string());
events.push(event); events.push(event);
} }
} }
@@ -377,6 +381,7 @@ impl CalDAVClient {
reminders: self.parse_alarms(&event)?, reminders: self.parse_alarms(&event)?,
etag: None, // Set by caller etag: None, // Set by caller
href: None, // Set by caller href: None, // Set by caller
calendar_path: None, // Set by caller
}) })
} }

View File

@@ -145,8 +145,9 @@ pub async fn get_user_info(
None None
} else { } else {
Some(CalendarInfo { Some(CalendarInfo {
path, path: path.clone(),
display_name, display_name,
color: generate_calendar_color(&path),
}) })
} }
}).collect(); }).collect();
@@ -158,6 +159,39 @@ pub async fn get_user_info(
})) }))
} }
// Helper function to generate a consistent color for a calendar based on its path
fn generate_calendar_color(path: &str) -> String {
// Predefined set of attractive, accessible colors for calendars
let colors = [
"#3B82F6", // Blue
"#10B981", // Emerald
"#F59E0B", // Amber
"#EF4444", // Red
"#8B5CF6", // Violet
"#06B6D4", // Cyan
"#84CC16", // Lime
"#F97316", // Orange
"#EC4899", // Pink
"#6366F1", // Indigo
"#14B8A6", // Teal
"#F3B806", // Yellow
"#8B5A2B", // Brown
"#6B7280", // Gray
"#DC2626", // Red-600
"#7C3AED", // Violet-600
];
// Create a simple hash from the path to ensure consistent color assignment
let mut hash: u32 = 0;
for byte in path.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
}
// Use the hash to select a color from our palette
let color_index = (hash as usize) % colors.len();
colors[color_index].to_string()
}
// Helper function to extract a readable calendar name from path // Helper function to extract a readable calendar name from path
fn extract_calendar_name(path: &str) -> String { fn extract_calendar_name(path: &str) -> String {
// Extract the last meaningful part of the path // Extract the last meaningful part of the path

View File

@@ -31,6 +31,7 @@ pub struct UserInfo {
pub struct CalendarInfo { pub struct CalendarInfo {
pub path: String, pub path: String,
pub display_name: String, pub display_name: String,
pub color: String,
} }
// Error handling // Error handling

View File

@@ -23,6 +23,15 @@ pub fn App() -> Html {
}); });
let user_info = use_state(|| -> Option<UserInfo> { None }); let user_info = use_state(|| -> Option<UserInfo> { None });
let color_picker_open = use_state(|| -> Option<String> { None }); // Store calendar path of open picker
// Available colors for calendar customization
let available_colors = [
"#3B82F6", "#10B981", "#F59E0B", "#EF4444",
"#8B5CF6", "#06B6D4", "#84CC16", "#F97316",
"#EC4899", "#6366F1", "#14B8A6", "#F3B806",
"#8B5A2B", "#6B7280", "#DC2626", "#7C3AED"
];
let on_login = { let on_login = {
let auth_token = auth_token.clone(); let auth_token = auth_token.clone();
@@ -67,7 +76,20 @@ pub fn App() -> Html {
if !password.is_empty() { if !password.is_empty() {
match calendar_service.fetch_user_info(&token, &password).await { match calendar_service.fetch_user_info(&token, &password).await {
Ok(info) => { Ok(mut info) => {
// Load saved colors from local storage
if let Ok(saved_colors_json) = LocalStorage::get::<String>("calendar_colors") {
if let Ok(saved_info) = serde_json::from_str::<UserInfo>(&saved_colors_json) {
// Update colors with saved preferences
for saved_cal in &saved_info.calendars {
for cal in &mut info.calendars {
if cal.path == saved_cal.path {
cal.color = saved_cal.color.clone();
}
}
}
}
}
user_info.set(Some(info)); user_info.set(Some(info));
} }
Err(err) => { Err(err) => {
@@ -84,9 +106,16 @@ pub fn App() -> Html {
}); });
} }
let on_outside_click = {
let color_picker_open = color_picker_open.clone();
Callback::from(move |_: MouseEvent| {
color_picker_open.set(None);
})
};
html! { html! {
<BrowserRouter> <BrowserRouter>
<div class="app"> <div class="app" onclick={on_outside_click}>
{ {
if auth_token.is_some() { if auth_token.is_some() {
html! { html! {
@@ -119,8 +148,71 @@ pub fn App() -> Html {
<ul> <ul>
{ {
info.calendars.iter().map(|cal| { info.calendars.iter().map(|cal| {
let cal_clone = cal.clone();
let color_picker_open_clone = color_picker_open.clone();
let on_color_click = {
let cal_path = cal.path.clone();
let color_picker_open = color_picker_open.clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
color_picker_open.set(Some(cal_path.clone()));
})
};
html! { html! {
<li key={cal.path.clone()}> <li key={cal.path.clone()}>
<span class="calendar-color"
style={format!("background-color: {}", cal.color)}
onclick={on_color_click}>
{
if color_picker_open_clone.as_ref() == Some(&cal.path) {
html! {
<div class="color-picker">
{
available_colors.iter().map(|&color| {
let color_str = color.to_string();
let cal_path = cal.path.clone();
let user_info_clone = user_info.clone();
let color_picker_open = color_picker_open.clone();
let on_color_select = Callback::from(move |_: MouseEvent| {
// Update the calendar color locally
if let Some(mut info) = (*user_info_clone).clone() {
for calendar in &mut info.calendars {
if calendar.path == cal_path {
calendar.color = color_str.clone();
break;
}
}
user_info_clone.set(Some(info.clone()));
// Save to local storage
if let Ok(json) = serde_json::to_string(&info) {
let _ = LocalStorage::set("calendar_colors", json);
}
}
color_picker_open.set(None);
});
let is_selected = cal.color == color;
let class_name = if is_selected { "color-option selected" } else { "color-option" };
html! {
<div class={class_name}
style={format!("background-color: {}", color)}
onclick={on_color_select}>
</div>
}
}).collect::<Html>()
}
</div>
}
} else {
html! {}
}
}
</span>
<span class="calendar-name">{&cal.display_name}</span> <span class="calendar-name">{&cal.display_name}</span>
</li> </li>
} }
@@ -162,7 +254,7 @@ pub fn App() -> Html {
} }
Route::Calendar => { Route::Calendar => {
if auth_token.is_some() { if auth_token.is_some() {
html! { <CalendarView /> } html! { <CalendarView user_info={(*user_info).clone()} /> }
} else { } else {
html! { <Redirect<Route> to={Route::Login}/> } html! { <Redirect<Route> to={Route::Login}/> }
} }
@@ -196,7 +288,7 @@ pub fn App() -> Html {
} }
Route::Calendar => { Route::Calendar => {
if auth_token.is_some() { if auth_token.is_some() {
html! { <CalendarView /> } html! { <CalendarView user_info={(*user_info).clone()} /> }
} else { } else {
html! { <Redirect<Route> to={Route::Login}/> } html! { <Redirect<Route> to={Route::Login}/> }
} }
@@ -212,8 +304,13 @@ pub fn App() -> Html {
} }
} }
#[derive(Properties, PartialEq)]
pub struct CalendarViewProps {
pub user_info: Option<UserInfo>,
}
#[function_component] #[function_component]
fn CalendarView() -> Html { fn CalendarView(props: &CalendarViewProps) -> Html {
let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new()); let events = use_state(|| HashMap::<NaiveDate, Vec<CalendarEvent>>::new());
let loading = use_state(|| true); let loading = use_state(|| true);
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
@@ -367,12 +464,12 @@ fn CalendarView() -> Html {
html! { html! {
<div class="calendar-error"> <div class="calendar-error">
<p>{format!("Error: {}", err)}</p> <p>{format!("Error: {}", err)}</p>
<Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} /> <Calendar events={HashMap::new()} on_event_click={dummy_callback} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} />
</div> </div>
} }
} else { } else {
html! { html! {
<Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} /> <Calendar events={(*events).clone()} on_event_click={on_event_click} refreshing_event_uid={(*refreshing_event).clone()} user_info={props.user_info.clone()} />
} }
} }
} }

View File

@@ -1,7 +1,7 @@
use yew::prelude::*; use yew::prelude::*;
use chrono::{Datelike, Local, NaiveDate, Duration, Weekday}; use chrono::{Datelike, Local, NaiveDate, Duration, Weekday};
use std::collections::HashMap; use std::collections::HashMap;
use crate::services::calendar_service::CalendarEvent; use crate::services::calendar_service::{CalendarEvent, UserInfo};
use crate::components::EventModal; use crate::components::EventModal;
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
@@ -11,6 +11,8 @@ pub struct CalendarProps {
pub on_event_click: Callback<CalendarEvent>, pub on_event_click: Callback<CalendarEvent>,
#[prop_or_default] #[prop_or_default]
pub refreshing_event_uid: Option<String>, pub refreshing_event_uid: Option<String>,
#[prop_or_default]
pub user_info: Option<UserInfo>,
} }
#[function_component] #[function_component]
@@ -20,6 +22,21 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let selected_day = use_state(|| today); let selected_day = use_state(|| today);
let selected_event = use_state(|| None::<CalendarEvent>); let selected_event = use_state(|| None::<CalendarEvent>);
// Helper function to get calendar color for an event
let get_event_color = |event: &CalendarEvent| -> String {
if let Some(user_info) = &props.user_info {
if let Some(calendar_path) = &event.calendar_path {
// Find the calendar that matches this event's path
if let Some(calendar) = user_info.calendars.iter()
.find(|cal| &cal.path == calendar_path) {
return calendar.color.clone();
}
}
}
// Default color if no match found
"#3B82F6".to_string()
};
let first_day_of_month = current_month.with_day(1).unwrap(); let first_day_of_month = current_month.with_day(1).unwrap();
let days_in_month = get_days_in_month(*current_month); let days_in_month = get_days_in_month(*current_month);
let first_weekday = first_day_of_month.weekday(); let first_weekday = first_day_of_month.weekday();
@@ -118,10 +135,12 @@ pub fn Calendar(props: &CalendarProps) -> Html {
let title = event.get_title(); let title = event.get_title();
let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid); let is_refreshing = props.refreshing_event_uid.as_ref() == Some(&event.uid);
let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" }; let class_name = if is_refreshing { "event-box refreshing" } else { "event-box" };
let event_color = get_event_color(&event);
html! { html! {
<div class={class_name} <div class={class_name}
title={title.clone()} title={title.clone()}
onclick={event_click}> onclick={event_click}
style={format!("background-color: {}", event_color)}>
{ {
if is_refreshing { if is_refreshing {
"🔄 Refreshing...".to_string() "🔄 Refreshing...".to_string()

View File

@@ -36,6 +36,7 @@ pub struct UserInfo {
pub struct CalendarInfo { pub struct CalendarInfo {
pub path: String, pub path: String,
pub display_name: String, pub display_name: String,
pub color: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -59,6 +60,7 @@ pub struct CalendarEvent {
pub reminders: Vec<EventReminder>, pub reminders: Vec<EventReminder>,
pub etag: Option<String>, pub etag: Option<String>,
pub href: Option<String>, pub href: Option<String>,
pub calendar_path: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]

View File

@@ -136,6 +136,7 @@ body {
border-radius: 6px; border-radius: 6px;
transition: all 0.2s; transition: all 0.2s;
cursor: pointer; cursor: pointer;
gap: 0.75rem;
} }
.calendar-list li:hover { .calendar-list li:hover {
@@ -143,10 +144,72 @@ body {
transform: translateX(2px); transform: translateX(2px);
} }
.calendar-color {
width: 16px;
height: 16px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.3);
transition: all 0.2s;
cursor: pointer;
position: relative;
}
.calendar-list li:hover .calendar-color {
border-color: rgba(255,255,255,0.6);
transform: scale(1.1);
}
.color-picker {
position: absolute;
top: 100%;
left: 0;
background: white;
border-radius: 8px;
padding: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
min-width: 120px;
border: 1px solid rgba(0,0,0,0.1);
}
.color-option {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid rgba(0,0,0,0.1);
cursor: pointer;
transition: all 0.2s;
}
.color-option:hover {
transform: scale(1.2);
border-color: rgba(0,0,0,0.3);
}
.color-option.selected {
border-color: #333;
border-width: 3px;
transform: scale(1.1);
}
.color-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.calendar-name { .calendar-name {
color: white; color: white;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 500; font-weight: 500;
flex: 1;
} }
.no-calendars { .no-calendars {
@@ -459,7 +522,7 @@ body {
} }
.event-box { .event-box {
background: #2196f3; /* Background color will be set inline via style attribute */
color: white; color: white;
padding: 2px 4px; padding: 2px 4px;
border-radius: 3px; border-radius: 3px;
@@ -469,16 +532,34 @@ body {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: all 0.2s ease;
border: 1px solid rgba(255,255,255,0.2);
text-shadow: 0 1px 1px rgba(0,0,0,0.3);
font-weight: 500;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
position: relative;
} }
.event-box:hover { .event-box:hover {
background: #1976d2; filter: brightness(1.15);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
} }
.event-box.refreshing { .event-box.refreshing {
background: #ff9800;
animation: pulse 1.5s ease-in-out infinite alternate; animation: pulse 1.5s ease-in-out infinite alternate;
border-color: #ff9800;
}
.event-box.refreshing::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 152, 0, 0.3);
pointer-events: none;
} }
@keyframes pulse { @keyframes pulse {