diff --git a/CHANGELOG.md b/CHANGELOG.md index ff234bd..0e219c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,43 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue ## [Unveröffentlicht] +## [0.8.0] – 2026-09-11 + +Lernzielkontrollen – und die strengste Zusage des ganzen Auftrags eingelöst. + +### Hinzugefügt + +- **Freigabe durch den Ausbilder**: Vorher sieht der Azubi die Fragen gar + nicht. Eine Freigabe lässt sich zurücknehmen, solange nicht abgegeben wurde +- **Fragebogen** mit offenen Fragen und Punktzahl je Frage; die Antworten + werden beim Tippen zwischengespeichert, mit Zeitstempel in der Kopfzeile. + Eine Kontrolle dauert 45 Minuten – ein verlorener Text wäre nicht + wiedergutzumachen +- **Musterlösungen erst nach dem Absenden.** Die Sperre sitzt serverseitig in + `sichtbareKontrolle()`: Die Lösung wird aus den Daten entfernt, **bevor** sie + in die Ausgabe gelangt – nicht erst in der Oberfläche versteckt. Wer den + Seitenquelltext liest oder die Serverantwort mitschneidet, findet nichts +- **Korrektur**: Antwort des Azubis und Musterlösung nebeneinander, Punktefeld + und Anmerkung je Frage, Anmerkung zur ganzen Kontrolle. Summe, Prozentsatz + und Note laufen beim Verteilen der Punkte mit +- **Notenschlüssel der IHK** (92/81/67/50/30 Prozent). Solange eine Frage ohne + Punkte ist, gilt das Ergebnis als vorläufig und die Kontrolle nicht als + korrigiert +- Ergebnis erscheint bei beiden; die Übersicht des Azubis zeigt zusätzlich den + Schnitt über alle korrigierten Kontrollen +- Abgegebene, noch nicht korrigierte Kontrollen erscheinen beim Ausbilder unter + „Was auf dich wartet" +- Abgegebene Kontrollen zählen im Wochenfortschritt mit +- 36 Tests, davon 21 allein für die Sichtbarkeit der Musterlösung – für jeden + Betrachter und jeden Zustand einzeln + +### Geprüft + +Der Wortlaut der Musterlösungen wurde im rohen Seitenquelltext gesucht, in +jedem Zustand. Vor der Abgabe: kein einziger Treffer. Nach der Abgabe: alle +fünf. Der Prüfaufbau weist sich damit selbst als tauglich aus – er findet die +Lösungen, wenn sie da sein dürfen. + ## [0.7.0] – 2026-09-10 Das Berichtsheft – der Teil, ohne den es keine Zulassung zur Abschlussprüfung @@ -319,6 +356,7 @@ anmelden und verwalten. - `npm run konto:anlegen` für das erste Ausbilderkonto [Unveröffentlicht]: #unveröffentlicht +[0.8.0]: #080--2026-09-11 [0.7.0]: #070--2026-09-10 [0.6.0]: #060--2026-09-10 [0.5.0]: #050--2026-09-10 diff --git a/DEVLOG.md b/DEVLOG.md index c3f24eb..ef00886 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -7,6 +7,45 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde` --- +## 2026-09-11 · Baustein 8 – Lernzielkontrollen + +| Von | Bis | Dauer | Tätigkeit | +|---|---|---|---| +| 06:25 | 06:35 | 0:10 | Notenschlüssel und Auswertung als reine Rechnung, 15 Tests | +| 06:35 | 06:45 | 0:10 | Sichtbarkeitsschicht für die Musterlösung, 21 Tests | +| 06:45 | 06:55 | 0:10 | Datenmodell, Migration, Repository, Serveraktionen | +| 06:55 | 07:15 | 0:20 | Fragebogen mit Zwischenspeichern, Korrekturansicht, Übersichten | +| 07:15 | 08:50 | 1:35 | Bau und Durchstich – davon über eine Stunde Wartezeit (siehe unten) | + +**Summe Baustein 8: rund 2 h 25 min, davon knapp 1 h reine Wartezeit** + +### Was aufgefallen ist + +- Der Kern dieses Bausteins ist eine einzige Funktion: `sichtbareKontrolle()` + entfernt die Musterlösung, bevor die Daten in die Ausgabe gelangen. Sie nimmt + bewusst den ganzen Betrachter und den Bearbeitungsstand entgegen statt eines + vorberechneten Wahrheitswerts – so kann kein Aufrufer versehentlich `true` + übergeben. +- Beim Lecktest zunächst ein Fehlalarm: Die Prüfung meldete + „Discover, Offer, Request, Acknowledge" im Quelltext. Das war meine **eigene + Testantwort**, die ich zuvor eingetippt hatte. Danach ein sauberer Aufbau, + der die Musterlösungen direkt aus der Inhaltsdatei liest und gegen Text + prüft, den ich nicht selbst eingebe. Ergebnis: vor der Abgabe null Treffer, + nach der Abgabe alle fünf – damit ist auch belegt, dass die Suche überhaupt + finden kann. +- **Eine Stunde an der Umgebung verloren.** Erst hing `next build` endlos, weil + der laufende Server `.next` festhielt und ich ihn mitten im Schreiben + abgeschossen hatte. Nach `Remove-Item .next -Recurse` lief der Bau in acht + Sekunden. Danach brauchte `next start` **2822 Sekunden** bis „Ready" – das + Dateisystem war zu dem Zeitpunkt extrem langsam, vermutlich ein Virenscanner + über dem frisch gebauten Verzeichnis. Lehre: Vor einem Bau immer erst den + Server stoppen, und ein hängender Start ist nicht zwingend ein Codefehler. +- Die laufende Summe beim Korrigieren war nachträglich die beste Idee an der + Oberfläche: Man sieht beim Verteilen der Punkte, auf welcher Note man landet, + statt es hinterher zu erfahren. + +--- + ## 2026-09-10 · Baustein 7 – Berichtsheft | Von | Bis | Dauer | Tätigkeit | diff --git a/ENTSCHEIDUNGEN.md b/ENTSCHEIDUNGEN.md index ef96c38..0fc3b38 100644 --- a/ENTSCHEIDUNGEN.md +++ b/ENTSCHEIDUNGEN.md @@ -309,3 +309,46 @@ Leerzeichen keine Wortgrenze. Genau dieser Fall war zuerst durchgerutscht. Tätigkeitsverben und die Gegenüberstellungen sind Bedienhilfe der Anwendung, kein Ausbildungsinhalt. Sie ändern sich nicht mit dem Lehrgang und gehören deshalb nicht in dieselbe Ablage wie Handouts und Aufgaben. + +## E32 · Die Musterlösung wird entfernt, nicht versteckt + +Der Auftrag verlangt: Musterlösungen dürfen dem Azubi erst nach dem Absenden +angezeigt werden und **niemals vorher über die Schnittstelle abrufbar** sein. + +Umgesetzt ist das in `src/lib/kontrollen/sicht.ts`. `sichtbareKontrolle()` +baut die Fassung, die den Browser erreichen darf; ist die Lösung nicht +freigegeben, steht dort `null`. Es gibt keine Stelle, an der die vollständige +Kontrolle an die Oberfläche gereicht und dort ausgeblendet wird. + +Zwei Feinheiten: + +- Die Funktion nimmt den **ganzen Betrachter** und den Bearbeitungsstand + entgegen, nicht ein vorberechnetes `darfSehen`. Ein Aufrufer kann also nicht + aus Versehen `true` übergeben. +- Die Zwischenspeicher-Aktion gibt nur einen Zeitstempel zurück. Sie reicht + bewusst keinen Datensatz durch, in dem die Lösung mitreisen könnte. + +Geprüft wird das doppelt: 21 Tests über alle Betrachter und Zustände, und ein +Durchstich, der den Wortlaut aus der Inhaltsdatei im rohen Seitenquelltext +sucht. + +## E33 · Notenschlüssel der IHK, und ein vorläufiges Ergebnis bleibt vorläufig + +Gerechnet wird mit dem 100-Punkte-Schlüssel der IHK (ab 92 / 81 / 67 / 50 / 30 +Prozent). Wer sich daran gewöhnt, erlebt in der Abschlussprüfung keine +Überraschung. + +Solange eine Frage ohne Punkte ist, gilt die Kontrolle **nicht** als +korrigiert: `korrigiert_am` bleibt leer, das Ergebnis heißt „vorläufig", und +die Kontrolle bleibt in der Liste „Was auf dich wartet". Ein halb korrigierter +Fragebogen, der als fertig erscheint, wäre schlimmer als gar keine Anzeige. + +## E34 · Antworten werden beim Tippen gespeichert, je Frage einzeln + +Eine Lernzielkontrolle dauert 45 Minuten. Ein Browserabsturz oder ein +versehentlich geschlossener Reiter darf nicht 45 Minuten kosten. + +Gespeichert wird je Frage einzeln mit knapp einer Sekunde Verzögerung – nicht +das ganze Formular auf einmal. So bleibt jeder Schreibvorgang klein, und eine +lange Antwort auf Frage 1 wird nicht bei jedem Tastendruck in Frage 6 neu +geschrieben. diff --git a/README.md b/README.md index d45d742..62edc00 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ und – der wichtigste Teil – den gesetzlich vorgeschriebenen Ausbildungsnachw Gebaut für **einen Ausbilder und ein bis drei Auszubildende**. Kein Lernplattform-Baukasten, keine Mandanten, keine Cloud. -> **Stand:** Bausteine 1 bis 7 von 10 sind fertig – Fundament, Inhalte, -> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten und das Berichtsheft. -> Der [Fahrplan](ROADMAP.md) zeigt, was folgt, das +> **Stand:** Bausteine 1 bis 8 von 10 sind fertig – Fundament, Inhalte, +> Wochenplan, Merkkarten, Praxisaufgaben, Checklisten, Berichtsheft und +> Lernzielkontrollen. Der [Fahrplan](ROADMAP.md) zeigt, was folgt, das > [Änderungsprotokoll](CHANGELOG.md), was schon drin ist. --- diff --git a/ROADMAP.md b/ROADMAP.md index b885909..dc1f164 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,7 +84,15 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig ## 0.5 – Bewertung -- [ ] **B8 · Lernzielkontrollen mit Freigabe, Abgabe, Korrektur** +- [x] **B8 · Lernzielkontrollen mit Freigabe, Abgabe, Korrektur** + - [x] Ausbilder gibt frei, kann die Freigabe vor der Abgabe zurücknehmen + - [x] Fragen erst ab der Freigabe sichtbar + - [x] Antworten werden beim Tippen zwischengespeichert + - [x] Musterlösungen erst nach dem Absenden – serverseitig entfernt, + nicht in der Oberfläche versteckt + - [x] Korrektur mit Punkten je Frage, Anmerkung je Frage und gesamt + - [x] Notenschlüssel der IHK, Zwischenstand beim Korrigieren sichtbar + - [x] Ergebnis bei beiden sichtbar, Verlauf über alle Kontrollen - [ ] **B9 · Auswertung, Rahmenplanabdeckung, Feedbackgespräch** ## 1.0 – Abrundung diff --git a/docs/bilder/02-ausbilder-ueberblick.png b/docs/bilder/02-ausbilder-ueberblick.png index 50bf49a..9c82325 100644 Binary files a/docs/bilder/02-ausbilder-ueberblick.png and b/docs/bilder/02-ausbilder-ueberblick.png differ diff --git a/docs/bilder/03-verwaltung.png b/docs/bilder/03-verwaltung.png index 8e79fa5..c92a812 100644 Binary files a/docs/bilder/03-verwaltung.png and b/docs/bilder/03-verwaltung.png differ diff --git a/docs/bilder/04-wochenplan.png b/docs/bilder/04-wochenplan.png index a34b07b..54e870b 100644 Binary files a/docs/bilder/04-wochenplan.png and b/docs/bilder/04-wochenplan.png differ diff --git a/docs/bilder/05-aufgabe-abnahme.png b/docs/bilder/05-aufgabe-abnahme.png index a2b1d72..5d08fde 100644 Binary files a/docs/bilder/05-aufgabe-abnahme.png and b/docs/bilder/05-aufgabe-abnahme.png differ diff --git a/docs/bilder/10-azubi-woche.png b/docs/bilder/10-azubi-woche.png index c9a8685..62efef8 100644 Binary files a/docs/bilder/10-azubi-woche.png and b/docs/bilder/10-azubi-woche.png differ diff --git a/docs/bilder/11-handouts-liste.png b/docs/bilder/11-handouts-liste.png index bd20e8e..20c14ae 100644 Binary files a/docs/bilder/11-handouts-liste.png and b/docs/bilder/11-handouts-liste.png differ diff --git a/docs/bilder/12-handouts-suche.png b/docs/bilder/12-handouts-suche.png index 0d42a06..6aded7f 100644 Binary files a/docs/bilder/12-handouts-suche.png and b/docs/bilder/12-handouts-suche.png differ diff --git a/docs/bilder/13-handout-n3.png b/docs/bilder/13-handout-n3.png index d255485..5c98386 100644 Binary files a/docs/bilder/13-handout-n3.png and b/docs/bilder/13-handout-n3.png differ diff --git a/docs/bilder/14-merkkarten.png b/docs/bilder/14-merkkarten.png index 0ec5b82..5e156d1 100644 Binary files a/docs/bilder/14-merkkarten.png and b/docs/bilder/14-merkkarten.png differ diff --git a/docs/bilder/15-glossar.png b/docs/bilder/15-glossar.png index 7edfe00..dae0d3c 100644 Binary files a/docs/bilder/15-glossar.png and b/docs/bilder/15-glossar.png differ diff --git a/docs/bilder/16-aufgaben-liste.png b/docs/bilder/16-aufgaben-liste.png index 284a1cb..6e4dcc7 100644 Binary files a/docs/bilder/16-aufgaben-liste.png and b/docs/bilder/16-aufgaben-liste.png differ diff --git a/docs/bilder/17-aufgabe-azubi.png b/docs/bilder/17-aufgabe-azubi.png index 31ab39a..b1409dd 100644 Binary files a/docs/bilder/17-aufgabe-azubi.png and b/docs/bilder/17-aufgabe-azubi.png differ diff --git a/docs/bilder/18-checklisten.png b/docs/bilder/18-checklisten.png index 2a07b28..079c47b 100644 Binary files a/docs/bilder/18-checklisten.png and b/docs/bilder/18-checklisten.png differ diff --git a/docs/bilder/20-dunkel.png b/docs/bilder/20-dunkel.png index 8fcaf6c..b755c51 100644 Binary files a/docs/bilder/20-dunkel.png and b/docs/bilder/20-dunkel.png differ diff --git a/docs/bilder/21-berichtsheft.png b/docs/bilder/21-berichtsheft.png index 132752f..e6a55f5 100644 Binary files a/docs/bilder/21-berichtsheft.png and b/docs/bilder/21-berichtsheft.png differ diff --git a/docs/bilder/23-kontrollen.png b/docs/bilder/23-kontrollen.png new file mode 100644 index 0000000..f6fe0bc Binary files /dev/null and b/docs/bilder/23-kontrollen.png differ diff --git a/docs/bilder/24-kontrolle-ergebnis.png b/docs/bilder/24-kontrolle-ergebnis.png new file mode 100644 index 0000000..13f361c Binary files /dev/null and b/docs/bilder/24-kontrolle-ergebnis.png differ diff --git a/docs/bilder/31-handy-lernmodus-frage.png b/docs/bilder/31-handy-lernmodus-frage.png index 2091cf5..19e2a3c 100644 Binary files a/docs/bilder/31-handy-lernmodus-frage.png and b/docs/bilder/31-handy-lernmodus-frage.png differ diff --git a/docs/bilder/32-handy-lernmodus-antwort.png b/docs/bilder/32-handy-lernmodus-antwort.png index 74a15fb..8616e73 100644 Binary files a/docs/bilder/32-handy-lernmodus-antwort.png and b/docs/bilder/32-handy-lernmodus-antwort.png differ diff --git a/scripts/pruefungen/musterloesung-dichtheit.mjs b/scripts/pruefungen/musterloesung-dichtheit.mjs new file mode 100644 index 0000000..182a12a --- /dev/null +++ b/scripts/pruefungen/musterloesung-dichtheit.mjs @@ -0,0 +1,104 @@ +import { chromium } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Gezielte Prüfung der einen Zusage, auf die es bei den Lernzielkontrollen + * ankommt: Die Musterlösung darf vor dem Absenden nirgends herauskommen. + * + * Geprüft wird gegen den **echten** Wortlaut aus der Inhaltsdatei, nicht gegen + * ausgedachte Zeichenketten – und im rohen Quelltext samt RSC-Nutzlast, nicht + * im sichtbaren Text. + */ + +const BASIS = 'http://localhost:3311'; +const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' }; +const AUSBILDER = { email: 'fw@wildeflorian.de', pw: 'ausbilder-start' }; + +// Musterlösungen direkt aus der Datei lesen – so kann der Test nicht veralten. +const yaml = fs.readFileSync(path.join(process.cwd(), 'content/lernzielkontrollen/woche-01.yaml'), 'utf8'); +const loesungen = [...yaml.matchAll(/musterloesung:\s*>-\s*\n((?:\s{6}.+\n)+)/g)].map((t) => + t[1].replace(/\s+/g, ' ').trim(), +); +// Aussagekräftige Bruchstücke: lang genug, um nicht zufällig vorzukommen. +const bruchstuecke = loesungen.map((l) => l.slice(40, 110)).filter((b) => b.length > 40); + +console.log(`${loesungen.length} Musterlösungen aus woche-01.yaml gelesen.`); +console.log('Geprüft wird auf Bruchstücke wie:', JSON.stringify(bruchstuecke[0]?.slice(0, 60))); + +const browser = await chromium.launch(); +const seite = await (await browser.newContext({ locale: 'de-DE' })).newPage(); + +async function anmelden(k) { + await seite.goto(`${BASIS}/anmelden`); + await seite.fill('#email', k.email); + await seite.fill('#passwort', k.pw); + await seite.click('button[type=submit]'); + await seite.waitForURL(`${BASIS}/`); +} +async function abmelden() { + await seite.click('button:has-text("Abmelden")'); + await seite.waitForURL(/anmelden/); +} + +/** Sucht im rohen HTML **und** in der RSC-Nutzlast. */ +async function lecksuche(beschriftung) { + const html = await seite.content(); + const rsc = await seite.evaluate(() => + // Next legt die Server-Nutzlast in self.__next_f ab – dort steht alles, + // was der Server an den Browser geschickt hat. + (globalThis.self?.__next_f ?? []).map((t) => (Array.isArray(t) ? String(t[1] ?? '') : '')).join(''), + ); + + const treffer = bruchstuecke.filter((b) => html.includes(b) || rsc.includes(b)); + const nullFelder = (rsc.match(/musterloesung.{0,12}null/g) ?? []).length; + + console.log( + ` ${beschriftung.padEnd(34)} Leck: ${treffer.length === 0 ? 'nein' : `JA (${treffer.length})`}` + + ` | RSC-Größe ${rsc.length} | "musterloesung":null ${nullFelder}x`, + ); + return treffer; +} + +console.log('\n1) Vor der Freigabe'); +await anmelden(AZUBI); +await seite.goto(`${BASIS}/kontrollen/woche-01`); +await seite.waitForSelector('h1'); +await lecksuche('gesperrt'); + +console.log('\n2) Ausbilder gibt frei'); +await abmelden(); +await anmelden(AUSBILDER); +await seite.goto(`${BASIS}/kontrollen`); +await seite.waitForSelector('.kontrollzeile'); +await seite.locator('.kontrollzeile', { hasText: 'Woche 1' }).locator('button:has-text("Freigeben")').click(); +await seite.waitForTimeout(1500); + +console.log('\n3) Nach der Freigabe, vor der Abgabe – der entscheidende Fall'); +await abmelden(); +await anmelden(AZUBI); +await seite.goto(`${BASIS}/kontrollen/woche-01`); +await seite.waitForSelector('.frage-text'); +console.log(` Fragen sichtbar: ${(await seite.$$('.frage-text')).length}`); +const leck = await lecksuche('freigegeben, nicht abgegeben'); + +console.log('\n4) Nach dem Zwischenspeichern einer Antwort'); +await seite.locator('textarea[id^=antwort-]').first().fill('Person, Gerät, Fehlerbild, Zeitpunkt.'); +await seite.waitForTimeout(1600); +await seite.reload(); +await seite.waitForSelector('.frage-text'); +const leck2 = await lecksuche('nach Zwischenspeichern'); + +console.log('\n5) Nach der Abgabe muss sie da sein'); +await seite.click('button:has-text("Kontrolle abgeben")'); +await seite.waitForTimeout(2500); +await seite.reload(); +await seite.waitForSelector('h1'); +const nachher = await lecksuche('abgegeben'); +console.log(` Musterlösungen jetzt sichtbar: ${nachher.length} von ${bruchstuecke.length}`); + +await browser.close(); + +const gescheitert = leck.length + leck2.length; +console.log(`\n${gescheitert === 0 ? 'BESTANDEN' : 'DURCHGEFALLEN'}: vor der Abgabe ${gescheitert} Leck(s).`); +process.exit(gescheitert === 0 && nachher.length === bruchstuecke.length ? 0 : 1); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index f179891..1df8833 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -126,6 +126,14 @@ async function main() { await seite.waitForSelector('h1'); await bild(seite, '21-berichtsheft'); + await seite.goto(`${BASIS}/kontrollen`); + await seite.waitForSelector('h1'); + await bild(seite, '23-kontrollen'); + + await seite.goto(`${BASIS}/kontrollen/woche-03`); + await seite.waitForSelector('h1'); + await bild(seite, '24-kontrolle-ergebnis'); + const blatt = await seite.$('.blattzeile-link'); if (blatt) { await blatt.click(); diff --git a/src/app/(angemeldet)/kontrollen/Fragebogen.tsx b/src/app/(angemeldet)/kontrollen/Fragebogen.tsx new file mode 100644 index 0000000..bf6ee94 --- /dev/null +++ b/src/app/(angemeldet)/kontrollen/Fragebogen.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useActionState, useEffect, useRef, useState } from 'react'; +import { Absenden, Meldung } from '@/komponenten/Formularmeldung'; +import { antwortZwischenspeichern, kontrolleAbgeben, type Rueckmeldung } from './aktionen'; +import type { SichtbareFrage } from '@/lib/kontrollen/sicht'; + +/** + * Der Fragebogen. + * + * Antworten werden beim Tippen zwischengespeichert – eine Kontrolle dauert + * 45 Minuten, und ein verlorener Text nach einem versehentlichen Schließen + * wäre nicht wiedergutzumachen. + * + * Zur Musterlösung: Sie steckt gar nicht in den Daten, die diese Komponente + * bekommt, solange nicht abgegeben wurde. Das entscheidet der Server. + */ +export function Fragebogen({ + kontrolleId, + fragen, + anfangsantworten, + gesamtpunkte, + dauerMinuten, +}: { + kontrolleId: string; + fragen: SichtbareFrage[]; + anfangsantworten: Record; + gesamtpunkte: number; + dauerMinuten: number | null; +}) { + const [antworten, setzeAntworten] = useState>(anfangsantworten); + const [gespeichert, setzeGespeichert] = useState(null); + const [speicherfehler, setzeSpeicherfehler] = useState(null); + const [zustand, absenden] = useActionState(kontrolleAbgeben, {}); + + const zeitgeber = useRef>>({}); + + useEffect(() => { + const laufende = zeitgeber.current; + return () => { + for (const gestellt of Object.values(laufende)) clearTimeout(gestellt); + }; + }, []); + + function aendern(frageId: string, wert: string) { + setzeAntworten((v) => ({ ...v, [frageId]: wert })); + + clearTimeout(zeitgeber.current[frageId]); + zeitgeber.current[frageId] = setTimeout(async () => { + const ergebnis = await antwortZwischenspeichern(kontrolleId, frageId, wert); + if ('fehler' in ergebnis) { + setzeSpeicherfehler(ergebnis.fehler); + } else { + setzeSpeicherfehler(null); + setzeGespeichert( + new Intl.DateTimeFormat('de-DE', { timeStyle: 'medium', timeZone: 'Europe/Berlin' }).format( + new Date(ergebnis.gespeichertUm), + ), + ); + } + }, 900); + } + + const beantwortet = fragen.filter((f) => (antworten[f.id] ?? '').trim() !== '').length; + + return ( +
+ + +
+
+ + {beantwortet} von {fragen.length} Fragen beantwortet + + + {gesamtpunkte} Punkte insgesamt + {dauerMinuten ? ` · ${dauerMinuten} Minuten vorgesehen` : ''} + +
+ + + {speicherfehler ? ( + {speicherfehler} + ) : gespeichert ? ( + `Zwischengespeichert um ${gespeichert}` + ) : ( + 'Wird beim Tippen zwischengespeichert' + )} + +
+ +
    + {fragen.map((frage, stelle) => ( +
  1. +
    + {stelle + 1} +

    {frage.frage}

    + + {frage.punkte} {frage.punkte === 1 ? 'Punkt' : 'Punkte'} + +
    + + +