All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
Drei Sachen, die zusammengehoeren — alle drei Ursachen dafuer, dass Fotos
fehlten oder nichts drauf zu erkennen war.
1. BACKGROUND SYNC (sw.js)
Nachliefern lief bisher NUR in der offenen Seite: online-Ereignis,
„wieder sichtbar", 15-Sekunden-Takt. Display aus oder App weggewischt =
eingefrorene Timer, es passierte nichts. Der Service Worker haengt sich
jetzt an dieselbe IndexedDB-Queue und laedt selbst hoch; die Anmeldung
steckt im HttpOnly-Cookie awl_sso und geht bei same-origin automatisch mit,
also braucht es dort kein Token-Handling.
- geloescht wird nur bei bestaetigtem Upload: 2xx UND application/json UND
relpath. 2xx mit HTML (Proxy/abgelaufene Anmeldung) gilt als Fehlschlag
- bleibt etwas offen, wird das Sync-Versprechen abgelehnt -> der Browser
stellt erneut zu
- Erfolg meldet der SW per postMessage an die App (Badge/Miniaturen)
- periodicSync zusaetzlich, falls der Browser ihn gewaehrt
2. VOLLE KAMERAQUALITAET (app.js)
Ausgeloest wird jetzt ueber ImageCapture.takePhoto() — das liefert die
native JPEG-Datei der Kamera in Sensoraufloesung samt EXIF, statt ein
Standbild aus dem Video-Stream abzugreifen. Kann der Browser das nicht,
bleibt das Standbild als Rueckfallebene (dann mit Qualitaet 0.95 statt 0.92).
Verkleinert wird nur noch, wenn es im Admin eingestellt ist (Vorgabe: nein).
3. PERSIST-FIRST STRENG (app.js)
Das Foto geht unveraendert und als allererstes in die Queue; Verkleinern
passiert erst DANACH und ersetzt den Eintrag nur bei Erfolg
(offline.replaceQueuedBlob). Vorher lag das Verkleinern davor — und weil es
bei ausgeschaltetem Display haengen bleibt, war das Foto in dem Moment
nirgends gesichert. Genau so gingen am 28.08. acht Fotos verloren. Jetzt
kann nach dem Sichern nichts mehr passieren, was ein Foto kostet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
274 lines
13 KiB
JavaScript
274 lines
13 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.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);
|
|
});
|
|
|
|
window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed,
|
|
acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync };
|
|
})();
|