Something went wrong. Try again.
Tea journaling on ATProto (alpha)
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293/** * Client-side data cache for Arabica * Caches beans, roasters, grinders, and brewers in localStorage * to reduce PDS round-trips on page loads. * * Data is fetched on page load (if stale) and after mutations. * No background polling — callers invalidate explicitly after CRUD ops. */
const CACHE_KEY = "arabica_data_cache";const CACHE_VERSION = 1;const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
// Module statelet isRefreshing = false;let listeners = [];
/** * Get the current cache from localStorage */function getCache() { try { const raw = localStorage.getItem(CACHE_KEY); if (!raw) return null;
const cache = JSON.parse(raw);
// Check version if (cache.version !== CACHE_VERSION) { localStorage.removeItem(CACHE_KEY); return null; }
return cache; } catch (e) { console.warn("Failed to read cache:", e); localStorage.removeItem(CACHE_KEY); return null; }}
/** * Save data to the cache * Stores the user DID alongside the data for validation */function setCache(data) { try { const cache = { version: CACHE_VERSION, timestamp: Date.now(), did: data.did || null, // Store user DID for cache validation data: data, }; localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); } catch (e) { console.warn("Failed to write cache:", e); }}
/** * Get the DID stored in the cache */function getCachedDID() { const cache = getCache(); return cache?.did || null;}
/** * Check if cache is valid (exists and not expired) */function isCacheValid() { const cache = getCache(); if (!cache) return false;
const age = Date.now() - cache.timestamp; return age < CACHE_TTL_MS;}
/** * Get the current user's DID from the page */function getCurrentUserDID() { return document.body?.dataset?.userDid || null;}
/** * Get cached data if available and valid for the current user */function getCachedData() { const cache = getCache(); if (!cache) return null;
// Validate that cached data belongs to the current user const currentDID = getCurrentUserDID(); const cachedDID = cache.did;
// If we have both DIDs and they don't match, cache is invalid if (currentDID && cachedDID && currentDID !== cachedDID) { console.log("Cache belongs to different user, invalidating"); invalidateCache(); return null; }
// Return data even if expired - caller can decide to refresh return cache.data;}
/** * Fetch fresh data from the API */async function fetchFreshData() { const headers = { "X-Page-Context": window.location.pathname, };
const response = await fetch("/api/data", { credentials: "same-origin", headers, });
if (!response.ok) { throw new Error(`Failed to fetch data: ${response.status}`); }
return await response.json();}
/** * Refresh the cache from the API * Returns the fresh data * @param {boolean} force - If true, always fetch fresh data even if a refresh is in progress */async function refreshCache(force = false) { if (isRefreshing) { // Wait for existing refresh to complete await new Promise((resolve) => { const checkInterval = setInterval(() => { if (!isRefreshing) { clearInterval(checkInterval); resolve(); } }, 100); });
// If not forcing, return the cached data from the completed refresh if (!force) { return getCachedData(); } // Otherwise, continue to do a new refresh with fresh data }
isRefreshing = true; try { const data = await fetchFreshData();
// Check if user changed (different DID) const cachedDID = getCachedDID(); if (cachedDID && data.did && cachedDID !== data.did) { console.log("User changed, clearing stale cache"); invalidateCache(); }
setCache(data); notifyListeners(data); return data; } finally { isRefreshing = false; }}
/** * Get data - returns cached if valid, otherwise fetches fresh * @param {boolean} forceRefresh - Force a refresh even if cache is valid */async function getData(forceRefresh = false) { if (!forceRefresh && isCacheValid()) { return getCachedData(); }
// Try to get cached data while refreshing const cached = getCachedData();
try { return await refreshCache(); } catch (e) { console.warn("Failed to refresh cache:", e); // Return stale data if available if (cached) { return cached; } throw e; }}
/** * Invalidate the cache (call after CRUD operations) */function invalidateCache() { localStorage.removeItem(CACHE_KEY);}
/** * Invalidate and immediately refresh the cache * Forces a fresh fetch even if a background refresh is in progress */async function invalidateAndRefresh() { invalidateCache(); return await refreshCache(true);}
/** * Register a listener for cache updates * @param {function} callback - Called with new data when cache is refreshed */function addListener(callback) { listeners.push(callback);}
/** * Remove a listener */function removeListener(callback) { listeners = listeners.filter((l) => l !== callback);}
/** * Notify all listeners of new data */function notifyListeners(data) { listeners.forEach((callback) => { try { callback(data); } catch (e) { console.warn("Cache listener error:", e); } });}
/** * Initialize the cache - call on page load for pages that need entity data. * Fetches once if cache is stale. No background polling. */async function init() { if (!isCacheValid()) { try { await refreshCache(); } catch (e) { console.warn("Initial cache load failed:", e); } }}
/** * Preload cache - useful to call after login */async function preload() { return await refreshCache();}
// Invalidate caches when entities are created/updated/deleteddocument.addEventListener("DOMContentLoaded", () => { document.body.addEventListener("refreshManage", () => { invalidateCache(); // Clear HTMX history cache so navigating to other pages fetches fresh data try { localStorage.removeItem("htmx-history-cache"); } catch (e) { // ignore } });
// Listen for entityDeleted events triggered by HX-Trigger response headers. // This covers all HTMX delete flows (brew list, entity tables, action bar, // comments) regardless of which page they originate from. document.body.addEventListener("entityDeleted", () => { invalidateCache(); });});
// Export as global for use in other scriptswindow.ArabicaCache = { getData, getCachedData, refreshCache, invalidateCache, invalidateAndRefresh, addListener, removeListener, init, preload, isCacheValid,};