From 2babef92932575b6a084ceb59b6b665cea5f2183 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 11 Sep 2026 15:47:06 +0200 Subject: [PATCH] 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 --- package.json | 3 +- public/sw.js | 161 ++++++++++ public/symbol-192.png | Bin 0 -> 2418 bytes public/symbol-512.png | Bin 0 -> 7473 bytes public/symbol-maskabel-512.png | Bin 0 -> 3596 bytes scripts/pruefungen/baustein-10.mjs | 292 ++++++++++++++++++ scripts/symbole.ts | 77 +++++ src/app/(angemeldet)/auswertung/page.tsx | 7 + src/app/(angemeldet)/glossar/page.tsx | 5 + src/app/(angemeldet)/handouts/[id]/page.tsx | 5 + .../(angemeldet)/karten/lernen/Lernmodus.tsx | 44 ++- src/app/(angemeldet)/karten/lernen/page.tsx | 2 +- src/app/(angemeldet)/layout.tsx | 2 + src/app/anmelden/page.tsx | 2 + src/app/api/lernstand/route.ts | 66 ++++ src/app/globals.css | 94 +++++- src/app/manifest.ts | 36 +++ src/app/offline/page.tsx | 38 +++ src/komponenten/Aufraeumen.tsx | 29 ++ src/komponenten/Druckknopf.tsx | 18 ++ src/komponenten/Netzzustand.tsx | 88 ++++++ src/lib/offline/nachtrag.test.ts | 24 ++ src/lib/offline/nachtrag.ts | 25 ++ src/lib/offline/warteschlange.ts | 114 +++++++ 24 files changed, 1126 insertions(+), 6 deletions(-) create mode 100644 public/sw.js create mode 100644 public/symbol-192.png create mode 100644 public/symbol-512.png create mode 100644 public/symbol-maskabel-512.png create mode 100644 scripts/pruefungen/baustein-10.mjs create mode 100644 scripts/symbole.ts create mode 100644 src/app/api/lernstand/route.ts create mode 100644 src/app/manifest.ts create mode 100644 src/app/offline/page.tsx create mode 100644 src/komponenten/Aufraeumen.tsx create mode 100644 src/komponenten/Druckknopf.tsx create mode 100644 src/komponenten/Netzzustand.tsx create mode 100644 src/lib/offline/nachtrag.test.ts create mode 100644 src/lib/offline/nachtrag.ts create mode 100644 src/lib/offline/warteschlange.ts diff --git a/package.json b/package.json index 3fa1a48..cdbe017 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "konto:anlegen": "tsx scripts/konto-anlegen.ts", "backup": "bash scripts/im-container/sicherung.sh", "restore": "bash scripts/im-container/wiederherstellen.sh", - "screenshots": "tsx scripts/screenshots.ts" + "screenshots": "tsx scripts/screenshots.ts", + "symbole": "tsx scripts/symbole.ts" }, "dependencies": { "@node-rs/argon2": "^2.0.2", diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..7cdea14 --- /dev/null +++ b/public/sw.js @@ -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)); + } +}); diff --git a/public/symbol-192.png b/public/symbol-192.png new file mode 100644 index 0000000000000000000000000000000000000000..8175d58f4a8a12f3ca32e93ae8a8a377de6b8235 GIT binary patch literal 2418 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 literal 0 HcmV?d00001 diff --git a/public/symbol-maskabel-512.png b/public/symbol-maskabel-512.png new file mode 100644 index 0000000000000000000000000000000000000000..93f575f39f2caf4fbf25bfe4380a24bf581d7581 GIT binary patch literal 3596 zcmeHK>sONL9(^-USxq`ksihK)Go|D8q=un+882BnsFmg=ktXv_7e(@lW{o-OF;_F8 zgBKbyl`+vVK_?B+tZ2eZ;9V3ok_;47LJEO*=s!4Xo%7L8&tCia@~mg=-*4|-6ddTc zbGylQ004HLJmGU10Ccoa2hjU`WSe+Xw0zG=pYKC5suy_?|Ct{7k+tIJm>!2l8?^0> ziRr$MH6s+>OnhOr!+$(xe}JN{`%~xy0bX)uqCC_GBlDFIt<(8gCHf6 zEsIS2xAz>3+j`Tu^|z7njAQLAcE)JbclM|D-07=X;)7Bd2lpEbBZ*QoK@hAybFNK& zTgQ{QrsMhTCP42X0FZQn19t)7r>}tP!8*W~{s7>$4e+$wxZL=J|4s>^*1F-GnRB3x zXweFD1%2Vo@@q~9A7Ud*nD<2CZXjs`4vOO}mOcco9GDFz_*r-PDZ5y%yhH3MSI3Fm zk3YPd<4C+lwt2?dN+imOs>>g{g=`oy^;G&;PSieTFHwEPofbcXG*C(Nd;MOSEg(BT z80WruL8+iHbEcoietit&K6FJ*rH`o%_PeFr@5{$@4XTDkH>WeE(_6RccX{NKNDbzPEa$*yJ>A8Jz#J2#R%O71U zWUgYMbh%Iots^v{Fq(4qsE6t;=9}7>*eym_EN(Sjs(NqQY>N;!mWp#{pUU>^ClwlvppHQ8z*E$$tfUyZv)7rr$GMSxKLH`H3OzFVui!3bC;C4uSJ=ao zQ`GR^`?_1ES4q6fsyXgmyJ)&-_~lE-^=LcCWnMCh2fNzjR9DIlq-UkBh#QQJ8=t~n zix#92XPIhyL{sikv%P=CaZ1;Dc}<5ZJn@2D+049(O&l&~#5mWuwsbYriv_09OX6yd zqZz^%XcW|TOg}l$g{z-Wzs#(25;^Ayg}2x0HP1&26{a22Q6_S+F+HO4T-f^XlCha( zNC6PyL#Y*b^V;v$hoQbmK#vJm`9yI`=Y1!-@<4^623arV)>+s^KLU7e4dQyko3!?V znW|?^UtTXB?@a9FwnYl&7v<+*74N$=qW(*W_D5>u$kvH)virZUQmHo%(u%CJ3wPiT z0EVr>g(Q=!pE^rkeV`5Nn(h<5Aj07Yn6Vvl)WLh64&N5F>)nwaLW^{$d%`VNdA4Q< z73mYR8%=s1D(=RA4H$C9edE8kxspJ(b?V9IAHOv@;}SNavD-w}+eH5P4u``ve_`Tc zh0S^pduV(-;?JT{`1vF7d7E(G@XZ@*d&8doSC1zD{A+`sf638nZ!QuTwY?Tv2%CDT zTUI)vMUyUC34iiC`)n?T6;QGloj~qw2*ew4#&v@N)9xA_b{dT!>jr6uKuU8CC`mwn z)-ZkNPOM^?%Qfd8rICZT=>+d8b8kydLpM=xKnCgI^-_`r(c1O`tF82h%~V?vfeTZq1Ff~-r3;T+LXirw{GxcXuJSR9N8KHO(HNFZe^|AL+87S zTg=Rk9+?|S@zwLhs?=^EHjX+0)zOF~*%l4;{@xm;1hnkGbRl)sUfS8=bny{Y%}#z8 zfN_QMH7y8_6O23v-L%;zXpiTLK ziF>bov~6jY;KLin07uTQOn1er7HZJP|7o?iU zb3E`$X50+BqMDhy^ywX5D64BxgEKjrlGr%n(McJDFR<>cS~)4fw6rMS@T_+jnggM2 zm@y)0Za1&_uO>Q$;#3OeroCf%$O$V&e>=pW)Gvl;k*rYDF?`%AVV$vRd{6B~X#In` zHcQGkoUTo-Ale~fE;Oo^*v5R>XrmWR&+}jOgL&P2hQK{Q4^QZ*PkthL&)dzo|jkTQ?ug#m~7 z<}OjO0jgukuN`eCYi95@FNX$aKzR+Q7&emx(dVjrOb7Q(O)`N7l}Jo4(_d2$lJeur zDJ9Pcdkz2C8Fn}&V`1T(RKGlvnv@B`)pR68*6Nm$mb3W!PObC{eS9YM zQT797_LH_t?)&wQADWzjEV($iK$%8H<=SYQs=pC&H*)^}r|c*XX{#Iv_EWCeYr&S! aWo|PN#nD#_aTZZp1~};(=+o?V{>DEfjpN4v literal 0 HcmV?d00001 diff --git a/scripts/pruefungen/baustein-10.mjs b/scripts/pruefungen/baustein-10.mjs new file mode 100644 index 0000000..6316a1a --- /dev/null +++ b/scripts/pruefungen/baustein-10.mjs @@ -0,0 +1,292 @@ +import { chromium } from '@playwright/test'; + +/** + * Durchstich für Baustein 10 – PWA, Offlinebetrieb, Druck. + * + * Die Zusage dieses Bausteins ist schmal und genau: Der Azubi soll ohne Netz + * weiterlernen können, und was er dabei bewertet, darf nicht verloren gehen. + * Gleichzeitig darf **nichts** offline liegen, was hinter einer eigenen + * Rechteprüfung steht – Musterlösungen und Bewertungen gehören nicht in den + * Browserspeicher. + * + * Geprüft wird gegen den laufenden Server, mit echtem Abschalten des Netzes. + */ + +const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000'; +const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' }; + +let fehler = 0; +function pruefe(bedingung, text, zusatz = '') { + console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`); + if (!bedingung) fehler += 1; +} + +const browser = await chromium.launch(); +const kontext = await browser.newContext({ locale: 'de-DE' }); +const seite = await kontext.newPage(); + +async function anmelden() { + await seite.goto(`${BASIS}/anmelden`); + await seite.fill('#email', AZUBI.email); + await seite.fill('#passwort', AZUBI.pw); + await seite.click('button[type=submit]'); + await seite.waitForURL(`${BASIS}/`); +} + +/** Wartet, bis der Dienstarbeiter das Ruder übernommen hat. */ +async function dienstarbeiterBereit() { + return seite.evaluate(async () => { + if (!('serviceWorker' in navigator)) return false; + const registrierung = await navigator.serviceWorker.ready; + // `ready` sagt nur „installiert". Erst ein Controller fängt Anfragen ab. + if (navigator.serviceWorker.controller) return true; + await new Promise((fertig) => { + navigator.serviceWorker.addEventListener('controllerchange', fertig, { once: true }); + window.setTimeout(fertig, 5000); + }); + return Boolean(navigator.serviceWorker.controller || registrierung.active); + }); +} + +// --- 1. Die Anwendung lässt sich installieren ------------------------------ + +console.log('\n1. Installierbar'); + +const manifest = await kontext.request.get(`${BASIS}/manifest.webmanifest`); +pruefe(manifest.status() === 200, 'Das Manifest wird ausgeliefert', `HTTP ${manifest.status()}`); + +const manifestInhalt = await manifest.json().catch(() => null); +pruefe(manifestInhalt?.name?.includes('Lern-Bar'), 'Es trägt den Namen der Anwendung'); +pruefe(manifestInhalt?.display === 'standalone', 'Es startet als eigenständige Anwendung'); +pruefe( + Array.isArray(manifestInhalt?.icons) && manifestInhalt.icons.some((s) => s.purpose === 'maskable'), + 'Es enthält ein Symbol für den Beschnitt unter Android', +); + +for (const datei of ['/symbol-192.png', '/symbol-512.png', '/symbol-maskabel-512.png', '/sw.js']) { + const antwort = await kontext.request.get(`${BASIS}${datei}`); + pruefe(antwort.status() === 200, `${datei} liegt bereit`, `HTTP ${antwort.status()}`); +} + +// Nichts davon darf von außen kommen – die Anwendung läuft in einem Netz +// ohne Internet. +await anmelden(); +const fremdeQuellen = await seite.evaluate(() => + [...document.querySelectorAll('script[src], link[href], img[src]')] + .map((element) => element.getAttribute('src') ?? element.getAttribute('href') ?? '') + .filter((adresse) => /^https?:\/\//.test(adresse) && !adresse.startsWith(location.origin)), +); +pruefe(fremdeQuellen.length === 0, 'Keine Quelle von außerhalb', fremdeQuellen.join(', ') || 'keine'); + +// --- 2. Der Dienstarbeiter übernimmt --------------------------------------- + +console.log('\n2. Dienstarbeiter'); + +pruefe(await dienstarbeiterBereit(), 'Der Dienstarbeiter ist eingerichtet und steuert die Seite'); + +// Lernmaterial einmal ansehen, damit es im Speicher landet. +await seite.goto(`${BASIS}/handouts/N3`); +await seite.waitForSelector('h1'); +await seite.goto(`${BASIS}/karten/lernen`); +await seite.waitForSelector('h1, .lernkarte'); + +// Und etwas, das **nicht** in den Speicher darf. +await seite.goto(`${BASIS}/kontrollen`); +await seite.waitForSelector('h1'); + +const imSpeicher = await seite.evaluate(async () => { + const namen = await caches.keys(); + const pfade = []; + for (const name of namen) { + const speicher = await caches.open(name); + for (const anfrage of await speicher.keys()) pfade.push(new URL(anfrage.url).pathname); + } + return pfade; +}); + +pruefe(imSpeicher.includes('/handouts/N3'), 'Das gelesene Handout liegt im Speicher'); +pruefe(imSpeicher.includes('/karten/lernen'), 'Der Lernmodus liegt im Speicher'); + +// Das ist die eigentliche Zusage – hier wird eine Regel geprüft, keine Laune. +const verboten = imSpeicher.filter((pfad) => + /^\/(kontrollen|berichtsheft|feedback|auswertung|aufgaben|checklisten|verwaltung|anhaenge)/.test(pfad), +); +pruefe( + verboten.length === 0, + 'Nichts Bewertungsrelevantes liegt im Browserspeicher', + verboten.join(', ') || 'nichts', +); +pruefe( + !imSpeicher.some((pfad) => pfad.endsWith('/pdf')), + 'Kein PDF liegt im Browserspeicher', +); + +// --- 3. Ohne Netz -------------------------------------------------------- + +console.log('\n3. Ohne Netz'); + +await kontext.setOffline(true); + +const handoutOffline = await seite.goto(`${BASIS}/handouts/N3`).catch(() => null); +pruefe(handoutOffline !== null && handoutOffline.ok(), 'Das gelesene Handout öffnet sich ohne Netz'); +pruefe( + (await seite.locator('h1').innerText().catch(() => '')).length > 0, + 'Es ist wirklich der Inhalt und keine Fehlerseite', + await seite.locator('h1').innerText().catch(() => '—'), +); + +const lernenOffline = await seite.goto(`${BASIS}/karten/lernen`).catch(() => null); +pruefe(lernenOffline !== null && lernenOffline.ok(), 'Der Lernmodus öffnet sich ohne Netz'); + +// Die Netzleiste sagt Bescheid. +await seite.waitForTimeout(500); +const leiste = await seite.locator('.netzleiste').count(); +pruefe(leiste > 0, 'Die Leiste „Ohne Netz" erscheint'); + +// Eine Seite, die nie im Speicher war, landet auf der Offlineseite. +const unbekannt = await seite.goto(`${BASIS}/kontrollen`).catch(() => null); +const unbekannterText = await seite.locator('body').innerText().catch(() => ''); +pruefe( + unbekannterText.includes('Kein Netz'), + 'Eine nicht gespeicherte Seite zeigt die Offlineseite', + `HTTP ${unbekannt?.status() ?? '—'}`, +); + +// --- 4. Bewertungen gehen nicht verloren ----------------------------------- + +console.log('\n4. Bewertungen ohne Netz'); + +await seite.goto(`${BASIS}/karten/lernen`); +const hatKarte = (await seite.locator('.lernkarte').count()) > 0; + +if (!hatKarte) { + console.log(' – heute ist keine Karte fällig, Warteschlange übersprungen'); +} else { + await seite.click('.lernkarte'); + await seite.waitForSelector('.bewerten-knopf'); + await seite.click('.bewerten-sitzt'); + await seite.waitForTimeout(800); + + const schlange = await seite.evaluate(() => + Object.keys(localStorage) + .filter((s) => s.startsWith('lernbar-warteschlange-')) + .flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')), + ); + pruefe(schlange.length >= 1, 'Die Bewertung liegt in der Warteschlange', `${schlange.length} Eintrag/Einträge`); + pruefe( + schlange.every((e) => e.karteId && e.bewertung && /^\d{4}-\d{2}-\d{2}$/.test(e.erfasstAm)), + 'Jeder Eintrag trägt Karte, Bewertung und den Tag der Bewertung', + ); + + const gemeldet = await seite.locator('.lernmodus-rueckmeldung').innerText(); + pruefe(/gemerkt/.test(gemeldet), 'Der Azubi sieht, dass die Bewertung gemerkt wurde', gemeldet.trim()); + + // --- Und wieder im Netz --- + console.log('\n5. Wieder im Netz'); + + // Wichtig: auf **derselben** Seite bleiben. Die Verbindung kehrt zurück, + // während der Azubi noch im Lernmodus steht – genau dort muss die + // Bestätigung erscheinen. Wer vorher die Seite wechselt, sieht sie nie, + // weil die Schlange dann schon leer ist. + await kontext.setOffline(false); + + const bestaetigung = await seite + .locator('.netzleiste') + .filter({ hasText: 'nachgereicht' }) + .first() + .innerText({ timeout: 15000 }) + .catch(() => ''); + pruefe(/nachgereicht/.test(bestaetigung), 'Der Azubi bekommt die Bestätigung zu sehen', bestaetigung.trim() || '—'); + + const nachher = await seite.evaluate(() => + Object.keys(localStorage) + .filter((s) => s.startsWith('lernbar-warteschlange-')) + .flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')), + ); + pruefe(nachher.length === 0, 'Die Warteschlange ist abgearbeitet', `${nachher.length} übrig`); + + // Und die Bewertung ist wirklich angekommen, nicht nur verschwunden. + await seite.goto(`${BASIS}/karten`); + await seite.waitForSelector('h1'); + const uebersicht = await seite.locator('body').innerText(); + pruefe( + !/0 von \d+ Karten/.test(uebersicht) || /sitzt/i.test(uebersicht), + 'Die Übersicht kennt den neuen Lernstand', + ); +} + +await kontext.setOffline(false); + +// --- 6. Die Schnittstelle prüft die Rechte --------------------------------- + +console.log('\n6. Rechte an der Schnittstelle'); + +const ohneAnmeldung = await browser.newContext(); +const abgewiesen = await ohneAnmeldung.request.post(`${BASIS}/api/lernstand`, { + data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] }, +}); +pruefe(abgewiesen.status() === 401, 'Ohne Anmeldung: abgewiesen', `HTTP ${abgewiesen.status()}`); +await ohneAnmeldung.close(); + +const alsAusbilder = await browser.newContext({ locale: 'de-DE' }); +const ausbilderSeite = await alsAusbilder.newPage(); +await ausbilderSeite.goto(`${BASIS}/anmelden`); +await ausbilderSeite.fill('#email', 'fw@wildeflorian.de'); +await ausbilderSeite.fill('#passwort', 'ausbilder-start'); +await ausbilderSeite.click('button[type=submit]'); +await ausbilderSeite.waitForURL(`${BASIS}/`); + +const alsAusbilderAbgewiesen = await alsAusbilder.request.post(`${BASIS}/api/lernstand`, { + data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] }, +}); +pruefe( + alsAusbilderAbgewiesen.status() === 403, + 'Der Ausbilder darf den Lernstand nicht führen', + `HTTP ${alsAusbilderAbgewiesen.status()}`, +); +await alsAusbilder.close(); + +// --- 7. Druck -------------------------------------------------------------- + +console.log('\n7. Druck'); + +await seite.goto(`${BASIS}/handouts/N3`); +await seite.waitForSelector('h1'); + +// Die dunkle Fassung einstellen – und dann drucken. Das ist der Fall, in dem +// früher schwarze Flächen aufs Papier gekommen wären. +await seite.evaluate(() => { + document.documentElement.setAttribute('data-thema', 'dunkel'); + localStorage.setItem('lernbar-thema', 'dunkel'); +}); +await seite.emulateMedia({ media: 'print' }); +await seite.waitForTimeout(200); + +const druckfarben = await seite.evaluate(() => { + const stil = getComputedStyle(document.body); + const wurzel = getComputedStyle(document.documentElement); + return { grund: stil.backgroundColor, text: stil.color, flaeche: wurzel.getPropertyValue('--flaeche').trim() }; +}); +pruefe( + druckfarben.grund === 'rgb(255, 255, 255)', + 'Im Druck ist der Grund weiß, auch bei eingestellter dunkler Fassung', + druckfarben.grund, +); +pruefe(druckfarben.text === 'rgb(0, 0, 0)', 'Der Text ist schwarz', druckfarben.text); +pruefe( + druckfarben.flaeche.toLowerCase() === '#f2f2f2', + 'Auch die Flächen sind hell', + druckfarben.flaeche, +); + +const versteckt = await seite.evaluate(() => { + const kopf = document.querySelector('.kopf'); + return kopf ? getComputedStyle(kopf).display : 'keine'; +}); +pruefe(versteckt === 'none', 'Die Navigation wird nicht mitgedruckt', versteckt); + +await seite.emulateMedia({ media: null }); +await browser.close(); + +console.log(`\n${fehler === 0 ? '✓ Baustein 10 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/scripts/symbole.ts b/scripts/symbole.ts new file mode 100644 index 0000000..5bf9894 --- /dev/null +++ b/scripts/symbole.ts @@ -0,0 +1,77 @@ +/** + * Erzeugt die Symbole für die installierte Anwendung aus dem Zeichen selbst – + * ohne Bilddienst, ohne Netz. + * + * npm run symbole + * + * Zwei Fassungen, weil Android sie unterschiedlich behandelt: + * + * symbol-192.png / symbol-512.png das Zeichen, wie es ist + * symbol-maskabel-512.png derselbe Inhalt, aber randvoll auf + * Petrol und nach innen gerückt. Android + * schneidet das Symbol in eine eigene + * Form – ein Kreis, ein Tropfen, was das + * Gerät gerade mag. Was zu weit außen + * liegt, ist dann weg. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { chromium } from '@playwright/test'; + +const ZIEL = path.join(process.cwd(), 'public'); + +/** Das Zeichen. Gleicher Inhalt wie `public/lernbar.svg`. */ +function zeichen(randAnteil: number, eckenRadius: number): string { + // Die drei Balken liegen im Original in einem Feld von 64 × 64 Punkten. + // `randAnteil` rückt sie nach innen, ohne den Grund zu verkleinern. + const innen = 64 * (1 - 2 * randAnteil); + const versatz = 64 * randAnteil; + + return ` + + + + + + +`; +} + +type Auftrag = { datei: string; groesse: number; svg: string }; + +const auftraege: Auftrag[] = [ + { datei: 'symbol-192.png', groesse: 192, svg: zeichen(0, 12) }, + { datei: 'symbol-512.png', groesse: 512, svg: zeichen(0, 12) }, + // Randvoll und nach innen gerückt: der Beschnitt darf nichts abschneiden. + { datei: 'symbol-maskabel-512.png', groesse: 512, svg: zeichen(0.14, 0) }, +]; + +async function main() { + const browser = await chromium.launch(); + + for (const auftrag of auftraege) { + const seite = await browser.newPage({ + viewport: { width: auftrag.groesse, height: auftrag.groesse }, + deviceScaleFactor: 1, + }); + + await seite.setContent( + `${auftrag.svg}`, + ); + + const datei = path.join(ZIEL, auftrag.datei); + await seite.screenshot({ path: datei, omitBackground: false }); + await seite.close(); + + console.log(` public/${auftrag.datei} (${auftrag.groesse}×${auftrag.groesse}, ${fs.statSync(datei).size} Bytes)`); + } + + await browser.close(); + console.log('Symbole erzeugt.'); +} + +main().catch((fehler) => { + console.error('Die Symbole konnten nicht erzeugt werden.'); + console.error(fehler instanceof Error ? fehler.message : fehler); + process.exit(1); +}); diff --git a/src/app/(angemeldet)/auswertung/page.tsx b/src/app/(angemeldet)/auswertung/page.tsx index 9ce1ea4..0393427 100644 --- a/src/app/(angemeldet)/auswertung/page.tsx +++ b/src/app/(angemeldet)/auswertung/page.tsx @@ -6,6 +6,7 @@ import { benutzerNachId } from '@/lib/repos/benutzer'; import { eigenerStand, rahmenplanabdeckung } from '@/lib/auswertung/sammeln'; import { bilanz, nachGruppen, stufenText, type Abdeckungsstufe } from '@/lib/auswertung/abdeckung'; import { verlauf } from '@/lib/kontrollen/bewertung'; +import { Druckknopf } from '@/komponenten/Druckknopf'; export const metadata: Metadata = { title: 'Auswertung' }; export const dynamic = 'force-dynamic'; @@ -72,6 +73,12 @@ export default async function AuswertungsSeite({ searchParams }: { searchParams: + + {/* Die Abdeckung ist die Ansicht, die man zum Gespräch mitnimmt – und zwar + auf Papier, weil dort niemand scrollt. */} +
+ +
); } diff --git a/src/app/(angemeldet)/glossar/page.tsx b/src/app/(angemeldet)/glossar/page.tsx index 84fbcec..776cc06 100644 --- a/src/app/(angemeldet)/glossar/page.tsx +++ b/src/app/(angemeldet)/glossar/page.tsx @@ -5,6 +5,7 @@ import { inhalte } from '@/lib/inhalte'; import { glossarAnker } from '@/lib/inhalte/glossarverweise'; import { normalisiere } from '@/lib/inhalte/laden'; import { GlossarSuche } from './GlossarSuche'; +import { Druckknopf } from '@/komponenten/Druckknopf'; export const metadata: Metadata = { title: 'Glossar' }; export const dynamic = 'force-dynamic'; @@ -67,6 +68,10 @@ export default async function GlossarSeite({ searchParams }: { searchParams: Pro ))} )} + +
+ +
); } diff --git a/src/app/(angemeldet)/handouts/[id]/page.tsx b/src/app/(angemeldet)/handouts/[id]/page.tsx index a2f088f..ed5b372 100644 --- a/src/app/(angemeldet)/handouts/[id]/page.tsx +++ b/src/app/(angemeldet)/handouts/[id]/page.tsx @@ -7,6 +7,7 @@ import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise'; import { gelesenAm } from '@/lib/repos/fortschritt'; import { alsZeitpunktText } from '@/lib/zeit'; import { gelesenUmschalten } from './aktionen'; +import { Druckknopf } from '@/komponenten/Druckknopf'; export const dynamic = 'force-dynamic'; @@ -159,6 +160,10 @@ export default async function HandoutSeite({ params }: { params: Promise<{ id: s ) : null} +
+ +
+