Files
lernbar/public/sw.js
Florian WildeandClaude Opus 5 2babef9293 Baustein 10a: PWA, Offlinebetrieb, Druckansichten
Installierbar: Manifest, Symbole aus dem eigenen Zeichen erzeugt
(scripts/symbole.ts, kein Bilddienst), Verknuepfungen zu Lernmodus,
Berichtsheft und Handouts.

Offline gilt eine Regel, und die steht im Dienstarbeiter: Nur das
Lernmaterial liegt im Browserspeicher - Handouts, Merkkarten, Glossar,
die eigene Woche. Lernzielkontrollen, Berichtsheft, Bewertungen,
Feedbackgespraeche, Anhaenge und jedes PDF niemals. Das ist keine
Sparsamkeit: Was im Browsercache liegt, ist an der serverseitigen
Rechtepruefung vorbei.

Bewertungen ohne Netz gehen in eine Warteschlange und werden
nachgereicht. Die Schlange haengt an der Benutzerkennung, nicht am
Geraet - auf einem geteilten Werkstattgeraet bekaeme sonst der Zweite
die offenen Bewertungen des Ersten aufs Konto. Der Tag der Bewertung
kommt vom Geraet und wird auf heute gedeckelt (tagDerBewertung, 4
Tests): Eine vorgestellte Uhr darf das Wiederholen nicht wegstellen.

Druck: Wer die dunkle Fassung eingestellt hatte, druckte bisher schwarze
Flaechen. Im Druck wird jetzt die komplette helle Palette gesetzt, dazu
A4-Raender, keine Umbrueche mitten in Karten und Zeilen, aufgeklappte
details. Druckknoepfe bei Handout, Glossar und Auswertung.

Durchstich in scripts/pruefungen/baustein-10.mjs mit echtem Abschalten
des Netzes: 33 Pruefungen, darunter der Nachweis, dass nichts
Bewertungsrelevantes im Speicher landet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:47:06 +02:00

