From b16603b50b8c048507a5b19067671fd7ef11d3ed Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Thu, 4 Sep 2025 15:35:42 -0400 Subject: [PATCH] Implement comprehensive external calendar event deduplication and fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add UID-based deduplication to prefer recurring events over single events with same UID - Implement RRULE-generated instance detection to filter duplicate occurrences - Add title normalization for case-insensitive matching and consolidation - Fix external calendar refresh button with proper error handling and loading states - Update context menu for external events to show only "View Event Details" option - Add comprehensive multi-pass deduplication: UID → title consolidation → RRULE filtering This resolves issues where Outlook calendars showed duplicate events with same UID but different RRULE states (e.g., "Dragster Stand Up" appearing both as recurring and single events). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- backend/src/handlers/ics_fetcher.rs | 440 +++++++++++++++++- frontend/src/app.rs | 104 ++++- frontend/src/components/event_context_menu.rs | 70 ++- frontend/src/components/sidebar.rs | 10 +- 4 files changed, 582 insertions(+), 42 deletions(-) diff --git a/backend/src/handlers/ics_fetcher.rs b/backend/src/handlers/ics_fetcher.rs index 1800e14..edaa0d1 100644 --- a/backend/src/handlers/ics_fetcher.rs +++ b/backend/src/handlers/ics_fetcher.rs @@ -2,7 +2,7 @@ use axum::{ extract::{Path, State}, response::Json, }; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Utc, Datelike}; use ical::parser::ical::component::IcalEvent; use reqwest::Client; use serde::Serialize; @@ -138,6 +138,9 @@ fn parse_ics_content(ics_content: &str) -> Result, Box) -> Option) -> Vec { + use std::collections::HashMap; + + let original_count = events.len(); + + // First pass: Group by UID and prefer recurring events over single events with same UID + let mut uid_groups: HashMap> = HashMap::new(); + + for event in events.drain(..) { + // Debug logging to understand what's happening + println!("🔍 Event: '{}' at {} (RRULE: {}) - UID: {}", + event.summary.as_ref().unwrap_or(&"No Title".to_string()), + event.dtstart.format("%Y-%m-%d %H:%M"), + if event.rrule.is_some() { "Yes" } else { "No" }, + event.uid + ); + + uid_groups.entry(event.uid.clone()).or_insert_with(Vec::new).push(event); + } + + let mut uid_deduplicated_events = Vec::new(); + + for (uid, mut events_with_uid) in uid_groups.drain() { + if events_with_uid.len() == 1 { + // Only one event with this UID, keep it + uid_deduplicated_events.push(events_with_uid.into_iter().next().unwrap()); + } else { + // Multiple events with same UID - prefer recurring over non-recurring + println!("🔍 Found {} events with UID '{}'", events_with_uid.len(), uid); + + // Sort by preference: recurring events first, then by completeness + events_with_uid.sort_by(|a, b| { + let a_has_rrule = a.rrule.is_some(); + let b_has_rrule = b.rrule.is_some(); + + match (a_has_rrule, b_has_rrule) { + (true, false) => std::cmp::Ordering::Less, // a (recurring) comes first + (false, true) => std::cmp::Ordering::Greater, // b (recurring) comes first + _ => { + // Both same type (both recurring or both single) - compare by completeness + event_completeness_score(b).cmp(&event_completeness_score(a)) + } + } + }); + + // Keep the first (preferred) event + let preferred_event = events_with_uid.into_iter().next().unwrap(); + println!("🔄 UID dedup: Keeping '{}' (RRULE: {})", + preferred_event.summary.as_ref().unwrap_or(&"No Title".to_string()), + if preferred_event.rrule.is_some() { "Yes" } else { "No" } + ); + uid_deduplicated_events.push(preferred_event); + } + } + + // Second pass: separate recurring and single events from UID-deduplicated set + let mut recurring_events = Vec::new(); + let mut single_events = Vec::new(); + + for event in uid_deduplicated_events.drain(..) { + if event.rrule.is_some() { + recurring_events.push(event); + } else { + single_events.push(event); + } + } + + // Third pass: Group recurring events by normalized title and consolidate different RRULE patterns + let mut title_groups: HashMap> = HashMap::new(); + + for event in recurring_events.drain(..) { + let title = normalize_title(event.summary.as_ref().unwrap_or(&String::new())); + title_groups.entry(title).or_insert_with(Vec::new).push(event); + } + + let mut deduplicated_recurring = Vec::new(); + + for (title, events_with_title) in title_groups.drain() { + if events_with_title.len() == 1 { + // Single event with this title, keep as-is + deduplicated_recurring.push(events_with_title.into_iter().next().unwrap()); + } else { + // Multiple events with same title - consolidate or deduplicate + println!("🔍 Found {} events with title '{}'", events_with_title.len(), title); + + // Check if these are actually different recurring patterns for the same logical event + let consolidated = consolidate_same_title_events(events_with_title); + deduplicated_recurring.extend(consolidated); + } + } + + // Fourth pass: filter single events, removing those that would be generated by recurring events + let mut deduplicated_single = Vec::new(); + let mut seen_single: HashMap = HashMap::new(); + + for event in single_events.drain(..) { + let normalized_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new())); + let dedup_key = format!( + "{}|{}", + event.dtstart.format("%Y%m%dT%H%M%S"), + normalized_title + ); + + // First check for exact duplicates among single events + if let Some(&existing_index) = seen_single.get(&dedup_key) { + let existing_event: &VEvent = &deduplicated_single[existing_index]; + let current_completeness = event_completeness_score(&event); + let existing_completeness = event_completeness_score(existing_event); + + if current_completeness > existing_completeness { + println!("🔄 Replacing single event: Keeping '{}' over '{}'", + event.summary.as_ref().unwrap_or(&"No Title".to_string()), + existing_event.summary.as_ref().unwrap_or(&"No Title".to_string()) + ); + deduplicated_single[existing_index] = event; + } else { + println!("🚫 Discarding duplicate single event: Keeping existing '{}'", + existing_event.summary.as_ref().unwrap_or(&"No Title".to_string()) + ); + } + continue; + } + + // Check if this single event would be generated by any recurring event + let is_rrule_generated = deduplicated_recurring.iter().any(|recurring_event| { + // Check if this single event matches the recurring event's pattern (use normalized titles) + let single_title = normalize_title(event.summary.as_ref().unwrap_or(&String::new())); + let recurring_title = normalize_title(recurring_event.summary.as_ref().unwrap_or(&String::new())); + + if single_title != recurring_title { + return false; // Different events + } + + // Check if this single event would be generated by the recurring event + would_event_be_generated_by_rrule(recurring_event, &event) + }); + + if is_rrule_generated { + println!("🚫 Discarding RRULE-generated instance: '{}' at {} would be generated by recurring event", + event.summary.as_ref().unwrap_or(&"No Title".to_string()), + event.dtstart.format("%Y-%m-%d %H:%M") + ); + } else { + // This is a unique single event + seen_single.insert(dedup_key, deduplicated_single.len()); + deduplicated_single.push(event); + } + } + + // Combine recurring and single events + let mut result = deduplicated_recurring; + result.extend(deduplicated_single); + + println!("📊 Deduplication complete: {} -> {} events ({} recurring, {} single)", + original_count, result.len(), + result.iter().filter(|e| e.rrule.is_some()).count(), + result.iter().filter(|e| e.rrule.is_none()).count() + ); + + result +} + +/// Normalize title for grouping similar events +fn normalize_title(title: &str) -> String { + title.trim() + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || c.is_whitespace()) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// Consolidate events with the same title but potentially different RRULE patterns +/// This handles cases where calendar systems provide multiple recurring definitions +/// for the same logical meeting (e.g., one RRULE for Tuesdays, another for Thursdays) +fn consolidate_same_title_events(events: Vec) -> Vec { + if events.is_empty() { + return events; + } + + // Log the RRULEs we're working with + for event in &events { + if let Some(rrule) = &event.rrule { + println!("🔍 RRULE for '{}': {}", + event.summary.as_ref().unwrap_or(&"No Title".to_string()), + rrule + ); + } + } + + // Check if all events have similar time patterns and could be consolidated + let first_event = &events[0]; + let base_time = first_event.dtstart.time(); + let base_duration = if let Some(end) = first_event.dtend { + Some(end.signed_duration_since(first_event.dtstart)) + } else { + None + }; + + // Check if all events have the same time and duration + let can_consolidate = events.iter().all(|event| { + let same_time = event.dtstart.time() == base_time; + let same_duration = match (event.dtend, base_duration) { + (Some(end), Some(base_dur)) => end.signed_duration_since(event.dtstart) == base_dur, + (None, None) => true, + _ => false, + }; + same_time && same_duration + }); + + if !can_consolidate { + println!("🚫 Cannot consolidate events - different times or durations"); + // Just deduplicate exact duplicates + return deduplicate_exact_recurring_events(events); + } + + // Try to detect if these are complementary weekly patterns + let weekly_events: Vec<_> = events.iter() + .filter(|e| e.rrule.as_ref().map_or(false, |r| r.contains("FREQ=WEEKLY"))) + .collect(); + + if weekly_events.len() >= 2 && weekly_events.len() == events.len() { + // All events are weekly - try to consolidate into a single multi-day weekly pattern + if let Some(consolidated) = consolidate_weekly_patterns(&events) { + println!("✅ Successfully consolidated {} weekly patterns into one", events.len()); + return vec![consolidated]; + } + } + + // If we can't consolidate, just deduplicate exact matches and keep the most complete one + println!("🚫 Cannot consolidate - keeping most complete event"); + let deduplicated = deduplicate_exact_recurring_events(events); + + // If we still have multiple events, keep only the most complete one + if deduplicated.len() > 1 { + let best_event = deduplicated.into_iter() + .max_by_key(|e| event_completeness_score(e)) + .unwrap(); + + println!("🎯 Kept most complete event: '{}'", + best_event.summary.as_ref().unwrap_or(&"No Title".to_string()) + ); + vec![best_event] + } else { + deduplicated + } +} + +/// Deduplicate exact recurring event matches +fn deduplicate_exact_recurring_events(events: Vec) -> Vec { + use std::collections::HashMap; + + let mut seen: HashMap = HashMap::new(); + let mut deduplicated = Vec::new(); + + for event in events { + let dedup_key = format!( + "{}|{}|{}", + event.dtstart.format("%Y%m%dT%H%M%S"), + event.summary.as_ref().unwrap_or(&String::new()), + event.rrule.as_ref().unwrap_or(&String::new()) + ); + + if let Some(&existing_index) = seen.get(&dedup_key) { + let existing_event: &VEvent = &deduplicated[existing_index]; + let current_completeness = event_completeness_score(&event); + let existing_completeness = event_completeness_score(existing_event); + + if current_completeness > existing_completeness { + println!("🔄 Replacing exact duplicate: Keeping more complete event"); + deduplicated[existing_index] = event; + } + } else { + seen.insert(dedup_key, deduplicated.len()); + deduplicated.push(event); + } + } + + deduplicated +} + +/// Attempt to consolidate multiple weekly RRULE patterns into a single pattern +fn consolidate_weekly_patterns(events: &[VEvent]) -> Option { + use std::collections::HashSet; + + let mut all_days = HashSet::new(); + let mut base_event = None; + + for event in events { + let Some(rrule) = &event.rrule else { continue; }; + + if !rrule.contains("FREQ=WEEKLY") { + continue; + } + + // Extract BYDAY if present + if let Some(byday_part) = rrule.split(';').find(|part| part.starts_with("BYDAY=")) { + let days_str = byday_part.strip_prefix("BYDAY=").unwrap_or(""); + for day in days_str.split(',') { + all_days.insert(day.trim().to_string()); + } + } else { + // If no BYDAY specified, use the weekday from the start date + let weekday = match event.dtstart.weekday() { + chrono::Weekday::Mon => "MO", + chrono::Weekday::Tue => "TU", + chrono::Weekday::Wed => "WE", + chrono::Weekday::Thu => "TH", + chrono::Weekday::Fri => "FR", + chrono::Weekday::Sat => "SA", + chrono::Weekday::Sun => "SU", + }; + all_days.insert(weekday.to_string()); + } + + // Use the first event as the base (we already know they have same time/duration) + if base_event.is_none() { + base_event = Some(event.clone()); + } + } + + if all_days.is_empty() || base_event.is_none() { + return None; + } + + // Create consolidated RRULE + let mut base = base_event.unwrap(); + let days_list: Vec<_> = all_days.into_iter().collect(); + let byday_str = days_list.join(","); + + // Build new RRULE with consolidated BYDAY + let new_rrule = if let Some(existing_rrule) = &base.rrule { + // Remove existing BYDAY and add our consolidated one + let parts: Vec<_> = existing_rrule.split(';') + .filter(|part| !part.starts_with("BYDAY=")) + .collect(); + format!("{};BYDAY={}", parts.join(";"), byday_str) + } else { + format!("FREQ=WEEKLY;BYDAY={}", byday_str) + }; + + base.rrule = Some(new_rrule); + + println!("🔗 Consolidated weekly pattern: BYDAY={}", byday_str); + Some(base) +} + +/// Check if a single event would be generated by a recurring event's RRULE +fn would_event_be_generated_by_rrule(recurring_event: &VEvent, single_event: &VEvent) -> bool { + let Some(rrule) = &recurring_event.rrule else { + return false; // No RRULE to check against + }; + + // Parse basic RRULE patterns + if rrule.contains("FREQ=DAILY") { + // Daily recurrence + let interval = extract_interval_from_rrule(rrule).unwrap_or(1); + let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days(); + + if days_diff >= 0 && days_diff % interval as i64 == 0 { + // Check if times match (allowing for timezone differences within same day) + let recurring_time = recurring_event.dtstart.time(); + let single_time = single_event.dtstart.time(); + return recurring_time == single_time; + } + } else if rrule.contains("FREQ=WEEKLY") { + // Weekly recurrence + let interval = extract_interval_from_rrule(rrule).unwrap_or(1); + let days_diff = (single_event.dtstart.date_naive() - recurring_event.dtstart.date_naive()).num_days(); + + // First check if it's the same day of week and time + let recurring_weekday = recurring_event.dtstart.weekday(); + let single_weekday = single_event.dtstart.weekday(); + let recurring_time = recurring_event.dtstart.time(); + let single_time = single_event.dtstart.time(); + + if recurring_weekday == single_weekday && recurring_time == single_time && days_diff >= 0 { + // Calculate how many weeks apart they are + let weeks_diff = days_diff / 7; + // Check if this falls on an interval boundary + return weeks_diff % interval as i64 == 0; + } + } else if rrule.contains("FREQ=MONTHLY") { + // Monthly recurrence - simplified check + let months_diff = (single_event.dtstart.year() - recurring_event.dtstart.year()) * 12 + + (single_event.dtstart.month() as i32 - recurring_event.dtstart.month() as i32); + + if months_diff >= 0 { + let interval = extract_interval_from_rrule(rrule).unwrap_or(1) as i32; + if months_diff % interval == 0 { + // Same day of month and time + return recurring_event.dtstart.day() == single_event.dtstart.day() + && recurring_event.dtstart.time() == single_event.dtstart.time(); + } + } + } + + false +} + +/// Extract INTERVAL value from RRULE string, defaulting to 1 if not found +fn extract_interval_from_rrule(rrule: &str) -> Option { + for part in rrule.split(';') { + if part.starts_with("INTERVAL=") { + return part.strip_prefix("INTERVAL=") + .and_then(|s| s.parse().ok()); + } + } + Some(1) // Default interval is 1 if not specified +} + +/// Calculate a completeness score for an event based on how many optional fields are filled +fn event_completeness_score(event: &VEvent) -> u32 { + let mut score = 0; + + if event.summary.is_some() { score += 1; } + if event.description.is_some() { score += 1; } + if event.location.is_some() { score += 1; } + if event.dtend.is_some() { score += 1; } + if event.rrule.is_some() { score += 1; } + if !event.categories.is_empty() { score += 1; } + if !event.alarms.is_empty() { score += 1; } + if event.organizer.is_some() { score += 1; } + if !event.attendees.is_empty() { score += 1; } + + score } \ No newline at end of file diff --git a/frontend/src/app.rs b/frontend/src/app.rs index edbf1f2..f9ce1f4 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -1,6 +1,6 @@ use crate::components::{ CalendarContextMenu, ContextMenu, CreateCalendarModal, CreateEventModal, DeleteAction, - EditAction, EventContextMenu, EventCreationData, ExternalCalendarModal, RouteHandler, + EditAction, EventContextMenu, EventModal, EventCreationData, ExternalCalendarModal, RouteHandler, Sidebar, Theme, ViewMode, }; use crate::components::sidebar::{Style}; @@ -72,6 +72,9 @@ pub fn App() -> Html { let create_event_modal_open = use_state(|| false); let selected_date_for_event = use_state(|| -> Option { None }); let event_edit_scope = use_state(|| -> Option { None }); + let view_event_modal_open = use_state(|| false); + let view_event_modal_event = use_state(|| -> Option { None }); + let refreshing_calendar_id = use_state(|| -> Option { None }); let _recurring_edit_modal_open = use_state(|| false); let _recurring_edit_event = use_state(|| -> Option { None }); let _recurring_edit_data = use_state(|| -> Option { None }); @@ -1112,34 +1115,65 @@ pub fn App() -> Html { on_external_calendar_refresh={Callback::from({ let external_calendar_events = external_calendar_events.clone(); let external_calendars = external_calendars.clone(); + let refreshing_calendar_id = refreshing_calendar_id.clone(); move |id: i32| { let external_calendar_events = external_calendar_events.clone(); let external_calendars = external_calendars.clone(); + let refreshing_calendar_id = refreshing_calendar_id.clone(); + + // Set loading state + refreshing_calendar_id.set(Some(id)); + wasm_bindgen_futures::spawn_local(async move { + web_sys::console::log_1(&format!("🔄 Refreshing external calendar {}", id).into()); + // Force refresh of this specific calendar - if let Ok(mut events) = CalendarService::fetch_external_calendar_events(id).await { - // Set calendar_path for color matching - for event in &mut events { - event.calendar_path = Some(format!("external_{}", id)); - } - - // Update events for this calendar - let mut all_events = (*external_calendar_events).clone(); - // Remove old events from this calendar - all_events.retain(|e| { - if let Some(ref calendar_path) = e.calendar_path { - calendar_path != &format!("external_{}", id) - } else { - true + match CalendarService::fetch_external_calendar_events(id).await { + Ok(mut events) => { + web_sys::console::log_1(&format!("✅ Successfully refreshed calendar {} with {} events", id, events.len()).into()); + + // Set calendar_path for color matching + for event in &mut events { + event.calendar_path = Some(format!("external_{}", id)); } - }); - // Add new events - all_events.extend(events); - external_calendar_events.set(all_events); - - // Update the last_fetched timestamp in calendars list - if let Ok(calendars) = CalendarService::get_external_calendars().await { - external_calendars.set(calendars); + + // Update events for this calendar + let mut all_events = (*external_calendar_events).clone(); + // Remove old events from this calendar + all_events.retain(|e| { + if let Some(ref calendar_path) = e.calendar_path { + calendar_path != &format!("external_{}", id) + } else { + true + } + }); + // Add new events + all_events.extend(events); + external_calendar_events.set(all_events); + + // Update the last_fetched timestamp in calendars list + match CalendarService::get_external_calendars().await { + Ok(calendars) => { + external_calendars.set(calendars); + web_sys::console::log_1(&"✅ Calendar list updated with new timestamps".into()); + } + Err(err) => { + web_sys::console::error_1(&format!("⚠️ Failed to update calendar list: {}", err).into()); + } + } + + // Clear loading state on success + refreshing_calendar_id.set(None); + } + Err(err) => { + web_sys::console::error_1(&format!("❌ Failed to refresh calendar {}: {}", id, err).into()); + // Show error to user + if let Some(window) = web_sys::window() { + let _ = window.alert_with_message(&format!("Failed to refresh calendar: {}", err)); + } + + // Clear loading state on error + refreshing_calendar_id.set(None); } } }); @@ -1149,6 +1183,7 @@ pub fn App() -> Html { on_color_change={on_color_change} on_color_picker_toggle={on_color_picker_toggle} available_colors={(*available_colors).clone()} + refreshing_calendar_id={(*refreshing_calendar_id).clone()} on_calendar_context_menu={on_calendar_context_menu} on_calendar_visibility_toggle={Callback::from({ let user_info = user_info.clone(); @@ -1397,6 +1432,17 @@ pub fn App() -> Html { } } })} + on_view_details={Callback::from({ + let event_context_menu_open = event_context_menu_open.clone(); + let view_event_modal_open = view_event_modal_open.clone(); + let view_event_modal_event = view_event_modal_event.clone(); + move |event: VEvent| { + // Set the event for viewing (read-only mode) + view_event_modal_event.set(Some(event)); + event_context_menu_open.set(false); + view_event_modal_open.set(true); + } + })} /> Html { } })} /> + + } diff --git a/frontend/src/components/event_context_menu.rs b/frontend/src/components/event_context_menu.rs index e54680a..130c108 100644 --- a/frontend/src/components/event_context_menu.rs +++ b/frontend/src/components/event_context_menu.rs @@ -24,6 +24,7 @@ pub struct EventContextMenuProps { pub event: Option, pub on_edit: Callback, pub on_delete: Callback, + pub on_view_details: Callback, pub on_close: Callback<()>, } @@ -90,6 +91,14 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { .as_ref() .map(|event| event.rrule.is_some()) .unwrap_or(false); + + // Check if the event is from an external calendar (read-only) + let is_external = props + .event + .as_ref() + .and_then(|event| event.calendar_path.as_ref()) + .map(|path| path.starts_with("external_")) + .unwrap_or(false); let create_edit_callback = |action: EditAction| { let on_edit = props.on_edit.clone(); @@ -109,6 +118,18 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { }) }; + let create_view_details_callback = { + let on_view_details = props.on_view_details.clone(); + let on_close = props.on_close.clone(); + let event = props.event.clone(); + Callback::from(move |_: MouseEvent| { + if let Some(event) = &event { + on_view_details.emit(event.clone()); + } + on_close.emit(()); + }) + }; + html! {
Html { style={style} > { - if is_recurring { + if is_external { + // External calendar events are read-only - only show "View Details" + html! { +
+ {"View Event Details"} +
+ } + } else if is_recurring { + // Regular recurring events - show edit options html! { <>
@@ -131,6 +160,7 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { } } else { + // Regular single events - show edit option html! {
{"Edit Event"} @@ -139,26 +169,32 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html { } } { - if is_recurring { - html! { - <> + if !is_external { + // Only show delete options for non-external events + if is_recurring { + html! { + <> +
+ {"Delete This Event"} +
+
+ {"Delete Following Events"} +
+
+ {"Delete Entire Series"} +
+ + } + } else { + html! {
- {"Delete This Event"} + {"Delete Event"}
-
- {"Delete Following Events"} -
-
- {"Delete Entire Series"} -
- + } } } else { - html! { -
- {"Delete Event"} -
- } + // No delete options for external events + html! {} } }
diff --git a/frontend/src/components/sidebar.rs b/frontend/src/components/sidebar.rs index d8defc5..3ab9b9d 100644 --- a/frontend/src/components/sidebar.rs +++ b/frontend/src/components/sidebar.rs @@ -110,6 +110,7 @@ pub struct SidebarProps { pub on_color_change: Callback<(String, String)>, pub on_color_picker_toggle: Callback, pub available_colors: Vec, + pub refreshing_calendar_id: Option, pub on_calendar_context_menu: Callback<(MouseEvent, String)>, pub on_calendar_visibility_toggle: Callback, pub current_view: ViewMode, @@ -304,8 +305,15 @@ pub fn sidebar(props: &SidebarProps) -> Html { on_refresh.emit(cal_id); }) }} + disabled={props.refreshing_calendar_id == Some(cal.id)} > - {"🔄"} + { + if props.refreshing_calendar_id == Some(cal.id) { + "⏳" // Loading spinner + } else { + "🔄" // Normal refresh icon + } + }