Compare commits
No commits in common. "aca014af9462a9fa03e74d230476038e11428e20" and "eaad796ab655d52c248c380167e46b9872f6ad48" have entirely different histories.
aca014af94
...
eaad796ab6
10 changed files with 50 additions and 487 deletions
21
README.md
21
README.md
|
|
@ -85,27 +85,6 @@ 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,76 +154,6 @@ 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,21 +1470,6 @@ 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,90 +423,6 @@ 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 () => {
|
||||||
|
|
@ -582,8 +498,7 @@ 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, stale, ts } = await loadMirrored('orders:open', () => api.listOrders({ open: 1 }));
|
const data = await 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);
|
||||||
|
|
@ -621,7 +536,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 = renderError(e);
|
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -647,28 +562,8 @@ 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 d = await loadOrders(q);
|
const data = await loadOrders();
|
||||||
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">
|
||||||
|
|
@ -695,24 +590,23 @@ router.on('/orders', async () => {
|
||||||
main().innerHTML = html;
|
main().innerHTML = html;
|
||||||
bindOrderCards();
|
bindOrderCards();
|
||||||
|
|
||||||
async function redrawOrders(q) {
|
document.getElementById('order-search').addEventListener('input', async (e) => {
|
||||||
try {
|
const q = e.target.value;
|
||||||
document.getElementById('order-list').innerHTML = renderOrderList(await fetchOrders(q));
|
const d = await loadOrders(q);
|
||||||
|
document.getElementById('order-list').innerHTML = renderOrderList(d.orders);
|
||||||
bindOrderCards();
|
bindOrderCards();
|
||||||
} catch (err) {
|
});
|
||||||
showToast((err && err.message) || 'Fehler', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('order-search').addEventListener('input', (e) => redrawOrders(e.target.value));
|
document.getElementById('show-all-toggle').addEventListener('change', async (e) => {
|
||||||
|
|
||||||
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');
|
||||||
redrawOrders(document.getElementById('order-search').value);
|
const q = 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 = renderError(e);
|
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -736,22 +630,12 @@ router.on('/orders/:id', async (args) => {
|
||||||
showLoader('Lade Auftrag…');
|
showLoader('Lade Auftrag…');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Als EIN Paket spiegeln — der Auftrag ohne seine Fotos und Notizen waere
|
const data = await api.getOrder(args.id);
|
||||||
// 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 [ph, nt] = await Promise.all([
|
const [photos, notes] = 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);
|
||||||
|
|
||||||
|
|
@ -1045,7 +929,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 = renderError(e);
|
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1186,24 +1070,8 @@ router.on('/customers', async () => {
|
||||||
setBack(false);
|
setBack(false);
|
||||||
showLoader('Lade Kunden…');
|
showLoader('Lade Kunden…');
|
||||||
|
|
||||||
async function fetchCustomers(q) {
|
|
||||||
try {
|
try {
|
||||||
const d = await api.listCustomers(q ? { q } : {});
|
const data = await api.listCustomers();
|
||||||
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;
|
||||||
|
|
@ -1214,16 +1082,12 @@ router.on('/customers', async () => {
|
||||||
`;
|
`;
|
||||||
bindCustomerCards();
|
bindCustomerCards();
|
||||||
document.getElementById('cust-search').addEventListener('input', async (e) => {
|
document.getElementById('cust-search').addEventListener('input', async (e) => {
|
||||||
try {
|
const d = await api.listCustomers({ q: e.target.value });
|
||||||
document.getElementById('cust-list').innerHTML =
|
document.getElementById('cust-list').innerHTML = renderCustomerList(d.customers);
|
||||||
renderCustomerList(await fetchCustomers(e.target.value));
|
|
||||||
bindCustomerCards();
|
bindCustomerCards();
|
||||||
} catch (err) {
|
|
||||||
showToast((err && err.message) || 'Fehler', 'error');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
main().innerHTML = renderError(e);
|
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1253,9 +1117,7 @@ router.on('/customers/:id', async (args) => {
|
||||||
showLoader('Lade Kunde…');
|
showLoader('Lade Kunde…');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mirrored = await loadMirrored('detail:customer:' + args.id, () => api.getCustomer(args.id));
|
const data = await 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 || '')]
|
||||||
|
|
@ -1316,7 +1178,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 = renderError(e);
|
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1337,8 +1199,7 @@ router.on('/reports', async () => {
|
||||||
setBack(false);
|
setBack(false);
|
||||||
showLoader('Lade Berichte…');
|
showLoader('Lade Berichte…');
|
||||||
try {
|
try {
|
||||||
const { data, stale, ts } = await loadMirrored('reports', () => api.listReports());
|
const data = await 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;
|
||||||
|
|
@ -1361,7 +1222,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 = renderError(e);
|
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1487,7 +1348,7 @@ router.on('/reports/:id', async (args) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
main().innerHTML = renderError(e);
|
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + e.message + '</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1541,9 +1402,6 @@ 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) {
|
||||||
|
|
@ -2382,7 +2240,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 = renderError(e);
|
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escapeHtml(e.message) + '</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2430,7 +2288,7 @@ router.on('/shipments/:id', async (args) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
main().innerHTML = renderError(e);
|
main().innerHTML = '<div class="empty-state"><div class="icon">⚠️</div>' + escapeHtml(e.message) + '</div>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
19
index.php
19
index.php
|
|
@ -137,25 +137,6 @@ 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,28 +43,6 @@
|
||||||
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,17 +55,6 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) => {
|
||||||
|
|
@ -107,5 +96,5 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.idb = { get, set, del, keys, queuePush, queueAll, queueDelete, queueUpdate };
|
window.idb = { get, set, del, queuePush, queueAll, queueDelete, queueUpdate };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@
|
||||||
// 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
|
||||||
|
|
@ -270,78 +269,6 @@
|
||||||
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,10 +27,6 @@
|
||||||
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 (cacheable(r)) c.put(request, r.clone()).then(trimMediaCache);
|
if (r && r.ok) 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 (cacheable(r)) { await c.put(request, r.clone()); trimMediaCache(); }
|
if (r && r.ok) { 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,69 +172,22 @@ 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(networkFirst(e.request));
|
e.respondWith(
|
||||||
|
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) => {
|
||||||
|
|
@ -245,19 +198,6 @@ 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