App ohne Netz lesbar statt "Failed to fetch"

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.
This commit is contained in:
Eduard Wisch 2026-08-31 19:07:09 +02:00
parent eaad796ab6
commit a108664854
10 changed files with 487 additions and 50 deletions

View file

@ -85,6 +85,27 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
- ✅ **Bild-Cache** `baustelle-media` (cache-first, LRU 300): schon angesehene Fotos sind auch ohne Netz da; überlebt Deploys, wird beim Abmelden geleert - ✅ **Bild-Cache** `baustelle-media` (cache-first, LRU 300): schon angesehene Fotos sind auch ohne Netz da; überlebt Deploys, wird beim Abmelden geleert
- ✅ **Keine Browser-Dialoge** — `confirm`/`alert`/`prompt` sind durch eigene Modale ersetzt (`confirmDialog`, `alertDialog`, `inputDialog`); der Hardware-Zurück-Button schließt sie als Abbruch - ✅ **Keine Browser-Dialoge** — `confirm`/`alert`/`prompt` sind durch eigene Modale ersetzt (`confirmDialog`, `alertDialog`, `inputDialog`); der Hardware-Zurück-Button schließt sie als Abbruch
**Ohne Netz** (Keller, Neubau, Funkloch — auf der Baustelle der Normalfall)
- ✅ **Datenspiegel**: Auftragsliste, Kundenliste, Berichte sowie geöffnete Auftrags- und
Kundendetails werden nach jedem erfolgreichen Abruf in IndexedDB gespiegelt und ohne Netz
von dort angezeigt (Details: die letzten 30). Zusammen mit dem Bild-Cache steht ein einmal
geöffneter Auftrag samt Fotos auch im Funkloch
- ✅ **Sichtbares Band** „📴 Kein Netz — angezeigt wird der Stand von HH:MM Uhr" — ohne den
Hinweis hält man alte Zahlen für aktuell. Kommt das Netz zurück, lädt die Ansicht neu und
das Band verschwindet
- ✅ **Suche greift auf den gespiegelten Stand zu**, statt ins Leere zu laufen (mit Hinweis)
- ✅ **Verständliche Meldungen**: ein `fetch()`, das nicht bis zum Server kommt, wird zu
„Keine Verbindung zum Server" — nicht mehr die rohe Browsermeldung „Failed to fetch".
Ein echter Serverfehler (401/403/500) wird weiterhin als solcher gemeldet
- ✅ **Start ohne Netz**: der Service Worker liefert die App-Hülle aus dem Cache; die Seite
meldet ihm nach dem Laden ihre tatsächlichen Asset-URLs zum Nachcachen, sonst wäre der
Cache direkt nach einem Deploy leer (`?v=<mtime>` verhindert eine feste Dateiliste)
- ✅ **Beim Abmelden wird der Spiegel gelöscht** — am geteilten Gerät sieht sonst der nächste
Benutzer offline die Kunden und Preise des vorherigen
- ⛔ **Ändern braucht Netz** — bewusst keine Schreib-Warteschlange für Auftragsdaten
(bräuchte Idempotenz + Konfliktbehandlung). Fotos sind die Ausnahme: die gehen über die
Persist-First-Queue und werden nachgeliefert
## Hosting ## Hosting
Deploy per Forgejo-Pipeline (Commit-Tag `[deploy]`) nach `/mnt/appdata/firma/dolibarr-202509/modules/baustelle/`. Erreichbar unter `https://awl.data-it-solution.de/custom/baustelle/`. Cache-Busting automatisch via `filemtime()` in `index.php` (`?v=<mtime>` an JS/CSS) — kein manuelles Versions-Hochzählen nötig, die PWA aktualisiert sich beim nächsten Laden. Deploy per Forgejo-Pipeline (Commit-Tag `[deploy]`) nach `/mnt/appdata/firma/dolibarr-202509/modules/baustelle/`. Erreichbar unter `https://awl.data-it-solution.de/custom/baustelle/`. Cache-Busting automatisch via `filemtime()` in `index.php` (`?v=<mtime>` an JS/CSS) — kein manuelles Versions-Hochzählen nötig, die PWA aktualisiert sich beim nächsten Laden.

View file

