baustelle-pwa/lib/offline.js
Eddy 5e7b1a9ec7
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 14s
Feature: Live-Kamera (Schnellschuss + Filmstreifen), Datenverlust-Schutz (Persist-First) [deploy]
- Kamera bleibt nach jedem Schnappschuss offen (getUserMedia Live-Video statt
  native capture-Input, der nach jedem Foto schliesst)
- Filmstreifen mit Status-Badges pro Foto (💾 gesichert →  lädt hoch → ✓ fertig)
- Persist-First: Foto zuerst in IndexedDB schreiben, Upload erst danach
- Foto verlässt Queue NUR nach HTTP-2xx mit gültigem relpath
- 2xx-HTML (abgelaufene Session, Proxy-Loginseite) wird als Fehler erkannt
- HTTP-Status auf Error-Objekt: 5xx/429/408 sind transient (nicht quarantänisiert)
- 45-Sekunden-Upload-Timeout via AbortController
- Quarantäne nach 6 Fehlversuchen, Recovery via Status-Badge-Tipp
- Beforeunload-Warnung wenn Fotos noch nicht gesichert
- idb.queueUpdate() für state-preserving ItemUpdates ohne Datenverlust

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:46:36 +02:00

176 lines
8.1 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();
return id;
}
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.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;
}
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;
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,
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;
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';
}
}
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);
});
window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed };
})();