waffensachkunde

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

/ app src shared lernstand.ts

13,4 KB Rohdatei
app/src/shared/lernstand.ts — 329 Zeilen
1 /**
2 * Datentypen des Lernstands.
3 *
4 * Der Lernstand liegt ausschließlich lokal in einer SQLite-Datei je Profil.
5 * Es gibt kein Konto, keine Cloud und keine Telemetrie.
6 */
7
8 import type { Reifestufe } from './reife';
9
10 /**
11 * Selbst- bzw. Systembewertung einer Antwort.
12 *
13 * Die vier Stufen entsprechen den Bewertungen des FSRS-Verfahrens, das später
14 * die Wiedervorlage plant. Bei Multiple Choice werden sie automatisch
15 * vergeben, bei offenen Fragen wählt der Lernende selbst.
16 */
17 export type Bewertung = 'nochmal' | 'schwer' | 'gut' | 'leicht';
18
19 export const BEWERTUNGEN: readonly Bewertung[] = ['nochmal', 'schwer', 'gut', 'leicht'];
20
21 export const BEWERTUNG_BEZEICHNUNG: Readonly<Record<Bewertung, string>> = Object.freeze({
22 nochmal: 'Nicht gewusst',
23 schwer: 'Mit Mühe gewusst',
24 gut: 'Gewusst',
25 leicht: 'Sicher gewusst',
26 });
27
28 /**
29 * Welche Bewertung als gewusst zählt.
30 *
31 * „Nicht gewusst“ ist der einzige Fehlschlag – „mit Mühe gewusst“ ist eine
32 * erfolgreiche, wenn auch mühsame Erinnerung (so rechnet auch FSRS).
33 *
34 * **Steht seit 0.22.0 hier und nicht mehr nur im Renderer**: Der
35 * Anwendungskern braucht dieselbe Grenze für den Reife-Beleg, seit sich eine
36 * richtige Auswahlantwort als geraten eingestehen lässt. Zwei Fassungen
37 * derselben Regel liefen auseinander, und eine davon wäre dann die falsche.
38 */
39 export function giltAlsRichtig(bewertung: Bewertung): boolean {
40 return bewertung !== 'nochmal';
41 }
42
43 /** Eine beantwortete Frage, wie sie protokolliert wird. */
44 export interface Antwortprotokoll {
45 readonly frageId: string;
46 /** Bei Multiple Choice die gewählten Labels, sonst leer. */
47 readonly auswahl: readonly string[];
48 /** Bei offenen Fragen der eingegebene Text (kann leer bleiben). */
49 readonly freitext?: string;
50 readonly richtig: boolean;
51 readonly bewertung: Bewertung;
52 /** Bearbeitungsdauer in Millisekunden. */
53 readonly dauerMs: number;
54 }
55
56 /** Lernstand einer einzelnen Frage. */
57 export interface FrageStand {
58 readonly frageId: string;
59 readonly versuche: number;
60 readonly richtige: number;
61 /** Zeitpunkt der letzten Antwort als ISO-Zeichenkette. */
62 readonly zuletztBeantwortet: string | null;
63 /** Fällig ab diesem Zeitpunkt; `null`, solange nie beantwortet. */
64 readonly faelligAb: string | null;
65 readonly gemerkt: boolean;
66 /** Zuletzt vergebene Bewertung. */
67 readonly letzteBewertung: Bewertung | null;
68 }
69
70 /** Zusammenfassung für einen Kapitel- oder Abschnittsbereich. */
71 export interface BereichStatistik {
72 /** Kapitel- oder Abschnitts-ID, z. B. „I.2“. */
73 readonly id: string;
74 readonly titel: string;
75 readonly fragenGesamt: number;
76 readonly beantwortet: number;
77 /**
78 * Fragen, deren Abruf belegt ist und noch frisch – siehe `shared/reife.ts`.
79 * Eine Untergrenze, keine Prognose.
80 */
81 readonly belegt: number;
82 /** Reifegrad, 0 bis 1. `belegt` ist dieser Wert auf ganze Fragen gerundet. */
83 readonly reifegrad: number;
84 readonly stufe: Reifestufe;
85 }
86
87 /**
88 * Dasselbe für eine Themengruppe dieser Software.
89 *
90 * **Warum es das zusätzlich gibt.** Der amtliche Katalog gliedert nur
91 * Kapitel I in Abschnitte. Die 230 Fragen der Kapitel II bis IV standen in
92 * der Aufschlüsselung deshalb als **drei** Balken – „Kapitel IV, 61 von 88“
93 * sagt einem Lernenden nicht, was er üben soll. Die Feingliederung in
94 * `content/themen.json` sagt es: 29 Gruppen, jede mit einem Titel wie
95 * „Notwehr und Notstand“ oder „Munitionsarten“.
96 *
97 * **Warum getrennt und nicht in {@link BereichStatistik} gemischt.** Die
98 * Gruppen sind **redaktionell**, nicht amtlich. In einer Liste mit den
99 * amtlichen Abschnitten sähen sie aus wie deren Geschwister. Getrennt kann
100 * die Oberfläche sagen, woher die Gliederung kommt – und die amtlichen
101 * Zahlen bleiben Zeichen für Zeichen dieselben wie vorher.
102 */
103 export interface Themengruppenstatistik extends BereichStatistik {
104 /** Kapitel, unter dem die Gruppe steht – „II“, „III“ oder „IV“. */
105 readonly kapitel: string;
106 }
107
108 /** Gesamtüberblick für den Startbildschirm. */
109 export interface Lernuebersicht {
110 readonly fragenGesamt: number;
111 readonly beantwortet: number;
112 /** Siehe {@link BereichStatistik.belegt}. */
113 readonly belegt: number;
114 readonly reifegrad: number;
115 /** Gesamtstufe, gedeckelt durch einen zurückliegenden K.-o.-Bereich. */
116 readonly stufe: Reifestufe;
117 /**
118 * Bereiche, die das Gesamturteil deckeln – heute nur Notwehr und Notstand.
119 * Leer, wenn keiner zurückliegt. Die Oberfläche nennt sie beim Namen,
120 * statt nur eine Stufe tiefer zu zeigen.
121 */
122 readonly deckelnd: readonly string[];
123 readonly faellig: number;
124 readonly gemerkt: number;
125 /**
126 * Offene Fragen im Lernumfang – die auszuformulierenden.
127 *
128 * Zählt wie {@link Lernuebersicht.fragenGesamt} nur, was das Zielprofil
129 * einschließt: Ohne Kapitel IV sind es 75 statt 104. Deshalb steht die
130 * Zahl hier und wird nicht in der Oberfläche aus dem Katalog gerechnet –
131 * dort wäre die Kapitelabwahl ein zweites Mal nachzubilden.
132 */
133 readonly offen: number;
134 /**
135 * Fragen, die zuletzt falsch beantwortet wurden.
136 *
137 * Dieselbe Menge, die der Einstieg „Nur Fehler“ vorlegt und die das
138 * Fehlerprotokoll druckt – gebildet aus derselben Regel, damit alle drei
139 * dasselbe sagen. Zählt wie {@link Lernuebersicht.fragenGesamt} nur, was
140 * das Zielprofil einschließt.
141 */
142 readonly fehler: number;
143 readonly heuteRichtig: number;
144 readonly heuteFalsch: number;
145 /**
146 * Fragen des Lernumfangs, die heute bearbeitet wurden.
147 *
148 * Eine Bestandszahl aus `frage_stand`, kein Protokoll: Sie zählt
149 * **verschiedene Fragen**, nicht Zeilen. Eine mit „Nicht gewusst“ bewertete
150 * Frage ist sofort wieder fällig und käme sonst zweimal vor.
151 */
152 readonly heuteBearbeitet: number;
153 /**
154 * Volle Kalendertage seit der letzten Antwort; `null`, solange nie
155 * beantwortet.
156 *
157 * Positiv für Vergangenes – anders als `tageBisTermin` im Lernplan, das
158 * vorwärts rechnet. Wer nach zwei Wochen zurückkommt, sieht einen
159 * gefallenen Reifegrad und ein gewachsenes Pensum; diese Zahl ist die
160 * Tatsache dazu.
161 */
162 readonly tageSeitLetzterAntwort: number | null;
163 /**
164 * Wie oft Wiederholungen nach mindestens einem Tag Abstand wirklich saßen.
165 *
166 * **Wozu.** Der Lernplan steuert auf eine Zielquote (0,90 bis 0,97), und die
167 * Reife-Ampel zeigt modellierte Abrufwahrscheinlichkeiten. Nirgends stand
168 * bis 0.22.0, wie oft der Lernende fällige Wiederholungen **tatsächlich**
169 * trifft. Diese Zahl schließt die Lücke zwischen der Behauptung des Modells
170 * und dem Befund am Menschen — im Geist des Projekts: Messung statt
171 * Behauptung.
172 *
173 * Eine **gemessene Vergangenheitszahl, keine Vorhersage**: Die abgelehnte
174 * Bestehenswahrscheinlichkeit bleibt außen vor. Gezählt werden Antworten,
175 * deren Vorgänger zur selben Frage mindestens einen Tag zurücklag —
176 * dieselbe Abstandsregel, die die Belegrechnung benutzt.
177 *
178 * `null`, solange zu wenige solcher Wiederholungen vorliegen: Eine Quote
179 * aus drei Antworten wäre eine Zahl ohne Aussage.
180 */
181 readonly behaltensquote?: Behaltensquote | null;
182 readonly bereiche: readonly BereichStatistik[];
183 /**
184 * Die Feingliederung der Kapitel II bis IV; leer, wenn keine vorliegt.
185 *
186 * Sie **ersetzt** die Kapitelzeilen in {@link bereiche} nicht, sondern
187 * steht darunter. Nachgemessen am Katalogstand 16.12.2024: Die 29 Gruppen
188 * decken alle 230 Fragen dieser Kapitel, jede genau einmal.
189 */
190 readonly themengruppen: readonly Themengruppenstatistik[];
191 }
192
193 /** Die gemessene Trefferquote bei Wiederholungen mit Abstand. */
194 export interface Behaltensquote {
195 /** Wiederholungen mit mindestens einem Tag Abstand. */
196 readonly gesamt: number;
197 /** Davon richtig beantwortet. */
198 readonly richtig: number;
199 }
200
201 /** Filter für die Zusammenstellung einer Lernsitzung. */
202 export interface SitzungsFilter {
203 /** Nur Fragen dieser Kapitel; leer bedeutet alle. */
204 readonly kapitel?: readonly string[];
205 /** Nur Fragen dieser Abschnitte; leer bedeutet alle. */
206 readonly abschnitte?: readonly string[];
207 /** Nur gemerkte Fragen. */
208 readonly nurGemerkte?: boolean;
209 /** Nur Fragen, die zuletzt falsch beantwortet wurden. */
210 readonly nurFehler?: boolean;
211 /**
212 * Nur hartnäckige Fragen – solche, die wiederholt danebengingen.
213 *
214 * **Warum das neben {@link SitzungsFilter.nurFehler} steht und nicht an
215 * seiner Stelle.** „Nur Fehler“ fragt die **letzte** Antwort: Wer eine
216 * Frage gestern zufällig richtig hatte, sieht sie dort nicht mehr – und
217 * das ist richtig so, das gedruckte Fehlerprotokoll sagt es ausdrücklich
218 * zu („Sobald Sie eine davon wieder richtig beantworten, verschwindet sie
219 * aus dieser Liste“). Dieser Filter fragt die **Historie**: Eine Frage, die
220 * über Wochen viermal durchfiel und einmal saß, ist nicht gekonnt.
221 *
222 * Die Schwelle steht in `shared/hartnaeckig.ts`. Rein deskriptiv – gezählte
223 * Fehlschläge aus dem Protokoll, keine erfundene Kennzahl.
224 */
225 readonly nurHartnaeckige?: boolean;
226 /** Nur noch nie beantwortete Fragen. */
227 readonly nurNeue?: boolean;
228 /**
229 * Nur offene Fragen – solche, die auszuformulieren sind.
230 *
231 * Der amtliche Katalog enthält davon 104 von 575, sehr ungleich verteilt:
232 * Kapitel I 61, II 13, III 1, IV 29. Wer Kapitel IV abgewählt hat, behält
233 * 75. Sie sind der Teil der Prüfung, den ein Mensch bewertet, und der
234 * einzige, den man nicht durch Ankreuzen erraten kann.
235 *
236 * Anders als die drei übrigen Filter fragt dieser nicht den Lernstand,
237 * sondern den Katalog: Der Fragetyp steht in `Frage.typ`, nicht in der
238 * Datenbank.
239 */
240 readonly nurOffene?: boolean;
241 /** Höchstzahl der Fragen in der Sitzung. */
242 readonly anzahl?: number;
243 /** Reihenfolge mischen (Vorgabe) oder Katalogreihenfolge beibehalten. */
244 readonly mischen?: boolean;
245 /**
246 * Antwortmöglichkeiten innerhalb der Frage mischen. Vorgabe ist `false`.
247 *
248 * Vollständig getrennt von {@link SitzungsFilter.mischen}, weil beides
249 * Unterschiedliches leistet: Die Fragenreihenfolge ist eine Frage der
250 * Abwechslung und folgenlos. Die Optionsreihenfolge kostet den Gleichlauf
251 * mit dem amtlichen Katalog – 83 % der Auswahlfragen erscheinen dann
252 * anders als dort – und nimmt jedem den Halt, der sich die Antworten über
253 * ihre Stelle merkt; bei einer Gedächtnis- oder Konzentrationsbeeinträchtigung
254 * ein üblicher Weg. Ein Lernvorteil, der das aufwöge, ist nicht belegt.
255 *
256 * Fehlt der Wert, wird **nicht** gemischt. Früher galt hier
257 * {@link SitzungsFilter.mischen} – diese Kopplung mischte die Antworten
258 * still mit, sobald ein Aufrufer nur die Fragen mischen wollte.
259 */
260 readonly optionenMischen?: boolean;
261 }
262
263 /** Ein Lernprofil. Mehrere Profile teilen sich ein Gerät. */
264 export interface Profil {
265 readonly id: number;
266 readonly name: string;
267 /** Prüfungstermin als ISO-Datum; steuert später den Lernplan. */
268 readonly pruefungstermin: string | null;
269 /**
270 * Kapitel, die dieses Profil dauerhaft nicht lernt – Kennungen wie „IV“.
271 *
272 * Nicht jede Prüfungsstelle prüft Kapitel IV („Not- und
273 * Seenotsignalmittel“). Wer es nie braucht, schleppte sonst 89 der 575
274 * Fragen durch jede Zahl: Fortschritt, Tagespensum, Prognose,
275 * Prüfungsreife. Die Abwahl je Simulationslauf gibt es davon getrennt und
276 * wirkt nur auf den gezogenen Bogen.
277 *
278 * Eine **Liste**, kein Wahrheitswert: Heute ist nur Kapitel IV abwählbar,
279 * aber ein `boolean` müsste beim nächsten Kapitel wieder migriert werden.
280 *
281 * Ausgeblendet, nicht gelöscht: Was in Kapitel IV bereits gelernt wurde,
282 * bleibt im Lernstand stehen und kehrt bei Wiederwahl vollständig zurück.
283 */
284 readonly kapitelAusschluss: readonly string[];
285 readonly erstelltAm: string;
286 }
287
288 /**
289 * Die Antwortoptionen einer Frage in Anzeigereihenfolge – im Regelfall die
290 * des amtlichen Katalogs, gemischt nur auf ausdrücklichen Wunsch. Die
291 * Zuordnung bleibt in jedem Fall über die Labels erhalten: Der Buchstabe
292 * gehört zum Inhalt, nicht zur Stelle.
293 */
294 export interface SitzungsFrage {
295 readonly frageId: string;
296 /** Reihenfolge der Optionslabels für diese Darstellung. */
297 readonly optionsReihenfolge: readonly string[];
298 /**
299 * Ist die Frage gemerkt?
300 *
301 * Kommt mit der Sitzung mit, weil die Oberfläche den Stern sonst erst
302 * kennt, nachdem die Frage beantwortet wurde: Bis Fassung 0.24.1 begann
303 * jede Sitzung mit einer leeren Merkliste, und der Stern stand an jeder
304 * Frage auf „nicht gemerkt“ – auch in einer Sitzung mit dem Filter
305 * „nur Gemerkte“, in der jede einzelne Frage gemerkt ist.
306 */
307 readonly gemerkt: boolean;
308 /**
309 * Was der Lernende bei dieser offenen Frage zuletzt geschrieben hat.
310 *
311 * Fehlt, wenn es keine offene Frage ist, wenn sie noch nie beantwortet
312 * wurde oder wenn das Feld leer blieb – Letzteres ist ausdrücklich erlaubt
313 * und soll nicht als Vorhaltung wiederkehren.
314 *
315 * **Wird erst nach dem Bestätigen gezeigt.** Vorher wäre es eine Vorlage
316 * zum Abschreiben und machte aus dem Ausformulieren ein Kopieren.
317 *
318 * Der Text stand bis Fassung 0.20.0 in `antwort_log.freitext` und wurde von
319 * keiner einzigen Abfrage gelesen – geschrieben, aufbewahrt, nie gezeigt.
320 */
321 readonly letzterFreitext?: LetzterFreitext;
322 }
323
324 /** Eine frühere eigene Antwort, mit dem Tag, an dem sie entstand. */
325 export interface LetzterFreitext {
326 readonly text: string;
327 /** Zeitpunkt als ISO-Zeichenkette. */
328 readonly zeitpunkt: string;
329 }