From a1086648549a7a8195c5d172779b5995c1d0a544 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Mon, 31 Aug 2026 19:07:09 +0200 Subject: [PATCH] App ohne Netz lesbar statt "Failed to fetch" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=) 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. --- README.md | 21 +++++ ROADMAP.md | 70 +++++++++++++++++ app.css | 15 ++++ app.js | 210 +++++++++++++++++++++++++++++++++++++++++-------- index.php | 19 +++++ lib/api.js | 22 ++++++ lib/idb.js | 13 ++- lib/offline.js | 75 +++++++++++++++++- lib/router.js | 4 + sw.js | 88 +++++++++++++++++---- 10 files changed, 487 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 4db4c17..12acf14 100644 --- a/README.md +++ b/README.md @@ -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 - ✅ **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=` 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 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=` an JS/CSS) — kein manuelles Versions-Hochzählen nötig, die PWA aktualisiert sich beim nächsten Laden. diff --git a/ROADMAP.md b/ROADMAP.md index 25b5ee9..f996ef1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 — 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=` 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:`, `:detail:customer:`) +- `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 ### 2026-08-22 diff --git a/app.css b/app.css index 4b133a3..e5a3a77 100644 --- a/app.css +++ b/app.css @@ -1470,6 +1470,21 @@ body { } /* 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 { position: fixed; left: 16px; diff --git a/app.js b/app.js index 556e3c9..34e384e 100644 --- a/app.js +++ b/app.js @@ -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 '
📴
' + msg + + '
Auf diesem Gerät ist zu dieser' + + ' Ansicht noch nichts gespeichert. Sobald wieder Netz da ist, lädt sie von selbst.
'; + } + return '
⚠️
' + msg + '
'; +} + +/* 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 ====== */ router.on('/login', async () => { @@ -498,7 +582,8 @@ router.on('/today', async () => { try { // 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(); today.setHours(0, 0, 0, 0); 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)); }); } catch (e) { - main().innerHTML = '
⚠️
' + e.message + '
'; + 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 { + 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 = await loadOrders(); + const data = { orders: await fetchOrders('') }; if (!data.orders.length) { main().innerHTML = `