@ -154,6 +154,76 @@ Vorher endete jeder Teilen-Vorgang in einer Sackgasse:
oben; innerhalb von 30 Minuten ist der letzte vorausgewählt (danach nur noch Schnellzugriff — oben; innerhalb von 30 Minuten ist der letzte vorausgewählt (danach nur noch Schnellzugriff —
eine Dauer-Vorauswahl würde irgendwann im falschen Auftrag landen) eine Dauer-Vorauswahl würde irgendwann im falschen Auftrag landen)
## 5. Offline lesbar statt „Failed to fetch" ✅ (2026-08-31)
Gemeldet von Eddy am 2026-08-31: Die App zeigte beim normalen Öffnen nur
**„⚠️ Failed to fetch"**, während das Teilen aus WhatsApp weiter funktionierte.
### Befund
- Prod-Deploy vollständig, alle Dateien liefern 200, die API antwortet korrekt
(403 ohne `X-Requested-With`, 401 ohne Anmeldung) — **kein Server- oder Deploy-Fehler.**
- Im frischen Desktop-Chromium lief der ganze Weg sauber durch
(`auth.php` → `verify.php` → `orders.php?open=1`, alle 200).
- „Failed to fetch" ist die *rohe Browsermeldung* eines `fetch()`, das gar nicht bis zum
Server kam. Sie stand 1:1 im UI, weil die Routen im `catch` stumpf `e.message` anzeigten.
- Warum es beim Teilen nicht auffiel: seit `eaad796` merkt sich `share.html` die letzten
Aufträge lokal und kommt ohne Netz aus. Die Hauptapp hatte für die Listen
**keinerlei Offline-Fallback** — `lib/offline.js` puffert nur Foto-Uploads.
### Umgesetzt (Muster aus KB #1037 / #1041, dort im Stundenzettel verifiziert)
- [x] **Netzfehler kenntlich machen** — `lib/api.js` wandelt den `TypeError` aus `fetch()`
in einen eigenen Fehler mit `offline = true` und deutschem Text
(„Keine Verbindung zum Server" / „Zeitüberschreitung"). Ein 4xx/5xx trägt die
Markierung **nicht** und wird weiterhin als Serverfehler gemeldet.
- [x] **`isTransient()` in `lib/offline.js` nachgezogen** — die Funktion erkannte Netzfehler
an `err instanceof TypeError`. Ohne Anpassung hätte ein Foto aus dem Funkloch als
*dauerhaft* fehlgeschlagen gegolten und wäre nach sechs Versuchen in der
Quarantäne gelandet, statt hochgeladen zu werden.
- [x] **Datenspiegel** (`offline.mirrorSave/mirrorLoad/mirrorClear`) — jede erfolgreiche
Antwort wird in IndexedDB gespiegelt: Auftragsliste, Kundenliste, Berichte sowie
Auftrags- und Kundendetails (letztere auf 30 Einträge begrenzt, der gerade
geschriebene Schlüssel ist beim Aufräumen geschützt — KB #1037).
- [x] **Sichtbares Band** „📴 Kein Netz — angezeigt wird der Stand von HH:MM Uhr".
Ohne den Hinweis hält man alte Zahlen für aktuell. Es gehört zur Ansicht und wird
vom Router bei jedem Routenwechsel zurückgesetzt.
- [x] **Suche ohne Netz** filtert im gespiegelten Stand statt ins Leere zu laufen —
mit einmaligem Hinweis, dass nur der gespeicherte Stand durchsucht wird.
- [x] **Beim Abmelden wird der Spiegel gelöscht** — sonst sähe der nächste Benutzer am
selben Gerät offline die Aufträge, Kunden und Preise des vorherigen (KB #1041).
- [x] **`sw.js`: echter Fallback.** Der Same-Origin-Zweig endete auf
`caches.match(e.request)`; fand der Cache nichts, wurde `respondWith(undefined)`
aufgerufen — und die Anfrage scheiterte erneut mit genau der Meldung, die der
Fallback verhindern sollte. Jetzt kommt immer eine echte Response: Seite →
App-Hülle aus dem Cache, sonst eine erklärende Seite; API → 503 mit JSON.
- [x] **Nur vollständige Antworten cachen** (`ok && status === 200 && type !== 'opaque'`) —
eine 206 (Teilinhalt beim Audio-Seek) liefert der Worker später als kaputte Datei aus.
- [x] **Precache nach Deploy** — der Cache trägt nach jedem Deploy einen neuen Namen und
startet leer, `app.js`/`app.css` inklusive. Eine feste Liste hilft nicht, weil die
URLs `?v=<mtime>` tragen. Die Seite meldet dem Worker nach dem Laden, was sie
tatsächlich geholt hat (`PRECACHE`-Nachricht), er cacht es einzeln nach (KB #1041).
### Verifiziert (2026-08-31, lokale Testinstanz, Chromium)
- Spiegel wird geschrieben (`mirror:orders:open`, `:customers`, `:reports`,
`:detail:order:<id>`, `:detail:customer:<id>`)
- `fetch` auf Fehler gesetzt: Auftragsliste zeigt 6 Aufträge aus dem Spiegel + Band
„Stand von 19:03 Uhr"; Kundenliste ohne Spiegel zeigt die verständliche Meldung
statt „Failed to fetch"
- Suche „Rolfs" ohne Netz → 2 Treffer aus dem Spiegel + Hinweis-Toast
- Band verschwindet auf Routen ohne Spiegel und nach Rückkehr des Netzes
- **Echtes Funkloch** (`context.setOffline(true)` + `reload()`): App startet aus dem
Cache, zeigt Band und 6 Aufträge; im Cache liegen `app.js`, `app.css` und alle
`lib/*.js` mit ihrem `?v=` — vorher wären sie nach einem Deploy nicht dagewesen
- Konsole beim Offline-Start: nur Netzfehler, keine JS-Fehler
### Bewusst nicht gebaut
Keine Schreib-Warteschlange für Auftragsdaten. Die bräuchte Idempotenz und
Konfliktbehandlung (zwei Geräte am selben Auftrag). Für Fotos existiert sie längst
(Persist-First-Queue), für alles andere gilt: lesen ja, ändern nur mit Netz.
## Erledigt ## Erledigt
### 2026-08-22 ### 2026-08-22

15
app.css
View file

@ -1470,6 +1470,21 @@ body {
} }
/* Hinweis auf noch nicht abgelegte, geteilte Inhalte (Web Share Target) */ /* Hinweis auf noch nicht abgelegte, geteilte Inhalte (Web Share Target) */
/* Offline-Band unter der Kopfzeile — zeigt an, dass die Daten von vorhin sind.
Ohne sichtbaren Hinweis haelt man den alten Stand fuer den aktuellen. */
#offline-band {
position: sticky;
top: 52px;
z-index: 9;
padding: 9px 16px;
background: #6b4b12;
color: #ffd88a;
border-bottom: 1px solid #8a6317;
font-size: 13px;
font-weight: 600;
text-align: center;
}
.shared-banner { .shared-banner {
position: fixed; position: fixed;
left: 16px; left: 16px;

198
app.js
View file

@ -423,6 +423,90 @@ window.appBoot = async function appBoot() {
} }
/* ==========================================================================
* Offline-Anzeige: Spiegel statt roher Browsermeldung
*
* Vorher zeigte jede Liste im Fehlerfall stumpf `e.message`. Kam die Anfrage nicht
* bis zum Server, war das woertlich "Failed to fetch" — fuer den Benutzer sinnlos,
* und die App war ohne Netz komplett leer (gemeldet 31.08.2026).
*
* Jetzt: erfolgreiche Antworten werden gespiegelt (offline.mirrorSave), und bei einem
* Netzfehler zeigt die Route den letzten Stand mit sichtbarem Zeitstempel. Ein
* Serverfehler (401/403/500) laeuft weiterhin in die Fehlermeldung — nur Anfragen,
* die gar nicht ankamen, tragen `e.offline`.
* ========================================================================== */
/* Frisch holen, spiegeln — und wenn kein Netz da ist, den Spiegel nehmen.
* Rueckgabe: { data, stale, ts }. Ohne Netz UND ohne Spiegel fliegt der Fehler weiter. */
async function loadMirrored(key, loader) {
try {
const data = await loader();
await offline.mirrorSave(key, data);
return { data, stale: false, ts: 0 };
} catch (e) {
if (!e || !e.offline) throw e; // echter Serverfehler -> ehrlich melden
const m = await offline.mirrorLoad(key);
if (!m) throw e; // nichts gespeichert -> ebenfalls melden
return { data: m.data, stale: true, ts: m.ts };
}
}
/* Orangefarbenes Band unter der Kopfzeile. Ohne sichtbaren Hinweis haelt man alte
* Zahlen fuer aktuell — genau davor warnt KB #1037. */
function setStale(stale, ts) {
let band = document.getElementById('offline-band');
if (!stale) { if (band) band.remove(); return; }
if (!band) {
band = document.createElement('div');
band.id = 'offline-band';
const bar = document.getElementById('topbar');
bar.parentNode.insertBefore(band, bar.nextSibling);
}
const d = ts ? new Date(ts) : null;
const wann = d
? (d.toDateString() === new Date().toDateString()
? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr'
: d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }))
: 'unbekannt';
band.textContent = '📴 Kein Netz — angezeigt wird der Stand von ' + wann;
}
/* Einheitliche Fehlerbox. escapeHtml, weil hier eine Servermeldung landen kann. */
function renderError(e) {
const msg = escapeHtml((e && e.message) || 'Unbekannter Fehler');
if (e && e.offline) {
return '<div class="empty-state"><div class="icon">📴</div>' + msg
+ '<div class="label" style="margin-top:10px;line-height:1.5">Auf diesem Gerät ist zu dieser'
+ ' Ansicht noch nichts gespeichert. Sobald wieder Netz da ist, lädt sie von selbst.</div></div>';
}
return '<div class="empty-state"><div class="icon">⚠️</div>' + msg + '</div>';
}
/* Ohne Netz kann nicht der Server suchen — dann im gespiegelten Stand filtern.
* Das wird angesagt (Toast), sonst haelt man das Ergebnis fuer eine Vollsuche. */
const ORDER_SEARCH_FIELDS = ['ref', 'customer.name', 'customer.town', 'customer.address', 'customer.zip'];
const CUSTOMER_SEARCH_FIELDS = ['name', 'town', 'zip', 'code', 'phone'];
let localSearchHintShown = false;
function localFilter(list, q, fields) {
const needle = (q || '').trim().toLowerCase();
if (!needle) return list;
return list.filter(o => fields.some(f => {
const v = f.split('.').reduce((a, k) => (a == null ? a : a[k]), o);
return v != null && String(v).toLowerCase().includes(needle);
}));
}
function localSearchHint() {
if (localSearchHintShown) return;
localSearchHintShown = true;
showToast('Ohne Netz wird nur im gespeicherten Stand gesucht');
}
/* Kommt das Netz zurueck, erst frische Daten holen — danach verschwindet das Band
* von selbst, weil die Route dann mit stale=false neu zeichnet. */
window.addEventListener('online', () => {
if (document.getElementById('offline-band')) router.navigate();
});
/* ====== ROUTES ====== */ /* ====== ROUTES ====== */
router.on('/login', async () => { router.on('/login', async () => {
@ -498,7 +582,8 @@ router.on('/today', async () => {
try { try {
// Wir nutzen einfach die offenen Aufträge und filtern clientseitig die von heute // Wir nutzen einfach die offenen Aufträge und filtern clientseitig die von heute
const data = await api.listOrders({ open: 1 }); const { data, stale, ts } = await loadMirrored('orders:open', () => api.listOrders({ open: 1 }));
setStale(stale, ts);
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
@ -536,7 +621,7 @@ router.on('/today', async () => {
c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id)); c.addEventListener('click', () => router.go('#/orders/' + c.dataset.id));
}); });
} catch (e) { } catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>'; main().innerHTML = renderError(e);
} }
}); });
@ -562,8 +647,28 @@ router.on('/orders', async () => {
}); });
} }
// Online fragt der Server, ohne Netz wird im gespiegelten Stand gefiltert.
// Gespiegelt wird nur die ungefilterte Liste — eine Suchantwort waere ein
// Ausschnitt und wuerde beim naechsten Offline-Start als "der Stand" gelten.
const mirrorKey = () => 'orders:' + (showAllOrders ? 'all' : 'open');
async function fetchOrders(q) {
try { try {
const data = await loadOrders(); const d = await loadOrders(q);
if (!q) await offline.mirrorSave(mirrorKey(), d);
setStale(false);
return d.orders || [];
} catch (e) {
if (!e || !e.offline) throw e;
const m = await offline.mirrorLoad(mirrorKey());
if (!m) throw e;
setStale(true, m.ts);
if (q) localSearchHint();
return localFilter((m.data && m.data.orders) || [], q, ORDER_SEARCH_FIELDS);
}
}
try {
const data = { orders: await fetchOrders('') };
if (!data.orders.length) { if (!data.orders.length) {
main().innerHTML = ` main().innerHTML = `
<div class="search-bar"> <div class="search-bar">
@ -590,23 +695,24 @@ router.on('/orders', async () => {
main().innerHTML = html; main().innerHTML = html;
bindOrderCards(); bindOrderCards();
document.getElementById('order-search').addEventListener('input', async (e) => { async function redrawOrders(q) {
const q = e.target.value; try {
const d = await loadOrders(q); document.getElementById('order-list').innerHTML = renderOrderList(await fetchOrders(q));
document.getElementById('order-list').innerHTML = renderOrderList(d.orders);
bindOrderCards(); bindOrderCards();
}); } catch (err) {
showToast((err && err.message) || 'Fehler', 'error');
}
}
document.getElementById('show-all-toggle').addEventListener('change', async (e) => { document.getElementById('order-search').addEventListener('input', (e) => redrawOrders(e.target.value));
document.getElementById('show-all-toggle').addEventListener('change', (e) => {
showAllOrders = e.target.checked; showAllOrders = e.target.checked;
localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0'); localStorage.setItem('pwa_show_all_orders', showAllOrders ? '1' : '0');
const q = document.getElementById('order-search').value; redrawOrders(document.getElementById('order-search').value);
const d = await loadOrders(q);
document.getElementById('order-list').innerHTML = renderOrderList(d.orders);
bindOrderCards();
}); });
} catch (e) { } catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>'; main().innerHTML = renderError(e);
} }
}); });
@ -630,12 +736,22 @@ router.on('/orders/:id', async (args) => {
showLoader('Lade Auftrag…'); showLoader('Lade Auftrag…');
try { try {
const data = await api.getOrder(args.id); // Als EIN Paket spiegeln — der Auftrag ohne seine Fotos und Notizen waere
// offline nur ein halber Auftrag. Die Vorschaubilder selbst liegen im
// Bild-Cache des Service Workers und stehen damit ebenfalls ohne Netz.
const bundle = await loadMirrored('detail:order:' + args.id, async () => {
const order = await api.getOrder(args.id);
// Beides parallel — nacheinander wäre auf der Baustelle eine Wartezeit mehr // Beides parallel — nacheinander wäre auf der Baustelle eine Wartezeit mehr
const [photos, notes] = await Promise.all([ const [ph, nt] = await Promise.all([
api.listOrderPhotos(args.id).catch(() => ({ photos: [] })), api.listOrderPhotos(args.id).catch(() => ({ photos: [] })),
api.listNotes(args.id).catch(() => ({ notes: [] })), api.listNotes(args.id).catch(() => ({ notes: [] })),
]); ]);
return { order, photos: ph, notes: nt };
});
setStale(bundle.stale, bundle.ts);
const data = bundle.data.order;
const photos = bundle.data.photos;
const notes = bundle.data.notes;
const notesCount = notes.notes.length; const notesCount = notes.notes.length;
title(data.order.ref); title(data.order.ref);
@ -929,7 +1045,7 @@ router.on('/orders/:id', async (args) => {
camInput.addEventListener('change', () => handleFiles(camInput.files)); camInput.addEventListener('change', () => handleFiles(camInput.files));
galInput.addEventListener('change', () => handleFiles(galInput.files)); galInput.addEventListener('change', () => handleFiles(galInput.files));
} catch (e) { } catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>'; main().innerHTML = renderError(e);
} }
}); });
@ -1070,8 +1186,24 @@ router.on('/customers', async () => {
setBack(false); setBack(false);
showLoader('Lade Kunden…'); showLoader('Lade Kunden…');
async function fetchCustomers(q) {
try { try {
const data = await api.listCustomers(); const d = await api.listCustomers(q ? { q } : {});
if (!q) await offline.mirrorSave('customers', d);
setStale(false);
return d.customers || [];
} catch (e) {
if (!e || !e.offline) throw e;
const m = await offline.mirrorLoad('customers');
if (!m) throw e;
setStale(true, m.ts);
if (q) localSearchHint();
return localFilter((m.data && m.data.customers) || [], q, CUSTOMER_SEARCH_FIELDS);
}
}
try {
const data = { customers: await fetchCustomers('') };
if (!data.customers.length) { if (!data.customers.length) {
main().innerHTML = '<div class="empty-state"><div class="icon">👥</div>Keine Kunden</div>'; main().innerHTML = '<div class="empty-state"><div class="icon">👥</div>Keine Kunden</div>';
return; return;
@ -1082,12 +1214,16 @@ router.on('/customers', async () => {
`; `;
bindCustomerCards(); bindCustomerCards();
document.getElementById('cust-search').addEventListener('input', async (e) => { document.getElementById('cust-search').addEventListener('input', async (e) => {
const d = await api.listCustomers({ q: e.target.value }); try {
document.getElementById('cust-list').innerHTML = renderCustomerList(d.customers); document.getElementById('cust-list').innerHTML =
renderCustomerList(await fetchCustomers(e.target.value));
bindCustomerCards(); bindCustomerCards();
} catch (err) {
showToast((err && err.message) || 'Fehler', 'error');
}
}); });
} catch (e) { } catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>'; main().innerHTML = renderError(e);
} }
}); });
@ -1117,7 +1253,9 @@ router.on('/customers/:id', async (args) => {
showLoader('Lade Kunde…'); showLoader('Lade Kunde…');
try { try {
const data = await api.getCustomer(args.id); const mirrored = await loadMirrored('detail:customer:' + args.id, () => api.getCustomer(args.id));
setStale(mirrored.stale, mirrored.ts);
const data = mirrored.data;
title(data.customer.name); title(data.customer.name);
const addr = [data.customer.address, (data.customer.zip || '') + ' ' + (data.customer.town || '')] const addr = [data.customer.address, (data.customer.zip || '') + ' ' + (data.customer.town || '')]
@ -1178,7 +1316,7 @@ router.on('/customers/:id', async (args) => {
el.addEventListener('click', () => router.go('#/reports/' + el.dataset.reportId)); el.addEventListener('click', () => router.go('#/reports/' + el.dataset.reportId));
}); });
} catch (e) { } catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>'; main().innerHTML = renderError(e);
} }
}); });
@ -1199,7 +1337,8 @@ router.on('/reports', async () => {
setBack(false); setBack(false);
showLoader('Lade Berichte…'); showLoader('Lade Berichte…');
try { try {
const data = await api.listReports(); const { data, stale, ts } = await loadMirrored('reports', () => api.listReports());
setStale(stale, ts);
if (!data.reports.length) { if (!data.reports.length) {
main().innerHTML = '<div class="empty-state"><div class="icon">📑</div>Noch keine Berichte</div>'; main().innerHTML = '<div class="empty-state"><div class="icon">📑</div>Noch keine Berichte</div>';
return; return;
@ -1222,7 +1361,7 @@ router.on('/reports', async () => {
c.addEventListener('click', () => router.go('#/reports/' + c.dataset.id)); c.addEventListener('click', () => router.go('#/reports/' + c.dataset.id));
}); });
} catch (e) { } catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>'; main().innerHTML = renderError(e);
} }
}); });
@ -1348,7 +1487,7 @@ router.on('/reports/:id', async (args) => {
}; };
}; };
} catch (e) { } catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>'; main().innerHTML = renderError(e);
} }
}); });
@ -1402,6 +1541,9 @@ router.on('/settings', async () => {
await api.logout(); await api.logout();
await idb.del('pin_hash'); await idb.del('pin_hash');
await idb.del('pin_salt'); await idb.del('pin_salt');
// Der Offline-Spiegel muss mit weg — sonst sieht der naechste Benutzer an
// diesem Geraet ohne Netz die Auftraege, Kunden und Preise des vorherigen.
await offline.mirrorClear();
// Gecachte Kundenfotos vom Gerät nehmen — der Bild-Cache überlebt sonst die Abmeldung // Gecachte Kundenfotos vom Gerät nehmen — der Bild-Cache überlebt sonst die Abmeldung
try { try {
if (navigator.serviceWorker && navigator.serviceWorker.controller) { if (navigator.serviceWorker && navigator.serviceWorker.controller) {
@ -2240,7 +2382,7 @@ router.on('/orders/:id/shipments', async (args) => {
c.addEventListener('click', () => router.go('#/shipments/' + c.dataset.id)); c.addEventListener('click', () => router.go('#/shipments/' + c.dataset.id));
}); });
} catch (e) { } catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escapeHtml(e.message) + '</div>'; main().innerHTML = renderError(e);
} }
}); });
@ -2288,7 +2430,7 @@ router.on('/shipments/:id', async (args) => {
}); });
}; };
} catch (e) { } catch (e) {
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escapeHtml(e.message) + '</div>'; main().innerHTML = renderError(e);
} }
}); });

View file

@ -137,6 +137,25 @@ if ('serviceWorker' in navigator) {
}); });
}); });
}).catch(function (e) { console.warn('SW reg failed', e); }); }).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 // Controller-Change → einmal neu laden
var reloaded = false; var reloaded = false;
navigator.serviceWorker.addEventListener('controllerchange', function () { navigator.serviceWorker.addEventListener('controllerchange', function () {

View file

@ -43,6 +43,28 @@
let r; let r;
try { try {
r = await fetch(API_BASE + path, { ...opts, headers, signal, credentials: 'same-origin' }); r = await fetch(API_BASE + path, { ...opts, headers, signal, credentials: 'same-origin' });
} catch (err) {
// Hier ist die Anfrage GAR NICHT bis zum Server gekommen: kein Netz, Funkloch,
// abgebrochene Verbindung, Zeitueberschreitung. fetch() wirft dafuer einen
// TypeError mit der englischen Rohmeldung "Failed to fetch" — die stand bis
// 31.08.2026 wortwoertlich in der App, weil die Routen `e.message` anzeigen.
//
// Wir uebersetzen das in einen eigenen Fehler mit `offline = true`. Daran
// erkennen die Routen, dass sie den Offline-Spiegel zeigen duerfen, statt eine
// Fehlermeldung — ein Serverfehler (4xx/5xx) traegt die Markierung NICHT.
//
// `name` wird bewusst mitgefuehrt: isTransient() in offline.js entscheidet
// daran (und an `offline`), ob ein Foto in der Upload-Queue bleibt. Ginge die
// Information verloren, landete jedes Foto aus dem Funkloch nach sechs
// Versuchen in der Quarantaene statt hochgeladen zu werden.
const abort = !!(err && err.name === 'AbortError');
const e = new Error(abort
? 'Zeitueberschreitung — der Server hat nicht rechtzeitig geantwortet'
: 'Keine Verbindung zum Server');
e.name = abort ? 'AbortError' : 'NetworkError';
e.offline = true;
e.cause = err;
throw e;
} finally { } finally {
if (toHandle) clearTimeout(toHandle); if (toHandle) clearTimeout(toHandle);
} }

View file

@ -55,6 +55,17 @@
}); });
} }
// Alle Schluessel im kv-Store — gebraucht, um den Offline-Spiegel aufzuraeumen
async function keys() {
const db = await open();
return new Promise((res, rej) => {
const tx = db.transaction('kv', 'readonly');
const r = tx.objectStore('kv').getAllKeys();
r.onsuccess = () => res(r.result || []);
r.onerror = () => rej(r.error);
});
}
async function queuePush(item) { async function queuePush(item) {
const db = await open(); const db = await open();
return new Promise((res, rej) => { return new Promise((res, rej) => {
@ -96,5 +107,5 @@
}); });
} }
window.idb = { get, set, del, queuePush, queueAll, queueDelete, queueUpdate }; window.idb = { get, set, del, keys, queuePush, queueAll, queueDelete, queueUpdate };
})(); })();

