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