/** * Der Lernbericht – das erste druckbare Dokument. * * **Warum dieses zuerst.** PLAN.md nennt unter D8 vier Dokumente: * Fragenlisten, Fehlerprotokoll, Statistik, Lernkarten. Der Bericht über den * eigenen Stand ist das einzige davon, das ohne neuen Datenkanal und ohne * Änderung am Datenbankschema auskommt – Übersicht, Lernplan und * Prüfungsverlauf liegen bereits vor. Er ist außerdem kurz genug, dass sich * das erzeugte PDF von Hand mit einem Prüfwerkzeug gegenlesen lässt; bei * einer Fragenliste, die ein Vielfaches davon umfasst, wäre das keinem mehr * zuzumuten. * * Und er enthält fast keinen amtlichen Wortlaut – nur die Kapitel- und * Abschnittstitel. Die Werkzeugkette samt Quellenangabe wird also einmal * vollständig gebaut und geprüft, bevor das erste Dokument entsteht, das den * Katalog wirklich wiedergibt. */ import type { Lernplan, Machbarkeit } from '../lernplan'; import type { Lernuebersicht } from '../lernstand'; import { reifesatz, STUFE_WORT } from '../reife'; import type { Pruefungsverlauf } from '../pruefung'; import { URTEIL_BEZEICHNUNG } from '../pruefung'; import { abschnitt, absatz, dokumentBauen, tabelle, type Quellenangabe, type Zelle, } from './dokument'; import type { Schriftgroesse } from './stil'; /** Anteil als ganze Prozent – wie in der Oberfläche. */ function prozent(anteil: number): string { return `${String(Math.round(anteil * 100))} %`; } /** * Bearbeitungsdauer in vollen Minuten. * * Gröber als am Bildschirm mit Absicht: Auf dem Papier steht die Zahl zum * Vergleich mehrerer Läufe untereinander, und dafür sind Sekunden Rauschen. * Aufgerundet auf mindestens eine Minute – „0 Minuten“ für einen Lauf, den es * gab, wäre keine Auskunft. */ function dauerInMinuten(millisekunden: number): string { const gerundet = Math.max(0, Math.round(millisekunden / 60_000)); const minuten = gerundet === 0 && millisekunden > 0 ? 1 : gerundet; return `${String(minuten)} ${minuten === 1 ? 'Minute' : 'Minuten'}`; } /** ISO-Datum als deutsches Datum; Unlesbares bleibt stehen. */ export function deutschesDatum(iso: string): string { const zeit = Date.parse(iso); if (Number.isNaN(zeit)) { return iso; } return new Date(zeit).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', }); } const MACHBARKEIT_SATZ: Readonly> = Object.freeze({ kein_termin: 'Es ist kein Prüfungstermin eingetragen.', termin_vorbei: 'Der eingetragene Prüfungstermin liegt in der Vergangenheit.', entspannt: 'Bis zum Termin bleibt reichlich Zeit.', machbar: 'Das Pensum ist bis zum Termin gut zu schaffen.', knapp: 'Bis zum Termin wird es knapp.', zu_wenig_zeit: 'Bis zum Termin reicht die Zeit für das nötige Pensum nicht aus.', }); export interface Lernberichtsdaten { readonly uebersicht: Lernuebersicht; /** `null`, wenn der Lernplan nicht geladen werden konnte. */ readonly plan: Lernplan | null; readonly verlauf: readonly Pruefungsverlauf[]; readonly profilName: string; readonly quelle: Quellenangabe; /** Zeitpunkt der Erstellung als ISO-Zeichenkette. */ readonly erstelltAm: string; readonly schriftgroesse: Schriftgroesse; } /** Wie viele Läufe der Bericht auflistet. */ export const VERLAUF_HOECHSTENS = 20; function ueberblick(u: Lernuebersicht): string { const offen = u.fragenGesamt - u.beantwortet; const zeilen: Zelle[][] = [ /* „Lernumfang“ und nicht „Katalog“: `fragenGesamt` zählt die Fragen dieses Profils. Wer Kapitel IV abgewählt hat, hat hier 486 stehen – unter der Überschrift „Fragen im Katalog“ wäre das eine Zahl, die etwas anderes behauptet, auf einem Blatt, das jemand aus der Hand gibt. */ [{ text: 'Fragen in Ihrem Lernumfang' }, { text: String(u.fragenGesamt), zahl: true }], [{ text: 'Schon einmal beantwortet' }, { text: String(u.beantwortet), zahl: true }], [{ text: 'Noch nie beantwortet' }, { text: String(offen), zahl: true }], /* Ohne Farbe: Ein grünes „0“ läse sich als gute Nachricht, und die Zeile sagt schon selbst, worum es geht. Farbe trägt hier nichts bei – sie würde ein Urteil andeuten, das die Zahl nicht hergibt. */ [{ text: 'Abruf belegt und frisch' }, { text: String(u.belegt), zahl: true }], [{ text: 'Heute zur Wiederholung fällig' }, { text: String(u.faellig), zahl: true }], [{ text: 'Auf der Merkliste' }, { text: String(u.gemerkt), zahl: true }], ]; return abschnitt('Ihr Stand insgesamt', [ absatz(`${STUFE_WORT[u.stufe]}: ${reifesatz(u.belegt, u.fragenGesamt, u.stufe, u.deckelnd)}`), /* Der Vorbehalt gehört in den Bericht, nicht nur auf den Bildschirm: Das PDF wird ausgedruckt, weitergereicht und später ohne die Anwendung daneben gelesen. Was es behauptet, muss für sich allein stimmen. */ absatz( 'Diese Zahl sagt, was belegt sitzt – nicht, was in der Prüfung herauskäme. Eine Frage ' + 'zählt erst, wenn sie nach mindestens einem Tag Abstand richtig beantwortet wurde, und ' + 'ihr Beitrag sinkt wieder, je länger das her ist. Geraten ist nicht eingerechnet, nie ' + 'Gesehenes gilt als nicht gekonnt. Über das Bestehen entscheidet allein der ' + 'Prüfungsausschuss.', 'hinweis', ), tabelle('Übersicht über den Lernstand', ['Kennzahl', 'Anzahl'], zeilen), ]); } function bereiche(u: Lernuebersicht): string { if (u.bereiche.length === 0) { return abschnitt('Nach Bereichen', [ absatz('Zu den einzelnen Bereichen liegen noch keine Zahlen vor.', 'hinweis'), ]); } const zeilen: Zelle[][] = u.bereiche.map((b) => [ { text: `${b.id} – ${b.titel}` }, { text: String(b.fragenGesamt), zahl: true }, { text: String(b.beantwortet), zahl: true }, { text: String(b.belegt), zahl: true }, { text: STUFE_WORT[b.stufe] }, ]); return abschnitt('Nach Bereichen', [ absatz( 'Die Bezeichnungen der Bereiche stammen aus dem amtlichen Fragenkatalog und sind unverändert übernommen.', 'hinweis', ), tabelle( 'Lernstand je Kapitel und Abschnitt', ['Bereich', 'Fragen', 'Beantwortet', 'Belegt', 'Stand'], zeilen, ), ]); } function planung(plan: Lernplan | null): string { if (plan === null) { return abschnitt('Ihr Lernplan', [ absatz('Der Lernplan konnte für diesen Bericht nicht ermittelt werden.', 'hinweis'), ]); } const teile: string[] = [ absatz(MACHBARKEIT_SATZ[plan.machbarkeit]), /* Ohne den Blick auf heute: Der steht als Zahl im Abschnitt davor, und seit dem Umbau ist es dieselbe Rechnung. Zweimal dieselbe Größe in zwei Einheiten – einmal als Fragenzahl, einmal als Prozentwert – war genau die Doppelung, die diesen Schritt ausgelöst hat. */ absatz(`Angestrebt ist ein Abrufstand von ${prozent(plan.zielquote)} je Frage.`), ]; if (plan.termin !== null) { const tage = plan.tageBisTermin; teile.push( /* Ein verstrichener Termin bekommt seinen eigenen Satz. Bis 0.27.2 stand hier „noch -33 Tage“; die Oberfläche kennt den Fall längst (`machbarkeitSatz`, Zweig `termin_vorbei`), das Papier nicht. */ absatz( `Prüfungstermin: ${deutschesDatum(plan.termin)}` + (tage === null ? '.' : tage < 0 ? ' – dieser Termin ist verstrichen.' : tage === 0 ? ' – das ist heute.' : ` – noch ${String(tage)} ${tage === 1 ? 'Tag' : 'Tage'}.`), ), ); if (plan.prognoseAmTermin !== null) { /* Die Prognose zum Termin gilt unter der Annahme, dass ab heute nicht mehr gelernt wird. Ohne diesen Zusatz läse sich die Zahl als Versprechen. */ teile.push( absatz( `Ohne weiteres Lernen wären es am Prüfungstag noch ${prozent(plan.prognoseAmTermin)}.`, ), ); } } teile.push( tabelle( 'Empfohlenes Tagespensum', ['Anteil', 'Fragen'], [ [{ text: 'Neue Fragen' }, { text: String(plan.pensum.neu), zahl: true }], [{ text: 'Wiederholungen' }, { text: String(plan.pensum.wiederholung), zahl: true }], [{ text: 'Zusammen' }, { text: String(plan.pensum.gesamt), zahl: true }], [ { text: 'Geschätzte Dauer in Minuten' }, { text: String(plan.pensum.minuten), zahl: true }, ], ], ), ); return abschnitt('Ihr Lernplan', teile); } /** * Die Zeitstufe als Wort. * * `null` bei Läufen aus der Zeit vor Schema-Version 4: Sie ist dort nicht * mehr zu ermitteln, und sie zu raten wäre schlechter, als sie offen zu * lassen. */ function zeitstufeText(zeitmodus: Pruefungsverlauf['zeitmodus']): string { if (zeitmodus === null) { return 'unbekannt'; } /* Kurzformen, weil die Spalte schmal ist – dieselben vier Stufen wie in `ZEITMODUS_BEZEICHNUNG`, nur auf Tabellenbreite. */ return zeitmodus === 'aus' ? 'ohne Uhr' : zeitmodus === 'normal' ? 'volle Zeit' : `+${zeitmodus.slice(4)} %`; } function simulationen(verlauf: readonly Pruefungsverlauf[]): string { if (verlauf.length === 0) { return abschnitt('Prüfungssimulationen', [ absatz('Es wurde noch keine Prüfungssimulation abgeschlossen.', 'hinweis'), ]); } const gezeigt = verlauf.slice(0, VERLAUF_HOECHSTENS); const zeilen: Zelle[][] = gezeigt.map((lauf) => [ { text: deutschesDatum(lauf.zeitpunkt) }, { text: lauf.profilName }, /* Ohne die Dauer bliebe auf dem Papier unsichtbar, ob jemand von 118 auf 87 Minuten heruntergekommen ist – Zeitnot ist ein eigenes Durchfallrisiko und nicht an der Trefferquote abzulesen. */ { text: dauerInMinuten(lauf.dauerMs), zahl: true }, { text: `${String(lauf.richtig)} von ${String(lauf.gesamt)}`, zahl: true }, { text: prozent(lauf.quote), zahl: true }, /* Unbeantwortete und Zeitstufe stehen mit in der Tabelle – beide Felder tragen im Vertrag die Begründung ihrer Existenz. Zur Zeitstufe: „Ohne diese Angabe stünden ein Lauf ohne Uhr und einer unter Zeitdruck in der Tabelle nebeneinander, als wären sie vergleichbar.“ Zu den Unbeantworteten: „`quote` zählt Unbeantwortete wie falsch beantwortete, und ein Lauf, in dem die Zeit ablief, sähe im Vergleich sonst wie ein Wissenseinbruch aus.“ Bis 0.27.2 stand auf dem Papier keines von beiden. */ { text: lauf.unbeantwortet === null ? '–' : String(lauf.unbeantwortet), zahl: true }, { text: zeitstufeText(lauf.zeitmodus) }, { /* Urteil als Wort, nicht als Farbe: Ein Graustufendruck macht aus Grün und Rot dasselbe Grau. */ text: URTEIL_BEZEICHNUNG[lauf.urteil], klasse: lauf.urteil === 'bestanden' ? 'gut' : lauf.urteil === 'nicht_bestanden' ? 'schlecht' : '', }, ]); const teile = [ tabelle( 'Abgeschlossene Prüfungssimulationen', ['Datum', 'Profil', 'Dauer', 'Richtig', 'Quote', 'Offen', 'Zeit', 'Urteil'], zeilen, ), ]; if (verlauf.length > gezeigt.length) { teile.push( absatz( `Angezeigt sind die ${String(gezeigt.length)} jüngsten von insgesamt ${String(verlauf.length)} Läufen.`, 'hinweis', ), ); } return abschnitt('Prüfungssimulationen', teile); } /** * Baut den Lernbericht als vollständige HTML-Datei. * * Enthält bewusst **keine** Frage im Wortlaut und keine Antwort: Der Bericht * soll den Stand zeigen, nicht den Katalog ersetzen. Dass er trotzdem die * Herkunft nennt, liegt an den Bereichsbezeichnungen, die aus dem amtlichen * Werk stammen. */ export function lernberichtBauen(daten: Lernberichtsdaten): string { return dokumentBauen({ titel: 'Lernbericht Waffensachkunde', augenbraue: `${daten.profilName} · erstellt am ${deutschesDatum(daten.erstelltAm)}`, quelle: daten.quelle, schriftgroesse: daten.schriftgroesse, abschnitte: [ ueberblick(daten.uebersicht), bereiche(daten.uebersicht), planung(daten.plan), simulationen(daten.verlauf), ], }); } /** Vorschlag für den Dateinamen; enthält keine Zeichen, die Dateisysteme stören. */ export function dateiname(profilName: string, erstelltAm: string): string { const datum = Number.isNaN(Date.parse(erstelltAm)) ? 'ohne-datum' : new Date(erstelltAm).toISOString().slice(0, 10); const name = profilName .normalize('NFKD') .replace(/[\u0300-\u036f]/gu, '') .replace(/[^A-Za-z0-9]+/gu, '-') .replace(/^-+|-+$/gu, '') .slice(0, 40); return `Lernbericht${name === '' ? '' : `-${name}`}-${datum}.pdf`; }