baustelle-pwa/lib/offline.js
Eduard Wisch a108664854 App ohne Netz lesbar statt "Failed to fetch"
Gemeldet: die App zeigte beim Oeffnen nur "Failed to fetch", waehrend das
Teilen aus WhatsApp weiter ging. Deploy und API waren in Ordnung — die
Meldung ist die rohe Browserausgabe eines fetch(), das gar nicht bis zum
Server kam, und stand 1:1 im UI, weil die Routen im catch e.message
anzeigten. Das Teilen fiel nicht auf, weil share.html sich die letzten
Auftraege seit eaad796 lokal merkt; die Hauptapp hatte fuer die Listen
keinen Offline-Fallback.

- api.js uebersetzt Netzfehler in einen eigenen Fehler mit offline=true
  und deutschem Text; 4xx/5xx bleiben Serverfehler
- isTransient() in offline.js erkannte Netzfehler an "instanceof TypeError".
  Ohne Anpassung waere ein Foto aus dem Funkloch als dauerhaft
  fehlgeschlagen gewertet und nach sechs Versuchen in Quarantaene gelandet
- Datenspiegel in IndexedDB: Auftrags-, Kunden- und Berichtsliste sowie
  geoeffnete Details (letzte 30). Ohne Netz wird der Stand angezeigt,
  mit Band "Kein Netz — Stand von HH:MM Uhr", und die Suche filtert darin
- beim Abmelden wird der Spiegel geloescht (geteiltes Geraet)
- sw.js: der Same-Origin-Zweig endete auf caches.match(); fand der Cache
  nichts, wurde respondWith(undefined) aufgerufen und die Anfrage
  scheiterte erneut mit genau der Meldung, die verhindert werden sollte.
  Jetzt immer eine echte Response, nur vollstaendige Antworten im Cache
- Precache nach Deploy: die Seite meldet dem Worker ihre echten Asset-URLs,
  sonst ist der Cache direkt nach einem Deploy leer (?v=<mtime>)

Muster aus KB #1037 und #1041. Verifiziert auf der lokalen Testinstanz
inklusive echtem Funkloch (setOffline + reload): App startet aus dem
Cache, zeigt Band und Auftragsliste, keine JS-Fehler.
2026-08-31 19:07:09 +02:00

347 lines
16 KiB
JavaScript

