Background Sync, volle Kameraqualitaet, Persist-First streng [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s

Drei Sachen, die zusammengehoeren — alle drei Ursachen dafuer, dass Fotos
fehlten oder nichts drauf zu erkennen war.

1. BACKGROUND SYNC (sw.js)
   Nachliefern lief bisher NUR in der offenen Seite: online-Ereignis,
   „wieder sichtbar", 15-Sekunden-Takt. Display aus oder App weggewischt =
   eingefrorene Timer, es passierte nichts. Der Service Worker haengt sich
   jetzt an dieselbe IndexedDB-Queue und laedt selbst hoch; die Anmeldung
   steckt im HttpOnly-Cookie awl_sso und geht bei same-origin automatisch mit,
   also braucht es dort kein Token-Handling.
   - geloescht wird nur bei bestaetigtem Upload: 2xx UND application/json UND
     relpath. 2xx mit HTML (Proxy/abgelaufene Anmeldung) gilt als Fehlschlag
   - bleibt etwas offen, wird das Sync-Versprechen abgelehnt -> der Browser
     stellt erneut zu
   - Erfolg meldet der SW per postMessage an die App (Badge/Miniaturen)
   - periodicSync zusaetzlich, falls der Browser ihn gewaehrt

2. VOLLE KAMERAQUALITAET (app.js)
   Ausgeloest wird jetzt ueber ImageCapture.takePhoto() — das liefert die
   native JPEG-Datei der Kamera in Sensoraufloesung samt EXIF, statt ein
   Standbild aus dem Video-Stream abzugreifen. Kann der Browser das nicht,
   bleibt das Standbild als Rueckfallebene (dann mit Qualitaet 0.95 statt 0.92).
   Verkleinert wird nur noch, wenn es im Admin eingestellt ist (Vorgabe: nein).

3. PERSIST-FIRST STRENG (app.js)
   Das Foto geht unveraendert und als allererstes in die Queue; Verkleinern
   passiert erst DANACH und ersetzt den Eintrag nur bei Erfolg
   (offline.replaceQueuedBlob). Vorher lag das Verkleinern davor — und weil es
   bei ausgeschaltetem Display haengen bleibt, war das Foto in dem Moment
   nirgends gesichert. Genau so gingen am 28.08. acht Fotos verloren. Jetzt
   kann nach dem Sichern nichts mehr passieren, was ein Foto kostet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-08-28 19:07:06 +02:00
parent 130d9d1ec3
commit c2bf4e7c30
4 changed files with 268 additions and 20 deletions

100
app.js
View file

@ -933,21 +933,52 @@ router.on('/orders/:id', async (args) => {
} }
}); });
/* Fotoeinstellungen aus dem Bericht-Modul (Admin Fotoqualität).
* Vorgabe ist Originalgroesse: 0 = gar nicht verkleinern. Der Wert wird lokal
* zwischengespeichert, damit die Kamera auch offline sofort weiss, was gilt.
*/
const PHOTO_CFG_KEY = 'baustelle.photo_cfg';
let photoCfg = (function () {
try { return JSON.parse(localStorage.getItem(PHOTO_CFG_KEY)) || null; } catch (_) { return null; }
})() || { photo_maxside: 0, photo_quality: 100 };
async function refreshPhotoConfig() {
try {
const c = await api.getConfig();
if (c && typeof c.photo_maxside === 'number') {
photoCfg = { photo_maxside: c.photo_maxside, photo_quality: c.photo_quality || 100 };
localStorage.setItem(PHOTO_CFG_KEY, JSON.stringify(photoCfg));
}
} catch (_) { /* offline o.ae. — der zwischengespeicherte Wert gilt weiter */ }
}
async function uploadPhoto(orderId, file) { async function uploadPhoto(orderId, file) {
// Persist-First gegen Datenverlust: Foto ZUERST verkleinern und SOFORT persistent // Persist-First, streng: das Foto geht UNVERAENDERT und als ALLERERSTES in die
// in die IndexedDB-Queue schreiben — BEVOR ein Upload versucht wird. Danach senden. // Queue. Frueher wurde davor verkleinert — und weil das Verkleinern bei
// So geht kein Foto verloren, egal ob Netz da ist, die Verbindung hängt oder die // ausgeschaltetem Display haengen bleibt (pausiertes Decoding), war das Foto in
// App mitten im Upload geschlossen wird. (syncQueue löscht nur bei bestätigtem Erfolg.) // dem Moment nirgends gesichert und die ganze Auswahl-Schleife stand. Genau so
let blob = file; // gingen am 28.08.2026 acht Fotos verloren. Jetzt kann nach dem Sichern nichts
try { blob = await resizeImage(file, 2000); } catch (e) { blob = file; } // mehr passieren, was das Foto kostet.
const name = file.name || ('foto_' + Date.now() + '.jpg'); const name = file.name || ('foto_' + Date.now() + '.jpg');
await offline.enqueuePhoto(orderId, blob, name); const qid = await offline.enqueuePhoto(orderId, file, name);
// Verkleinern ist ab hier reine Kuer: nur wenn im Admin eingestellt, und wenn es
// schiefgeht oder haengt, bleibt schlicht das Original in der Queue.
if (photoCfg.photo_maxside > 0) {
try {
const small = await resizeImage(file, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100);
if (small && small !== file && small.size < file.size) {
await offline.replaceQueuedBlob(qid, small);
}
} catch (_) { /* Original bleibt gesichert */ }
}
if (navigator.onLine) showToast('Sende ' + name + '…'); if (navigator.onLine) showToast('Sende ' + name + '…');
else showToast('Offline — ' + name + ' gesichert, wird später gesendet', 'warn'); else showToast('Offline — ' + name + ' gesichert, wird später gesendet', 'warn');
await offline.syncQueue(); await offline.syncQueue();
} }
async function resizeImage(file, maxSide, timeoutMs) { async function resizeImage(file, maxSide, timeoutMs, quality) {
// SCHUTZZEIT (v1.1): Schaltet sich das Display waehrend einer Serie ab, pausiert der // SCHUTZZEIT (v1.1): Schaltet sich das Display waehrend einer Serie ab, pausiert der
// Browser Bild-Decoding und Canvas — dann feuert weder onload noch onerror und das // Browser Bild-Decoding und Canvas — dann feuert weder onload noch onerror und das
// Promise loeste NIE auf. Das passiert VOR dem Sichern in der Queue, die betroffenen // Promise loeste NIE auf. Das passiert VOR dem Sichern in der Queue, die betroffenen
@ -966,7 +997,7 @@ async function resizeImage(file, maxSide, timeoutMs) {
c.width = Math.round(img.width * scale); c.width = Math.round(img.width * scale);
c.height = Math.round(img.height * scale); c.height = Math.round(img.height * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
c.toBlob(b => resolve(b || file), 'image/jpeg', 0.85); c.toBlob(b => resolve(b || file), 'image/jpeg', typeof quality === 'number' ? quality : 0.85);
}; };
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); }; img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
img.src = url; img.src = url;
@ -1464,6 +1495,7 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
const helpBtn = document.getElementById('help-btn'); const helpBtn = document.getElementById('help-btn');
if (helpBtn) helpBtn.addEventListener('click', openHelpModal); if (helpBtn) helpBtn.addEventListener('click', openHelpModal);
refreshPhotoConfig(); // Admin-Vorgabe zur Fotoqualitaet nachziehen
}); });
/** /**
@ -3243,28 +3275,58 @@ async function openCameraModal(orderId) {
} }
} }
/* Echtes Kamerafoto in voller Sensoraufloesung.
*
* Ein Standbild aus dem Video-Stream hat hoechstens Stream-Aufloesung (hier bis 4K
* angefordert, real oft weniger) die Kamera selbst kann ein Vielfaches. Auf
* Typenschildern und Zaehlerstaenden war deshalb wenig zu erkennen. ImageCapture
* .takePhoto() loest dagegen richtig aus und liefert die native JPEG-Datei der
* Kamera, inklusive EXIF. Kann der Browser das nicht (iOS/Safari, aeltere Stände),
* bleibt das Standbild als Rueckfallebene.
*/
async function grabFullResolution() {
try {
if (typeof ImageCapture === 'undefined' || !stream) return null;
const track = stream.getVideoTracks()[0];
if (!track || typeof track.getCapabilities !== 'function') return null;
const cap = new ImageCapture(track);
const blob = await cap.takePhoto();
return (blob && blob.size > 0) ? blob : null;
} catch (_) {
return null; // z.B. „takePhoto not supported" — Standbild nehmen
}
}
async function capture() { async function capture() {
if (!stream || !video.videoWidth) { showToast('Kamera noch nicht bereit', 'warn'); return; } 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'); shutter.classList.add('flash');
setTimeout(() => shutter.classList.remove('flash'), 150); setTimeout(() => shutter.classList.remove('flash'), 150);
shots++; shots++;
updateCount(); updateCount();
const raw = await new Promise(res => c.toBlob(b => res(b), 'image/jpeg', 0.92));
let raw = await grabFullResolution();
if (!raw) {
// Rueckfallebene: Standbild aus dem laufenden Stream
const c = document.createElement('canvas');
c.width = video.videoWidth;
c.height = video.videoHeight;
c.getContext('2d').drawImage(video, 0, 0, c.width, c.height);
raw = await new Promise(res => c.toBlob(b => res(b), 'image/jpeg', 0.95));
}
if (!raw) { shots = Math.max(0, shots - 1); updateCount(); showToast('Foto fehlgeschlagen', 'error'); return; } if (!raw) { shots = Math.max(0, shots - 1); updateCount(); showToast('Foto fehlgeschlagen', 'error'); return; }
const thumb = addThumb(URL.createObjectURL(raw)); const thumb = addThumb(URL.createObjectURL(raw));
try { try {
// Auf max. 2000px verkleinern (wie beim normalen Upload) und SOFORT persistent sichern // Erst sichern, dann erst ueber Verkleinern nachdenken (siehe uploadPhoto)
let blob = raw;
try { blob = await resizeImage(raw, 2000); } catch (_) { blob = raw; }
const name = 'foto_' + Date.now() + '_' + shots + '.jpg'; const name = 'foto_' + Date.now() + '_' + shots + '.jpg';
const qid = await offline.enqueuePhoto(orderId, blob, name); const qid = await offline.enqueuePhoto(orderId, raw, name);
if (photoCfg.photo_maxside > 0) {
try {
const small = await resizeImage(raw, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100);
if (small && small !== raw && small.size < raw.size) await offline.replaceQueuedBlob(qid, small);
} catch (_) { /* Original bleibt gesichert */ }
}
thumb.dataset.qid = qid; thumb.dataset.qid = qid;
thumb.classList.remove('saving'); thumb.classList.remove('saving');
thumb.classList.add('pending'); thumb.classList.add('pending');

View file

@ -103,6 +103,11 @@
return request('/orders.php' + (qs ? '?' + qs : '')); return request('/orders.php' + (qs ? '?' + qs : ''));
} }
// Fotoeinstellungen (Admin → Bericht-Modul): Verkleinern ja/nein, JPEG-Qualitaet
async function getConfig() {
return request('/config.php');
}
async function getOrder(id) { async function getOrder(id) {
return request('/orders.php?id=' + id); return request('/orders.php?id=' + id);
} }
@ -392,5 +397,6 @@
getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl, getPhotoBlobUrl, clearPhotoCache, getFileBlobUrl, photoThumbUrl, photoUrl,
deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages, deletePage, updatePageNote, uploadSignature, getPdfBlobUrl, reorderPages,
listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment, listShipments, getShipment, getShipmentPdfBlobUrl, confirmShipment,
getConfig,
}; };
})(); })();

