Files
FireAdministration/public/sw.js
T
Florian WildeandClaude Opus 5 791523027f Terminal im Geraetehaus mit Offline-Betrieb ergaenzen
Phase 4: eine eigene Oberflaeche unter /kiosk fuer das Wandtablet in der
Fahrzeughalle — mit Handschuhen bedienbar und arbeitsfaehig, wenn das Netz
weg ist.

Datenmodell: Fahrt, Anwesenheit, Waescheabgabe, Inventurgang,
Inventurposten, KioskVorgang und SyncKonflikt. Die Kennung eines Vorgangs
erzeugt der Browser beim Einreihen und ist damit der Idempotenzschluessel;
kommt derselbe Vorgang nach abgebrochener Uebertragung erneut an, erkennt
ihn der Server wieder. Die Datenbank setzt vier Regeln selbst durch und
verweigert UPDATE und DELETE auf kiosk_vorgang.

Offline: von Hand geschriebener Service Worker, Warteschlange in der
IndexedDB (Dexie), Fotos als Blob daneben, sichtbarer Zustand als Leiste
ueber allem. Was sich beim Nachtragen nicht anwenden laesst, landet als
Konflikt unter /terminal statt verworfen zu werden.

Dazu: Anmeldung per Ausweis, PIN oder signierter QR-Marke vom Handy,
Hallenmonitor mit optionalen DWD-Warnungen, Installation als App.

Behoben: die Warteschlange zog ihren Wiederholungstakt bei jedem Rendern
neu auf und haette einen liegen gebliebenen Vorgang nie ein zweites Mal
geschickt. Gefunden vom Offline-E2E-Test.

31 neue Tests (20 Unit, 11 gegen eine echte Datenbank, 9 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:35:48 +02:00

164 lines
5.3 KiB
JavaScript

/**
* Service Worker für das Terminal im Gerätehaus.
*
* Aufgabe: das Tablet soll auch dann etwas anzeigen, wenn das WLAN in der
* Halle gerade weg ist. Drei Strategien, je nachdem worum es geht:
*
* Navigation (HTML) Netz zuerst, bei Fehlschlag der zwischengespeicherte
* Stand, sonst die Offline-Seite. So sieht man immer die
* aktuelle Fassung, wenn es geht — und trotzdem etwas,
* wenn nicht.
*
* Abfragen (tRPC GET) Zwischenspeicher zuerst anzeigen, im Hintergrund
* erneuern. Für Stammdaten wie Fahrzeuge und Geräte ist
* ein paar Minuten alt völlig ausreichend.
*
* Änderungen (POST) Gar nicht anfassen. Was geschrieben wird, geht über
* die Warteschlange in der IndexedDB (siehe
* lib/kiosk/outbox.ts) — ein Service Worker, der
* Schreibvorgänge zwischenspeichert, führt zu Daten, die
* niemand mehr zuordnen kann.
*
* Bewusst von Hand geschrieben statt über ein Werkzeug erzeugt: es sind
* achtzig Zeilen, sie sind lesbar, und sie bringen keine Abhängigkeit mit, die
* bei jedem Update mitgezogen werden müsste.
*/
const VERSION = "florian-kiosk-v1";
const HUELLE = `${VERSION}-huelle`;
const DATEN = `${VERSION}-daten`;
/** Was beim Einrichten schon einmal geholt wird. */
const HUELLE_DATEIEN = ["/kiosk", "/kiosk/offline", "/manifest.webmanifest"];
self.addEventListener("install", (ereignis) => {
ereignis.waitUntil(
caches
.open(HUELLE)
.then((speicher) => speicher.addAll(HUELLE_DATEIEN))
// Ein fehlender Eintrag darf die Einrichtung nicht verhindern.
.catch(() => undefined)
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (ereignis) => {
ereignis.waitUntil(
caches
.keys()
.then((namen) =>
Promise.all(
namen
.filter((name) => !name.startsWith(VERSION))
.map((name) => caches.delete(name)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (ereignis) => {
const anfrage = ereignis.request;
const adresse = new URL(anfrage.url);
// Nur eigene Adressen. Alles andere geht den Service Worker nichts an.
if (adresse.origin !== self.location.origin) return;
// Schreibende Aufrufe niemals abfangen.
if (anfrage.method !== "GET") return;
// Der Anmeldeweg bleibt außen vor: ein zwischengespeicherter Anmeldezustand
// wäre eine Sicherheitslücke, keine Bequemlichkeit.
if (adresse.pathname.startsWith("/api/auth")) return;
if (anfrage.mode === "navigate") {
ereignis.respondWith(navigationBeantworten(anfrage));
return;
}
if (adresse.pathname.startsWith("/api/trpc")) {
ereignis.respondWith(abfrageBeantworten(anfrage));
return;
}
// Statische Dateien: erst nachsehen, dann holen.
if (adresse.pathname.startsWith("/_next/static") || adresse.pathname.startsWith("/icons")) {
ereignis.respondWith(zuerstSpeicher(anfrage));
}
});
/** Netz zuerst, Zwischenspeicher als Rückfall, Offline-Seite als letzte Rettung. */
async function navigationBeantworten(anfrage) {
try {
const antwort = await fetch(anfrage);
const speicher = await caches.open(HUELLE);
speicher.put(anfrage, antwort.clone());
return antwort;
} catch (fehler) {
const speicher = await caches.open(HUELLE);
const gespeichert = await speicher.match(anfrage);
if (gespeichert) return gespeichert;
const offlineSeite = await speicher.match("/kiosk/offline");
if (offlineSeite) return offlineSeite;
return new Response(
"<!doctype html><meta charset=utf-8><title>Offline</title>" +
"<p style='font:16px system-ui;padding:2rem'>Kein Netz und nichts im Zwischenspeicher.</p>",
{ status: 503, headers: { "Content-Type": "text/html; charset=utf-8" } },
);
}
}
/** Zwischenspeicher zuerst, im Hintergrund erneuern. */
async function abfrageBeantworten(anfrage) {
const speicher = await caches.open(DATEN);
const gespeichert = await speicher.match(anfrage);
const netz = fetch(anfrage)
.then((antwort) => {
if (antwort.ok) speicher.put(anfrage, antwort.clone());
return antwort;
})
.catch(() => null);
if (gespeichert) {
// Die Erneuerung läuft weiter, das Ergebnis interessiert hier nicht mehr.
void netz;
return gespeichert;
}
const antwort = await netz;
if (antwort) return antwort;
return new Response(JSON.stringify({ fehler: "offline" }), {
status: 503,
headers: { "Content-Type": "application/json" },
});
}
async function zuerstSpeicher(anfrage) {
const speicher = await caches.open(HUELLE);
const gespeichert = await speicher.match(anfrage);
if (gespeichert) return gespeichert;
try {
const antwort = await fetch(anfrage);
if (antwort.ok) speicher.put(anfrage, antwort.clone());
return antwort;
} catch (fehler) {
return new Response("", { status: 504 });
}
}
/**
* Die Anwendung kann den Zwischenspeicher der Daten leeren lassen — etwa nach
* dem Abmelden am Terminal, damit der nächste Anwender keinen fremden Stand
* sieht.
*/
self.addEventListener("message", (ereignis) => {
if (ereignis.data === "daten-leeren") {
ereignis.waitUntil(caches.delete(DATEN));
}
});