baustelle-pwa/lib/offline.js
Eddy 130d9d1ec3
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 14s
Fotos gingen beim Screen-Aus verloren: Verkleinern konnte ewig haengen [deploy]
Eddy: „beim ersten Upload nach Screen schliessen kamen nicht mehr alle an, ich
habe noch die gruenen Labels gesehen, aber die Bilder waren nicht da".

Ursache: uploadPhoto() verkleinert das Foto, BEVOR es in die Queue geschrieben
wird. resizeImage() haengt an img.onload / canvas.toBlob und hatte keinen
Timeout — schaltet sich das Display ab, pausiert der Browser Decoding und
Canvas, es feuert weder onload noch onerror, das Promise loest nie auf. Damit
war das Foto NIRGENDS gesichert (nicht in der Queue, nicht am Server), und weil
die Galerie-Auswahl sequentiell laeuft, stand die komplette restliche Auswahl.
Deshalb hat auch nichts automatisch nachgeladen: es gab nichts nachzuholen.
An Auftrag (PROV105) fehlten so 8 von 19 Fotos.

- resizeImage() bekommt eine Schutzzeit (Promise.race, 15s): im Zweifel wird
  das unverkleinerte Original gesichert statt gar nichts
- Auswahl-Schleife: jedes Foto einzeln abgesichert, ein Fehlschlag blockiert die
  restliche Auswahl nicht mehr
- Wake Lock schon waehrend der Auswahl-Verarbeitung, nicht erst beim Sync — das
  Verkleinern ist genau die Stelle, die pausiert wurde. Refcount, weil sich
  Auswahl-Schleife und Sync verschachteln

Schutzzeit isoliert geprueft (haengendes Decoding simuliert): faellt nach der
Frist auf das Original zurueck statt zu blockieren.

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

218 lines
10 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.
// 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';
}
}
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,
acquireWakeLock, releaseWakeLock };
})();