Compare commits

..

2 commits

Author SHA1 Message Date
aca014af94 Trigger: Deploy — Offline-Faehigkeit (Datenspiegel, SW-Fallback) [deploy]
All checks were successful
Deploy baustelle-pwa / deploy (push) Successful in 13s
2026-08-31 19:11:15 +02:00
a108664854 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.
2026-08-31 19:07:09 +02:00
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;

210
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 {
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 { try {
const data = await loadOrders(); 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
// Beides parallel — nacheinander wäre auf der Baustelle eine Wartezeit mehr // offline nur ein halber Auftrag. Die Vorschaubilder selbst liegen im
const [photos, notes] = await Promise.all([ // Bild-Cache des Service Workers und stehen damit ebenfalls ohne Netz.
api.listOrderPhotos(args.id).catch(() => ({ photos: [] })), const bundle = await loadMirrored('detail:order:' + args.id, async () => {
api.listNotes(args.id).catch(() => ({ notes: [] })), const order = await api.getOrder(args.id);
]); // Beides parallel — nacheinander wäre auf der Baustelle eine Wartezeit mehr
const [ph, nt] = await Promise.all([
api.listOrderPhotos(args.id).catch(() => ({ photos: [] })),
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 {
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 { try {
const data = await api.listCustomers(); 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 =
bindCustomerCards(); renderCustomerList(await fetchCustomers(e.target.value));
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)))
)));
}
}); });
/* ========================================================================== /* ==========================================================================