/* Offline-Queue für Foto-Uploads (Persist-First / Write-Ahead).
*
* WICHTIG gegen Datenverlust:
* Jedes Foto wird vom Aufrufer ZUERST via enqueuePhoto() persistent in IndexedDB
* geschrieben und ERST DANACH hochgeladen. Ein Item wird nur nach BESTÄTIGTEM
* Upload (HTTP 2xx) aus der Queue gelöscht. Dadurch überlebt jedes Foto:
* - fehlendes/schwaches Netz (navigator.onLine lügt bei WLAN ohne Internet)
* - hängende Uploads (Timeout in api.uploadOrderPhoto)
* - App-Schließen / OS-Kill mitten im Upload
*/
(function () {
let syncing = false;
let syncAgain = false; // wurde während eines Laufs erneut angefragt?
let lastUnsynced = 0; // ALLE noch nicht bestätigten Items (für Beforeunload-Warnung)
const MAX_ATTEMPTS = 6; // nach so vielen Dauerfehlern → Quarantäne (blockiert Queue nicht)
async function enqueuePhoto(orderId, fileBlob, filename) {
// Blob → ArrayBuffer für IndexedDB-Speicherung
const buf = await fileBlob.arrayBuffer();
const id = await idb.queuePush({
type: 'photo',
order_id: orderId,
filename,
mime: fileBlob.type || 'image/jpeg',
data: buf,
attempts: 0,
failed: false,
created: Date.now(),
});
await updateBadge();
emitChange();
registerBackgroundSync(); // auch ohne offene App nachliefern
return id;
}
// Blob eines bereits gesicherten Queue-Items ersetzen. Gebraucht fuer das
// Verkleinern: gesichert wird zuerst das Original, die kleinere Fassung ersetzt
// es erst danach. Klappt das Verkleinern nicht, bleibt das Original — nie ein Loch.
async function replaceQueuedBlob(id, fileBlob) {
const items = await idb.queueAll().catch(() => []);
const it = items.find(x => x.id === id);
if (!it) return false; // schon hochgeladen und geloescht
it.data = await fileBlob.arrayBuffer();
it.mime = fileBlob.type || it.mime || 'image/jpeg';
await idb.queueUpdate(it);
return true;
}
// Background Sync: der Browser holt die Queue auch dann nach, wenn die App gar
// nicht offen ist (Display aus, App weggewischt). Der Service Worker laedt selbst
// hoch — die Auth steckt im HttpOnly-Cookie awl_sso und geht bei same-origin
// automatisch mit. Ohne das lief Nachliefern nur, solange die Seite im
// Vordergrund war; im Hintergrund friert der Browser Timer ein.
async function registerBackgroundSync() {
try {
if (!('serviceWorker' in navigator) || !('SyncManager' in window)) return false;
const reg = await navigator.serviceWorker.ready;
if (!reg.sync) return false;
await reg.sync.register('photo-queue');
// Zusaetzlich, falls der Browser es gewaehrt (installierte PWA): ein
// periodischer Weckruf. Faengt den Fall ab, dass gar kein sync-Ereignis
// mehr kommt, weil das Geraet lange offline war.
try {
if (reg.periodicSync && navigator.permissions) {
const st = await navigator.permissions.query({ name: 'periodic-background-sync' });
if (st.state === 'granted') {
await reg.periodicSync.register('photo-queue', { minInterval: 15 * 60 * 1000 });
}
}
} catch (_) { /* nicht unterstuetzt — der normale Sync reicht */ }
return true;
} catch (_) { return false; } // z.B. Berechtigung verweigert — Timer bleiben als Netz
}
function emitChange() {
try { window.dispatchEvent(new CustomEvent('queue-changed')); } catch (_) {}
}
// Netzfehler / Timeout / Auth / Serverfehler → Item behalten (später erneut versuchen).
// NUR echte Client-Fehler (400/413/415: falscher Dateityp / zu groß) sind dauerhaft.
function isTransient(err) {
if (!err) return true;
if (err.offline) return true; // api.js: Anfrage kam nicht bis zum Server
if (err.name === 'AbortError') return true; // Timeout via AbortController
if (err instanceof TypeError) return true; // fetch: „Failed to fetch"
if (err.uploadUnconfirmed) return true; // 2xx, aber kein relpath → nicht bestätigt
if (err.nonJson) return true; // 2xx-HTML von Proxy/Apache → Auth/Proxy-Problem
// 5xx (Deploy-Neustart, PHP-Fatal, DB-Lock), 429 (Rate-Limit), 408 (Timeout) sind vorübergehend
if (err.status && (err.status >= 500 || err.status === 429 || err.status === 408)) return true;
const m = (err.message || '').toLowerCase();
if (m.includes('fetch') || m.includes('network') || m.includes('authentifiziert')) return true;
return false;
}
// Screen wachhalten, solange Uploads laufen. Schaltet sich das Display waehrend einer
// Foto-Serie ab, friert der Browser die laufenden fetch-Aufrufe ein und der Rest der
// Serie bleibt liegen (real am 28.08.2026: halbe Serie kam nicht an, der zweite Anlauf
// erzeugte dann Duplikate). Scheitert die Anforderung (Akkusparmodus, alter Browser),
// laeuft der Sync unveraendert weiter — die Queue faengt den Abbruch ohnehin ab.
// Referenzzaehler, weil zwei Stellen den Lock brauchen und sich verschachteln:
// die Auswahl-Schleife (Verkleinern + Sichern) und der Sync selbst. Wer zuerst
// fertig ist, darf dem anderen den Lock nicht wegnehmen.
let wakeLock = null;
let wakeRefs = 0;
async function acquireWakeLock() {
wakeRefs++;
try {
if (!navigator.wakeLock || wakeLock) return;
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', function () { wakeLock = null; });
} catch (_) { wakeLock = null; }
}
function releaseWakeLock() {
wakeRefs = Math.max(0, wakeRefs - 1);
if (wakeRefs > 0) return;
try { if (wakeLock) wakeLock.release(); } catch (_) {}
wakeLock = null;
}
// Ein Wake Lock geht verloren, sobald die Seite in den Hintergrund geht (Anruf,
// App-Wechsel). Kommt sie zurueck und laeuft noch ein Sync, neu anfordern.
try {
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible' && wakeRefs > 0 && !wakeLock) {
wakeRefs--; // acquireWakeLock zaehlt gleich wieder hoch
acquireWakeLock();
}
});
} catch (_) {}
async function syncQueue() {
// Läuft schon einer? Dann nur merken, dass danach nochmal geprüft werden soll
// (so werden Fotos erfasst, die während eines laufenden Syncs neu dazukamen).
if (syncing) { syncAgain = true; return; }
if (!navigator.onLine) return;
syncing = true;
await acquireWakeLock();
try {
do {
syncAgain = false;
const items = await idb.queueAll();
let stoppedByNetwork = false;
for (const it of items) {
if (it.failed) continue; // quarantänisiert: behalten, überspringen
if (it.type !== 'photo') { await idb.queueDelete(it.id); continue; }
try {
const blob = new Blob([it.data], { type: it.mime || 'image/jpeg' });
const res = await api.uploadOrderPhoto(it.order_id, blob, it.filename);
await idb.queueDelete(it.id);
try {
window.dispatchEvent(new CustomEvent('photo-uploaded', {
detail: {
queueId: it.id,
relpath: res && res.relpath,
duplicate: !!(res && res.duplicate),
orderId: it.order_id,
filename: it.filename,
},
}));
} catch (_) {}
} catch (e) {
console.warn('[Sync] Item ' + it.id + ' fehlgeschlagen:', e);
if (isTransient(e)) {
// Netzproblem → abbrechen; ALLES bleibt erhalten, nächster Versuch später
stoppedByNetwork = true;
break;
}
// Dauerhafter Fehler → Versuchszähler hoch, ggf. Quarantäne,
// aber weiter mit den nächsten Items (ein „Poison-Item" blockiert nicht mehr die Queue).
it.attempts = (it.attempts || 0) + 1;
it.last_error = (e && e.message) || 'Fehler';
if (it.attempts >= MAX_ATTEMPTS) it.failed = true;
try { await idb.queueUpdate(it); } catch (_) {}
}
}
if (stoppedByNetwork) break;
} while (syncAgain);
} finally {
syncing = false;
releaseWakeLock();
await updateBadge();
emitChange();
}
}
async function queueCount() {
try { return (await idb.queueAll()).length; } catch (_) { return 0; }
}
// Alle Queue-Items (für das Recovery-UI: Warteschlange ansehen / erneut senden / teilen)
async function listQueue() {
return idb.queueAll().catch(() => []);
}
// Quarantänisierte Items reaktivieren und erneut versuchen
async function retryFailed() {
const items = await idb.queueAll().catch(() => []);
for (const it of items) {
if (it.failed) {
it.failed = false;
it.attempts = 0;
delete it.last_error;
try { await idb.queueUpdate(it); } catch (_) {}
}
}
await updateBadge();
emitChange();
syncQueue();
}
async function updateBadge() {
const items = await idb.queueAll().catch(() => []);
const pending = items.filter(i => !i.failed).length;
const failed = items.filter(i => i.failed).length;
lastUnsynced = items.length; // failed zählt mit — sie sind ebenfalls noch nicht gesichert
const badge = document.getElementById('status-badge');
if (!badge) return;
if (!navigator.onLine) {
badge.textContent = items.length ? '🔴 ' + items.length : '🔴';
badge.title = items.length ? items.length + ' Fotos warten (offline, gesichert)' : 'Offline';
} else if (pending) {
badge.textContent = '🟡 ' + pending;
badge.title = pending + ' Fotos werden hochgeladen…';
} else if (failed) {
badge.textContent = '⚠️ ' + failed;
badge.title = failed + ' Uploads fehlgeschlagen — bitte prüfen';
} else {
badge.textContent = '🟢';
badge.title = 'Online — alle Fotos gesichert';
}
}
// Der Service Worker meldet, wenn er im Hintergrund etwas hochgeladen hat
try {
navigator.serviceWorker.addEventListener('message', (e) => {
const d = e.data || {};
if (d.type !== 'photo-uploaded') return;
updateBadge();
emitChange();
try {
window.dispatchEvent(new CustomEvent('photo-uploaded', {
detail: { queueId: d.queueId, relpath: d.relpath, orderId: d.orderId,
filename: d.filename, duplicate: !!d.duplicate },
}));
} catch (_) {}
});
} catch (_) {}
window.addEventListener('online', () => { updateBadge(); syncQueue(); });
window.addEventListener('offline', updateBadge);
// Warnung, wenn die App mit noch nicht hochgeladenen Fotos geschlossen/neu geladen wird.
// Feuert in einer Hash-Router-SPA NICHT bei normaler In-App-Navigation, nur beim echten Verlassen.
// lastUnsynced schließt quarantänisierte (failed) Fotos ein — die sind erst recht gefährdet.
window.addEventListener('beforeunload', (e) => {
if (lastUnsynced > 0) { e.preventDefault(); e.returnValue = ''; return ''; }
});
// Zurück in den Vordergrund → sofort erneut synchronisieren
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && navigator.onLine) syncQueue();
});
document.addEventListener('DOMContentLoaded', () => {
updateBadge();
if (navigator.onLine) syncQueue();
// Periodischer Sync alle 15s (fängt Items ein, deren erster Versuch scheiterte)
setInterval(() => { if (navigator.onLine) syncQueue(); }, 15000);
});
/* ======================================================================
* Datenspiegel — damit die App im Funkloch nicht leer ist
*
* Die Queue oben sichert, was RAUS soll (Fotos). Hier geht es um das Gegenstueck:
* was zuletzt REIN kam. Bis 31.08.2026 hatte die App dafuer gar nichts — ohne Netz
* stand auf der Auftragsliste die rohe Browsermeldung "Failed to fetch" und sonst
* nichts. Auf der Baustelle ist schlechtes Netz aber der Normalfall, nicht die
* Ausnahme (Keller, Neubau, Rohbau ohne Fenster).
*
* Bewusst nur LESEN: jede erfolgreiche Antwort wird gespiegelt, bei Netzfehler wird
* der letzte Stand mit sichtbarem Zeitstempel angezeigt. Keine Schreib-Queue fuer
* Auftragsdaten — die braeuchte Idempotenz und Konfliktbehandlung (siehe KB #1037).
*
* Der Spiegel liegt im kv-Store derselben IndexedDB. Beim Abmelden wird er geloescht,
* sonst saehe der naechste Benutzer am selben Geraet offline die Kunden und Preise
* des vorherigen (KB #1041).
* ====================================================================== */
const MIRROR_PREFIX = 'mirror:';
const MIRROR_MAX_DETAILS = 30; // wie viele Auftrags-/Kundendetails aufgehoben werden
async function mirrorSave(key, data) {
const full = MIRROR_PREFIX + key;
try {
await idb.set(full, { data, ts: Date.now() });
} catch (_) {
return false; // Speicher voll / privater Modus — kein Grund, den Abruf zu versenken
}
if (key.indexOf('detail:') === 0) mirrorPrune(full).catch(() => {});
return true;
}
async function mirrorLoad(key) {
try {
const v = await idb.get(MIRROR_PREFIX + key);
if (!v || typeof v !== 'object' || !('data' in v)) return null;
return v; // { data, ts }
} catch (_) {
return null;
}
}
/* Nur die Detailseiten wachsen unbegrenzt (ein Eintrag je geoeffnetem Auftrag).
* Aeltere wegraeumen — den gerade geschriebenen Schluessel dabei ausdruecklich
* schuetzen: werden zwei Staende in derselben Millisekunde gespeichert, ist die
* Sortierung nach Zeitstempel nicht eindeutig und es fliegt sonst ausgerechnet
* der neue Eintrag raus (KB #1037). */
async function mirrorPrune(keepKey) {
const all = (await idb.keys().catch(() => []))
.filter(k => typeof k === 'string' && k.indexOf(MIRROR_PREFIX + 'detail:') === 0);
if (all.length <= MIRROR_MAX_DETAILS) return;
const withTs = [];
for (const k of all) {
const v = await idb.get(k).catch(() => null);
withTs.push({ k, ts: (v && v.ts) || 0 });
}
withTs.sort((a, b) => b.ts - a.ts);
for (const e of withTs.slice(MIRROR_MAX_DETAILS)) {
if (e.k === keepKey) continue;
await idb.del(e.k).catch(() => {});
}
}
/* Beim Abmelden: alles Gespiegelte vom Geraet nehmen. */
async function mirrorClear() {
const all = (await idb.keys().catch(() => []))
.filter(k => typeof k === 'string' && k.indexOf(MIRROR_PREFIX) === 0);
for (const k of all) await idb.del(k).catch(() => {});
return all.length;
}
window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed,
acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync,
mirrorSave, mirrorLoad, mirrorClear };
})();