waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src shared druck lernbericht.ts

10,4 KB Rohdatei
app/src/shared/druck/lernbericht.ts — 285 Zeilen
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 }