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:
parent
eaad796ab6
commit
a108664854
10 changed files with 487 additions and 50 deletions
21
README.md
21
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
|
- ✅ **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.
|
||||||
|
|
|
||||||
70
ROADMAP.md
70
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 —
|
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
15
app.css
|
|
@ -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
198
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 '<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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
19
index.php
19
index.php
|
|
@ -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 () {
|
||||||
|
|
|
||||||
22
lib/api.js
22
lib/api.js
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
lib/idb.js
13
lib/idb.js
|
|
@ -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 };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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
88
sw.js
|
|
@ -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)))
|
||||||
|
)));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue