baustelle-pwa/index.php
Eddy 30202f8f28 Keine Doppel-Uploads mehr, App-Update laedt nicht mitten in der Arbeit neu
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>
2026-09-18 23:49:02 +02:00

185 lines
8.5 KiB
PHP

<?php
/* Baustelle PWA — Einstiegsseite.
* Cache-Versionierung via filemtime() nach Pattern claude-db #201.
* Bei jedem Deploy ändern sich die mtimes → neue URLs → Browser holt frische Files.
*/
$cssVersion = @filemtime(__DIR__.'/app.css') ?: time();
$jsVersion = max(
@filemtime(__DIR__.'/app.js') ?: time(),
@filemtime(__DIR__.'/lib/idb.js') ?: time(),
@filemtime(__DIR__.'/lib/api.js') ?: time(),
@filemtime(__DIR__.'/lib/offline.js')?: time(),
@filemtime(__DIR__.'/lib/router.js') ?: time()
);
$manifestVersion = @filemtime(__DIR__.'/manifest.webmanifest') ?: time();
$swVersion = max($cssVersion, $jsVersion, $manifestVersion);
// Einstiegsseite NIE cachen
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#1a1a1f">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Baustelle</title>
<link rel="manifest" href="manifest.webmanifest?v=<?php echo $manifestVersion; ?>">
<link rel="icon" type="image/svg+xml" href="icons/icon.svg">
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icons/icon-512.png">
<link rel="apple-touch-icon" href="icons/icon-192.png">
<link rel="stylesheet" href="app.css?v=<?php echo $cssVersion; ?>">
</head>
<body>
<div id="app">
<header id="topbar">
<button id="back-btn" class="icon-btn" style="display:none">←</button>
<h1 id="page-title">Baustelle</h1>
<button id="install-btn" class="icon-btn" title="Als App installieren" style="display:none">📲</button>
<button id="help-btn" class="icon-btn" title="Hilfe">❓</button>
<span id="status-badge">🟢</span>
</header>
<main id="main"></main>
<nav id="bottom-nav" style="display:none">
<button data-route="today">☀️ Heute</button>
<button data-route="orders" class="active">📋 Aufträge</button>
<button data-route="customers">👥 Kunden</button>
<button data-route="reports">📑 Berichte</button>
<button data-route="settings">⚙️</button>
</nav>
</div>
<button id="fab-new-order" class="fab" title="Neuer Auftrag" aria-label="Neuer Auftrag" hidden>+</button>
<div id="toast-container"></div>
<script>
console.log('[Init] PDF.js wird geladen von: /custom/bericht/js/lib/pdf.min.js');
</script>
<script src="/custom/bericht/js/lib/pdf.min.js?v=<?php echo $jsVersion; ?>"></script>
<script>
// PDF.js wird als pdfjsLib exportiert, nicht als pdfjs
const pdfjs = window.pdfjsLib;
console.log('[PDF.js] Nach Script-Load: typeof pdfjs =', typeof pdfjs);
// PDF.js Worker setzen NACHDEM pdf.min.js geladen wurde
if (typeof pdfjs !== 'undefined') {
pdfjs.GlobalWorkerOptions.workerSrc = '/custom/bericht/js/lib/pdf.worker.min.js?v=<?php echo $jsVersion; ?>';
console.log('[PDF.js] ✓ Geladen! Worker:', pdfjs.GlobalWorkerOptions.workerSrc);
} else {
console.error('[PDF.js] ❌ NICHT GELADEN!');
}
</script>
<script src="lib/idb.js?v=<?php echo $jsVersion; ?>"></script>
<script src="lib/api.js?v=<?php echo $jsVersion; ?>"></script>
<script src="lib/offline.js?v=<?php echo $jsVersion; ?>"></script>
<script src="lib/router.js?v=<?php echo $jsVersion; ?>"></script>
<script src="app.js?v=<?php echo $jsVersion; ?>"></script>
<!-- awlauth WebAuthn/Passkey (zentrales Modul, same-origin unter /custom/awlauth) -->
<script>window.AWLAUTH_BASE = '/custom/awlauth';</script>
<script src="/custom/awlauth/js/awlauth-webauthn.js?v=<?php echo @filemtime($_SERVER['DOCUMENT_ROOT'].'/custom/awlauth/js/awlauth-webauthn.js') ?: $jsVersion; ?>"></script>
<script>
/* PWA Install Prompt abfangen */
let deferredInstallPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
deferredInstallPrompt = e;
var btn = document.getElementById('install-btn');
if (btn) btn.style.display = '';
console.log('PWA: Install-Prompt ist verfügbar');
});
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('install-btn');
if (btn) btn.addEventListener('click', async function () {
if (!deferredInstallPrompt) {
alert('Installation nicht verfügbar. Falls du die App bereits installiert hast, öffne sie über das Home-Screen-Icon.\n\nAuf iPhone: Safari → Teilen → "Zum Home-Bildschirm".');
return;
}
deferredInstallPrompt.prompt();
var choice = await deferredInstallPrompt.userChoice;
if (choice.outcome === 'accepted') {
btn.style.display = 'none';
}
deferredInstallPrompt = null;
});
});
window.addEventListener('appinstalled', function () {
var btn = document.getElementById('install-btn');
if (btn) btn.style.display = 'none';
console.log('PWA: Installation abgeschlossen');
});
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('sw.js?v=<?php echo $swVersion; ?>').then(function (reg) {
// Periodisch updates prüfen
setInterval(function () { reg.update(); }, 60000);
// Bei Tab-Wechsel zurück: sofort prüfen
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') reg.update();
});
// Neuer SW installed → sofort aktivieren
reg.addEventListener('updatefound', function () {
var nw = reg.installing;
if (!nw) return;
nw.addEventListener('statechange', function () {
if (nw.state === 'installed' && navigator.serviceWorker.controller) {
nw.postMessage({ type: 'SKIP_WAITING' });
}
});
});
}).catch(function (e) { console.warn('SW reg failed', e); });
/* Dem Service Worker melden, was diese Seite tatsaechlich geladen hat, damit er
es nachcachen kann. Noetig, weil der Cache nach jedem Deploy einen neuen Namen
traegt und leer startet — und weil die Asset-URLs ein ?v=<mtime> tragen, laesst
sich im Worker keine feste Dateiliste hinterlegen (KB #1041). Ohne das steht,
wer die neue Fassung laedt und dann ins Funkloch faehrt, vor einer toten App. */
navigator.serviceWorker.ready.then(function (reg) {
var ziel = reg.active || navigator.serviceWorker.controller;
if (!ziel) return;
var urls = performance.getEntriesByType('resource')
.map(function (r) { return r.name; })
.filter(function (u) {
return u.indexOf(location.origin) === 0
&& /\.(css|js|woff2?|png|svg|ico|webmanifest)(\?|$)/i.test(u);
});
urls.push(location.origin + location.pathname);
ziel.postMessage({ type: 'PRECACHE', urls: urls });
}).catch(function () {});
/* Controller-Change → einmal neu laden — aber nicht mitten in der Arbeit.
Frueher kam der Reload sofort: offene Kamera zu, getippte Notiz weg, halbe
Unterschrift weg (und lagen noch Fotos in der Warteschlange, zeigte der Browser
dazu seinen eigenen "Seite verlassen?"-Dialog). Jetzt fragt die Seite app.js
(window.appBusy) und holt den Reload nach, sobald nichts mehr offen ist —
spaetestens, wenn die App in den Hintergrund geht und dabei nichts offen ist. */
var reloadWanted = false, reloaded = false;
function tryReload() {
if (!reloadWanted || reloaded) return;
var busy = false;
try { busy = (typeof window.appBusy === 'function') && window.appBusy(); } catch (_) {}
if (busy) return;
reloaded = true;
window.__selfReload = true; // offline.js: eigener Reload, keine beforeunload-Warnung
window.location.reload();
}
navigator.serviceWorker.addEventListener('controllerchange', function () {
reloadWanted = true;
tryReload();
});
setInterval(tryReload, 3000);
document.addEventListener('visibilitychange', tryReload);
});
}
</script>
</body>
</html>