/* ============================================================ BAN YARO — Service Worker Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ const CACHE_VERSION = 'by-v144'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten // index.html wird NICHT pre-gecacht (immer Network-First) const STATIC_ASSETS = [ '/css/design-system.css', '/css/layout.css', '/css/components.css', '/icons/phosphor.svg', '/js/api.js', '/js/ui.js', '/js/app.js', '/js/leaflet.markercluster.js', '/css/MarkerCluster.css', '/css/MarkerCluster.Default.css', '/manifest.json', '/icons/icon-192.png', ]; // ---------------------------------------------------------- // INSTALL — App Shell cachen // ---------------------------------------------------------- self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_STATIC) .then(cache => cache.addAll(STATIC_ASSETS)) .then(() => self.skipWaiting()) ); }); // ---------------------------------------------------------- // ACTIVATE — alte Caches aufräumen (CACHE_TILES behalten) // ---------------------------------------------------------- self.addEventListener('activate', event => { event.waitUntil( caches.keys() .then(keys => Promise.all( keys .filter(k => k !== CACHE_STATIC && k !== CACHE_TILES) .map(k => caches.delete(k)) )) .then(() => self.clients.claim()) ); }); // ---------------------------------------------------------- // FETCH — Network-First für HTML, Cache-First für Assets, Network für API // ---------------------------------------------------------- self.addEventListener('fetch', event => { const url = new URL(event.request.url); // API-Calls: immer Network, kein Cache if (url.pathname.startsWith('/api/')) { event.respondWith( fetch(event.request).catch(() => new Response(JSON.stringify({ detail: 'Offline — keine Verbindung.' }), { status: 503, headers: { 'Content-Type': 'application/json' } }) ) ); return; } // OSM-Kartenkacheln: eigener persistenter Cache 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()); return response; }); }) ).catch(() => new Response('', { status: 503 })) ); return; } // Seiten-Module (/js/pages/…): immer Network-First (versioniert über ?v=, kein alter Cache-Treffer) if (url.pathname.startsWith('/js/pages/')) { event.respondWith( 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; }) .catch(() => caches.match(event.request)) ); return; } // Navigation (index.html): immer Network-First if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request) .then(response => { const clone = response.clone(); caches.open(CACHE_STATIC).then(c => c.put(event.request, clone)); return response; }) .catch(() => caches.match('/')) ); return; } // Statische Assets: Cache-First event.respondWith( caches.match(event.request) .then(cached => cached || fetch(event.request) .then(response => { if (response.ok && event.request.method === 'GET') { const clone = response.clone(); caches.open(CACHE_STATIC).then(c => c.put(event.request, clone)); } return response; }) ) .catch(() => { if (event.request.mode === 'navigate') { return caches.match('/'); } }) ); }); // ---------------------------------------------------------- // MESSAGE — Tile-Vorausladung (Offline-Speicherung) // ---------------------------------------------------------- self.addEventListener('message', event => { 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 }); 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) ); }); // ---------------------------------------------------------- // 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); }) ); });