/* 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 }; })();