Gemeldet: die App zeigte beim Oeffnen nur "Failed to fetch", waehrend das
Teilen aus WhatsApp weiter ging. Deploy und API waren in Ordnung — die
Meldung ist die rohe Browserausgabe eines fetch(), das gar nicht bis zum
Server kam, und stand 1:1 im UI, weil die Routen im catch e.message
anzeigten. Das Teilen fiel nicht auf, weil share.html sich die letzten
Auftraege seit eaad796 lokal merkt; die Hauptapp hatte fuer die Listen
keinen Offline-Fallback.
- api.js uebersetzt Netzfehler in einen eigenen Fehler mit offline=true
und deutschem Text; 4xx/5xx bleiben Serverfehler
- isTransient() in offline.js erkannte Netzfehler an "instanceof TypeError".
Ohne Anpassung waere ein Foto aus dem Funkloch als dauerhaft
fehlgeschlagen gewertet und nach sechs Versuchen in Quarantaene gelandet
- Datenspiegel in IndexedDB: Auftrags-, Kunden- und Berichtsliste sowie
geoeffnete Details (letzte 30). Ohne Netz wird der Stand angezeigt,
mit Band "Kein Netz — Stand von HH:MM Uhr", und die Suche filtert darin
- beim Abmelden wird der Spiegel geloescht (geteiltes Geraet)
- sw.js: der Same-Origin-Zweig endete auf caches.match(); fand der Cache
nichts, wurde respondWith(undefined) aufgerufen und die Anfrage
scheiterte erneut mit genau der Meldung, die verhindert werden sollte.
Jetzt immer eine echte Response, nur vollstaendige Antworten im Cache
- Precache nach Deploy: die Seite meldet dem Worker ihre echten Asset-URLs,
sonst ist der Cache direkt nach einem Deploy leer (?v=<mtime>)
Muster aus KB #1037 und #1041. Verifiziert auf der lokalen Testinstanz
inklusive echtem Funkloch (setOffline + reload): App startet aus dem
Cache, zeigt Band und Auftragsliste, keine JS-Fehler.
347 lines
16 KiB
JavaScript
347 lines
16 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.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;
|
|
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);
|
|
});
|
|
|
|
/* ======================================================================
|
|
* 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;
|
|
}
|
|
|
|
window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed,
|
|
acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync,
|
|
mirrorSave, mirrorLoad, mirrorClear };
|
|
})();
|