waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared druck lernbericht.ts
| 1 | /** |
| 2 | * Der Lernbericht – das erste druckbare Dokument. |
| 3 | * |
| 4 | * **Warum dieses zuerst.** PLAN.md nennt unter D8 vier Dokumente: |
| 5 | * Fragenlisten, Fehlerprotokoll, Statistik, Lernkarten. Der Bericht über den |
| 6 | * eigenen Stand ist das einzige davon, das ohne neuen Datenkanal und ohne |
| 7 | * Änderung am Datenbankschema auskommt – Übersicht, Lernplan und |
| 8 | * Prüfungsverlauf liegen bereits vor. Er ist außerdem kurz genug, dass sich |
| 9 | * das erzeugte PDF von Hand mit einem Prüfwerkzeug gegenlesen lässt; bei |
| 10 | * einer Fragenliste von rund 300 Seiten wäre das keinem mehr zuzumuten. |
| 11 | * |
| 12 | * Und er enthält fast keinen amtlichen Wortlaut – nur die Kapitel- und |
| 13 | * Abschnittstitel. Die Werkzeugkette samt Quellenangabe wird also einmal |
| 14 | * vollständig gebaut und geprüft, bevor das erste Dokument entsteht, das den |
| 15 | * Katalog wirklich wiedergibt. |
| 16 | */ |
| 17 | |
| 18 | import type { Lernplan, Machbarkeit } from '../lernplan'; |
| 19 | import type { Lernuebersicht } from '../lernstand'; |
| 20 | import { reifesatz, STUFE_WORT } from '../reife'; |
| 21 | import type { Pruefungsverlauf } from '../pruefung'; |
| 22 | import { URTEIL_BEZEICHNUNG } from '../pruefung'; |
| 23 | import { |
| 24 | abschnitt, |
| 25 | absatz, |
| 26 | dokumentBauen, |
| 27 | tabelle, |
| 28 | type Quellenangabe, |
| 29 | type Zelle, |
| 30 | } from './dokument'; |
| 31 | import type { Schriftgroesse } from './stil'; |
| 32 | |
| 33 | /** Anteil als ganze Prozent – wie in der Oberfläche. */ |
| 34 | function prozent(anteil: number): string { |
| 35 | return `${String(Math.round(anteil * 100))} %`; |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Bearbeitungsdauer in vollen Minuten. |
| 40 | * |
| 41 | * Gröber als am Bildschirm mit Absicht: Auf dem Papier steht die Zahl zum |
| 42 | * Vergleich mehrerer Läufe untereinander, und dafür sind Sekunden Rauschen. |
| 43 | * Aufgerundet auf mindestens eine Minute – „0 Minuten“ für einen Lauf, den es |
| 44 | * gab, wäre keine Auskunft. |
| 45 | */ |
| 46 | function dauerInMinuten(millisekunden: number): string { |
| 47 | const gerundet = Math.max(0, Math.round(millisekunden / 60_000)); |
| 48 | const minuten = gerundet === 0 && millisekunden > 0 ? 1 : gerundet; |
| 49 | return `${String(minuten)} ${minuten === 1 ? 'Minute' : 'Minuten'}`; |
| 50 | } |
| 51 | |
| 52 | /** ISO-Datum als deutsches Datum; Unlesbares bleibt stehen. */ |
| 53 | export function deutschesDatum(iso: string): string { |
| 54 | const zeit = Date.parse(iso); |
| 55 | if (Number.isNaN(zeit)) { |
| 56 | return iso; |
| 57 | } |
| 58 | return new Date(zeit).toLocaleDateString('de-DE', { |
| 59 | day: '2-digit', |
| 60 | month: '2-digit', |
| 61 | year: 'numeric', |
| 62 | }); |
| 63 | } |
| 64 | |
| 65 | const MACHBARKEIT_SATZ: Readonly<Record<Machbarkeit, string>> = Object.freeze({ |
| 66 | kein_termin: 'Es ist kein Prüfungstermin eingetragen.', |
| 67 | termin_vorbei: 'Der eingetragene Prüfungstermin liegt in der Vergangenheit.', |
| 68 | entspannt: 'Bis zum Termin bleibt reichlich Zeit.', |
| 69 | machbar: 'Das Pensum ist bis zum Termin gut zu schaffen.', |
| 70 | knapp: 'Bis zum Termin wird es knapp.', |
| 71 | zu_wenig_zeit: 'Bis zum Termin reicht die Zeit für das nötige Pensum nicht aus.', |
| 72 | }); |
| 73 | |
| 74 | export interface Lernberichtsdaten { |
| 75 | readonly uebersicht: Lernuebersicht; |
| 76 | /** `null`, wenn der Lernplan nicht geladen werden konnte. */ |
| 77 | readonly plan: Lernplan | null; |
| 78 | readonly verlauf: readonly Pruefungsverlauf[]; |
| 79 | readonly profilName: string; |
| 80 | readonly quelle: Quellenangabe; |
| 81 | /** Zeitpunkt der Erstellung als ISO-Zeichenkette. */ |
| 82 | readonly erstelltAm: string; |
| 83 | readonly schriftgroesse: Schriftgroesse; |
| 84 | } |
| 85 | |
| 86 | /** Wie viele Läufe der Bericht auflistet. */ |
| 87 | export const VERLAUF_HOECHSTENS = 20; |
| 88 | |
| 89 | function ueberblick(u: Lernuebersicht): string { |
| 90 | const offen = u.fragenGesamt - u.beantwortet; |
| 91 | const zeilen: Zelle[][] = [ |
| 92 | [{ text: 'Fragen im Katalog' }, { text: String(u.fragenGesamt), zahl: true }], |
| 93 | [{ text: 'Schon einmal beantwortet' }, { text: String(u.beantwortet), zahl: true }], |
| 94 | [{ text: 'Noch nie beantwortet' }, { text: String(offen), zahl: true }], |
| 95 | /* Ohne Farbe: Ein grünes „0“ läse sich als gute Nachricht, und die Zeile |
| 96 | sagt schon selbst, worum es geht. Farbe trägt hier nichts bei – sie |
| 97 | würde ein Urteil andeuten, das die Zahl nicht hergibt. */ |
| 98 | [{ text: 'Abruf belegt und frisch' }, { text: String(u.belegt), zahl: true }], |
| 99 | [{ text: 'Heute zur Wiederholung fällig' }, { text: String(u.faellig), zahl: true }], |
| 100 | [{ text: 'Auf der Merkliste' }, { text: String(u.gemerkt), zahl: true }], |
| 101 | ]; |
| 102 | |
| 103 | return abschnitt('Ihr Stand insgesamt', [ |
| 104 | absatz(`${STUFE_WORT[u.stufe]}: ${reifesatz(u.belegt, u.fragenGesamt, u.stufe, u.deckelnd)}`), |
| 105 | /* Der Vorbehalt gehört in den Bericht, nicht nur auf den Bildschirm: Das |
| 106 | PDF wird ausgedruckt, weitergereicht und später ohne die Anwendung |
| 107 | daneben gelesen. Was es behauptet, muss für sich allein stimmen. */ |
| 108 | absatz( |
| 109 | 'Diese Zahl sagt, was belegt sitzt – nicht, was in der Prüfung herauskäme. Eine Frage ' + |
| 110 | 'zählt erst, wenn sie nach mindestens einem Tag Abstand richtig beantwortet wurde, und ' + |
| 111 | 'ihr Beitrag sinkt wieder, je länger das her ist. Geraten ist nicht eingerechnet, nie ' + |
| 112 | 'Gesehenes gilt als nicht gekonnt. Über das Bestehen entscheidet allein der ' + |
| 113 | 'Prüfungsausschuss.', |
| 114 | 'hinweis', |
| 115 | ), |
| 116 | tabelle('Übersicht über den Lernstand', ['Kennzahl', 'Anzahl'], zeilen), |
| 117 | ]); |
| 118 | } |
| 119 | |
| 120 | function bereiche(u: Lernuebersicht): string { |
| 121 | if (u.bereiche.length === 0) { |
| 122 | return abschnitt('Nach Bereichen', [ |
| 123 | absatz('Zu den einzelnen Bereichen liegen noch keine Zahlen vor.', 'hinweis'), |
| 124 | ]); |
| 125 | } |
| 126 | |
| 127 | const zeilen: Zelle[][] = u.bereiche.map((b) => [ |
| 128 | { text: `${b.id} – ${b.titel}` }, |
| 129 | { text: String(b.fragenGesamt), zahl: true }, |
| 130 | { text: String(b.beantwortet), zahl: true }, |
| 131 | { text: String(b.belegt), zahl: true }, |
| 132 | { text: STUFE_WORT[b.stufe] }, |
| 133 | ]); |
| 134 | |
| 135 | return abschnitt('Nach Bereichen', [ |
| 136 | absatz( |
| 137 | 'Die Bezeichnungen der Bereiche stammen aus dem amtlichen Fragenkatalog und sind unverändert übernommen.', |
| 138 | 'hinweis', |
| 139 | ), |
| 140 | tabelle( |
| 141 | 'Lernstand je Kapitel und Abschnitt', |
| 142 | ['Bereich', 'Fragen', 'Beantwortet', 'Belegt', 'Stand'], |
| 143 | zeilen, |
| 144 | ), |
| 145 | ]); |
| 146 | } |
| 147 | |
| 148 | function planung(plan: Lernplan | null): string { |
| 149 | if (plan === null) { |
| 150 | return abschnitt('Ihr Lernplan', [ |
| 151 | absatz('Der Lernplan konnte für diesen Bericht nicht ermittelt werden.', 'hinweis'), |
| 152 | ]); |
| 153 | } |
| 154 | |
| 155 | const teile: string[] = [ |
| 156 | absatz(MACHBARKEIT_SATZ[plan.machbarkeit]), |
| 157 | /* Ohne den Blick auf heute: Der steht als Zahl im Abschnitt davor, und |
| 158 | seit dem Umbau ist es dieselbe Rechnung. Zweimal dieselbe Größe in |
| 159 | zwei Einheiten – einmal als Fragenzahl, einmal als Prozentwert – war |
| 160 | genau die Doppelung, die diesen Schritt ausgelöst hat. */ |
| 161 | absatz(`Angestrebt ist ein Abrufstand von ${prozent(plan.zielquote)} je Frage.`), |
| 162 | ]; |
| 163 | |
| 164 | if (plan.termin !== null) { |
| 165 | const tage = plan.tageBisTermin; |
| 166 | teile.push( |
| 167 | absatz( |
| 168 | `Prüfungstermin: ${deutschesDatum(plan.termin)}` + |
| 169 | (tage === null ? '.' : ` – noch ${String(tage)} Tage.`), |
| 170 | ), |
| 171 | ); |
| 172 | if (plan.prognoseAmTermin !== null) { |
| 173 | /* Die Prognose zum Termin gilt unter der Annahme, dass ab heute nicht |
| 174 | mehr gelernt wird. Ohne diesen Zusatz läse sich die Zahl als |
| 175 | Versprechen. */ |
| 176 | teile.push( |
| 177 | absatz( |
| 178 | `Ohne weiteres Lernen wären es am Prüfungstag noch ${prozent(plan.prognoseAmTermin)}.`, |
| 179 | ), |
| 180 | ); |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | teile.push( |
| 185 | tabelle( |
| 186 | 'Empfohlenes Tagespensum', |
| 187 | ['Anteil', 'Fragen'], |
| 188 | [ |
| 189 | [{ text: 'Neue Fragen' }, { text: String(plan.pensum.neu), zahl: true }], |
| 190 | [{ text: 'Wiederholungen' }, { text: String(plan.pensum.wiederholung), zahl: true }], |
| 191 | [{ text: 'Zusammen' }, { text: String(plan.pensum.gesamt), zahl: true }], |
| 192 | [ |
| 193 | { text: 'Geschätzte Dauer in Minuten' }, |
| 194 | { text: String(plan.pensum.minuten), zahl: true }, |
| 195 | ], |
| 196 | ], |
| 197 | ), |
| 198 | ); |
| 199 | |
| 200 | return abschnitt('Ihr Lernplan', teile); |
| 201 | } |
| 202 | |
| 203 | function simulationen(verlauf: readonly Pruefungsverlauf[]): string { |
| 204 | if (verlauf.length === 0) { |
| 205 | return abschnitt('Prüfungssimulationen', [ |
| 206 | absatz('Es wurde noch keine Prüfungssimulation abgeschlossen.', 'hinweis'), |
| 207 | ]); |
| 208 | } |
| 209 | |
| 210 | const gezeigt = verlauf.slice(0, VERLAUF_HOECHSTENS); |
| 211 | const zeilen: Zelle[][] = gezeigt.map((lauf) => [ |
| 212 | { text: deutschesDatum(lauf.zeitpunkt) }, |
| 213 | { text: lauf.profilName }, |
| 214 | /* Ohne die Dauer bliebe auf dem Papier unsichtbar, ob jemand von 118 auf |
| 215 | 87 Minuten heruntergekommen ist – Zeitnot ist ein eigenes |
| 216 | Durchfallrisiko und nicht an der Trefferquote abzulesen. */ |
| 217 | { text: dauerInMinuten(lauf.dauerMs), zahl: true }, |
| 218 | { text: `${String(lauf.richtig)} von ${String(lauf.gesamt)}`, zahl: true }, |
| 219 | { text: prozent(lauf.quote), zahl: true }, |
| 220 | { |
| 221 | /* Urteil als Wort, nicht als Farbe: Ein Graustufendruck macht aus |
| 222 | Grün und Rot dasselbe Grau. */ |
| 223 | text: URTEIL_BEZEICHNUNG[lauf.urteil], |
| 224 | klasse: |
| 225 | lauf.urteil === 'bestanden' ? 'gut' : lauf.urteil === 'nicht_bestanden' ? 'schlecht' : '', |
| 226 | }, |
| 227 | ]); |
| 228 | |
| 229 | const teile = [ |
| 230 | tabelle( |
| 231 | 'Abgeschlossene Prüfungssimulationen', |
| 232 | ['Datum', 'Profil', 'Dauer', 'Richtig', 'Quote', 'Urteil'], |
| 233 | zeilen, |
| 234 | ), |
| 235 | ]; |
| 236 | |
| 237 | if (verlauf.length > gezeigt.length) { |
| 238 | teile.push( |
| 239 | absatz( |
| 240 | `Angezeigt sind die ${String(gezeigt.length)} jüngsten von insgesamt ${String(verlauf.length)} Läufen.`, |
| 241 | 'hinweis', |
| 242 | ), |
| 243 | ); |
| 244 | } |
| 245 | |
| 246 | return abschnitt('Prüfungssimulationen', teile); |
| 247 | } |
| 248 | |
| 249 | /** |
| 250 | * Baut den Lernbericht als vollständige HTML-Datei. |
| 251 | * |
| 252 | * Enthält bewusst **keine** Frage im Wortlaut und keine Antwort: Der Bericht |
| 253 | * soll den Stand zeigen, nicht den Katalog ersetzen. Dass er trotzdem die |
| 254 | * Herkunft nennt, liegt an den Bereichsbezeichnungen, die aus dem amtlichen |
| 255 | * Werk stammen. |
| 256 | */ |
| 257 | export function lernberichtBauen(daten: Lernberichtsdaten): string { |
| 258 | return dokumentBauen({ |
| 259 | titel: 'Lernbericht Waffensachkunde', |
| 260 | augenbraue: `${daten.profilName} · erstellt am ${deutschesDatum(daten.erstelltAm)}`, |
| 261 | quelle: daten.quelle, |
| 262 | schriftgroesse: daten.schriftgroesse, |
| 263 | abschnitte: [ |
| 264 | ueberblick(daten.uebersicht), |
| 265 | bereiche(daten.uebersicht), |
| 266 | planung(daten.plan), |
| 267 | simulationen(daten.verlauf), |
| 268 | ], |
| 269 | }); |
| 270 | } |
| 271 | |
| 272 | /** Vorschlag für den Dateinamen; enthält keine Zeichen, die Dateisysteme stören. */ |
| 273 | export function dateiname(profilName: string, erstelltAm: string): string { |
| 274 | const datum = Number.isNaN(Date.parse(erstelltAm)) |
| 275 | ? 'ohne-datum' |
| 276 | : new Date(erstelltAm).toISOString().slice(0, 10); |
| 277 | const name = profilName |
| 278 | .normalize('NFKD') |
| 279 | .replace(/[\u0300-\u036f]/gu, '') |
| 280 | .replace(/[^A-Za-z0-9]+/gu, '-') |
| 281 | .replace(/^-+|-+$/gu, '') |
| 282 | .slice(0, 40); |
| 283 | |
| 284 | return `Lernbericht${name === '' ? '' : `-${name}`}-${datum}.pdf`; |
| 285 | } |