+
+
+
%sveltekit.head%
diff --git a/frontend/src/lib/stores/pocketbase.svelte.ts b/frontend/src/lib/stores/pocketbase.svelte.ts
index af8f23f..5c12459 100644
--- a/frontend/src/lib/stores/pocketbase.svelte.ts
+++ b/frontend/src/lib/stores/pocketbase.svelte.ts
@@ -69,6 +69,12 @@ class AuthStore {
logout = async () => {
api.authStore.clear()
document.cookie = 'pb_auth="";-1; path=/'
+
+ // Der Service Worker haelt gelesene API-Antworten vor, damit die App
+ // im Funkloch etwas anzuzeigen hat. Nach dem Abmelden hat sie das
+ // nicht mehr: Auf einem geteilten Geraet stuenden sonst die Daten des
+ // vorigen Nutzers offline weiter da.
+ navigator.serviceWorker?.controller?.postMessage('clear-api-cache')
}
diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts
new file mode 100644
index 0000000..1549c8b
--- /dev/null
+++ b/frontend/src/service-worker.ts
@@ -0,0 +1,125 @@
+///
+///
+
+import { build, files, version } from '$service-worker'
+
+/**
+ * Service Worker für die Zeitnahme im Funkloch.
+ *
+ * Ohne ihn ist „offline" nur so lange wahr, wie der Tab offen bleibt: Ein
+ * Neuladen an der Stage endete in einer weißen Seite, und die Zeiten im
+ * Ausgang wären mit ihr verschwunden. Eine Stage lässt sich nicht
+ * wiederholen — deshalb dieser Aufwand.
+ *
+ * Drei Sorten Anfragen, drei Strategien:
+ *
+ * 1. Was zum Build gehört (JS, CSS, Bilder), liegt unter seiner Versions-URL
+ * und ändert sich nie. Cache zuerst, ohne Rückfrage.
+ * 2. Seitenaufrufe kommen vom Server (die App läuft mit SSR). Netz zuerst,
+ * die Antwort wandert in den Cache; ohne Netz die zuletzt gesehene
+ * Fassung derselben Seite.
+ * 3. Lesende Anfragen an PocketBase ebenso — nur so steht nach einem
+ * Neuladen im Funkloch überhaupt ein Kader da, gegen den sich stoppen
+ * lässt. Schreibende Anfragen werden nie aus dem Cache beantwortet: Ein
+ * Start, der aus dem Cache „gelingt", wäre eine Zeit, die es nicht gibt.
+ * Dafür ist der Ausgang in timeOutbox zuständig.
+ */
+
+const sw = self as unknown as ServiceWorkerGlobalScope
+
+const ASSETS = `assets-${version}`
+const PAGES = `pages-${version}`
+const API = `api-${version}`
+
+const PRECACHE = [...build, ...files]
+
+sw.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches
+ .open(ASSETS)
+ .then((cache) => cache.addAll(PRECACHE))
+ .then(() => sw.skipWaiting()),
+ )
+})
+
+sw.addEventListener('activate', (event) => {
+ // Alte Versionen wegräumen. Ein Gerät an der Strecke soll nicht mit dem
+ // Vorrat von vor drei Events dastehen.
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys
+ .filter((k) => k !== ASSETS && k !== PAGES && k !== API)
+ .map((k) => caches.delete(k)),
+ ),
+ )
+ .then(() => sw.clients.claim()),
+ )
+})
+
+/**
+ * Beim Abmelden fliegt der Vorrat an API-Antworten raus. Sonst stünden die
+ * Daten des vorigen Nutzers offline noch da — auf einem geteilten Gerät ein
+ * Einblick, den niemand gegeben hat.
+ */
+sw.addEventListener('message', (event) => {
+ if (event.data === 'clear-api-cache') {
+ event.waitUntil(caches.delete(API))
+ }
+})
+
+async function cacheFirst(request: Request): Promise {
+ const cached = await caches.match(request)
+ if (cached) return cached
+ return fetch(request)
+}
+
+/** Netz zuerst, Cache als Auffangnetz — und jede geglückte Antwort merken. */
+async function networkFirst(request: Request, cacheName: string): Promise {
+ const cache = await caches.open(cacheName)
+
+ try {
+ const response = await fetch(request)
+ // Nur Vollstaendiges merken: Ein 206 oder ein Fehler waere im
+ // Funkloch schlimmer als gar nichts.
+ if (response.ok && response.status === 200) {
+ cache.put(request, response.clone())
+ }
+ return response
+ } catch (err) {
+ const cached = await cache.match(request)
+ if (cached) return cached
+ throw err
+ }
+}
+
+sw.addEventListener('fetch', (event) => {
+ const { request } = event
+ if (request.method !== 'GET') return
+
+ const url = new URL(request.url)
+
+ // Andere Ursprünge (Kartenkacheln, Schriften) gehen den normalen Weg.
+ // Kacheln zu horten waere ein Vorrat ohne Ende.
+ const sameOrigin = url.origin === sw.location.origin
+ const isApi = url.pathname.startsWith('/api/')
+
+ if (sameOrigin && PRECACHE.includes(url.pathname)) {
+ event.respondWith(cacheFirst(request))
+ return
+ }
+
+ if (isApi) {
+ // Der Uhrenabgleich darf nie aus dem Cache kommen — eine gemerkte
+ // Uhrzeit ist das Gegenteil von dem, wozu er da ist.
+ if (url.pathname === '/api/clock') return
+ event.respondWith(networkFirst(request, API))
+ return
+ }
+
+ if (sameOrigin && request.mode === 'navigate') {
+ event.respondWith(networkFirst(request, PAGES))
+ }
+})
diff --git a/frontend/static/manifest.webmanifest b/frontend/static/manifest.webmanifest
new file mode 100644
index 0000000..e52a07f
--- /dev/null
+++ b/frontend/static/manifest.webmanifest
@@ -0,0 +1,20 @@
+{
+ "name": "Stammtisch Hersbruck",
+ "short_name": "Stammtisch",
+ "description": "Events, Stages, Zeiten und Trails des Stammtisch Hersbruck",
+ "start_url": "/dashboard",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait-primary",
+ "background_color": "#ffffff",
+ "theme_color": "#eab308",
+ "lang": "de",
+ "icons": [
+ {
+ "src": "/schildl.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ }
+ ]
+}