From 5cc27f852b02446659e0ab85305916c9f5e5a5f0 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 6 Aug 2025 00:12:19 +0200 Subject: feat: Implement pull-to-refresh functionality across various components - Added `PullToRefresh` component to enable pull-to-refresh behavior in `StopList` and `Estimates` pages. - Integrated `usePullToRefresh` hook to manage pull-to-refresh state and actions. - Created `UpdateNotification` component to inform users of available updates from the service worker. - Enhanced service worker management with `ServiceWorkerManager` class for better update handling and caching strategies. - Updated CSS styles for new components and improved layout for better user experience. - Refactored API caching logic in service worker to handle multiple endpoints and dynamic cache expiration. - Added auto-refresh functionality for estimates data to keep information up-to-date. --- src/frontend/public/sw.js | 133 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 117 insertions(+), 16 deletions(-) (limited to 'src/frontend/public/sw.js') diff --git a/src/frontend/public/sw.js b/src/frontend/public/sw.js index 01d403a..ca826f6 100644 --- a/src/frontend/public/sw.js +++ b/src/frontend/public/sw.js @@ -1,52 +1,153 @@ -const API_CACHE_NAME = "api-cache-v1"; -const API_URL_PATTERN = /\/api\/(GetStopList)/; +const CACHE_VERSION = "v2"; +const API_CACHE_NAME = `api-cache-${CACHE_VERSION}`; +const STATIC_CACHE_NAME = `static-cache-${CACHE_VERSION}`; +const API_URL_PATTERN = /\/api\/(GetStopList|GetStopEstimates|GetStopTimetable)/; const API_MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours +const ESTIMATES_MIN_AGE = 15 * 1000; // 15 seconds minimum +const ESTIMATES_MAX_AGE = 30 * 1000; // 30 seconds maximum self.addEventListener("install", (event) => { - event.waitUntil(self.skipWaiting()); + console.log("SW: Installing new version"); + event.waitUntil( + caches.open(STATIC_CACHE_NAME).then((cache) => { + return cache.addAll([ + "/", + "/manifest.webmanifest", + "/stops.json", + "/favicon.ico", + "/logo-256.png", + "/logo-512.jpg" + ]); + }).then(() => { + return self.skipWaiting(); + }) + ); }); self.addEventListener("activate", (event) => { - event.waitUntil(self.clients.claim()); + console.log("SW: Activating new version"); + event.waitUntil( + Promise.all([ + // Clean up old caches + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== API_CACHE_NAME && cacheName !== STATIC_CACHE_NAME) { + console.log("SW: Deleting old cache:", cacheName); + return caches.delete(cacheName); + } + }) + ); + }), + // Take control of all clients + self.clients.claim(), + // Notify clients about the update + self.clients.matchAll().then((clients) => { + clients.forEach((client) => { + client.postMessage({ type: "SW_UPDATED" }); + }); + }) + ]) + ); }); self.addEventListener("fetch", async (event) => { const url = new URL(event.request.url); - if (event.request.method !== "GET" || !API_URL_PATTERN.test(url.pathname)) { + // Handle API requests with caching + if (event.request.method === "GET" && API_URL_PATTERN.test(url.pathname)) { + event.respondWith(handleApiRequest(event.request)); return; } - event.respondWith(apiCacheFirst(event.request)); + // Handle static assets + if (event.request.method === "GET") { + event.respondWith(handleStaticRequest(event.request)); + return; + } }); -async function apiCacheFirst(request) { +async function handleApiRequest(request) { + const url = new URL(request.url); + const isEstimates = url.pathname.includes("GetStopEstimates"); + // Random cache age between 15-30 seconds for estimates to prevent thundering herd + const maxAge = isEstimates + ? ESTIMATES_MIN_AGE + Math.random() * (ESTIMATES_MAX_AGE - ESTIMATES_MIN_AGE) + : API_MAX_AGE; + const cache = await caches.open(API_CACHE_NAME); const cachedResponse = await cache.match(request); if (cachedResponse) { - const age = - Date.now() - new Date(cachedResponse.headers.get("date")).getTime(); - if (age < API_MAX_AGE) { + const age = Date.now() - new Date(cachedResponse.headers.get("date")).getTime(); + if (age < maxAge) { console.debug(`SW: Cache HIT for ${request.url}`); return cachedResponse; } - // Cache is too old, fetch a fresh copy cache.delete(request); } try { const netResponse = await fetch(request); + + if (netResponse.ok) { + const responseToCache = netResponse.clone(); + cache.put(request, responseToCache); + console.debug(`SW: Cache MISS for ${request.url}`); + } - const responseToCache = netResponse.clone(); - - cache.put(request, responseToCache); - - console.debug(`SW: Cache MISS for ${request.url}`); + return netResponse; + } catch (error) { + // If network fails and we have a cached response (even if old), return it + if (cachedResponse) { + console.debug(`SW: Network failed, returning stale cache for ${request.url}`); + return cachedResponse; + } + throw error; + } +} +async function handleStaticRequest(request) { + const cache = await caches.open(STATIC_CACHE_NAME); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + try { + const netResponse = await fetch(request); + if (netResponse.ok) { + cache.put(request, netResponse.clone()); + } return netResponse; } catch (error) { + // Return a basic offline page for navigation requests + if (request.mode === 'navigate') { + return new Response('App is offline', { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'text/plain' } + }); + } throw error; } } + +// Handle messages from the main thread +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "SKIP_WAITING") { + self.skipWaiting(); + } + + if (event.data && event.data.type === "CLEAR_CACHE") { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => caches.delete(cacheName)) + ); + }) + ); + } +}); -- cgit v1.3