Doppel-Uploads (Prod-Log 17.09.: reihenweise duplicate:true): Seite
(syncQueue) und Service Worker (Background Sync) arbeiteten dieselbe
IndexedDB-Queue ab, ohne voneinander zu wissen. Dank md5-Abgleich
folgenlos, aber doppeltes Datenvolumen. Mit eingestelltem Verkleinern
sogar zwei verschiedene Dateien im Auftrag: der Worker lud das
Original, die Seite danach die kleine Fassung.
- idb.js: queueKeys/queuePatch/queueClaim/queueRelease - Lesen-Aendern-
Schreiben in einer readwrite-Transaktion, ueber Kontexte atomar
- syncQueue (Seite) und drainQueue (Worker) belegen jedes Foto vor dem
Upload (Lease 90 s > laengster Upload-Timeout); belegt = ueberspringen,
abgelaufen = uebernehmen. Nur noch ein Foto gleichzeitig im Speicher
- enqueuePhoto({hold:true}) + releaseHold: waehrend des Verkleinerns
bleibt das Foto belegt
- Versuchszaehler, retryFailed und replaceQueuedBlob per Patch statt
ganzes Item zurueckschreiben
Reload nach App-Update: controllerchange lud sofort neu - offene Kamera,
getippte Notiz, halbe Unterschrift weg; mit Fotos in der Warteschlange
kam dazu der Browserdialog "Seite verlassen?".
- window.appBusy(): Modal, Auswahl, laufender Upload, Eingabe im Fokus,
laufende Sprachnotiz. PIN-Sperre zaehlt bewusst nicht
- index.php holt den Reload nach, sobald nichts mehr offen ist; eigener
Reload setzt __selfReload, beforeunload warnt dann nicht
Verifiziert lokal in Chromium: echte Kamera-Oberflaeche mit Canvas-
Stream offline, paralleler Zugriff Seite+Worker (3 Fotos = 3 POSTs im
Serverlog, kein duplicate), Verkleinern (eine Datei, kleine Fassung),
fremde Lease frisch/abgelaufen, aufgeschobener Reload ohne Dialog.
Nicht getestet: Kamera-Hardware und Background Sync bei Display aus am
Handy. Kein Deploy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
401 lines
19 KiB
JavaScript
401 lines
19 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)
|
|
|
|
/* 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 };
|
|
})();
|