All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 14s
- 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>
176 lines
8.1 KiB
JavaScript
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 };
|
|
})();
|