baustelle-pwa/lib/offline.js
Eddy 65bc09d9f1
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 16s
Upload-Serie: Screen-Aus bricht nicht mehr ab, Duplikate erkannt [deploy]
Schaltet sich das Display waehrend einer Foto-Serie ab, friert der Browser die
laufenden fetch-Aufrufe ein — die halbe Serie blieb liegen, der zweite Anlauf
erzeugte Duplikate (28.08.2026, Auftrag PROV105: 11 doppelte Fotos).

- Wake Lock waehrend syncQueue(), inkl. Neuanforderung nach App-Wechsel
  (visibilitychange). Scheitert die Anforderung, laeuft der Sync unveraendert
  weiter — die Queue faengt Abbrueche ohnehin ab
- duplicate:true aus der Server-Antwort wandert durch das photo-uploaded-Event;
  die Miniatur zeigt dann „War schon hochgeladen" statt eines zweiten Fotos
- api.js: Kommentar auf den neuen Server-Vertrag gebracht (Bericht 1.6.0 legt
  identische Dateien nicht erneut ab, at-least-once bleibt Rueckfallebene)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:42:12 +02:00

207 lines
9.4 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;
}
// 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.
let wakeLock = null;
async function acquireWakeLock() {
try {
if (!navigator.wakeLock || wakeLock) return;
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', function () { wakeLock = null; });
} catch (_) { wakeLock = null; }
}
function releaseWakeLock() {
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' && syncing) 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';
}
}
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 };
})();