banyaro/backend/static/sw.js
rene 1fdba57365 Feature: UX-Fixes — Zahnrad weg, POI-Kombi-Typen, exp-fab-Position, Welten-Config in DB (SW by-v653)
- worlds-settings Zahnrad komplett entfernt (war auf Mobile sichtbar, auf Desktop schon hidden)
- exp-fab: bottom jetzt calc(--nav-bottom-height + --safe-bottom + --space-2) — kein Overlap mit worlds-back auf iPhone
- Karte POI: neue Typen bank, bank_kotbeutel, bank_kotbeutel_abfall, kotbeutel_abfall (Backend + Frontend)
- Welten-Chip-Config: GET/PUT /profile/world-config, Spalte users.world_config TEXT (Migration), Sync bei Init + Speichern
2026-05-03 19:50:04 +02:00

483 lines
16 KiB
JavaScript

/* ============================================================
BAN YARO — Service Worker
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v653';
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 = [
'/css/design-system.css?v=545',
'/css/layout.css?v=545',
'/css/components.css?v=545',
'/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',
];
// ----------------------------------------------------------
// 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/,
// 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 _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde
const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten
const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart)
function _cacheTTL(pathname) {
return _STABLE_GET.some(re => re.test(pathname)) ? _TTL_STABLE : _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(); // Sofort übernehmen — kein Warten auf Cache-Aufbau
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(() => {})
))
);
});
// ----------------------------------------------------------
// 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 && 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);
// 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);
caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
}
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;
}
// 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
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;
}
// CSS, app.js + Seiten-Module: immer Network-First — damit iOS nie veraltete Versionen cached
if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/')
|| url.pathname === '/js/app.js' || url.pathname === '/js/ui.js' || url.pathname === '/js/api.js') {
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('/');
}
})
);
});
// ----------------------------------------------------------
// 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 || [];
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)
);
// 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);
})
);
});