Platsch! (Funk-Blues-Groove, md5 7f5197a0) und die ruhige Ballade Bester Freund (md5 c244dd15, Album-Ruhepol) ans Ende des SONGS-Arrays. make bump → v1300.
643 lines
24 KiB
JavaScript
643 lines
24 KiB
JavaScript
/* ============================================================
|
|
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 = '1300';
|
|
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);
|
|
})
|
|
);
|
|
});
|