/* 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) /* Wer ein Foto gerade hochlaedt, belegt es (idb.queueClaim). Die Seite und der Service * Worker (Background Sync) arbeiten dieselbe Queue ab — ohne Absprache luden beide * dasselbe Foto hoch (Prod-Log 17.09.2026: reihenweise duplicate:true, im Mobilfunk das * doppelte Datenvolumen). Die Lease muss laenger halten als der laengste Upload-Timeout * (Seite 45 s, Service Worker 60 s), sonst uebernimmt der andere einen LAUFENDEN Upload. * Nach Ablauf darf er uebernehmen — eine eingefrorene Seite blockiert ihr Foto nicht ewig. * Der Besitzer ist je Seiten-Instanz eindeutig: zwei offene Fenster sind zwei Besitzer. */ const OWNER = 'page-' + Math.random().toString(36).slice(2, 10); const HOLD_OWNER = OWNER + ':resize'; const LEASE_MS = 90000; /* opts.hold: Das Foto kommt BELEGT in die Queue. Noetig, wenn es gleich noch verkleinert * wird — sonst greift sich der Service Worker das Original, waehrend die Seite die kleine * Fassung nachschiebt, und im Auftrag liegen zwei verschiedene Dateien (der md5-Abgleich * des Servers erkennt sie nicht als Duplikat). Der Aufrufer MUSS danach releaseHold() * rufen; vergisst er es oder stirbt die Seite, laeuft die Lease nach 90 s von selbst ab. */ async function enqueuePhoto(orderId, fileBlob, filename, opts) { // Blob → ArrayBuffer für IndexedDB-Speicherung const buf = await fileBlob.arrayBuffer(); const item = { type: 'photo', order_id: orderId, filename, mime: fileBlob.type || 'image/jpeg', data: buf, attempts: 0, failed: false, created: Date.now(), }; if (opts && opts.hold) { item.uploading_since = Date.now(); item.uploading_by = HOLD_OWNER; } const id = await idb.queuePush(item); await updateBadge(); emitChange(); registerBackgroundSync(); // auch ohne offene App nachliefern return id; } async function releaseHold(id) { try { await idb.queuePatch(id, (it) => { if (it.uploading_by !== HOLD_OWNER) return false; // laengst von jemandem uebernommen delete it.uploading_since; delete it.uploading_by; }); } catch (_) {} } // 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. // Per Patch in einer Transaktion: das fruehere "alles lesen, ganzes Item zurueckschreiben" // ueberschrieb, was ein anderer in der Zwischenzeit am Eintrag geaendert hatte. async function replaceQueuedBlob(id, fileBlob) { const buf = await fileBlob.arrayBuffer(); const it = await idb.queuePatch(id, (x) => { x.data = buf; x.mime = fileBlob.type || x.mime || 'image/jpeg'; }); return !!it; // null: schon hochgeladen und geloescht } // 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.offline) return true; // api.js: Anfrage kam nicht bis zum Server 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; // Nur die Ids holen und jedes Foto einzeln belegen: so liegt immer nur EIN // Foto im Speicher, und gelesen wird der Stand zum Zeitpunkt des Uploads // (also die verkleinerte Fassung, falls sie inzwischen da ist). const ids = await idb.queueKeys(); let stoppedByNetwork = false; for (const id of ids) { // null: schon weg, in Quarantaene — oder gerade vom Service Worker bzw. // vom Verkleinern belegt. Dann nicht anfassen, der naechste Lauf sieht es wieder. const it = await idb.queueClaim(id, OWNER, LEASE_MS); if (!it) continue; 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. // Lease sofort freigeben, sonst wartet der Service Worker 90 s auf nichts. try { await idb.queueRelease(it.id); } catch (_) {} 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). const msg = (e && e.message) || 'Fehler'; try { await idb.queueRelease(it.id, (x) => { x.attempts = (x.attempts || 0) + 1; x.last_error = msg; if (x.attempts >= MAX_ATTEMPTS) x.failed = true; }); } 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 ids = await idb.queueKeys().catch(() => []); for (const id of ids) { try { await idb.queuePatch(id, (it) => { if (!it.failed) return false; it.failed = false; it.attempts = 0; delete it.last_error; }); } 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. // Nicht beim eigenen Reload nach einem App-Update (index.php setzt __selfReload): die Fotos // liegen in IndexedDB, ein Reload verliert nichts — der Browserdialog waere nur Laerm. window.addEventListener('beforeunload', (e) => { if (window.__selfReload) return; 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); }); /* ====================================================================== * Datenspiegel — damit die App im Funkloch nicht leer ist * * Die Queue oben sichert, was RAUS soll (Fotos). Hier geht es um das Gegenstueck: * was zuletzt REIN kam. Bis 31.08.2026 hatte die App dafuer gar nichts — ohne Netz * stand auf der Auftragsliste die rohe Browsermeldung "Failed to fetch" und sonst * nichts. Auf der Baustelle ist schlechtes Netz aber der Normalfall, nicht die * Ausnahme (Keller, Neubau, Rohbau ohne Fenster). * * Bewusst nur LESEN: jede erfolgreiche Antwort wird gespiegelt, bei Netzfehler wird * der letzte Stand mit sichtbarem Zeitstempel angezeigt. Keine Schreib-Queue fuer * Auftragsdaten — die braeuchte Idempotenz und Konfliktbehandlung (siehe KB #1037). * * Der Spiegel liegt im kv-Store derselben IndexedDB. Beim Abmelden wird er geloescht, * sonst saehe der naechste Benutzer am selben Geraet offline die Kunden und Preise * des vorherigen (KB #1041). * ====================================================================== */ const MIRROR_PREFIX = 'mirror:'; const MIRROR_MAX_DETAILS = 30; // wie viele Auftrags-/Kundendetails aufgehoben werden async function mirrorSave(key, data) { const full = MIRROR_PREFIX + key; try { await idb.set(full, { data, ts: Date.now() }); } catch (_) { return false; // Speicher voll / privater Modus — kein Grund, den Abruf zu versenken } if (key.indexOf('detail:') === 0) mirrorPrune(full).catch(() => {}); return true; } async function mirrorLoad(key) { try { const v = await idb.get(MIRROR_PREFIX + key); if (!v || typeof v !== 'object' || !('data' in v)) return null; return v; // { data, ts } } catch (_) { return null; } } /* Nur die Detailseiten wachsen unbegrenzt (ein Eintrag je geoeffnetem Auftrag). * Aeltere wegraeumen — den gerade geschriebenen Schluessel dabei ausdruecklich * schuetzen: werden zwei Staende in derselben Millisekunde gespeichert, ist die * Sortierung nach Zeitstempel nicht eindeutig und es fliegt sonst ausgerechnet * der neue Eintrag raus (KB #1037). */ async function mirrorPrune(keepKey) { const all = (await idb.keys().catch(() => [])) .filter(k => typeof k === 'string' && k.indexOf(MIRROR_PREFIX + 'detail:') === 0); if (all.length <= MIRROR_MAX_DETAILS) return; const withTs = []; for (const k of all) { const v = await idb.get(k).catch(() => null); withTs.push({ k, ts: (v && v.ts) || 0 }); } withTs.sort((a, b) => b.ts - a.ts); for (const e of withTs.slice(MIRROR_MAX_DETAILS)) { if (e.k === keepKey) continue; await idb.del(e.k).catch(() => {}); } } /* Beim Abmelden: alles Gespiegelte vom Geraet nehmen. */ async function mirrorClear() { const all = (await idb.keys().catch(() => [])) .filter(k => typeof k === 'string' && k.indexOf(MIRROR_PREFIX) === 0); for (const k of all) await idb.del(k).catch(() => {}); return all.length; } // Laeuft gerade ein Upload? Daran haengt u.a., ob die Seite nach einem App-Update neu // laden darf (index.php) — ein Reload risse einen halb gesendeten 8-MB-Upload ab. function isSyncing() { return syncing; } window.offline = { enqueuePhoto, releaseHold, syncQueue, isSyncing, updateBadge, queueCount, listQueue, retryFailed, acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync, mirrorSave, mirrorLoad, mirrorClear }; })();