View file

@ -80,6 +80,7 @@
// NUR echte Client-Fehler (400/413/415: falscher Dateityp / zu groß) sind dauerhaft. // NUR echte Client-Fehler (400/413/415: falscher Dateityp / zu groß) sind dauerhaft.
function isTransient(err) { function isTransient(err) {
if (!err) return true; 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.name === 'AbortError') return true; // Timeout via AbortController
if (err instanceof TypeError) return true; // fetch: „Failed to fetch" if (err instanceof TypeError) return true; // fetch: „Failed to fetch"
if (err.uploadUnconfirmed) return true; // 2xx, aber kein relpath → nicht bestätigt if (err.uploadUnconfirmed) return true; // 2xx, aber kein relpath → nicht bestätigt
@ -269,6 +270,78 @@
setInterval(() => { if (navigator.onLine) syncQueue(); }, 15000); 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, window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed,
acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync }; acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync,
mirrorSave, mirrorLoad, mirrorClear };
})(); })();

View file

@ -27,6 +27,10 @@
const args = {}; const args = {};
r.params.forEach((p, i) => { args[p] = decodeURIComponent(m[i + 1]); }); r.params.forEach((p, i) => { args[p] = decodeURIComponent(m[i + 1]); });
currentRoute = { pattern: key, args }; currentRoute = { pattern: key, args };
// Das Offline-Band gilt immer nur fuer die gerade gezeichnete Ansicht.
// Ohne dieses Zuruecksetzen bliebe es auf Seiten stehen, die gar keinen
// Spiegel haben (Einstellungen, Lieferungen).
if (typeof window.setStale === 'function') window.setStale(false);
try { try {
await r.handler(args); await r.handler(args);
} catch (e) { } catch (e) {

88
sw.js
View file

@ -32,13 +32,13 @@ async function mediaCacheFirst(request) {
if (hit) { if (hit) {
// Auffrischen ohne den Nutzer warten zu lassen // Auffrischen ohne den Nutzer warten zu lassen
fetch(request).then(r => { fetch(request).then(r => {
if (r && r.ok) c.put(request, r.clone()).then(trimMediaCache); if (cacheable(r)) c.put(request, r.clone()).then(trimMediaCache);
}).catch(() => null); }).catch(() => null);
return hit; return hit;
} }
try { try {
const r = await fetch(request); const r = await fetch(request);
if (r && r.ok) { await c.put(request, r.clone()); trimMediaCache(); } if (cacheable(r)) { await c.put(request, r.clone()); trimMediaCache(); }
return r; return r;
} catch (e) { } catch (e) {
// Offline und nichts im Cache — der Aufrufer zeigt seinen Platzhalter // Offline und nichts im Cache — der Aufrufer zeigt seinen Platzhalter
@ -172,22 +172,69 @@ self.addEventListener('fetch', (e) => {
// Eigene Assets: Network-First mit Cache-Fallback // Eigene Assets: Network-First mit Cache-Fallback
if (url.origin === location.origin) { if (url.origin === location.origin) {
e.respondWith( e.respondWith(networkFirst(e.request));
fetch(e.request)
.then(response => {
// Cache aktualisieren (nur erfolgreiche responses)
if (response && response.ok) {
const clone = response.clone();
caches.open(CACHE).then(c => c.put(e.request, clone)).catch(() => null);
}
return response;
})
.catch(() => caches.match(e.request))
);
return; return;
} }
}); });
/* Nur vollstaendige Antworten cachen. Eine 206 (Teilinhalt, z.B. Audio-Seek) oder eine
* opaque Antwort liefert der Worker spaeter als kaputte Datei aus. */
function cacheable(response) {
return !!response && response.ok && response.status === 200 && response.type !== 'opaque';
}
/* Network-First fuer eigene Dateien.
*
* ACHTUNG, hier lag ein echter Fehler: frueher endete der Zweig auf
* `.catch(() => caches.match(e.request))`. Findet der Cache nichts, ist das Ergebnis
* `undefined` — und `respondWith(undefined)` laesst die Anfrage erneut scheitern, mit
* exakt derselben Meldung "Failed to fetch", die der Fallback verhindern sollte.
* Deshalb gibt diese Fassung IMMER eine echte Response zurueck. */
async function networkFirst(request) {
try {
const response = await fetch(request);
if (cacheable(response)) {
const clone = response.clone();
caches.open(CACHE).then(c => c.put(request, clone)).catch(() => null);
}
return response;
} catch (_) {
const hit = await caches.match(request);
if (hit) return hit;
return offlineFallback(request);
}
}
/* Kein Netz und nichts im Cache. Fuer eine Seitennavigation die App-Huelle nehmen
* (die Startseite liegt im Precache) — die App startet dann und zeigt ihren eigenen
* Offline-Stand. Nur wenn selbst die fehlt, eine erklaerende Seite statt der
* Browser-Fehlerseite. */
async function offlineFallback(request) {
const accept = request.headers.get('accept') || '';
const isPage = request.mode === 'navigate' || accept.includes('text/html');
if (isPage) {
const shell = (await caches.match('./index.php')) || (await caches.match('./'));
if (shell) return shell;
return new Response(
'<!doctype html><html lang="de"><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
+ '<title>Baustelle — offline</title></head>'
+ '<body style="margin:0;background:#1a1a1f;color:#f0f0f0;'
+ 'font-family:-apple-system,Roboto,sans-serif;display:flex;align-items:center;'
+ 'justify-content:center;height:100vh;text-align:center">'
+ '<div style="padding:24px"><div style="font-size:44px">\u{1F4F4}</div>'
+ '<h1 style="font-size:18px">Keine Verbindung</h1>'
+ '<p style="opacity:.7;line-height:1.5">Die App konnte nicht geladen werden.<br>'
+ 'Sobald wieder Netz da ist, erneut \u00f6ffnen.</p></div></body></html>',
{ status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
}
return new Response(JSON.stringify({ error: 'offline', offline: true }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
/* Message-Handler: Client kann "SKIP_WAITING" schicken um ein wartenden SW /* Message-Handler: Client kann "SKIP_WAITING" schicken um ein wartenden SW
* sofort zu aktivieren (für In-App-Update-Button). */ * sofort zu aktivieren (für In-App-Update-Button). */
self.addEventListener('message', (e) => { self.addEventListener('message', (e) => {
@ -198,6 +245,19 @@ self.addEventListener('message', (e) => {
if (e.data && e.data.type === 'CLEAR_MEDIA') { if (e.data && e.data.type === 'CLEAR_MEDIA') {
e.waitUntil(caches.delete(MEDIA_CACHE)); e.waitUntil(caches.delete(MEDIA_CACHE));
} }
/* Nach jedem Deploy traegt der Cache einen neuen Namen und ist erst einmal LEER —
* inklusive app.js und app.css. Wer die neue Fassung morgens laedt und dann ins
* Funkloch faehrt, haette die Browser-Fehlerseite (KB #1041).
*
* Eine feste Dateiliste im install-Schritt hilft nicht: die Asset-URLs tragen
* ?v=<mtime> und aendern sich mit jedem Deploy. Deshalb meldet die Seite nach dem
* Laden, was sie tatsaechlich geholt hat, und das wird nachgecached — einzeln, denn
* ein addAll() wuerde bei einer einzigen 404 alles verwerfen. */
if (e.data && e.data.type === 'PRECACHE' && Array.isArray(e.data.urls)) {
e.waitUntil(caches.open(CACHE).then(c => Promise.all(
e.data.urls.map(u => c.match(u).then(hit => hit ? null : c.add(u).catch(() => null)))
)));
}
}); });
/* ========================================================================== /* ==========================================================================