Add intelligent caching and auto-refresh for external calendars

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 <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-09-03 22:06:32 -04:00
parent 6a01a75cce
commit 28b3946e86
7 changed files with 289 additions and 91 deletions

View File

@@ -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,
}))
}