162 lines
5.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable */
/**
* Der Dienstarbeiter der Lern-Bar.
*
* Er hat genau eine Aufgabe: Der Azubi soll im Serverraum, im Keller oder im
* Zug weiterlernen können, wo kein Netz ist. Alles andere wäre Ballast.
*
* ─── Was offline liegen darf ──────────────────────────────────────────────
*
* Nur das Lernmaterial: Handouts, Merkkarten, Glossar und die eigene Woche.
*
* **Nicht** zwischengespeichert werden Lernzielkontrollen, Berichtsheft,
* Bewertungen, Feedbackgespräche, Anhänge und jedes PDF. Der Grund ist keine
* Sparsamkeit, sondern eine Zusage aus dem Pflichtenheft: Musterlösungen und
* Bewertungsdaten stehen hinter einer eigenen Prüfung. Was im Browsercache
* liegt, ist an dieser Prüfung vorbei – also liegt es dort nicht.
*
* ─── Wie zwischengespeichert wird ─────────────────────────────────────────
*
* Seiten: erst das Netz, dann der Speicher. So sieht man nach einer
* Aktualisierung sofort die neue Fassung und nur bei fehlendem Netz die alte.
*
* Dateien unter /_next/static/: erst der Speicher. Die Namen tragen eine
* Prüfsumme, ihr Inhalt ändert sich nie.
*
* Alles andere – Serveraktionen, Anmeldung, POST – geht immer ans Netz.
*/
const FASSUNG = 'v1';
const SCHALE = `lernbar-schale-${FASSUNG}`;
const SEITEN = `lernbar-seiten-${FASSUNG}`;
const DATEIEN = `lernbar-dateien-${FASSUNG}`;
/** Was schon beim Einrichten vorliegen muss. */
const GRUNDVORRAT = ['/offline', '/symbol-192.png', '/lernbar.svg', '/manifest.webmanifest'];
/** Höchstens so viele Seiten liegen im Speicher – ein Handy ist kein Archiv. */
const HOECHSTZAHL_SEITEN = 60;
/** Nur diese Pfade dürfen offline vorliegen. Alles andere niemals. */
function darfOfflineLiegen(pfad) {
if (pfad === '/' || pfad === '/offline' || pfad === '/glossar') return true;
if (pfad === '/handouts' || pfad.startsWith('/handouts/')) return true;
if (pfad === '/karten' || pfad.startsWith('/karten/')) return true;
if (/^\/woche\/\d+$/.test(pfad)) return true;
return false;
}
self.addEventListener('install', (ereignis) => {
ereignis.waitUntil(
caches
.open(SCHALE)
// Einzeln, damit ein fehlendes Stück nicht die ganze Einrichtung kippt.
.then((speicher) => Promise.all(GRUNDVORRAT.map((pfad) => speicher.add(pfad).catch(() => undefined))))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (ereignis) => {
ereignis.waitUntil(
caches
.keys()
.then((namen) =>
Promise.all(
namen
.filter((name) => name.startsWith('lernbar-') && ![SCHALE, SEITEN, DATEIEN].includes(name))
.map((name) => caches.delete(name)),
),
)
.then(() => self.clients.claim()),
);
});
/**
* Beim Abmelden wird alles Persönliche weggeräumt.
*
* Ein geteiltes Werkstattgerät soll nach dem Abmelden nicht noch die Woche des
* vorigen Azubis im Speicher haben.
*/
self.addEventListener('message', (ereignis) => {
if (ereignis.data && ereignis.data.typ === 'aufraeumen') {
ereignis.waitUntil(Promise.all([caches.delete(SEITEN), caches.delete(DATEIEN)]));
}
});
async function speicherKuerzen(name, hoechstzahl) {
const speicher = await caches.open(name);
const schluessel = await speicher.keys();
// Die ältesten zuerst – `keys()` liefert sie in der Reihenfolge des Ablegens.
for (const alt of schluessel.slice(0, Math.max(0, schluessel.length - hoechstzahl))) {
await speicher.delete(alt);
}
}
/** Seiten: erst das Netz, bei Ausfall der Speicher, sonst die Offlineseite. */
async function seiteHolen(anfrage) {
const pfad = new URL(anfrage.url).pathname;
try {
const antwort = await fetch(anfrage);
if (antwort.ok && darfOfflineLiegen(pfad)) {
const speicher = await caches.open(SEITEN);
await speicher.put(anfrage, antwort.clone());
await speicherKuerzen(SEITEN, HOECHSTZAHL_SEITEN);
}
return antwort;
} catch (fehler) {
const gespeichert = await caches.match(anfrage, { ignoreSearch: false });
if (gespeichert) return gespeichert;
const offline = await caches.match('/offline');
if (offline) return offline;
return new Response('Kein Netz und nichts im Speicher.', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
}
/** Unveränderliche Dateien: erst der Speicher. */
async function dateiHolen(anfrage) {
const gespeichert = await caches.match(anfrage);
if (gespeichert) return gespeichert;
const antwort = await fetch(anfrage);
if (antwort.ok) {
const speicher = await caches.open(DATEIEN);
await speicher.put(anfrage, antwort.clone());
}
return antwort;
}
self.addEventListener('fetch', (ereignis) => {
const anfrage = ereignis.request;
// Nur eigene Adressen, nur Lesezugriffe.
if (anfrage.method !== 'GET') return;
const adresse = new URL(anfrage.url);
if (adresse.origin !== self.location.origin) return;
// Serveraktionen und Datenschnittstellen gehen immer ans Netz – eine
// zwischengespeicherte Antwort wäre hier schlicht falsch.
if (adresse.pathname.startsWith('/api/') || anfrage.headers.has('Next-Action')) return;
// Auch die Teilnachladungen von Next (RSC) nicht: Sie gehören zu einer
// Sitzung und sollen nicht als Seite verwechselt werden.
if (adresse.searchParams.has('_rsc')) return;
if (anfrage.mode === 'navigate') {
ereignis.respondWith(seiteHolen(anfrage));
return;
}
if (adresse.pathname.startsWith('/_next/static/') || adresse.pathname.endsWith('.svg') || adresse.pathname.endsWith('.png')) {
ereignis.respondWith(dateiHolen(anfrage));
}
});