From 28b3946e865ebfacc8099b4f2b9cf0a86256c880 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Wed, 3 Sep 2025 22:06:32 -0400 Subject: [PATCH] Add intelligent caching and auto-refresh for external calendars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements server-side database caching with 5-minute refresh intervals to dramatically improve external calendar performance while keeping data fresh. Backend changes: - New external_calendar_cache table with ICS data storage - Smart cache logic: serves from cache if < 5min old, fetches fresh otherwise - Cache repository methods for get/update/clear operations - Migration script for cache table creation Frontend changes: - 5-minute auto-refresh interval for background updates - Manual refresh button (🔄) for each external calendar - Last updated timestamps showing when each calendar was refreshed - Centralized refresh function with proper cleanup on logout Performance improvements: - Initial load: instant from cache vs slow external HTTP requests - Background updates: fresh data without user waiting - Reduced external API calls: only when cache is stale - Scalable: handles multiple external calendars efficiently 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- ...7_create_external_calendar_cache_table.sql | 14 ++ backend/src/db.rs | 60 ++++++- backend/src/handlers/ics_fetcher.rs | 72 ++++++-- frontend/src/app.rs | 169 ++++++++++-------- frontend/src/components/sidebar.rs | 33 +++- frontend/src/services/calendar_service.rs | 1 + frontend/styles.css | 31 ++++ 7 files changed, 289 insertions(+), 91 deletions(-) create mode 100644 backend/migrations/007_create_external_calendar_cache_table.sql diff --git a/backend/migrations/007_create_external_calendar_cache_table.sql b/backend/migrations/007_create_external_calendar_cache_table.sql new file mode 100644 index 0000000..0b7aa10 --- /dev/null +++ b/backend/migrations/007_create_external_calendar_cache_table.sql @@ -0,0 +1,14 @@ +-- Create external calendar cache table for storing ICS data +CREATE TABLE external_calendar_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_calendar_id INTEGER NOT NULL, + ics_data TEXT NOT NULL, + cached_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + etag TEXT, + FOREIGN KEY (external_calendar_id) REFERENCES external_calendars(id) ON DELETE CASCADE, + UNIQUE(external_calendar_id) +); + +-- Index for faster lookups +CREATE INDEX idx_external_calendar_cache_calendar_id ON external_calendar_cache(external_calendar_id); +CREATE INDEX idx_external_calendar_cache_cached_at ON external_calendar_cache(cached_at); \ No newline at end of file diff --git a/backend/src/db.rs b/backend/src/db.rs index b91a19a..95f5973 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use sqlx::sqlite::{SqlitePool, SqlitePoolOptions}; use sqlx::{FromRow, Result}; @@ -427,4 +427,62 @@ impl<'a> ExternalCalendarRepository<'a> { Ok(()) } + + /// Get cached ICS data for an external calendar + pub async fn get_cached_data(&self, external_calendar_id: i32) -> Result)>> { + let result = sqlx::query_as::<_, (String, DateTime)>( + "SELECT ics_data, cached_at FROM external_calendar_cache WHERE external_calendar_id = ?", + ) + .bind(external_calendar_id) + .fetch_optional(self.db.pool()) + .await?; + + Ok(result) + } + + /// Update cache with new ICS data + pub async fn update_cache(&self, external_calendar_id: i32, ics_data: &str, etag: Option<&str>) -> Result<()> { + sqlx::query( + "INSERT INTO external_calendar_cache (external_calendar_id, ics_data, etag, cached_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(external_calendar_id) DO UPDATE SET + ics_data = excluded.ics_data, + etag = excluded.etag, + cached_at = excluded.cached_at", + ) + .bind(external_calendar_id) + .bind(ics_data) + .bind(etag) + .bind(Utc::now()) + .execute(self.db.pool()) + .await?; + + Ok(()) + } + + /// Check if cache is stale (older than max_age_minutes) + pub async fn is_cache_stale(&self, external_calendar_id: i32, max_age_minutes: i64) -> Result { + let cutoff_time = Utc::now() - Duration::minutes(max_age_minutes); + + let result = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM external_calendar_cache + WHERE external_calendar_id = ? AND cached_at > ?", + ) + .bind(external_calendar_id) + .bind(cutoff_time) + .fetch_one(self.db.pool()) + .await?; + + Ok(result == 0) + } + + /// Clear cache for an external calendar + pub async fn clear_cache(&self, external_calendar_id: i32) -> Result<()> { + sqlx::query("DELETE FROM external_calendar_cache WHERE external_calendar_id = ?") + .bind(external_calendar_id) + .execute(self.db.pool()) + .await?; + + Ok(()) + } } \ No newline at end of file diff --git a/backend/src/handlers/ics_fetcher.rs b/backend/src/handlers/ics_fetcher.rs index e7465ec..eb62cef 100644 --- a/backend/src/handlers/ics_fetcher.rs +++ b/backend/src/handlers/ics_fetcher.rs @@ -53,35 +53,69 @@ pub async fn fetch_external_calendar_events( })); } - // Fetch ICS content from URL - let client = Client::new(); - let response = client - .get(&calendar.url) - .send() - .await - .map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?; + // Check cache first + let cache_max_age_minutes = 5; + let mut ics_content = String::new(); + let mut last_fetched = Utc::now(); + let mut fetched_from_cache = false; - if !response.status().is_success() { - return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status()))); + // Try to get from cache if not stale + match repo.is_cache_stale(id, cache_max_age_minutes).await { + Ok(is_stale) => { + if !is_stale { + // Cache is fresh, use it + if let Ok(Some((cached_data, cached_at))) = repo.get_cached_data(id).await { + ics_content = cached_data; + last_fetched = cached_at; + fetched_from_cache = true; + } + } + } + Err(_) => { + // If cache check fails, proceed to fetch from URL + } } - let ics_content = response - .text() - .await - .map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?; + // If not fetched from cache, get from external URL + if !fetched_from_cache { + let client = Client::new(); + let response = client + .get(&calendar.url) + .send() + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch calendar: {}", e)))?; + + if !response.status().is_success() { + return Err(ApiError::Internal(format!("Calendar server returned: {}", response.status()))); + } + + ics_content = response + .text() + .await + .map_err(|e| ApiError::Internal(format!("Failed to read calendar content: {}", e)))?; + + // Store in cache for future requests + let etag = None; // TODO: Extract ETag from response headers if available + if let Err(e) = repo.update_cache(id, &ics_content, etag).await { + // Log error but don't fail the request + eprintln!("Failed to update cache for calendar {}: {}", id, e); + } + + // Update last_fetched timestamp + if let Err(e) = repo.update_last_fetched(id, &user.id).await { + eprintln!("Failed to update last_fetched for calendar {}: {}", id, e); + } + + last_fetched = Utc::now(); + } // Parse ICS content let events = parse_ics_content(&ics_content) .map_err(|e| ApiError::BadRequest(format!("Failed to parse calendar: {}", e)))?; - // Update last_fetched timestamp - repo.update_last_fetched(id, &user.id) - .await - .map_err(|e| ApiError::Database(format!("Failed to update last_fetched: {}", e)))?; - Ok(Json(ExternalCalendarEventsResponse { events, - last_fetched: Utc::now(), + last_fetched, })) } diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 923da7b..dfbbe7b 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -8,6 +8,7 @@ use crate::models::ical::VEvent; use crate::services::{calendar_service::{UserInfo, ExternalCalendar}, CalendarService}; use chrono::NaiveDate; use gloo_storage::{LocalStorage, Storage}; +use gloo_timers::callback::Interval; use wasm_bindgen::JsCast; use web_sys::MouseEvent; use yew::prelude::*; @@ -79,6 +80,7 @@ pub fn App() -> Html { let external_calendars = use_state(|| -> Vec { Vec::new() }); let external_calendar_events = use_state(|| -> Vec { Vec::new() }); let external_calendar_modal_open = use_state(|| false); + let refresh_interval = use_state(|| -> Option { None }); // Calendar view state - load from localStorage if available let current_view = use_state(|| { @@ -307,51 +309,77 @@ pub fn App() -> Html { }); } - // Load external calendars when auth token is available + // Function to refresh external calendars + let refresh_external_calendars = { + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + Callback::from(move |_| { + let external_calendars = external_calendars.clone(); + let external_calendar_events = external_calendar_events.clone(); + + wasm_bindgen_futures::spawn_local(async move { + // Load external calendars + match CalendarService::get_external_calendars().await { + Ok(calendars) => { + external_calendars.set(calendars.clone()); + + // Load events for visible external calendars + let mut all_events = Vec::new(); + for calendar in calendars { + if calendar.is_visible { + if let Ok(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await { + // Set calendar_path for color matching + for event in &mut events { + event.calendar_path = Some(format!("external_{}", calendar.id)); + } + all_events.extend(events); + } + } + } + external_calendar_events.set(all_events); + } + Err(err) => { + web_sys::console::log_1( + &format!("Failed to load external calendars: {}", err).into(), + ); + } + } + }); + }) + }; + + // Load external calendars when auth token is available and set up auto-refresh { let auth_token = auth_token.clone(); + let refresh_external_calendars = refresh_external_calendars.clone(); + let refresh_interval = refresh_interval.clone(); let external_calendars = external_calendars.clone(); let external_calendar_events = external_calendar_events.clone(); use_effect_with((*auth_token).clone(), move |token| { - if token.is_some() { - let external_calendars = external_calendars.clone(); - let external_calendar_events = external_calendar_events.clone(); - - wasm_bindgen_futures::spawn_local(async move { - // Load external calendars - match CalendarService::get_external_calendars().await { - Ok(calendars) => { - external_calendars.set(calendars.clone()); - - // Load events for visible external calendars - let mut all_events = Vec::new(); - for calendar in calendars { - if calendar.is_visible { - if let Ok(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await { - // Set calendar_path for color matching - for event in &mut events { - event.calendar_path = Some(format!("external_{}", calendar.id)); - } - all_events.extend(events); - } - } - } - external_calendar_events.set(all_events); - } - Err(err) => { - web_sys::console::log_1( - &format!("Failed to load external calendars: {}", err).into(), - ); - } - } + if let Some(_) = token { + // Initial load + refresh_external_calendars.emit(()); + + // Set up 5-minute refresh interval + let refresh_external_calendars = refresh_external_calendars.clone(); + let interval = Interval::new(5 * 60 * 1000, move || { + refresh_external_calendars.emit(()); }); + refresh_interval.set(Some(interval)); } else { + // Clear data and interval when logged out external_calendars.set(Vec::new()); external_calendar_events.set(Vec::new()); + refresh_interval.set(None); } - || () + // Cleanup function + let refresh_interval = refresh_interval.clone(); + move || { + // Clear interval on cleanup + refresh_interval.set(None); + } }); } @@ -1011,7 +1039,7 @@ pub fn App() -> Html { external_calendars.set(calendars.clone()); - // Reload events for all visible external calendars + // Reload events for all visible external calendars let mut all_events = Vec::new(); for cal in calendars { if cal.is_visible { @@ -1062,6 +1090,42 @@ pub fn App() -> Html { }); } })} + on_external_calendar_refresh={Callback::from({ + let external_calendar_events = external_calendar_events.clone(); + let external_calendars = external_calendars.clone(); + move |id: i32| { + let external_calendar_events = external_calendar_events.clone(); + let external_calendars = external_calendars.clone(); + wasm_bindgen_futures::spawn_local(async move { + // 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 + } + }); + // 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); + } + } + }); + } + })} color_picker_open={(*color_picker_open).clone()} on_color_change={on_color_change} on_color_picker_toggle={on_color_picker_toggle} @@ -1355,42 +1419,7 @@ pub fn App() -> Html { let external_calendar_modal_open = external_calendar_modal_open.clone(); move |_| external_calendar_modal_open.set(false) })} - on_success={Callback::from({ - let external_calendars = external_calendars.clone(); - let external_calendar_events = external_calendar_events.clone(); - move |_| { - // Reload external calendars - let external_calendars = external_calendars.clone(); - let external_calendar_events = external_calendar_events.clone(); - wasm_bindgen_futures::spawn_local(async move { - match CalendarService::get_external_calendars().await { - Ok(calendars) => { - external_calendars.set(calendars.clone()); - - // Load events for visible external calendars - let mut all_events = Vec::new(); - for calendar in calendars { - if calendar.is_visible { - if let Ok(mut events) = CalendarService::fetch_external_calendar_events(calendar.id).await { - // Set calendar_path for color matching - for event in &mut events { - event.calendar_path = Some(format!("external_{}", calendar.id)); - } - all_events.extend(events); - } - } - } - external_calendar_events.set(all_events); - } - Err(err) => { - web_sys::console::log_1( - &format!("Failed to reload external calendars: {}", err).into(), - ); - } - } - }); - } - })} + on_success={refresh_external_calendars.clone()} /> diff --git a/frontend/src/components/sidebar.rs b/frontend/src/components/sidebar.rs index 3708e2c..854351b 100644 --- a/frontend/src/components/sidebar.rs +++ b/frontend/src/components/sidebar.rs @@ -1,5 +1,6 @@ use crate::components::CalendarListItem; use crate::services::calendar_service::{UserInfo, ExternalCalendar}; +use chrono::{DateTime, Local, TimeZone, Utc}; use web_sys::HtmlSelectElement; use yew::prelude::*; use yew_router::prelude::*; @@ -105,6 +106,7 @@ pub struct SidebarProps { pub external_calendars: Vec, pub on_external_calendar_toggle: Callback, pub on_external_calendar_delete: Callback, + pub on_external_calendar_refresh: Callback, pub color_picker_open: Option, pub on_color_change: Callback<(String, String)>, pub on_color_picker_toggle: Callback, @@ -277,7 +279,36 @@ pub fn sidebar(props: &SidebarProps) -> Html { style={format!("background-color: {}", cal.color)} /> {&cal.name} - +
+ { + if let Some(last_fetched) = cal.last_fetched { + let local_time = last_fetched.with_timezone(&chrono::Local); + html! { + + {format!("{}", local_time.format("%H:%M"))} + + } + } else { + html! { + {"Never"} + } + } + } + +
{ if *external_context_menu_open == Some(cal.id) { diff --git a/frontend/src/services/calendar_service.rs b/frontend/src/services/calendar_service.rs index 60b87ad..79462f4 100644 --- a/frontend/src/services/calendar_service.rs +++ b/frontend/src/services/calendar_service.rs @@ -2089,6 +2089,7 @@ impl CalendarService { #[derive(Deserialize)] struct ExternalCalendarEventsResponse { events: Vec, + last_fetched: chrono::DateTime, } let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json) diff --git a/frontend/styles.css b/frontend/styles.css index 249bd0c..78a4dd0 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -3816,6 +3816,37 @@ body { white-space: nowrap; } +.external-calendar-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.last-updated { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.6); + opacity: 0.8; +} + +.external-calendar-refresh-btn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 0.8rem; + padding: 2px 4px; + border-radius: 3px; + transition: all 0.2s ease; + line-height: 1; +} + +.external-calendar-refresh-btn:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); + transform: rotate(180deg); +} + .external-calendar-indicator { font-size: 0.8rem; opacity: 0.7;