banyaro/backend/static/sw.js
rene ebe4ce20cf Sprint 10: OSM-POI-Cache, Karten-Clustering, Routen-Redesign
Karte (map.js):
- OSM Overpass API: Restaurants, Tierärzte, Parkplätze, Bänke, Wasserstellen
- Leaflet.markercluster für alle OSM-Layer
- Standort-Dot mit GPS-Genauigkeitskreis, Wake-Lock bei Aufzeichnung
- Community-Pins setzen/löschen, Meldungen, Crosshair-Placement
- Layer-Sichtbarkeit in localStorage (by_map_visible_v1)

Routen (routes.js + routen.py):
- Komoot-Stil: SVG-Track-Preview, Foto-Upload, Nearby-POIs im Detail-Modal
- Neue Felder: is_public, hunde_tauglichkeit, foto_urls
- Rate-Endpoint (POST /api/routes/{id}/rate)
- Foto-Upload (POST /api/routes/{id}/photo)
- Fix: json_extract $[-1] → $[#-1] (SQLite-kompatibler Pfad für letztes Element)

Backend (osm.py, database.py, scheduler.py):
- /api/osm/pois: OSM-Overpass-Cache mit Tile-Logik (14 Tage TTL)
- /api/osm/user-poi: Community-Marker CRUD
- /api/osm/report: Marker als ungültig melden
- Neue Tabellen: osm_pois, osm_tiles, user_map_pois, osm_reports
- Giftköder-Archiv-Job (täglich 03:00, soft-delete nach Ablauf)
- Giftköder-Archiv-Job als APScheduler-CronJob

UI: Orte-Menüpunkt entfernt (in Karte integriert), APP_VER auf 62
2026-04-15 16:30:10 +02:00

240 lines
7.2 KiB
JavaScript

/* ============================================================
BAN YARO — Service Worker
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v62';
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',
'/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,
};
// 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' || data?.page) {
url = `/#${data.page || 'poison'}`;
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);
})
);
});