/* ============================================================ BAN YARO — Service Worker Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab const VER = '1292'; const CACHE_VERSION = `by-v${VER}`; 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 // Prioritäts-Seiten: werden nach Install im Hintergrund gecacht (nicht blockierend) // Diese Seiten MÜSSEN offline funktionieren — auch wenn der User sie noch nie geöffnet hat. const PRIORITY_PAGES = [ '/js/pages/diary.js', '/js/pages/health.js', '/js/pages/map.js', '/js/pages/walks.js', '/js/pages/erste-hilfe.js', '/js/pages/notes.js', '/js/pages/expenses.js', '/js/pages/routes.js', '/js/pages/poison.js', '/js/pages/lost.js', // GL-Karten-Stack: ohne diese Dateien ist die Karte offline TOT, obwohl // Kacheln/Marker in IndexedDB liegen (Gerätetest 2026-06-08). '/js/vendor/maplibre-gl.js', '/js/vendor/maplibre-gl.css', '/js/vendor/pmtiles.js', '/js/map-gl-style.js', '/js/map-offline.js', '/js/map-gl-markers.js', '/js/map-gl-mini.js', // Yaro-Navi-Sounds — müssen auch im Funkloch bellen (zusammen ~40 KB) '/sounds/wuff.mp3', '/sounds/klaeffen.mp3', ]; // index.html wird NICHT pre-gecacht (immer Network-First) const STATIC_ASSETS = [ `/css/design-system.css?v=${VER}`, `/css/layout.css?v=${VER}`, `/css/components.css?v=${VER}`, '/icons/phosphor.svg', `/js/api.js?v=${VER}`, `/js/ui.js?v=${VER}`, `/js/app.js?v=${VER}`, `/js/worlds.js?v=${VER}`, `/js/offline-indicator.js?v=${VER}`, `/js/boot-early.js?v=${VER}`, `/js/boot.js?v=${VER}`, '/js/leaflet.markercluster.js', '/css/MarkerCluster.css', '/css/MarkerCluster.Default.css', '/manifest.json', '/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'] }, { re: /^\/api\/lost\/report$/, methods: ['POST'] }, { re: /^\/api\/walks$/, 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'); } // Tile-Cache LRU: Eviction wenn zu viele Tiles drin sind // cache.keys() liefert Insertion-Order — daher löschen wir vom Anfang. // Bei jedem Tile-Add: trimTileCache() im Hintergrund (kein await vor respondWith). const _TILE_MAX_ENTRIES = 500; let _tileTrimRunning = false; async function trimTileCache(maxEntries = _TILE_MAX_ENTRIES) { if (_tileTrimRunning) return; // gleichzeitige Trims verhindern _tileTrimRunning = true; try { const cache = await caches.open(CACHE_TILES); const keys = await cache.keys(); if (keys.length > maxEntries) { const toDelete = keys.slice(0, keys.length - maxEntries); await Promise.all(toDelete.map(k => cache.delete(k))); } } catch {} finally { _tileTrimRunning = false; } } // 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(?!\/ki-)/, // ki-berichte + ki-zusammenfassung nie cachen /^\/api\/training\/exercises/, /^\/api\/training\/progress/, /^\/api\/training\/plan-progress/, /^\/api\/wiki\/rassen/, /^\/api\/dogs\/\d+\/diary\/stats/, /^\/api\/routes/, /^\/api\/places/, /^\/api\/breeder\/map-markers/, /^\/api\/gassi-zeiten/, /^\/api\/poison/, /^\/api\/walks/, /^\/api\/lost/, /^\/api\/expenses/, /^\/api\/notes/, // Drei Welten — offline-fähig /^\/api\/streak\/\d+/, /^\/api\/forum\/threads/, /^\/api\/weather$/, /^\/api\/passport\/\d+$/, ]; function _isCacheableGet(pathname) { return _CACHEABLE_GET.some(re => re.test(pathname)); } // Cache-TTL: stabile Daten länger, dynamische kürzer const _STABLE_GET = [/^\/api\/training\/exercises/, /^\/api\/wiki\/rassen/]; const _SHORT_GET = [/^\/api\/training\/progress/, /^\/api\/training\/plan-progress/]; const _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde const _TTL_SHORT = 30 * 1000; // 30 Sekunden (Fortschritte, hund-spezifisch) const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart) function _cacheTTL(pathname) { if (_STABLE_GET.some(re => re.test(pathname))) return _TTL_STABLE; if (_SHORT_GET.some(re => re.test(pathname))) return _TTL_SHORT; return _TTL_DEFAULT; } function _cacheStale(pathname) { const ts = _cacheTs.get(pathname); return !ts || (Date.now() - ts) > _cacheTTL(pathname); } function _cacheMark(pathname) { _cacheTs.set(pathname, Date.now()); } // ---------------------------------------------------------- // INSTALL — App Shell cachen // ---------------------------------------------------------- self.addEventListener('install', event => { self.skipWaiting(); event.waitUntil( caches.open(CACHE_STATIC) .then(cache => cache.addAll(STATIC_ASSETS)) .then(() => { // Prioritäts-Seiten nicht-blockierend im Hintergrund cachen caches.open(CACHE_STATIC).then(cache => { PRIORITY_PAGES.forEach(page => fetch(page).then(r => { if (r.ok) cache.put(page, r.clone()); }).catch(() => {}) ); }); // Training-Exercises vorwärmen return caches.open(CACHE_API).then(c => fetch('/api/training/exercises').then(r => { if (r.ok) c.put('/api/training/exercises', r); }).catch(() => {}) ); }) ); }); // ---------------------------------------------------------- // ACTIVATE — alte Caches aufräumen (CACHE_TILES + CACHE_API behalten) // ---------------------------------------------------------- // Carry-Over VOR dem Löschen: Einträge der alten Static-Caches in den neuen übernehmen, // die dort noch fehlen. Sonst reißt ein Versions-Update ein Offline-Loch — alte Caches // weg, neuer erst teilweise befüllt (Hintergrund-Precache) → Karte offline tot, obwohl // die Pfote „ready" zeigt (Gerätetest 2026-06-08). Network-First ersetzt die übernommenen // Einträge online sukzessive durch frische. async function _migrateStaticCaches() { const names = (await caches.keys()).filter(k => /^by-v\d+-static$/.test(k) && k !== CACHE_STATIC); if (!names.length) return; const fresh = await caches.open(CACHE_STATIC); const have = new Set((await fresh.keys()).map(r => r.url.split('?')[0])); for (const name of names) { const oldCache = await caches.open(name); for (const req of await oldCache.keys()) { const bare = req.url.split('?')[0]; if (have.has(bare)) continue; // neue Version schon vorhanden const resp = await oldCache.match(req); if (resp) { // Unter NACKTEM Key (ohne ?v=) ablegen: der Online-Refresh (PRIORITY_PAGES / // Network-First) überschreibt genau diesen Key — keine Versions-Duplikate. try { await fresh.put(bare, resp); have.add(bare); } catch (e) {} } } } } self.addEventListener('activate', event => { event.waitUntil( _migrateStaticCaches() .catch(() => {}) .then(() => caches.keys()) .then(keys => Promise.all( keys .filter(k => k !== CACHE_STATIC && k !== CACHE_TILES && k !== CACHE_API) .map(k => caches.delete(k)) )) .then(() => self.clients.claim()) ); }); // ---------------------------------------------------------- // 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); // Force-Update: SW komplett umgehen → direkt vom Netzwerk if (url.searchParams.has('_nocache')) { event.respondWith(fetch(event.request.url.replace(/[?&]_nocache=[^&]*/,'') || '/', { cache: 'no-store' })); return; } // DWD-Regenradar: NICHT abfangen — manifest.json wechselt alle 5 Min bei gleicher URL // (no-store vom Server) und die PMTiles-Range-Requests (206) sind eh nicht cachebar. if (url.pathname.startsWith('/radar/')) return; // 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 stale = _cacheStale(url.pathname); const networkPromise = _fetchTimeout(event.request.clone(), 8000) .then(resp => { if (resp.ok) { _cacheMark(url.pathname); const toCache = resp.clone(); caches.open(CACHE_API).then(c => c.put(event.request, toCache)); } return resp; }) .catch(() => null); // Cache noch frisch → sofort zurückgeben, Netz im Hintergrund if (cached && !stale) { networkPromise.catch(() => {}); return cached; } // Cache vorhanden aber abgelaufen → Netz zuerst, Cache als Fallback const fresh = await networkPromise; if (fresh) return fresh; if (cached) return cached; // lieber veraltet als nichts return new Response(JSON.stringify({ detail: 'Offline — keine Daten im Cache.' }), { status: 503, headers: { 'Content-Type': 'application/json' } }); })()); return; } // Media-Uploads + KI-Endpoints: direkt ans Netzwerk — kein Clone, kein Timeout, kein Queue // KI-Anfragen ans lokale LLM können mehrere Minuten dauern if (method === 'POST' && (_isMediaUpload(event.request) || url.pathname.startsWith('/api/ki/') || url.pathname.includes('/health/ki-') || url.pathname.includes('/health/symptom'))) return; // Admin-Endpoints: kein SW-Timeout, direkt ans Netzwerk if (url.pathname.startsWith('/api/admin/')) 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( _fetchTimeout(event.request, 8000).catch(() => new Response(JSON.stringify({ detail: 'Offline — keine Verbindung.' }), { status: 503, headers: { 'Content-Type': 'application/json' } }) ) ); return; } // OSM-Kartenkacheln: eigener persistenter Cache (Cache-First mit LRU-Eviction) if (url.hostname.endsWith('tile.openstreetmap.org')) { event.respondWith( caches.open(CACHE_TILES).then(cache => cache.match(event.request).then(cached => { if (cached) return cached; return fetch(event.request).then(response => { if (response.ok) { cache.put(event.request, response.clone()) .then(() => trimTileCache()) // im Hintergrund — blockiert respondWith nicht .catch(() => {}); } return response; }); }) ).catch(() => new Response('', { status: 503 })) ); return; } // CSS, Core-JS + Seiten-Module: Network-First — aber nach 2,5 s ohne Antwort kommt der // CACHE (stale-while-revalidate): bei schwachem Empfang blieb die App sonst SEHR LANGE // weiß, weil der Fetch nicht fehlschlägt, sondern tröpfelt (Gerätetest 2026-06-08). // Das Netz-Ergebnis aktualisiert den Cache im Hintergrund weiter; echte Versions-Updates // zieht der Update-Mechanismus (x-app-version + controllerchange) ohnehin separat. if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/') || url.pathname.startsWith('/js/app.js') || url.pathname.startsWith('/js/ui.js') || url.pathname.startsWith('/js/api.js') || url.pathname.startsWith('/js/worlds.js') || url.pathname === '/datenschutz' || url.pathname === '/agb' || url.pathname === '/impressum') { event.respondWith((async () => { const network = fetch(event.request).then(response => { if (response.ok) { const clone = response.clone(); caches.open(CACHE_STATIC).then(c => c.put(event.request, clone)); } return response; }); const winner = await Promise.race([ network.catch(() => null), new Promise(res => setTimeout(() => res(null), 2500)), ]); if (winner) return winner; const cached = await caches.match(event.request, { ignoreSearch: true }); if (cached) { network.catch(() => {}); return cached; } // Netz läuft im Hintergrund weiter return network.catch(() => new Response('', { status: 503 })); })()); return; } // Navigation (index.html): Network-First, nach 2,5 s ohne Antwort die gecachte Shell // (gleicher Schwachempfang-Schutz wie oben; Offline-Fallback bleibt caches.match('/')). if (event.request.mode === 'navigate') { event.respondWith((async () => { const network = fetch(event.request).then(response => { const clone = response.clone(); caches.open(CACHE_STATIC).then(c => c.put(event.request, clone)); return response; }); const winner = await Promise.race([ network.catch(() => null), new Promise(res => setTimeout(() => res(null), 2500)), ]); if (winner) return winner; const cached = await caches.match('/'); if (cached) { network.catch(() => {}); return cached; } return network.catch(() => caches.match('/')); })()); return; } // Statische Assets: Cache-First. Für eigene /js/ + /css/ zusätzlich ignoreSearch- // Fallback — Lazy-Loads hängen ?v=APP_VER an, Carry-Over-Einträge tragen aber den // alten ?v= bzw. gar keinen (PRIORITY_PAGES). Online ersetzt der Fetch sie frisch. event.respondWith((async () => { let cached = await caches.match(event.request); if (!cached && url.origin === self.location.origin && (url.pathname.startsWith('/js/') || url.pathname.startsWith('/css/'))) { cached = await caches.match(event.request, { ignoreSearch: true }); } if (cached) return cached; try { const response = await fetch(event.request); if (response.ok && event.request.method === 'GET') { const clone = response.clone(); caches.open(CACHE_STATIC).then(c => c.put(event.request, clone)).catch(() => {}); } return response; } catch (e) { if (event.request.mode === 'navigate') return caches.match('/'); return new Response('', { status: 503 }); } })()); }); // ---------------------------------------------------------- // 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 === 'SKIP_WAITING') { self.skipWaiting(); return; } if (event.data?.type === 'INVALIDATE_CACHE') { // Cache-Timestamps für angegebene Pfade zurücksetzen → nächster Request geht ans Netz const paths = event.data.paths || []; paths.forEach(p => _cacheTs.delete(p)); return; } 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 || []; const source = event.source; let done = 0; const total = urls.length; if (total === 0) { source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done: 0, total: 0 }); return; } caches.open(CACHE_TILES).then(cache => { const queue = [...urls]; function fetchBatch() { if (queue.length === 0) { source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done: total, total }); trimTileCache(); // nach Bulk-Vorausladen einmal trimmen return; } const batch = queue.splice(0, 8); Promise.all(batch.map(url => cache.match(url).then(cached => { if (cached) { done++; return; } return fetch(url, { mode: 'cors' }) .then(r => { if (r.ok) cache.put(url, r); done++; }) .catch(() => { done++; }); }) )).then(() => { source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done, total }); setTimeout(fetchBatch, 30); }); } fetchBatch(); }); }); // ---------------------------------------------------------- // PUSH NOTIFICATIONS // ---------------------------------------------------------- self.addEventListener('push', event => { if (!event.data) return; const data = event.data.json(); const options = { body: data.body || '', icon: data.icon || '/icons/icon-192.png', badge: data.badge || '/icons/icon-192.png', tag: data.tag || 'ban-yaro', data: data.data || {}, actions: data.actions || [], vibrate: data.vibrate || [100, 50, 100], requireInteraction: data.requireInteraction || false, }; // Chat-Nachricht if (data.type === 'chat_message') { options.tag = data.tag || `chat-${data.data?.conversation_id}`; options.renotify = true; options.actions = [ { action: 'reply', title: 'Antworten' }, { action: 'dismiss', title: 'Schließen' }, ]; } // Freundschaftsanfrage if (data.type === 'friend_request') { options.tag = 'friend-request'; options.actions = [{ action: 'view', title: 'Anzeigen' }]; } // Giftköder-Alarm: besondere Darstellung if (data.type === 'poison_alert') { options.tag = 'poison-alert'; options.requireInteraction = true; options.vibrate = [200, 100, 200, 100, 200]; options.actions = [ { action: 'view', title: 'Auf Karte zeigen' }, { action: 'dismiss', title: 'Verstanden' }, ]; } event.waitUntil( self.registration.showNotification(data.title || 'Ban Yaro', options) ); // App informieren damit sie Nearby-Alerts neu prüft if (data.type === 'poison_alert' || data.type === 'lost_dog_alert') { self.clients.matchAll({ type: 'window' }).then(clients => { clients.forEach(c => c.postMessage({ type: 'CHECK_NEARBY_ALERTS' })); }); } }); // ---------------------------------------------------------- // NOTIFICATION CLICK // ---------------------------------------------------------- self.addEventListener('notificationclick', event => { event.notification.close(); const data = event.notification.data; const action = event.action; let url = '/'; if (action === 'view' || action === 'reply' || data?.page) { url = `/#${data.page || 'poison'}`; if (data.conversation_id) url += `?conversation_id=${data.conversation_id}`; else if (data.id) url += `?id=${data.id}`; } event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }) .then(windowClients => { for (const client of windowClients) { if (client.url.includes(self.location.origin)) { client.focus(); client.navigate(url); return; } } return clients.openWindow(url); }) ); });