Feature: Live-Kamera (Schnellschuss + Filmstreifen), Datenverlust-Schutz (Persist-First) [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 14s

- Kamera bleibt nach jedem Schnappschuss offen (getUserMedia Live-Video statt
  native capture-Input, der nach jedem Foto schliesst)
- Filmstreifen mit Status-Badges pro Foto (💾 gesichert →  lädt hoch → ✓ fertig)
- Persist-First: Foto zuerst in IndexedDB schreiben, Upload erst danach
- Foto verlässt Queue NUR nach HTTP-2xx mit gültigem relpath
- 2xx-HTML (abgelaufene Session, Proxy-Loginseite) wird als Fehler erkannt
- HTTP-Status auf Error-Objekt: 5xx/429/408 sind transient (nicht quarantänisiert)
- 45-Sekunden-Upload-Timeout via AbortController
- Quarantäne nach 6 Fehlversuchen, Recovery via Status-Badge-Tipp
- Beforeunload-Warnung wenn Fotos noch nicht gesichert
- idb.queueUpdate() für state-preserving ItemUpdates ohne Datenverlust

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-07-03 10:46:36 +02:00
parent fdd0ab163d
commit 5e7b1a9ec7
5 changed files with 570 additions and 48 deletions

105
app.css
View file

@ -1174,3 +1174,108 @@ body {
.ship-sign-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
.ship-sign-side { border-right: none; border-bottom: 1px solid #333; }
}
/* ============================================================
* Live-Kamera (Schnellschuss + Filmstreifen)
* ============================================================ */
.camera-modal { background: #000; }
.camera-modal .cam-stage {
position: relative;
flex: 1;
min-height: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* contain: die Vorschau zeigt exakt das, was gespeichert wird (wichtig für Doku-Fotos) */
.camera-modal #cam-video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.camera-modal .cam-hint {
position: absolute;
color: #fff;
font-size: 14px;
opacity: 0.85;
pointer-events: none;
text-align: center;
padding: 0 20px;
}
.camera-modal .cam-filmstrip {
display: flex;
gap: 8px;
padding: 8px 10px;
overflow-x: auto;
background: rgba(0,0,0,0.85);
-webkit-overflow-scrolling: touch;
}
.camera-modal .cam-filmstrip:empty { padding: 0; }
.cam-thumb {
position: relative;
flex: 0 0 auto;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
border: 2px solid #444;
background: #222;
}
.cam-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cam-thumb .cam-thumb-badge {
position: absolute;
right: 2px;
bottom: 2px;
font-size: 11px;
line-height: 1;
padding: 1px 3px;
border-radius: 6px;
background: rgba(0,0,0,0.65);
color: #fff;
}
.cam-thumb.saving { border-color: #888; }
.cam-thumb.saving .cam-thumb-badge::after { content: '💾'; }
.cam-thumb.pending { border-color: #e0a300; }
.cam-thumb.pending .cam-thumb-badge::after { content: '⏳'; }
.cam-thumb.done { border-color: #2ecc71; }
.cam-thumb.done .cam-thumb-badge::after { content: '✓'; }
.cam-thumb.error { border-color: #e74c3c; }
.cam-thumb.error .cam-thumb-badge::after { content: '⚠'; }
.cam-thumb .cam-thumb-del {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(200,30,30,0.78);
color: #fff;
border: none;
font-size: 22px;
cursor: pointer;
}
.cam-thumb.confirm-del .cam-thumb-del { display: flex; }
.camera-modal .cam-controls {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px calc(14px + env(safe-area-inset-bottom));
background: #000;
}
.camera-modal .cam-count { flex: 1; color: #bbb; font-size: 13px; text-align: left; }
.camera-modal .cam-done-wrap { flex: 1; display: flex; justify-content: flex-end; }
.cam-shutter {
flex: 0 0 auto;
width: 68px;
height: 68px;
border-radius: 50%;
background: #fff;
border: 4px solid rgba(255,255,255,0.35);
box-shadow: 0 0 0 2px #000 inset;
cursor: pointer;
transition: transform 0.08s ease, background 0.08s ease;
}
.cam-shutter:active { transform: scale(0.92); }
.cam-shutter.flash { background: #ffd34d; }

294
app.js
View file

@ -769,7 +769,9 @@ router.on('/orders/:id', async (args) => {
const camInput = document.getElementById('camera-input');
const galInput = document.getElementById('gallery-input');
document.getElementById('btn-take-photo').onclick = () => camInput.click();
// Live-Kamera (bleibt im Kamera-Modus für mehrere Fotos). Fällt intern auf
// den nativen camera-input zurück, wenn getUserMedia nicht verfügbar ist.
document.getElementById('btn-take-photo').onclick = () => openCameraModal(args.id);
document.getElementById('btn-pick-photo').onclick = () => galInput.click();
async function handleFiles(files) {
@ -788,20 +790,17 @@ router.on('/orders/:id', async (args) => {
});
async function uploadPhoto(orderId, file) {
showToast('Optimiere & sende ' + file.name);
const blob = await resizeImage(file, 2000);
if (!navigator.onLine) {
await offline.enqueuePhoto(orderId, blob, file.name);
showToast('Offline — Foto in Queue', 'warn');
return;
}
try {
await api.uploadOrderPhoto(orderId, blob, file.name);
showToast('✓ ' + file.name + ' hochgeladen');
} catch (e) {
await offline.enqueuePhoto(orderId, blob, file.name);
showToast('Upload fehlgeschlagen — in Queue', 'error');
}
// Persist-First gegen Datenverlust: Foto ZUERST verkleinern und SOFORT persistent
// in die IndexedDB-Queue schreiben — BEVOR ein Upload versucht wird. Danach senden.
// So geht kein Foto verloren, egal ob Netz da ist, die Verbindung hängt oder die
// App mitten im Upload geschlossen wird. (syncQueue löscht nur bei bestätigtem Erfolg.)
let blob = file;
try { blob = await resizeImage(file, 2000); } catch (e) { blob = file; }
const name = file.name || ('foto_' + Date.now() + '.jpg');
await offline.enqueuePhoto(orderId, blob, name);
if (navigator.onLine) showToast('Sende ' + name + '…');
else showToast('Offline — ' + name + ' gesichert, wird später gesendet', 'warn');
await offline.syncQueue();
}
async function resizeImage(file, maxSide) {
@ -2818,6 +2817,271 @@ async function openVoiceModal(orderId) {
};
}
/* ============================================================
* LIVE-KAMERA Schnellschuss + Filmstreifen
* Bleibt im Kamera-Modus: mehrere Fotos ohne Rauswerfen.
* Jedes Foto wird SOFORT persistent in die Offline-Queue geschrieben
* (kein Datenverlust) und dann im Hintergrund hochgeladen.
* ============================================================ */
async function openCameraModal(orderId) {
// Kein getUserMedia (alter Browser / unsicherer Kontext)? → System-Kamera nutzen
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const ci = document.getElementById('camera-input');
if (ci) ci.click();
return;
}
const modal = document.createElement('div');
modal.className = 'fullscreen-modal camera-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="cam-close"></button>
<div class="fs-title">📷 Kamera</div>
<button class="icon-btn" id="cam-flip" title="Kamera wechseln">🔄</button>
</div>
<div class="cam-stage">
<video id="cam-video" playsinline autoplay muted></video>
<div class="cam-hint" id="cam-hint">Kamera wird gestartet</div>
</div>
<div class="cam-filmstrip" id="cam-strip"></div>
<div class="cam-controls">
<div class="cam-count" id="cam-count"></div>
<button class="cam-shutter" id="cam-shutter" aria-label="Auslösen"></button>
<div class="cam-done-wrap"><button class="btn btn-small" id="cam-done">Fertig</button></div>
</div>
`;
document.body.appendChild(modal);
const video = modal.querySelector('#cam-video');
const strip = modal.querySelector('#cam-strip');
const hint = modal.querySelector('#cam-hint');
const countEl = modal.querySelector('#cam-count');
const shutter = modal.querySelector('#cam-shutter');
let stream = null;
let facing = 'environment';
let shots = 0;
let gen = 0; // Generations-Token: entwertet in-flight getUserMedia-Aufrufe (Schließen/Flip)
function stopStream() {
if (stream) { try { stream.getTracks().forEach(t => t.stop()); } catch (_) {} stream = null; }
try { video.srcObject = null; } catch (_) {}
}
// Upload bestätigt → passende Miniatur auf „fertig" setzen und relpath merken (für Löschen)
function onUploaded(e) {
const d = e.detail || {};
const thumb = strip.querySelector('.cam-thumb[data-qid="' + d.queueId + '"]');
if (!thumb) return;
if (d.relpath) thumb.dataset.relpath = d.relpath;
thumb.classList.remove('pending', 'saving');
thumb.classList.add('done');
}
window.addEventListener('photo-uploaded', onUploaded);
// Aufräumen bei Schließen / Android-Back: Kamera aus, Listener weg, Blob-URLs freigeben,
// Auftragsseite neu laden (zeigt die frisch hochgeladenen Fotos).
pushModal(modal, () => {
gen++; // entwertet einen evtl. noch laufenden getUserMedia-Aufruf
stopStream();
window.removeEventListener('photo-uploaded', onUploaded);
strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
// Auftragsseite nur neu laden, wenn wirklich Fotos aufgenommen wurden (sonst unnötiger Roundtrip)
if (shots > 0) { try { router.navigate(); } catch (_) {} }
});
modal.querySelector('#cam-close').onclick = () => closeModal(modal);
modal.querySelector('#cam-done').onclick = () => closeModal(modal);
modal.querySelector('#cam-flip').onclick = () => {
facing = (facing === 'environment') ? 'user' : 'environment';
startStream();
};
function updateCount() {
countEl.textContent = shots ? (shots + (shots === 1 ? ' Foto' : ' Fotos')) : '';
}
async function startStream() {
const my = ++gen;
stopStream();
hint.style.display = '';
hint.textContent = 'Kamera wird gestartet…';
try {
const s = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: facing }, width: { ideal: 3840 }, height: { ideal: 2160 } },
audio: false,
});
// Inzwischen geschlossen oder neuer Start (Flip) angefordert? → diesen Stream sofort beenden,
// damit keine verwaiste Kamera (LED an) zurückbleibt.
if (my !== gen || !document.body.contains(modal)) {
try { s.getTracks().forEach(t => t.stop()); } catch (_) {}
return;
}
stream = s;
video.srcObject = stream;
await video.play().catch(() => {});
hint.style.display = 'none';
} catch (err) {
if (my !== gen || !document.body.contains(modal)) return; // abgebrochen, nicht mehr relevant
console.warn('[Kamera]', err);
showToast('Kamera nicht verfügbar — nutze System-Kamera', 'warn');
closeModal(modal);
const ci = document.getElementById('camera-input');
if (ci) ci.click();
}
}
function addThumb(url) {
const t = document.createElement('div');
t.className = 'cam-thumb saving';
t.innerHTML = '<img src="' + url + '" alt=""><span class="cam-thumb-badge"></span>'
+ '<button class="cam-thumb-del" title="Löschen">🗑</button>';
// Tipp auf Miniatur → Löschen-Overlay ein/aus
t.querySelector('img').onclick = () => t.classList.toggle('confirm-del');
t.querySelector('.cam-thumb-del').onclick = (ev) => { ev.stopPropagation(); deleteThumb(t); };
strip.prepend(t);
return t;
}
async function deleteThumb(t) {
if (t.dataset.deleting === '1') return; // Doppel-Tap-Schutz
const relpath = t.dataset.relpath;
const qid = t.dataset.qid;
if (!relpath && !qid) { showToast('Foto wird noch gesichert…', 'warn'); return; }
// Noch nicht hochgeladen (nur lokal) → Rückfrage, da unwiederbringlich
if (!relpath && qid) {
if (!confirm('Dieses Foto ist noch nicht hochgeladen. Wirklich löschen?')) return;
}
t.dataset.deleting = '1';
const delBtn = t.querySelector('.cam-thumb-del');
if (delBtn) delBtn.disabled = true;
try {
if (relpath) {
await api.deletePhoto(relpath); // schon hochgeladen → serverseitig löschen
} else {
await idb.queueDelete(Number(qid)); // noch in Queue → aus Queue entfernen
await offline.updateBadge();
}
try { URL.revokeObjectURL(t.querySelector('img').src); } catch (_) {}
t.remove();
shots = Math.max(0, shots - 1);
updateCount();
showToast('Foto gelöscht');
} catch (e) {
t.dataset.deleting = '';
if (delBtn) delBtn.disabled = false;
showToast('Löschen fehlgeschlagen: ' + (e.message || ''), 'error');
}
}
async function capture() {
if (!stream || !video.videoWidth) { showToast('Kamera noch nicht bereit', 'warn'); return; }
// Frame sofort abgreifen (schnell), damit rasches Mehrfach-Knipsen flüssig bleibt.
const c = document.createElement('canvas');
c.width = video.videoWidth;
c.height = video.videoHeight;
c.getContext('2d').drawImage(video, 0, 0, c.width, c.height);
shutter.classList.add('flash');
setTimeout(() => shutter.classList.remove('flash'), 150);
shots++;
updateCount();
const raw = await new Promise(res => c.toBlob(b => res(b), 'image/jpeg', 0.92));
if (!raw) { shots = Math.max(0, shots - 1); updateCount(); showToast('Foto fehlgeschlagen', 'error'); return; }
const thumb = addThumb(URL.createObjectURL(raw));
try {
// Auf max. 2000px verkleinern (wie beim normalen Upload) und SOFORT persistent sichern
let blob = raw;
try { blob = await resizeImage(raw, 2000); } catch (_) { blob = raw; }
const name = 'foto_' + Date.now() + '_' + shots + '.jpg';
const qid = await offline.enqueuePhoto(orderId, blob, name);
thumb.dataset.qid = qid;
thumb.classList.remove('saving');
thumb.classList.add('pending');
offline.syncQueue(); // im Hintergrund hochladen (löscht nur bei bestätigtem Erfolg)
} catch (e) {
thumb.classList.remove('saving');
thumb.classList.add('error');
const quota = e && (e.name === 'QuotaExceededError' || /quota/i.test(e.message || ''));
showToast(quota
? '⚠ Speicher voll — bitte erst vorhandene Fotos hochladen, dann erneut aufnehmen'
: ('Foto sichern fehlgeschlagen: ' + (e.message || '')), 'error');
}
}
shutter.onclick = capture;
startStream();
}
/* ============================================================
* UPLOAD-WARTESCHLANGE Recovery-UI (per Klick auf das Status-Badge)
* Zeigt offene und fehlgeschlagene Uploads, erlaubt erneutes Senden und
* das Retten fehlgeschlagener Fotos per Teilen/Speichern. So ist ein Foto
* NIE in einer stillen Sackgasse.
* ============================================================ */
async function openUploadQueueModal() {
const items = await offline.listQueue().catch(() => []);
if (!items.length) {
showToast(navigator.onLine ? 'Alle Fotos hochgeladen ✓' : 'Keine offenen Uploads');
return;
}
const pending = items.filter(i => !i.failed);
const failed = items.filter(i => i.failed);
const modal = document.createElement('div');
modal.className = 'fullscreen-modal queue-modal';
modal.innerHTML = `
<div class="fs-header">
<button class="icon-btn" id="q-close"></button>
<div class="fs-title"> Upload-Warteschlange</div>
<span></span>
</div>
<div class="fs-body" style="flex-direction:column;align-items:stretch;justify-content:flex-start;gap:12px;color:#fff;">
<p style="margin:0;">${pending.length} warten auf Upload${failed.length ? ', <strong>' + failed.length + ' fehlgeschlagen</strong>' : ''}.</p>
<button class="btn" id="q-sync">🔄 Jetzt synchronisieren</button>
${failed.length ? '<button class="btn btn-secondary" id="q-retry">↻ Fehlgeschlagene erneut senden</button>' : ''}
${failed.length ? '<button class="btn btn-secondary" id="q-share">📤 Fehlgeschlagene sichern / teilen</button>' : ''}
<p style="margin:0;font-size:12px;color:#aaa;">Fotos bleiben gespeichert, bis der Upload bestätigt ist. Schließe die App nicht, solange hier noch Einträge stehen.</p>
</div>
`;
document.body.appendChild(modal);
pushModal(modal);
modal.querySelector('#q-close').onclick = () => closeModal(modal);
modal.querySelector('#q-sync').onclick = () => {
showToast('Synchronisiere…');
offline.syncQueue();
closeModal(modal);
};
const retryBtn = modal.querySelector('#q-retry');
if (retryBtn) retryBtn.onclick = async () => {
await offline.retryFailed();
showToast('Erneut senden gestartet…');
closeModal(modal);
};
const shareBtn = modal.querySelector('#q-share');
if (shareBtn) shareBtn.onclick = async () => {
const files = failed.map(it => ({
blob: new Blob([it.data], { type: it.mime || 'image/jpeg' }),
filename: it.filename || 'foto.jpg',
mime: it.mime || 'image/jpeg',
}));
if (!files.length) return;
await shareFiles(files, 'Nicht hochgeladene Baustellen-Fotos');
};
}
// Status-Badge in der Kopfzeile klickbar machen → Upload-Warteschlange öffnen
(function wireStatusBadge() {
const badge = document.getElementById('status-badge');
if (badge) {
badge.style.cursor = 'pointer';
badge.title = 'Upload-Warteschlange anzeigen';
badge.addEventListener('click', () => openUploadQueueModal());
}
})();
/* ============================================================
* SKIZZEN-EDITOR (Touch-fähig, einfache Vektor-Zeichnung)
* ============================================================ */

View file

@ -30,14 +30,40 @@
if (!headers['Content-Type'] && !(opts.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const r = await fetch(API_BASE + path, { ...opts, headers });
// Optionaler Timeout: bricht hängende Requests ab, statt ewig zu warten
// (schwaches/totes Netz auf der Baustelle → Foto landet zuverlässig in der Queue)
let signal = opts.signal;
let toHandle = null;
if (opts.timeoutMs && typeof AbortController !== 'undefined') {
const ctrl = new AbortController();
signal = ctrl.signal;
toHandle = setTimeout(() => { try { ctrl.abort(); } catch (_) {} }, opts.timeoutMs);
}
let r;
try {
r = await fetch(API_BASE + path, { ...opts, headers, signal });
} finally {
if (toHandle) clearTimeout(toHandle);
}
if (r.status === 401) {
await clearToken();
window.location.hash = '#/login';
throw new Error('Nicht authentifiziert');
const e = new Error('Nicht authentifiziert'); e.status = 401; throw e;
}
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'API-Fehler');
// Antwort MUSS JSON sein. Ein 2xx mit HTML (z.B. Apache/Proxy-Login- oder
// Wartungsseite bei abgelaufener Session, ErrorDocument 200) darf NIEMALS als
// Erfolg gelten — sonst würde ein Foto fälschlich als hochgeladen aus der Queue
// gelöscht und wäre verloren.
const ct = (r.headers.get('Content-Type') || '').toLowerCase();
let data = {};
if (ct.includes('application/json')) {
data = await r.json().catch(() => ({}));
} else if (r.ok) {
const e = new Error('Unerwartete Server-Antwort (kein JSON)');
e.status = r.status; e.nonJson = true;
throw e;
}
if (!r.ok) { const e = new Error(data.error || 'API-Fehler'); e.status = r.status; throw e; }
return data;
}
@ -82,13 +108,27 @@
return request('/orders.php?id=' + id + '&action=photos');
}
// Semantik: at-least-once. Geht die Server-Antwort NACH dem Speichern verloren
// (z.B. Netz-Abbruch, Proxy-2xx-HTML), gilt der Upload als unbestätigt und wird
// wiederholt → das Foto kann dadurch doppelt auf dem Server landen (der Dateiname
// enthält uniqid(), es wird also nicht überschrieben). Bewusst gewählt: lieber ein
// löschbares Duplikat als ein verlorenes Foto.
async function uploadOrderPhoto(orderId, fileBlob, filename) {
const fd = new FormData();
fd.append('file', fileBlob, filename || 'photo.jpg');
return request('/orders.php?id=' + orderId + '&action=upload_photo', {
const res = await request('/orders.php?id=' + orderId + '&action=upload_photo', {
method: 'POST',
body: fd,
timeoutMs: 45000,
});
// Der Server MUSS einen relpath liefern, wenn er die Datei wirklich gespeichert hat.
// Fehlt er, gilt der Upload als NICHT bestätigt → Foto bleibt in der Queue (Retry).
if (!res || typeof res.relpath !== 'string' || res.relpath === '') {
const e = new Error('Upload nicht bestätigt (kein relpath)');
e.uploadUnconfirmed = true;
throw e;
}
return res;
}
async function createOrder(payload) {

View file

@ -85,5 +85,16 @@
});
}
window.idb = { get, set, del, queuePush, queueAll, queueDelete };
// Item aktualisieren (z.B. Versuchszähler / Fehler-Markierung) — put upsert-t per keyPath id
async function queueUpdate(item) {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('queue', 'readwrite');
tx.objectStore('queue').put(item);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
window.idb = { get, set, del, queuePush, queueAll, queueDelete, queueUpdate };
})();

View file

@ -1,9 +1,18 @@
/* Offline-Queue für Foto-Uploads.
* Wenn der Upload fehlschlägt (offline), wird er in IndexedDB abgelegt
* und beim nächsten Online-Event automatisch nachgesendet.
/* 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
@ -14,61 +23,154 @@
filename,
mime: fileBlob.type || 'image/jpeg',
data: buf,
attempts: 0,
failed: false,
created: Date.now(),
});
updateBadge();
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() {
if (syncing) return;
// 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 {
if (it.type === 'photo') {
const blob = new Blob([it.data], { type: it.mime });
await api.uploadOrderPhoto(it.order_id, blob, it.filename);
}
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 failed for item', it.id, e);
// Nicht weiter versuchen wenn ein Item failed
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;
updateBadge();
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();
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 + ' Uploads warten';
} else if (items.length) {
badge.textContent = '🟡 ' + items.length;
badge.title = items.length + ' werden synchronisiert';
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';
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 30s
setInterval(() => { if (navigator.onLine) syncQueue(); }, 30000);
// Periodischer Sync alle 15s (fängt Items ein, deren erster Versuch scheiterte)
setInterval(() => { if (navigator.onLine) syncQueue(); }, 15000);
});
window.offline = { enqueuePhoto, syncQueue, updateBadge };
window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed };
})();