Files
FireAdministration/public/sw.js
T
Florian WildeandClaude Opus 5 7196c7110c Aufraeumarbeiten: ESLint, DOM-Verschachtelung, eigene Testwehr
ESLint hatte keine Konfiguration — das Skript "npm run lint" pruefte nichts.
Jetzt Flat Config ohne Formatierungsregeln, dafuer mit den Regeln, die die
Fehler dieses Projekts gemeldet haetten. Er hat gleich zwei gefunden, die
niemand gesucht hatte.

Das Badge rendert ein span statt eines div. Damit ist die Meldung
"div cannot be a descendant of p" weg, die seit Phase 3 auf der Einsatzseite
stand — sie ist der klassische Ausloeser fuer einen Hydration-Fehler.

Die E2E-Tests laufen gegen eine eigene Wehr. Nach fuenf Phasen standen in der
Beispielwehr 120 Atemschutz-Ueberwachungen statt einer und 115 Geraete statt
45; bei jedem Lauf aenderten sich alle Bildschirmfotos. Der Bestand ist einmal
zurueckgesetzt, npm run db:test-reset baut die Testwehr neu auf.

Behoben nebenbei:
- Die Fehlerflaeche der Karte konnte nie etwas anzeigen.
- Zwei useMemo der Lagekarte rechneten bei jedem Rendern neu.
- tests/e2e war vom Typecheck ausgenommen und liess einen doppelt
  deklarierten Bezeichner durch.

Closes #10
Closes #11
Closes #12

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

215 lines
6.9 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 {
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 {
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));
}
});
/**
* Web-Push (Phase 7).
*
* Ein Push erreicht den Browser auch dann, wenn FLORIAN nicht offen ist —
* deshalb muss der Service Worker ihn annehmen. Er tut zwei Dinge und sonst
* nichts: die Meldung anzeigen und beim Antippen die passende Seite öffnen.
*
* `tag` sorgt dafür, dass eine neue Meldung zum selben Ereignis die alte
* ersetzt statt sich daneben zu legen. Sonst stapeln sich nachts fünf
* Erinnerungen an denselben Termin.
*/
self.addEventListener("push", (ereignis) => {
if (!ereignis.data) return;
let inhalt = {};
try {
inhalt = ereignis.data.json();
} catch {
inhalt = { titel: "FLORIAN", text: ereignis.data.text() };
}
ereignis.waitUntil(
self.registration.showNotification(inhalt.titel || "FLORIAN", {
body: inhalt.text || "",
icon: "/icons/florian-192.png",
badge: "/icons/florian-192.png",
tag: inhalt.kennzeichen || "florian",
renotify: Boolean(inhalt.kennzeichen),
data: { verweis: inhalt.verweis || "/" },
}),
);
});
self.addEventListener("notificationclick", (ereignis) => {
ereignis.notification.close();
const ziel = (ereignis.notification.data && ereignis.notification.data.verweis) || "/";
ereignis.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((fenster) => {
// Ein schon offenes FLORIAN in den Vordergrund holen, statt ein zweites
// aufzumachen — sonst hat man nach einer Woche zwölf Tabs.
for (const eintrag of fenster) {
if (eintrag.url.includes(new URL(ziel, self.location.origin).pathname)) {
return eintrag.focus();
}
}
return self.clients.openWindow(ziel);
}),
);
});