diff --git a/TODO.adoc b/TODO.adoc index 5a2ae6c..1fd7dd7 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -16,7 +16,7 @@ independence from connectivity. * [ ] Import Park4Night / iOverlander / Freecampsites data * [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates * [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline -** [ ] Cache the radar tile *images* too, not just the forecast text — needs a Service Worker (own bigger task: registration, cache strategy, invalidation); app has HTTPS everywhere (reverse proxy, incl. the in-van local server) so it's viable, just not done yet +** [x] Cache the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb == NPS visual style diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..0c8bd4d --- /dev/null +++ b/public/sw.js @@ -0,0 +1,63 @@ +// Caches RainViewer radar tile images so the last-seen frames stay visible +// once the network is actually gone — nothing else here. No app-shell +// precaching: the app itself is served by the same local server that's +// already the offline resource in the van, so there's no "app unreachable +// but should still work" scenario to solve, unlike the radar tiles (a +// third-party CDN). +const CACHE_VERSION = "v1"; +const RADAR_CACHE = `radar-tiles-${CACHE_VERSION}`; + +// ponytail: a rough cap, not real LRU/expiry — trims oldest-by-insertion via +// Cache API iteration order (stable in practice, not spec-guaranteed). +// Revisit with timestamp-based expiry if storage actually becomes a +// problem; 400 tiles is at most a few MB either way. +const MAX_RADAR_TILES = 400; + +function isRadarTile(url) { + return url.hostname.endsWith("rainviewer.com") && url.pathname.includes("/v2/radar/"); +} + +self.addEventListener("install", (event) => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== RADAR_CACHE).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + if (!isRadarTile(url)) return; + + event.respondWith(networkFallingBackToCache(event.request)); +}); + +// Network first (frame ids rotate as new radar sweeps come in, so a cached +// tile can go stale) — cache is only the answer once the network actually +// fails, not a shortcut when it's merely slow. +async function networkFallingBackToCache(request) { + const cache = await caches.open(RADAR_CACHE); + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + trimRadarCache(cache); + } + return response; + } catch (err) { + const cached = await cache.match(request); + if (cached) return cached; + throw err; + } +} + +async function trimRadarCache(cache) { + const keys = await cache.keys(); + if (keys.length <= MAX_RADAR_TILES) return; + await Promise.all(keys.slice(0, keys.length - MAX_RADAR_TILES).map((key) => cache.delete(key))); +} diff --git a/views/layout.erb b/views/layout.erb index b8e184c..13f16e6 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -9,6 +9,16 @@
<%= yield %> + <%# Vendored libs above don't change between deploys, so they're left