waffensachkunde

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

/ app src shared druck lernbericht.ts

12,5 KB Rohdatei
app/src/shared/druck/lernbericht.ts — 330 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, 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 }