View file

@ -29,9 +29,49 @@
}); });
await updateBadge(); await updateBadge();
emitChange(); emitChange();
registerBackgroundSync(); // auch ohne offene App nachliefern
return id; 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() { function emitChange() {
try { window.dispatchEvent(new CustomEvent('queue-changed')); } catch (_) {} try { window.dispatchEvent(new CustomEvent('queue-changed')); } catch (_) {}
} }
@ -191,6 +231,22 @@
} }
} }
// 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('online', () => { updateBadge(); syncQueue(); });
window.addEventListener('offline', updateBadge); window.addEventListener('offline', updateBadge);
@ -214,5 +270,5 @@
}); });
window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed, window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed,
acquireWakeLock, releaseWakeLock }; acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync };
})(); })();

124
sw.js
View file

@ -168,3 +168,127 @@ self.addEventListener('message', (e) => {
e.waitUntil(caches.delete(MEDIA_CACHE)); e.waitUntil(caches.delete(MEDIA_CACHE));
} }
}); });
/* ==========================================================================
* Background Sync Fotos nachliefern, auch wenn die App gar nicht offen ist
*
* Bis hierher lief das Nachliefern ausschliesslich in der Seite selbst: über
* das online-Ereignis, App wieder sichtbar" und einen 15-Sekunden-Takt. Ist
* das Display aus oder die App weggewischt, friert der Browser diese Timer ein
* es passierte schlicht nichts, bis jemand die App wieder aufmachte.
*
* Der Service Worker läuft unabhängig davon. Er greift auf dieselbe
* IndexedDB-Queue zu (baustelle-pwa-v1 / Store "queue") und lädt selbst hoch;
* die Anmeldung steckt im HttpOnly-Cookie awl_sso und geht bei same-origin
* automatisch mit deshalb braucht es hier kein Token-Handling.
*
* Gelöscht wird ein Eintrag nur bei BESTÄTIGTEM Upload: HTTP 2xx, echtes JSON
* und ein relpath in der Antwort. Alles andere bleibt liegen; wir lehnen das
* Sync-Versprechen ab, dann plant der Browser den nächsten Versuch selbst.
* ========================================================================== */
const QUEUE_DB = 'baustelle-pwa-v1';
const QUEUE_STORE = 'queue';
const UPLOAD_BASE = '/custom/bericht/api/orders.php';
function queueDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(QUEUE_DB); // KEINE Version angeben:
req.onsuccess = () => resolve(req.result); // das Schema legt die Seite an
req.onerror = () => reject(req.error);
});
}
function queueRead(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readonly');
const req = tx.objectStore(QUEUE_STORE).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
function queueRemove(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readwrite');
tx.objectStore(QUEUE_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function tellClients(msg) {
const list = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' });
list.forEach(c => { try { c.postMessage(msg); } catch (_) {} });
}
/* Ein Foto hochladen. Rückgabe: die Serverantwort bei bestätigtem Upload,
* sonst wird geworfen der Aufrufer lässt den Eintrag dann in der Queue. */
async function uploadQueued(item) {
const blob = new Blob([item.data], { type: item.mime || 'image/jpeg' });
const fd = new FormData();
fd.append('file', blob, item.filename || 'foto.jpg');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 60000); // haengende Verbindung nicht ewig halten
let r;
try {
r = await fetch(UPLOAD_BASE + '?id=' + encodeURIComponent(item.order_id) + '&action=upload_photo', {
method: 'POST',
body: fd,
credentials: 'same-origin',
signal: ctrl.signal,
});
} finally {
clearTimeout(timer);
}
if (!r.ok) throw new Error('HTTP ' + r.status);
// 2xx mit HTML-Body heisst: Proxy oder abgelaufene Anmeldung, NICHT gespeichert.
const ct = (r.headers.get('content-type') || '').toLowerCase();
if (ct.indexOf('application/json') === -1) throw new Error('Antwort ist kein JSON');
const data = await r.json();
if (!data || typeof data.relpath !== 'string' || data.relpath === '') {
throw new Error('Upload nicht bestaetigt (kein relpath)');
}
return data;
}
async function drainQueue() {
const db = await queueDb();
const items = (await queueRead(db)).filter(i => i.type === 'photo' && !i.failed);
if (!items.length) return;
let left = 0;
for (const it of items) {
try {
const res = await uploadQueued(it);
await queueRemove(db, it.id);
await tellClients({
type: 'photo-uploaded',
queueId: it.id,
relpath: res.relpath,
orderId: it.order_id,
filename: it.filename,
duplicate: !!res.duplicate,
});
} catch (_) {
left++; // bleibt in der Queue, naechster Sync versucht es erneut
}
}
// Noch etwas offen? Ablehnen, damit der Browser erneut zustellt.
if (left) throw new Error(left + ' Foto(s) weiterhin offen');
}
self.addEventListener('sync', (e) => {
if (e.tag === 'photo-queue') e.waitUntil(drainQueue());
});
/* Periodic Background Sync, falls der Browser ihn gewährt (installierte PWA,
* Chrome/Android). Fängt den Fall ab, dass gar kein sync-Ereignis mehr kommt. */
self.addEventListener('periodicsync', (e) => {
if (e.tag === 'photo-queue') e.waitUntil(drainQueue().catch(() => {}));
});