banyaro/backend/static/sw.js
rene 553e9e7854 Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405
Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations

Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil

Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware

Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
2026-04-25 20:44:46 +02:00

265 lines
8.1 KiB
JavaScript

/* ============================================================
BAN YARO — Service Worker
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v405';
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?v=382',
'/css/layout.css?v=382',
'/css/components.css?v=382',
'/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;
}
// CSS + Seiten-Module: immer Network-First — damit iOS nie veraltete CSS cached
if (url.pathname.startsWith('/css/') || 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)
);
// 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);
})
);
});