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

@@ -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<ExternalCalendar> { Vec::new() });
let external_calendar_events = use_state(|| -> Vec<VEvent> { Vec::new() });
let external_calendar_modal_open = use_state(|| false);
let refresh_interval = use_state(|| -> Option<Interval> { 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()}
/>
</div>
</BrowserRouter>

View File

@@ -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<ExternalCalendar>,
pub on_external_calendar_toggle: Callback<i32>,
pub on_external_calendar_delete: Callback<i32>,
pub on_external_calendar_refresh: Callback<i32>,
pub color_picker_open: Option<String>,
pub on_color_change: Callback<(String, String)>,
pub on_color_picker_toggle: Callback<String>,
@@ -277,7 +279,36 @@ pub fn sidebar(props: &SidebarProps) -> Html {
style={format!("background-color: {}", cal.color)}
/>
<span class="external-calendar-name">{&cal.name}</span>
<span class="external-calendar-indicator"></span>
<div class="external-calendar-actions">
{
if let Some(last_fetched) = cal.last_fetched {
let local_time = last_fetched.with_timezone(&chrono::Local);
html! {
<span class="last-updated" title={format!("Last updated: {}", local_time.format("%Y-%m-%d %H:%M"))}>
{format!("{}", local_time.format("%H:%M"))}
</span>
}
} else {
html! {
<span class="last-updated">{"Never"}</span>
}
}
}
<button
class="external-calendar-refresh-btn"
title="Refresh calendar"
onclick={{
let on_refresh = props.on_external_calendar_refresh.clone();
let cal_id = cal.id;
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
on_refresh.emit(cal_id);
})
}}
>
{"🔄"}
</button>
</div>
</div>
{
if *external_context_menu_open == Some(cal.id) {

View File

@@ -2089,6 +2089,7 @@ impl CalendarService {
#[derive(Deserialize)]
struct ExternalCalendarEventsResponse {
events: Vec<VEvent>,
last_fetched: chrono::DateTime<chrono::Utc>,
}
let response: ExternalCalendarEventsResponse = serde_wasm_bindgen::from_value(json)

View File

@@ -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;