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>
This commit is contained in:
Florian Wilde
2026-09-11 15:47:06 +02:00
co-authored by Claude Opus 5
parent be2a674c7a
commit 2babef9293
24 changed files with 1126 additions and 6 deletions
+161
View File
@@ -0,0 +1,161 @@
/* 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));
}
});