Implement comprehensive external calendar event deduplication and fixes
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m11s

- 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 <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-09-04 15:35:42 -04:00
parent c6eea88002
commit b16603b50b
4 changed files with 582 additions and 42 deletions

View File

@@ -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<NaiveDate> { None });
let event_edit_scope = use_state(|| -> Option<EditAction> { None });
let view_event_modal_open = use_state(|| false);
let view_event_modal_event = use_state(|| -> Option<VEvent> { None });
let refreshing_calendar_id = use_state(|| -> Option<i32> { None });
let _recurring_edit_modal_open = use_state(|| false);
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { 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);
}
})}
/>
<CalendarContextMenu
@@ -1484,6 +1530,18 @@ pub fn App() -> Html {
}
})}
/>
<EventModal
event={if *view_event_modal_open { (*view_event_modal_event).clone() } else { None }}
on_close={Callback::from({
let view_event_modal_open = view_event_modal_open.clone();
let view_event_modal_event = view_event_modal_event.clone();
move |_| {
view_event_modal_open.set(false);
view_event_modal_event.set(None);
}
})}
/>
</div>
</BrowserRouter>
}

View File

@@ -24,6 +24,7 @@ pub struct EventContextMenuProps {
pub event: Option<VEvent>,
pub on_edit: Callback<EditAction>,
pub on_delete: Callback<DeleteAction>,
pub on_view_details: Callback<VEvent>,
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! {
<div
ref={menu_ref}
@@ -116,7 +137,15 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
style={style}
>
{
if is_recurring {
if is_external {
// External calendar events are read-only - only show "View Details"
html! {
<div class="context-menu-item" onclick={create_view_details_callback}>
{"View Event Details"}
</div>
}
} else if is_recurring {
// Regular recurring events - show edit options
html! {
<>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
@@ -131,6 +160,7 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
</>
}
} else {
// Regular single events - show edit option
html! {
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
{"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! {
<>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
{"Delete This Event"}
</div>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
{"Delete Following Events"}
</div>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
{"Delete Entire Series"}
</div>
</>
}
} else {
html! {
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
{"Delete This Event"}
{"Delete Event"}
</div>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteFollowing)}>
{"Delete Following Events"}
</div>
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteSeries)}>
{"Delete Entire Series"}
</div>
</>
}
}
} else {
html! {
<div class="context-menu-item context-menu-delete" onclick={create_delete_callback(DeleteAction::DeleteThis)}>
{"Delete Event"}
</div>
}
// No delete options for external events
html! {}
}
}
</div>

View File

@@ -110,6 +110,7 @@ pub struct SidebarProps {
pub on_color_change: Callback<(String, String)>,
pub on_color_picker_toggle: Callback<String>,
pub available_colors: Vec<String>,
pub refreshing_calendar_id: Option<i32>,
pub on_calendar_context_menu: Callback<(MouseEvent, String)>,
pub on_calendar_visibility_toggle: Callback<String>,
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
}
}
</button>
</div>
</div>