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