Files
FireAdministration/public/sw.js
T
Florian WildeandClaude Opus 5 0b8874da26 Phase 7: Benachrichtigungen und Termine
Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und
Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP,
ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als
ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite.

Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere
Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr
verlaesst, bekommt nur die Kurzfassung ohne Namen und Details.

Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp
(Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) --
beides im README begruendet.

56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0.

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

215 lines
7.0 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));
}
});
/**
* 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 (fehler) {
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);
}),
);
});