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:
co-authored by
Claude Opus 5
parent
be2a674c7a
commit
2babef9293
+161
@@ -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));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user