diff --git a/backend/static/index.html b/backend/static/index.html index ce53a42..3f4acbb 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -95,7 +95,18 @@ -
Kein Internet — du bist offline
+ @@ -456,30 +467,33 @@ @@ -508,6 +522,35 @@ }); navigator.serviceWorker.addEventListener('message', e => { + if (e.data?.type === 'QUEUE_PROCESSED') { + const { synced, failed, total } = e.data; + if (total === 0) return; + if (synced > 0 && window.UI?.toast) { + window.UI.toast.success( + synced === 1 + ? '1 offline gespeicherter Eintrag synchronisiert' + : `${synced} offline gespeicherte Einträge synchronisiert` + ); + // Aktuelle Seite neu laden + window.App?.state && window.pages?.[window.App.state.page]?.module?.refresh?.(); + } + if (failed > 0 && window.UI?.toast) { + window.UI.toast.warning(`${failed} Eintrag${failed > 1 ? 'e' : ''} noch nicht synchronisiert — kein Netz`); + } + return; + } + if (e.data?.type === 'QUEUE_COUNT') { + const badge = document.getElementById('offline-queue-badge'); + if (badge) { + if (e.data.count > 0) { + badge.textContent = e.data.count; + badge.style.display = ''; + } else { + badge.style.display = 'none'; + } + } + return; + } if (e.data?.type === 'CHECK_NEARBY_ALERTS') { window.App?._checkNearbyAlerts?.(); } diff --git a/backend/static/js/api.js b/backend/static/js/api.js index e4623f0..4abebef 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -58,6 +58,14 @@ const API = (() => { throw new APIError(message, response.status, isOffline ? 'network' : data?.code); } + // SW hat die Anfrage in die Offline-Queue eingereiht + if (data?._queued) { + if (typeof UI !== 'undefined' && UI.toast) { + UI.toast.info('Offline gespeichert — wird automatisch synchronisiert'); + } + return data; + } + return data; } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 65730e5..f93ae86 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '485'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '486'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/sw.js b/backend/static/sw.js index e48c785..befb521 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,9 +3,10 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v508'; +const CACHE_VERSION = 'by-v509'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten +const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache // index.html wird NICHT pre-gecacht (immer Network-First) const STATIC_ASSETS = [ @@ -23,6 +24,112 @@ const STATIC_ASSETS = [ '/icons/icon-192.png', ]; +// ---------------------------------------------------------- +// HILFSFUNKTIONEN +// ---------------------------------------------------------- + +// Fetch mit Timeout (verhindert Hängen bei schlechtem Netz) +function _fetchTimeout(request, ms = 8000) { + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), ms); + return fetch(request, { signal: ctrl.signal }).finally(() => clearTimeout(tid)); +} + +// IndexedDB Write-Queue (in SW-Kontext verfügbar) +function _queueDb() { + return new Promise((res, rej) => { + const req = indexedDB.open('by-offline-queue', 1); + req.onupgradeneeded = e => { + const db = e.target.result; + if (!db.objectStoreNames.contains('q')) + db.createObjectStore('q', { keyPath: 'id', autoIncrement: true }); + }; + req.onsuccess = e => res(e.target.result); + req.onerror = e => rej(e.target.error); + }); +} +async function _queueAdd(entry) { + const db = await _queueDb(); + return new Promise((res, rej) => { + const tx = db.transaction('q', 'readwrite'); + const r = tx.objectStore('q').add(entry); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); +} +async function _queueAll() { + const db = await _queueDb(); + return new Promise((res, rej) => { + const tx = db.transaction('q', 'readonly'); + const r = tx.objectStore('q').getAll(); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); +} +async function _queueDel(id) { + const db = await _queueDb(); + return new Promise((res, rej) => { + const tx = db.transaction('q', 'readwrite'); + const r = tx.objectStore('q').delete(id); + r.onsuccess = () => res(); + r.onerror = () => rej(r.error); + }); +} + +// Queue abarbeiten (bei Background-Sync oder manuellem Trigger) +async function _processQueue() { + const items = await _queueAll(); + let synced = 0, failed = 0; + for (const entry of items) { + try { + const resp = await fetch(entry.url, { + method: entry.method, + headers: entry.headers, + body: entry.body || undefined, + }); + // 2xx und 4xx aus Queue entfernen (4xx = Userfehler, nicht Netzwerk) + if (resp.status < 500) { await _queueDel(entry.id); synced++; } + else { failed++; } + } catch { failed++; } + } + // Clients benachrichtigen + const clients = await self.clients.matchAll({ includeUncontrolled: true }); + clients.forEach(c => c.postMessage({ type: 'QUEUE_PROCESSED', synced, failed, total: items.length })); + return { synced, failed }; +} + +// Kann dieser Endpunkt offline in die Queue? +const _QUEUEABLE = [ + { re: /^\/api\/dogs\/\d+\/diary$/, methods: ['POST'] }, + { re: /^\/api\/dogs\/\d+\/diary\/\d+$/, methods: ['PATCH'] }, + { re: /^\/api\/dogs\/\d+\/health$/, methods: ['POST'] }, + { re: /^\/api\/dogs\/\d+\/diary\/\d+\/media$/, methods: ['POST'], skipBody: true }, + { re: /^\/api\/training\/sessions$/, methods: ['POST'] }, + { re: /^\/api\/training\/progress$/, methods: ['POST'] }, + { re: /^\/api\/poison$/, methods: ['POST'] }, +]; +function _isQueueable(pathname, method) { + return _QUEUEABLE.some(q => q.methods.includes(method) && q.re.test(pathname)); +} +function _isMediaUpload(request) { + return (request.headers.get('Content-Type') || '').includes('multipart'); +} + +// Welche GET-API-Endpoints sollen gecacht werden? +const _CACHEABLE_GET = [ + /^\/api\/dogs(\/\d+)?$/, + /^\/api\/dogs\/\d+\/welcome-dashboard/, + /^\/api\/dogs\/\d+\/diary/, + /^\/api\/dogs\/\d+\/health/, + /^\/api\/training\/exercises/, + /^\/api\/training\/progress/, + /^\/api\/wiki\/rassen/, + /^\/api\/dogs\/\d+\/diary\/stats/, +]; +function _isCacheableGet(pathname) { + return _CACHEABLE_GET.some(re => re.test(pathname)); +} + // ---------------------------------------------------------- // INSTALL — App Shell cachen // ---------------------------------------------------------- @@ -30,19 +137,22 @@ self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_STATIC) .then(cache => cache.addAll(STATIC_ASSETS)) + .then(() => caches.open(CACHE_API).then(c => + fetch('/api/training/exercises').then(r => { if (r.ok) c.put('/api/training/exercises', r); }).catch(() => {}) + )) .then(() => self.skipWaiting()) ); }); // ---------------------------------------------------------- -// ACTIVATE — alte Caches aufräumen (CACHE_TILES behalten) +// ACTIVATE — alte Caches aufräumen (CACHE_TILES + CACHE_API behalten) // ---------------------------------------------------------- self.addEventListener('activate', event => { event.waitUntil( caches.keys() .then(keys => Promise.all( keys - .filter(k => k !== CACHE_STATIC && k !== CACHE_TILES) + .filter(k => k !== CACHE_STATIC && k !== CACHE_TILES && k !== CACHE_API) .map(k => caches.delete(k)) )) .then(() => self.clients.claim()) @@ -50,15 +160,71 @@ self.addEventListener('activate', event => { }); // ---------------------------------------------------------- -// FETCH — Network-First für HTML, Cache-First für Assets, Network für API +// FETCH — Network-First für HTML, Cache-First für Assets, API mit Offline-Logik // ---------------------------------------------------------- self.addEventListener('fetch', event => { const url = new URL(event.request.url); - // API-Calls: immer Network, kein Cache + // API-Calls mit Timeout, Caching und Write-Queue if (url.pathname.startsWith('/api/')) { + const method = event.request.method; + + // GET: Network-First mit Timeout, Cache-Fallback, im Hintergrund aktualisieren + if (method === 'GET' && _isCacheableGet(url.pathname)) { + event.respondWith((async () => { + const cached = await caches.match(event.request); + const networkPromise = _fetchTimeout(event.request.clone(), 8000) + .then(resp => { + if (resp.ok) caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); + return resp; + }) + .catch(() => null); + // Stale-While-Revalidate: sofort aus Cache, im Hintergrund holen + if (cached) { + networkPromise.catch(() => {}); // fire and forget + return cached; + } + const fresh = await networkPromise; + if (fresh) return fresh; + return new Response(JSON.stringify({ detail: 'Offline — keine Daten im Cache.' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }); + })()); + return; + } + + // Mutationen (POST/PATCH/PUT/DELETE): mit Timeout, bei Offline → Queue + if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) { + event.respondWith((async () => { + try { + return await _fetchTimeout(event.request.clone(), 10000); + } catch { + if (!_isQueueable(url.pathname, method)) + return new Response(JSON.stringify({ detail: 'Offline — keine Verbindung.' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }); + if (_isMediaUpload(event.request)) + return new Response(JSON.stringify({ detail: 'Medien-Upload offline nicht möglich.' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }); + // In Queue speichern + let body = ''; + try { body = await event.request.text(); } catch {} + await _queueAdd({ + url: url.pathname + url.search, + method, + headers: { 'Content-Type': event.request.headers.get('Content-Type') || 'application/json' }, + body, + createdAt: new Date().toISOString(), + }); + try { await self.registration.sync.register('by-offline-mutations'); } catch {} + return new Response(JSON.stringify({ _queued: true, detail: 'Offline gespeichert — wird synchronisiert wenn Verbindung besteht.' }), + { status: 202, headers: { 'Content-Type': 'application/json' } }); + } + })()); + return; + } + + // Alle anderen API-Calls (GET ohne Caching): mit Timeout event.respondWith( - fetch(event.request).catch(() => + _fetchTimeout(event.request, 8000).catch(() => new Response(JSON.stringify({ detail: 'Offline — keine Verbindung.' }), { status: 503, headers: { 'Content-Type': 'application/json' } }) ) @@ -134,9 +300,29 @@ self.addEventListener('fetch', event => { }); // ---------------------------------------------------------- -// MESSAGE — Tile-Vorausladung (Offline-Speicherung) +// BACKGROUND SYNC — Queue abarbeiten wenn Verbindung wiederhergestellt +// ---------------------------------------------------------- +self.addEventListener('sync', event => { + if (event.tag === 'by-offline-mutations') { + event.waitUntil(_processQueue()); + } +}); + +// ---------------------------------------------------------- +// MESSAGE — Tile-Vorausladung (Offline-Speicherung) + Queue-Steuerung // ---------------------------------------------------------- self.addEventListener('message', event => { + if (event.data?.type === 'PROCESS_QUEUE') { + event.waitUntil(_processQueue()); + return; + } + if (event.data?.type === 'QUEUE_COUNT') { + _queueAll().then(items => + event.source?.postMessage({ type: 'QUEUE_COUNT', count: items.length }) + ); + return; + } + if (event.data?.type !== 'CACHE_TILES') return; const urls = event.data.urls || [];