Feature: Offline-Stufen 1+2+3 — Timeout, API-Cache, Write-Queue (IndexedDB + BackgroundSync) — SW by-v509, APP_VER 486

This commit is contained in:
rene 2026-04-29 19:13:04 +02:00
parent ad3b73d687
commit 2411151b17
4 changed files with 268 additions and 31 deletions

View file

@ -95,7 +95,18 @@
<body> <body>
<!-- Offline-Banner --> <!-- Offline-Banner -->
<div id="offline-banner">Kein Internet — du bist offline</div> <div id="offline-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;
background:#1f2937;color:#f3f4f6;font-size:0.78rem;font-weight:500;
padding:7px 16px;align-items:center;justify-content:center;gap:8px;
box-shadow:0 2px 8px rgba(0,0,0,.3)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M213.92,210.62l-160-176A8,8,0,1,0,42.08,45.38L81.06,88.86A152.34,152.34,0,0,0,26.49,130a8,8,0,0,0,11,11.61,136.36,136.36,0,0,1,52-37.29l19.2,21.12A96.09,96.09,0,0,0,67.6,160.59,8,8,0,1,0,79,172.2a80.12,80.12,0,0,1,33.5-23.89L128,165.37V224a8,8,0,0,0,16,0V183.94l69.92,76.92a8,8,0,1,0,11.84-10.76ZM128,141.46,108.42,120A80.38,80.38,0,0,1,128,116a79.91,79.91,0,0,1,19.59,2.43l-19.59,23Zm0-85.46a167.9,167.9,0,0,1,101.51,34.17,8,8,0,1,0,9.72-12.72A183.82,183.82,0,0,0,128,40a183.5,183.5,0,0,0-48.55,6.55L95,64.18A168.23,168.23,0,0,1,128,56Zm57.09,72.41a8,8,0,0,0,11.22-1.36,8,8,0,0,0-1.36-11.22,136.72,136.72,0,0,0-31.62-18.23L178,114.26A120.52,120.52,0,0,1,185.09,128.41Z"/>
</svg>
<span id="offline-banner-text">Offline — Änderungen werden gespeichert und synchronisiert sobald Verbindung besteht</span>
<span id="offline-queue-badge" style="display:none;background:rgba(239,68,68,.8);
border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"></span>
</div>
<!-- Backdrop + Sidebar direkt im body (kein Ancestor-Stacking-Context) --> <!-- Backdrop + Sidebar direkt im body (kein Ancestor-Stacking-Context) -->
<div id="sidebar-backdrop" class="sidebar-backdrop"></div> <div id="sidebar-backdrop" class="sidebar-backdrop"></div>
@ -456,30 +467,33 @@
<!-- Offline-Banner Logik --> <!-- Offline-Banner Logik -->
<script> <script>
(function() { (function() {
var _wasOffline = false; function _updateBanner() {
var banner = document.getElementById('offline-banner'); var banner = document.getElementById('offline-banner');
if (!banner) return;
function setOffline() { banner.style.display = navigator.onLine ? 'none' : 'flex';
_wasOffline = true;
if (banner) banner.style.display = 'block';
} }
window.addEventListener('offline', function() {
function setOnline() { _updateBanner();
if (banner) banner.style.display = 'none'; // Queue-Count abfragen
if (_wasOffline) { if (navigator.serviceWorker) {
_wasOffline = false; navigator.serviceWorker.ready.then(function(reg) {
// UI.toast ist verfügbar sobald ui.js geladen ist if (reg.active) reg.active.postMessage({ type: 'QUEUE_COUNT' });
if (window.UI && UI.toast) { });
UI.toast.success('Wieder online');
}
} }
} });
window.addEventListener('online', function() {
window.addEventListener('offline', setOffline); _updateBanner();
window.addEventListener('online', setOnline); var badge = document.getElementById('offline-queue-badge');
if (badge) badge.style.display = 'none';
// Initialzustand prüfen // Queue abarbeiten
if (!navigator.onLine) setOffline(); if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'PROCESS_QUEUE' });
});
}
});
// Initial prüfen
_updateBanner();
})(); })();
</script> </script>
@ -508,6 +522,35 @@
}); });
navigator.serviceWorker.addEventListener('message', e => { navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'QUEUE_PROCESSED') {
const { synced, failed, total } = e.data;
if (total === 0) return;
if (synced > 0 && window.UI?.toast) {
window.UI.toast.success(
synced === 1
? '1 offline gespeicherter Eintrag synchronisiert'
: `${synced} offline gespeicherte Einträge synchronisiert`
);
// Aktuelle Seite neu laden
window.App?.state && window.pages?.[window.App.state.page]?.module?.refresh?.();
}
if (failed > 0 && window.UI?.toast) {
window.UI.toast.warning(`${failed} Eintrag${failed > 1 ? 'e' : ''} noch nicht synchronisiert — kein Netz`);
}
return;
}
if (e.data?.type === 'QUEUE_COUNT') {
const badge = document.getElementById('offline-queue-badge');
if (badge) {
if (e.data.count > 0) {
badge.textContent = e.data.count;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
return;
}
if (e.data?.type === 'CHECK_NEARBY_ALERTS') { if (e.data?.type === 'CHECK_NEARBY_ALERTS') {
window.App?._checkNearbyAlerts?.(); window.App?._checkNearbyAlerts?.();
} }

View file

@ -58,6 +58,14 @@ const API = (() => {
throw new APIError(message, response.status, isOffline ? 'network' : data?.code); throw new APIError(message, response.status, isOffline ? 'network' : data?.code);
} }
// SW hat die Anfrage in die Offline-Queue eingereiht
if (data?._queued) {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast.info('Offline gespeichert — wird automatisch synchronisiert');
}
return data;
}
return data; return data;
} }

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '485'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '486'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => { const App = (() => {

View file

@ -3,9 +3,10 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v508'; const CACHE_VERSION = 'by-v509';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten 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) // index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [ const STATIC_ASSETS = [
@ -23,6 +24,112 @@ const STATIC_ASSETS = [
'/icons/icon-192.png', '/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/,
];
function _isCacheableGet(pathname) {
return _CACHEABLE_GET.some(re => re.test(pathname));
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// INSTALL — App Shell cachen // INSTALL — App Shell cachen
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -30,19 +137,22 @@ self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_STATIC) caches.open(CACHE_STATIC)
.then(cache => cache.addAll(STATIC_ASSETS)) .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(() => {})
))
.then(() => self.skipWaiting()) .then(() => self.skipWaiting())
); );
}); });
// ---------------------------------------------------------- // ----------------------------------------------------------
// ACTIVATE — alte Caches aufräumen (CACHE_TILES behalten) // ACTIVATE — alte Caches aufräumen (CACHE_TILES + CACHE_API behalten)
// ---------------------------------------------------------- // ----------------------------------------------------------
self.addEventListener('activate', event => { self.addEventListener('activate', event => {
event.waitUntil( event.waitUntil(
caches.keys() caches.keys()
.then(keys => Promise.all( .then(keys => Promise.all(
keys keys
.filter(k => k !== CACHE_STATIC && k !== CACHE_TILES) .filter(k => k !== CACHE_STATIC && k !== CACHE_TILES && k !== CACHE_API)
.map(k => caches.delete(k)) .map(k => caches.delete(k))
)) ))
.then(() => self.clients.claim()) .then(() => self.clients.claim())
@ -50,15 +160,71 @@ self.addEventListener('activate', event => {
}); });
// ---------------------------------------------------------- // ----------------------------------------------------------
// FETCH — Network-First für HTML, Cache-First für Assets, Network für API // FETCH — Network-First für HTML, Cache-First für Assets, API mit Offline-Logik
// ---------------------------------------------------------- // ----------------------------------------------------------
self.addEventListener('fetch', event => { self.addEventListener('fetch', event => {
const url = new URL(event.request.url); const url = new URL(event.request.url);
// API-Calls: immer Network, kein Cache // API-Calls mit Timeout, Caching und Write-Queue
if (url.pathname.startsWith('/api/')) { 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 networkPromise = _fetchTimeout(event.request.clone(), 8000)
.then(resp => {
if (resp.ok) caches.open(CACHE_API).then(c => c.put(event.request, resp.clone()));
return resp;
})
.catch(() => null);
// Stale-While-Revalidate: sofort aus Cache, im Hintergrund holen
if (cached) {
networkPromise.catch(() => {}); // fire and forget
return cached;
}
const fresh = await networkPromise;
if (fresh) return fresh;
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( event.respondWith(
fetch(event.request).catch(() => _fetchTimeout(event.request, 8000).catch(() =>
new Response(JSON.stringify({ detail: 'Offline — keine Verbindung.' }), new Response(JSON.stringify({ detail: 'Offline — keine Verbindung.' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }) { status: 503, headers: { 'Content-Type': 'application/json' } })
) )
@ -134,9 +300,29 @@ self.addEventListener('fetch', event => {
}); });
// ---------------------------------------------------------- // ----------------------------------------------------------
// MESSAGE — Tile-Vorausladung (Offline-Speicherung) // 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 => { 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; if (event.data?.type !== 'CACHE_TILES') return;
const urls = event.data.urls || []; const urls = event.data.urls || [];