waffensachkunde

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

/ app src main lernstand.ts

97,5 KB Rohdatei
app/src/main/lernstand.ts — 2468 Zeilen
1 /**
2 * Lernstand in SQLite – Profile, Wiedervorlage, Merkliste, Statistik.
3 *
4 * Alles liegt lokal in einer einzigen Datei im `userData`-Verzeichnis. Es
5 * gibt kein Konto, keine Synchronisierung und keine Telemetrie.
6 *
7 * Die Klasse {@link Lernstand} bekommt Datenbank und Katalog übergeben und
8 * kennt Electron nicht. Dadurch lässt sie sich in Tests gegen eine
9 * `:memory:`-Datenbank betreiben; die Anbindung an das Dateisystem und den
10 * Anwendungspfad übernimmt {@link lernstandInstanz}.
11 *
12 * Grundsatz an der IPC-Grenze: jede Nutzlast aus dem Renderer ist
13 * unbekannt, bis sie geprüft wurde. Alle öffentlichen Methoden nehmen
14 * deshalb `unknown` entgegen und validieren selbst. SQL wird ausschließlich
15 * mit vorbereiteten Anweisungen und gebundenen Parametern ausgeführt.
16 */
17
18 import { existsSync, mkdirSync, renameSync } from 'node:fs';
19 import { dirname } from 'node:path';
20
21 import type BetterSqlite3 from 'better-sqlite3';
22
23 import { abrufwahrscheinlichkeit, FSRS_GRAD, type Gedaechtnisstand } from '../shared/fsrs';
24 import { reifeverlauf, type Verlaufspunkt } from '../shared/reifeverlauf';
25 import type { Katalogwechsel } from '../shared/ipc';
26 import { bewerteAuswahl, type Frage, type Katalog } from '../shared/katalog';
27 import {
28 BEWERTUNGEN,
29 giltAlsRichtig,
30 type Behaltensquote,
31 type BereichStatistik,
32 type Bewertung,
33 type FrageStand,
34 type Lernuebersicht,
35 type Profil,
36 type LetzterFreitext,
37 type SitzungsFrage,
38 type Themengruppenstatistik,
39 } from '../shared/lernstand';
40 import {
41 einfuehrungsrate,
42 lernplanBerechnen,
43 wiedervorlageBerechnen,
44 type Lernplan,
45 type PlanFrage,
46 ausgeglichenesIntervall,
47 MAX_INTERVALL_TAGE,
48 } from '../shared/lernplan';
49 import {
50 abweisen,
51 entschaerft,
52 ganzeZahl,
53 gemischt,
54 nutzlast,
55 textliste,
56 wahrheitswert,
57 } from './eingaben';
58 import {
59 belegteFragen,
60 gesamtstufeMitDeckel,
61 MINDESTABSTAND_TAGE,
62 reifegradVon,
63 stufeFuer,
64 } from '../shared/reife';
65 import { HARTNAECKIG_AB_TAGEN, type HartnaeckigeFrage } from '../shared/hartnaeckig';
66 import { istKoFrage, KO_BEREICHE } from '../shared/pruefung';
67 import { THEMEN_LEER, type Themen } from '../shared/themen';
68 import { LERNSTAND_SCHEMA, MIGRATIONEN, SCHEMA_VERSION, TAG_MS } from './schema';
69 import type { DatenbankKonstruktor } from './sicherung';
70
71 /** Plausible Obergrenze für eine einzelne Bearbeitungsdauer: 24 Stunden. */
72 const MAX_DAUER_MS = 24 * 60 * 60 * 1000;
73
74 /**
75 * Meldung bei einer beschädigten Lernstandsdatei.
76 *
77 * Als Konstante und nicht als Satz an der Fundstelle, weil zwei Stellen sie
78 * brauchen: die Prüfung selbst und {@link istBeschaedigt}, an dem der
79 * Hauptprozess diesen einen Fall von allen anderen Öffnungsfehlern
80 * unterscheidet – nur bei ihm gibt es etwas anzubieten.
81 */
82 export const LERNSTAND_BESCHAEDIGT =
83 'Die Datei mit Ihrem Lernstand ist beschädigt und lässt sich nicht öffnen.';
84
85 /** Ob ein Fehler aus dem Öffnen von einer beschädigten Datei stammt. */
86 export function istBeschaedigt(fehler: unknown): boolean {
87 return fehler instanceof Error && fehler.message.startsWith(LERNSTAND_BESCHAEDIGT);
88 }
89
90 /**
91 * Wie viele der letzten Antworten in die Tempo-Schätzung eingehen.
92 *
93 * Genug, um Ausreißer auszumitteln, und wenig genug, dass sich ein
94 * tatsächlich schneller gewordenes Tempo auch niederschlägt.
95 */
96 const TEMPO_STICHPROBE = 200;
97
98 /** Unter dieser Zahl von Antworten wird nicht geschätzt, sondern angenommen. */
99 const TEMPO_MINDESTZAHL = 20;
100
101 /**
102 * Unter so vielen Wiederholungen wird keine Behaltensquote gezeigt.
103 *
104 * Eine Quote aus drei Antworten schwankt zwischen 0 und 100 Prozent und sagt
105 * nichts — sie sähe nur aus wie eine Auskunft. Zwanzig ist die Zahl, ab der
106 * ein einzelner Ausrutscher die Quote nicht mehr umwirft.
107 */
108 const BEHALTENSQUOTE_MINDESTZAHL = 20;
109
110 const MAX_FREITEXT_LAENGE = 4000;
111 const MAX_PROFILNAME_LAENGE = 60;
112
113 /**
114 * Obergrenze für `filter.anzahl`. Liegt bewusst über der Katalogröße, damit
115 * sich auch der gesamte Katalog anfordern lässt; alles darüber ist ein
116 * Eingabefehler und wird abgewiesen.
117 */
118 const MAX_SITZUNGSGROESSE = 1000;
119
120 /** Name des Profils, das beim ersten Start automatisch entsteht. */
121 const STANDARDPROFIL = 'Standard';
122
123 /**
124 * Höchstzahl der Profile.
125 *
126 * Keine technische Grenze, sondern eine gegen Versehen: Die Anwendung ist für
127 * eine Handvoll Menschen gedacht, die sich einen Rechner teilen. Wer bei
128 * zwanzig ankommt, hat sich vertippt oder etwas anderes vor – in beiden
129 * Fällen hilft eine Meldung mehr als eine endlose Liste.
130 */
131 const MAX_PROFILE = 20;
132
133 /** Anzahl der Platzhalter je `DELETE`-Stapel – bleibt unter SQLites Parametergrenze. */
134 const STAPELGROESSE = 400;
135
136 const ISO_DATUM_MUSTER = /^\d{4}-\d{2}-\d{2}$/u;
137
138 // ─── Zeilentypen ────────────────────────────────────────────────────────────
139
140 interface ProfilZeile {
141 readonly id: number;
142 readonly name: string;
143 readonly pruefungstermin: string | null;
144 /** Roher JSON-Text; erst {@link Lernstand.zuProfil} macht eine Liste daraus. */
145 readonly kapitel_ausschluss: string;
146 readonly erstellt_am: string;
147 }
148
149 interface StandZeile {
150 readonly frage_id: string;
151 readonly versuche: number;
152 readonly richtige: number;
153 readonly zuletzt_beantwortet: string | null;
154 readonly faellig_ab: string | null;
155 readonly gemerkt: number;
156 readonly letzte_bewertung: string | null;
157 readonly intervall_tage: number;
158 /** FSRS-Stabilität in Tagen; `null` vor der ersten Antwort. */
159 readonly stabilitaet: number | null;
160 /** FSRS-Schwierigkeit zwischen 1 und 10; `null` vor der ersten Antwort. */
161 readonly schwierigkeit: number | null;
162 /** 1, sobald der Abruf belegt ist – siehe `shared/reife.ts`. */
163 readonly bestaetigt: number;
164 }
165
166 interface LetzteAntwortZeile {
167 readonly frage_id: string;
168 readonly richtig: number;
169 }
170
171 /** Eine Zeile der Auszählung hartnäckiger Fragen. */
172 interface HartnaeckigZeile {
173 readonly frage_id: string;
174 readonly fehlschlaege: number;
175 readonly tage: number;
176 readonly zuletzt: string;
177 }
178
179 interface TagesbilanzZeile {
180 readonly richtig: number;
181 readonly falsch: number;
182 }
183
184 // ─── Prüfhilfen ─────────────────────────────────────────────────────────────
185 // Die allgemeinen Prüfhilfen stehen in `eingaben.ts`; hier bleibt nur, was
186 // ausschließlich den Lernstand betrifft.
187
188 function istBewertung(wert: unknown): wert is Bewertung {
189 return typeof wert === 'string' && (BEWERTUNGEN as readonly string[]).includes(wert);
190 }
191
192 /**
193 * Wie weit der Reifegrad-Verlauf zurückreicht, wenn niemand etwas anderes
194 * sagt: vier Wochen.
195 *
196 * Nicht länger, weil die Anzeige sonst zu einer Linie mit fünfzig Punkten
197 * wird, auf der niemand mehr etwas erkennt – und nicht kürzer, weil eine
198 * Woche zu wenig ist, um zwischen einem schlechten Tag und einem Trend zu
199 * unterscheiden.
200 */
201 export const VERLAUF_TAGE = 28;
202
203 /**
204 * Die Obergrenze für eine Anfrage aus dem Renderer.
205 *
206 * Ein Jahr. Der Wert kommt aus dem Renderer und wird deshalb geprüft: Die
207 * Rechnung ist Stichtage mal Fragen, und ohne Grenze wäre sie der einzige
208 * Weg, den Anwendungskern von der Oberfläche aus lahmzulegen.
209 */
210 export const VERLAUF_TAGE_MAX = 365;
211
212 // ─── Wiedervorlage ──────────────────────────────────────────────────────────
213
214 /**
215 * Bisheriger Gedächtnisstand aus einer Datenbankzeile.
216 *
217 * `null`, solange die Frage nie beantwortet wurde – oder solange der Lernstand
218 * aus einer Version vor der FSRS-Umstellung stammt. Beide Fälle sind
219 * gleichbedeutend zu behandeln: Die Frage bekommt bei der nächsten Antwort
220 * einen Anfangsstand. Der bisherige Fortschritt in `versuche`, `richtige` und
221 * `faellig_ab` bleibt davon unberührt.
222 */
223 function gedaechtnisstandAus(zeile: StandZeile | undefined): Gedaechtnisstand | null {
224 if (zeile?.stabilitaet == null || zeile.schwierigkeit == null) {
225 return null;
226 }
227 if (!Number.isFinite(zeile.stabilitaet) || !Number.isFinite(zeile.schwierigkeit)) {
228 return null;
229 }
230 return { stabilitaet: zeile.stabilitaet, schwierigkeit: zeile.schwierigkeit };
231 }
232
233 /**
234 * Prüft ein ISO-Datum auf einen Tag, den es wirklich gibt.
235 *
236 * `Date.parse` genügt dafür nicht: Es lässt den 30. Februar durchgehen und
237 * rechnet ihn stillschweigend in den 1. oder 2. März um. Ein Prüfungstermin,
238 * der beim Speichern zu einem anderen Tag wird, wäre schlimmer als eine
239 * Abweisung – der Lernplan rechnet auf diesen Tag hin.
240 *
241 * Der Rückvergleich deckt das auf: Nur wenn das erzeugte Datum wieder
242 * dieselbe Zeichenkette ergibt, hat es den Tag nicht verschoben.
243 */
244 function istEchtesDatum(iso: string): boolean {
245 const zeit = Date.parse(`${iso}T00:00:00Z`);
246 if (Number.isNaN(zeit)) {
247 return false;
248 }
249 return new Date(zeit).toISOString().startsWith(iso);
250 }
251
252 /** Volle Tage zwischen zwei Zeitpunkten; nie negativ. */
253 function tageZwischen(frueher: string | null, spaeter: Date): number {
254 if (frueher === null) {
255 return 0;
256 }
257 const start = Date.parse(frueher);
258 if (Number.isNaN(start)) {
259 return 0;
260 }
261 return Math.max(0, (spaeter.getTime() - start) / TAG_MS);
262 }
263
264 // ─── Lernstand ──────────────────────────────────────────────────────────────
265
266 export interface LernstandOptionen {
267 /** Zeitgeber – in Tests überschreibbar, damit Fälligkeiten prüfbar sind. */
268 readonly jetzt?: () => Date;
269 /** Zufallsquelle für das Mischen – in Tests überschreibbar. */
270 readonly zufall?: () => number;
271 /**
272 * Die redaktionelle Feingliederung der Kapitel II bis IV.
273 *
274 * Wahlfrei und mit `THEMEN_LEER` als Rückfall: Der Lernstand bekommt sie
275 * gereicht, statt sie zu laden – `main/themen.ts` hängt an Electron, dieses
276 * Modul läuft im Prüfstand gegen echte SQLite-Dateien. Fehlt sie, bleibt
277 * `themengruppen` leer und die Oberfläche zeigt die Feingliederung nicht;
278 * keine einzige amtliche Zahl ändert sich dadurch.
279 */
280 readonly themen?: Themen;
281 }
282
283 /** Geprüfter Sitzungsfilter mit aufgelösten Vorgabewerten. */
284 interface GepruefterFilter {
285 readonly kapitel: readonly string[];
286 readonly abschnitte: readonly string[];
287 readonly nurGemerkte: boolean;
288 readonly nurFehler: boolean;
289 readonly nurHartnaeckige: boolean;
290 readonly nurNeue: boolean;
291 readonly nurOffene: boolean;
292 readonly anzahl: number;
293 readonly mischen: boolean;
294 readonly optionenMischen: boolean;
295 }
296
297 /** Geprüftes Antwortprotokoll – `richtig` ist bereits amtlich nachgerechnet. */
298 interface GepruefterProtokoll {
299 readonly frage: Frage;
300 readonly auswahl: readonly string[];
301 readonly freitext: string | null;
302 readonly richtig: boolean;
303 readonly bewertung: Bewertung;
304 readonly dauerMs: number;
305 }
306
307 export class Lernstand {
308 private readonly db: BetterSqlite3.Database;
309 private readonly katalog: Katalog;
310 private readonly fragen: ReadonlyMap<string, Frage>;
311 private readonly kapitelIds: ReadonlySet<string>;
312 private readonly abschnittIds: ReadonlySet<string>;
313 private readonly jetzt: () => Date;
314 private readonly zufall: () => number;
315 private readonly themen: Themen;
316 /** Befund des Katalogstand-Abgleichs beim Öffnen; `null` bei Gleichstand. */
317 private katalogwechselBefund: Katalogwechsel | null = null;
318
319 constructor(
320 datenbank: BetterSqlite3.Database,
321 katalog: Katalog,
322 optionen: LernstandOptionen = {},
323 ) {
324 this.db = datenbank;
325 this.katalog = katalog;
326 this.fragen = new Map(katalog.fragen.map((f) => [f.id, f]));
327 this.kapitelIds = new Set(katalog.kapitel.map((k) => k.id));
328 this.abschnittIds = new Set(katalog.kapitel.flatMap((k) => k.abschnitte.map((a) => a.id)));
329 this.jetzt = optionen.jetzt ?? ((): Date => new Date());
330 this.zufall = optionen.zufall ?? Math.random;
331 this.themen = optionen.themen ?? THEMEN_LEER;
332
333 this.vorbereiten();
334 }
335
336 // ── Aufbau ───────────────────────────────────────────────────────────
337
338 private vorbereiten(): void {
339 /* Noch vor allem anderen: Ist die Datei überhaupt heil? Eine beschädigte
340 Seite bringt sonst irgendeine spätere Abfrage zu Fall – und zwar mitten
341 im Betrieb statt hier, wo sich etwas dagegen tun lässt. */
342 this.heilPruefen();
343
344 /* Dann nachsehen, ob wir diese Datei überhaupt anfassen dürfen.
345 Erst danach WAL einschalten und das Schema anwenden: Ein Lernstand aus
346 einer neueren Programmversion soll unberührt bleiben, damit ihn die
347 neuere Fassung später noch öffnen kann. */
348 this.versionSperrePruefen();
349
350 // WAL: gleichzeitiges Lesen und Schreiben ohne Sperrkonflikte.
351 // Bei `:memory:` bleibt SQLite bei „memory“ – das ist unschädlich.
352 this.db.pragma('journal_mode = WAL');
353 this.db.pragma('foreign_keys = ON');
354
355 this.db.exec(LERNSTAND_SCHEMA);
356 this.migrieren();
357 this.katalogstandAbgleichen();
358 this.standardprofilSicherstellen();
359 }
360
361 /**
362 * Weist eine beschädigte Datenbank ab, bevor sie in Betrieb geht.
363 *
364 * **Warum das hier fehlte und trotzdem wichtig ist.** Für *fremde*
365 * Sicherungsdateien läuft seit jeher eine neunstufige Prüfkette
366 * (`sicherung.ts`, Stufe 4 mit `integrity_check`). Die eigene, laufende
367 * Datei bekam nie eine: Ein Bitfehler, ein defekter Datenträger oder ein
368 * abgebrochener Schreibvorgang auf einem USB-Stick blieben unbemerkt, bis
369 * irgendeine einzelne Abfrage `SQLITE_CORRUPT` warf – irgendwann mitten in
370 * einer Sitzung, mit „Ihr Lernprofil konnte nicht geladen werden“ als
371 * einziger Auskunft und ohne jedes Angebot.
372 *
373 * `quick_check` statt `integrity_check`: Es lässt die aufwendige Prüfung
374 * der Indexinhalte weg und findet trotzdem jede beschädigte Seite. Bei
375 * einer Datei dieser Größe kostet es Millisekunden – wenig genug, um es bei
376 * jedem Start zu tun.
377 *
378 * Wie beim Einspielweg gilt: `quick_check` **wirft** bei schwerer
379 * Beschädigung, statt einen Wert zu liefern. Beide Wege werden behandelt.
380 */
381 private heilPruefen(): void {
382 let heil = false;
383 try {
384 const zeilen = this.db.pragma('quick_check') as { quick_check: string }[];
385 heil = zeilen.length === 1 && zeilen[0]?.quick_check === 'ok';
386 } catch {
387 heil = false;
388 }
389
390 if (!heil) {
391 abweisen(LERNSTAND_BESCHAEDIGT);
392 }
393 }
394
395 /**
396 * Weist einen Lernstand ab, der von einer neueren Programmversion stammt –
397 * bevor irgendetwas geschrieben wird.
398 *
399 * Fehlt die Tabelle `schema_version`, ist die Datei neu oder leer; dann gibt
400 * es nichts zu schützen und die Prüfung entfällt.
401 */
402 private versionSperrePruefen(): void {
403 const tabelle = this.db
404 .prepare<[], { name: string }>(
405 "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'",
406 )
407 .get();
408 if (tabelle === undefined) {
409 return;
410 }
411
412 const zeile = this.db
413 .prepare<[], { version: number | null }>('SELECT MAX(version) AS version FROM schema_version')
414 .get();
415 const vorhanden = zeile?.version ?? 0;
416
417 if (vorhanden > SCHEMA_VERSION) {
418 abweisen(
419 `Der Lernstand wurde mit einer neueren Programmversion angelegt ` +
420 `(Schema ${String(vorhanden)}, unterstützt wird ${String(SCHEMA_VERSION)}). ` +
421 'Bitte die Anwendung aktualisieren.',
422 );
423 }
424 }
425
426 private migrieren(): void {
427 const zeile = this.db
428 .prepare<[], { version: number | null }>('SELECT MAX(version) AS version FROM schema_version')
429 .get();
430 const vorhanden = zeile?.version ?? 0;
431
432 if (vorhanden > SCHEMA_VERSION) {
433 abweisen(
434 `Der Lernstand wurde mit einer neueren Programmversion angelegt ` +
435 `(Schema ${String(vorhanden)}, unterstützt wird ${String(SCHEMA_VERSION)}). ` +
436 'Bitte die Anwendung aktualisieren.',
437 );
438 }
439
440 const vermerken = this.db.prepare<[number, string]>(
441 'INSERT OR IGNORE INTO schema_version (version, angewendet_am) VALUES (?, ?)',
442 );
443
444 const lauf = this.db.transaction(() => {
445 for (let ziel = vorhanden + 1; ziel <= SCHEMA_VERSION; ziel += 1) {
446 const schritt = Object.prototype.hasOwnProperty.call(MIGRATIONEN, ziel)
447 ? MIGRATIONEN[ziel]
448 : undefined;
449 if (typeof schritt === 'function') {
450 schritt(this.db);
451 } else if (schritt !== undefined && schritt.trim().length > 0) {
452 this.db.exec(schritt);
453 }
454 vermerken.run(ziel, this.jetztIso());
455 }
456 });
457 lauf();
458 }
459
460 /**
461 * Vergleicht den gespeicherten mit dem geladenen Katalogstand.
462 *
463 * Ein vollständiger Migrationspfad für eine neue BVA-Fassung ist ohne die
464 * künftige Fassung nicht baubar – was sich bauen lässt, ist Ehrlichkeit:
465 * erkennen, beziffern, melden. Gezählt wird, wie viele Zeilen auf Frage-IDs
466 * zeigen, die es im geladenen Katalog nicht gibt. **Gelöscht wird nichts**:
467 * Eine spätere Programmfassung kann die Zeilen vielleicht noch zuordnen –
468 * gelöscht kann sie es sicher nicht mehr.
469 *
470 * Der neue Stand wird erst NACH dem Festhalten des Befunds vermerkt. Der
471 * Vermerk ist der Punkt, ab dem jeder weitere Start Gleichstand vorfindet
472 * und schweigt; stünde er zuerst und bräche das Öffnen dazwischen ab, wäre
473 * die Abweichung für immer unbemerkt.
474 */
475 private katalogstandAbgleichen(): void {
476 const geladen = this.katalog.meta.stand;
477 const zeile = this.db
478 .prepare<[], { stand: string }>('SELECT stand FROM katalog_stand ORDER BY id DESC LIMIT 1')
479 .get();
480
481 if (zeile === undefined) {
482 /* Erstes Öffnen mit Schemafassung 9 – für frische wie für migrierte
483 Datenbanken derselbe Weg. Gegen welchen Stand ältere Zeilen wirklich
484 entstanden, wurde nie festgehalten und lässt sich nicht
485 rekonstruieren; der geladene Stand ist der ehrlichste verfügbare
486 Ausgangswert (siehe Schema-Version 9 in `schema.ts`). Keine Meldung:
487 Für den Nutzer hat sich nichts geändert. */
488 this.katalogstandVermerken(geladen);
489 return;
490 }
491
492 if (zeile.stand === geladen) {
493 return; // Gleicher Stand: keine Meldung, kein Rauschen.
494 }
495
496 this.katalogwechselBefund = {
497 vorher: zeile.stand,
498 nachher: geladen,
499 verwaisteStaende: this.verwaisteZeilen('frage_stand'),
500 verwaisteAntworten: this.verwaisteZeilen('antwort_log'),
501 };
502 this.katalogstandVermerken(geladen);
503 }
504
505 private katalogstandVermerken(stand: string): void {
506 this.db
507 .prepare<[string, string]>('INSERT INTO katalog_stand (stand, vermerkt_am) VALUES (?, ?)')
508 .run(stand, this.jetztIso());
509 }
510
511 /** Zeilen der Tabelle, deren Frage-ID es im geladenen Katalog nicht gibt. */
512 private verwaisteZeilen(tabelle: 'frage_stand' | 'antwort_log'): number {
513 /* Der Tabellenname lässt sich nicht als Parameter binden; er stammt aus
514 dem Literaltyp des Parameters, nie aus einer Eingabe. Gruppiert je
515 Frage-ID statt Zeile für Zeile: wenige hundert Gruppen gegen die
516 bekannten IDs zu halten ist billiger, als jede Protokollzeile einzeln
517 herüberzureichen. */
518 const gruppen = this.db
519 .prepare<[], { frage_id: string; anzahl: number }>(
520 `SELECT frage_id, COUNT(*) AS anzahl FROM ${tabelle} GROUP BY frage_id`,
521 )
522 .all();
523
524 let summe = 0;
525 for (const gruppe of gruppen) {
526 if (!this.fragen.has(gruppe.frage_id)) {
527 summe += gruppe.anzahl;
528 }
529 }
530 return summe;
531 }
532
533 /**
534 * Legt beim ersten Start automatisch ein Profil an, damit die Anwendung
535 * ohne Einrichtungsdialog benutzbar ist.
536 */
537 private standardprofilSicherstellen(): void {
538 const zeile = this.db
539 .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil')
540 .get();
541 if ((zeile?.anzahl ?? 0) === 0) {
542 this.profilEinfuegen(STANDARDPROFIL);
543 }
544 }
545
546 private jetztIso(): string {
547 return this.jetzt().toISOString();
548 }
549
550 /**
551 * Die geöffnete Verbindung – für Module, die auf denselben Lernstand
552 * schreiben, allen voran die Prüfungssimulation.
553 *
554 * Bewusst nur lesend nach außen gegeben: der Lernstand bleibt Eigentümer
555 * der Verbindung, wendet das Schema an und schließt sie wieder. Eine
556 * zweite Verbindung auf dieselbe Datei würde sich unnötig sperren.
557 */
558 get datenbank(): BetterSqlite3.Database {
559 return this.db;
560 }
561
562 /**
563 * Befund des Katalogstand-Abgleichs beim Öffnen.
564 *
565 * `null` bei Gleichstand – der Regelfall, und er bleibt bewusst stumm.
566 * Ein Befund gilt für die Laufzeit dieser Instanz: Der neue Stand ist
567 * bereits vermerkt, der nächste Start findet Gleichstand vor. Die
568 * Systemauskunft (`anwendung:info`) reicht ihn an die Oberfläche weiter.
569 */
570 get katalogwechsel(): Katalogwechsel | null {
571 return this.katalogwechselBefund;
572 }
573
574 /** Schließt die Datenbank. */
575 schliessen(): void {
576 if (this.db.open) {
577 this.db.close();
578 }
579 }
580
581 /**
582 * Die Fragen, die dieses Profil lernt.
583 *
584 * ## Warum eine Methode und keine sechs Filter
585 *
586 * `this.katalog.fragen` stand an sechs Stellen und hieß überall
587 * stillschweigend „alle 575“: in der Sitzungsschleife, in der Übersicht, in
588 * der Bereichsaufschlüsselung, im Lernplan und zweimal in Zählungen. Sechs
589 * Bedingungen einzeln einzubauen hieße, dass die siebte Stelle sie
590 * irgendwann vergisst – und der Schaden wäre still: Die Sitzung zöge aus
591 * 486 Fragen, während der Startbildschirm weiter „8 von 575 sicher“ sagt
592 * und der Fortschrittsbalken für immer 89 Fragen unter dem Maximum hängt.
593 *
594 * Deshalb ein Wechsel der **Quelle** statt zusätzlicher Bedingungen. Wer
595 * künftig über Fragen läuft, die zum Lernen gehören, schreibt
596 * `lernfragen(profilId)` und trifft damit von selbst das Richtige.
597 *
598 * ## Was hier ausdrücklich NICHT gefiltert wird
599 *
600 * Die **Volltextsuche** und der Fragen-Browser: Nachschlagen ist kein
601 * Lernen, und die Suchansicht sagt „alle 575 amtlichen Fragen“ zu. Sie
602 * gehen nie über diese Methode, sondern über den Katalog im Renderer.
603 *
604 * Die **Tagesbilanz** („heute richtig/falsch“): Sie liest `antwort_log`
605 * ohne Katalogbezug und sagt, was jemand heute getan hat – nicht, was zu
606 * seinem Umfang gehört. Wer heute eine Frage aus Kapitel IV beantwortet und
607 * es danach abwählt, sieht sie dort weiterhin. Das ist gewollt: Die
608 * Tagesbilanz ist ein Protokoll, keine Bestandszahl.
609 *
610 * Das **Zurücksetzen**: Es räumt auf, was da ist, nicht was gelernt wird.
611 */
612 private lernfragen(profilId: number): readonly Frage[] {
613 const ausschluss = this.kapitelAusschlussVon(profilId);
614 if (ausschluss.size === 0) {
615 return this.katalog.fragen;
616 }
617 return this.katalog.fragen.filter((frage) => !ausschluss.has(frage.kapitel));
618 }
619
620 /** Die abgewählten Kapitel eines Profils, als Menge. */
621 private kapitelAusschlussVon(profilId: number): ReadonlySet<string> {
622 const zeile = this.db
623 .prepare<[number], { kapitel_ausschluss: string }>(
624 'SELECT kapitel_ausschluss FROM profil WHERE id = ?',
625 )
626 .get(profilId);
627 return new Set(this.kapitelAusschlussLesen(zeile?.kapitel_ausschluss ?? '[]'));
628 }
629
630 // ── Profile ──────────────────────────────────────────────────────────
631
632 private profilEinfuegen(name: string): Profil {
633 const erstelltAm = this.jetztIso();
634 const ergebnis = this.db
635 .prepare<[string, string]>(
636 'INSERT INTO profil (name, pruefungstermin, erstellt_am) VALUES (?, NULL, ?)',
637 )
638 .run(name, erstelltAm);
639
640 return {
641 id: Number(ergebnis.lastInsertRowid),
642 name,
643 pruefungstermin: null,
644 /* Muss zum DEFAULT der Spalte passen: Das INSERT oben nennt sie nicht,
645 der Wert kommt aus dem Schema. Stünde hier etwas anderes, wiche das
646 frisch angelegte Profil vom gelesenen ab. */
647 kapitelAusschluss: [],
648 erstelltAm,
649 };
650 }
651
652 private zuProfil(zeile: ProfilZeile): Profil {
653 return {
654 id: zeile.id,
655 name: zeile.name,
656 pruefungstermin: zeile.pruefungstermin,
657 kapitelAusschluss: this.kapitelAusschlussLesen(zeile.kapitel_ausschluss),
658 erstelltAm: zeile.erstellt_am,
659 };
660 }
661
662 /**
663 * Liest die gespeicherte Kapitelliste – wohlwollend, nie werfend.
664 *
665 * Dieselbe Vorsicht wie bei `tageBisTermin`: Was in der Datenbank steht,
666 * kann aus einer früheren Fassung stammen. Hier wiegt das schwerer als beim
667 * Termin, denn eine neue BVA-Katalogfassung kann Kapitelkennungen
668 * verschieben (siehe `docs/stand.md`). Eine unbekannte Kennung fällt still
669 * heraus; ein Fehler beim Lesen dürfte die Anwendung nicht am Starten
670 * hindern, nur weil jemand einmal ein Kapitel abgewählt hat.
671 */
672 private kapitelAusschlussLesen(roh: string): readonly string[] {
673 let gelesen: unknown;
674 try {
675 gelesen = JSON.parse(roh);
676 } catch {
677 return [];
678 }
679 if (!Array.isArray(gelesen)) {
680 return [];
681 }
682 return gelesen.filter(
683 (eintrag): eintrag is string => typeof eintrag === 'string' && this.kapitelIds.has(eintrag),
684 );
685 }
686
687 /** Alle Profile in der Reihenfolge ihrer Anlage. */
688 profile(): Profil[] {
689 return this.db
690 .prepare<[], ProfilZeile>(
691 'SELECT id, name, pruefungstermin, kapitel_ausschluss, erstellt_am FROM profil ORDER BY id',
692 )
693 .all()
694 .map((zeile) => this.zuProfil(zeile));
695 }
696
697 private profilnamePruefen(wert: unknown): string {
698 if (typeof wert !== 'string') {
699 abweisen('Ungültige Anfrage: Der Profilname muss eine Zeichenkette sein.');
700 }
701 // Steuerzeichen werden zu Leerzeichen – der Name landet in der
702 // Oberfläche und in Fehlermeldungen.
703 const name = entschaerft(wert, ' ').trim();
704 if (name.length === 0) {
705 abweisen('Der Profilname darf nicht leer sein.');
706 }
707 if (name.length > MAX_PROFILNAME_LAENGE) {
708 abweisen(`Der Profilname darf höchstens ${String(MAX_PROFILNAME_LAENGE)} Zeichen lang sein.`);
709 }
710 return name;
711 }
712
713 /**
714 * Gibt es diesen Namen schon – unabhängig von Groß- und Kleinschreibung?
715 *
716 * Die UNIQUE-Bedingung auf `profil.name` vergleicht buchstabengenau, lässt
717 * also „Olaf“, „olaf“ und „OLAF“ nebeneinander stehen. In einer Liste,
718 * aus der jemand sein Profil wiedererkennen soll, ist das keine
719 * Unterscheidung, sondern eine Falle. `COLLATE NOCASE` deckt die
720 * lateinischen Buchstaben ab; „Ü“ gegen “ü” bleibt offen, weil SQLite
721 * ohne ICU nicht mehr kann – besser die Hälfte als nichts.
722 */
723 private namenskonflikt(name: string, ausserId?: number): boolean {
724 const zeile =
725 ausserId === undefined
726 ? this.db
727 .prepare<[string], { id: number }>(
728 'SELECT id FROM profil WHERE name = ? COLLATE NOCASE',
729 )
730 .get(name)
731 : this.db
732 .prepare<[string, number], { id: number }>(
733 'SELECT id FROM profil WHERE name = ? COLLATE NOCASE AND id <> ?',
734 )
735 .get(name, ausserId);
736 return zeile !== undefined;
737 }
738
739 profilAnlegen(nameRoh: unknown): Profil {
740 const name = this.profilnamePruefen(nameRoh);
741
742 const anzahl =
743 this.db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil').get()
744 ?.anzahl ?? 0;
745 if (anzahl >= MAX_PROFILE) {
746 abweisen(
747 `Es sind höchstens ${String(MAX_PROFILE)} Profile möglich. ` +
748 'Löschen Sie ein nicht mehr gebrauchtes, bevor Sie ein neues anlegen.',
749 );
750 }
751
752 if (this.namenskonflikt(name)) {
753 abweisen(`Es gibt bereits ein Profil mit dem Namen „${entschaerft(name)}“.`);
754 }
755 return this.profilEinfuegen(name);
756 }
757
758 /**
759 * Löscht ein Profil samt allem, was daran hängt.
760 *
761 * Eine Zeile genügt: `frage_stand`, `antwort_log` und `pruefung_lauf`
762 * verweisen mit `ON DELETE CASCADE` auf `profil`, und `PRAGMA foreign_keys`
763 * ist eingeschaltet. Der Test `löscht alles, was am Profil hängt` weist
764 * das nach – ohne ihn wäre es eine Annahme über SQLite, keine Zusage.
765 *
766 * Das letzte Profil bleibt. Ohne Profil hätte die Anwendung keinen Ort für
767 * Antworten mehr, und ein neues entstünde erst beim nächsten Start – die
768 * Anwendung stünde bis dahin ohne Lernstand da.
769 */
770 profilLoeschen(profilIdRoh: unknown): Profil[] {
771 const profilId = this.profilIdPruefen(profilIdRoh);
772
773 const anzahl =
774 this.db.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil').get()
775 ?.anzahl ?? 0;
776 if (anzahl <= 1) {
777 abweisen(
778 'Das letzte Profil lässt sich nicht löschen. ' +
779 'Wenn Sie neu anfangen wollen, setzen Sie stattdessen den Lernstand zurück.',
780 );
781 }
782
783 this.db.prepare<[number]>('DELETE FROM profil WHERE id = ?').run(profilId);
784 return this.profile();
785 }
786
787 /**
788 * Ändert Name, Prüfungstermin und/oder die abgewählten Kapitel. Nicht
789 * angegebene Felder bleiben unverändert; `pruefungstermin: null` löscht den
790 * Termin.
791 *
792 * Für `kapitelAusschluss` gibt es bewusst **keinen** null-Zweig: Die leere
793 * Liste ist bereits die Abwahl von nichts. Ein zweiter Weg dorthin wäre nur
794 * eine zweite Schreibweise für dasselbe.
795 */
796 profilAktualisieren(anfrageRoh: unknown): Profil {
797 const anfrage = nutzlast(anfrageRoh, 'Die Profiländerung');
798 const id = this.profilIdPruefen(anfrage['id']);
799
800 const nameRoh = anfrage['name'];
801 const name = nameRoh === undefined ? undefined : this.profilnamePruefen(nameRoh);
802
803 const terminRoh = anfrage['pruefungstermin'];
804 let termin: string | null | undefined;
805 if (terminRoh === undefined) {
806 termin = undefined;
807 } else if (terminRoh === null) {
808 termin = null;
809 } else if (typeof terminRoh === 'string' && ISO_DATUM_MUSTER.test(terminRoh)) {
810 if (!istEchtesDatum(terminRoh)) {
811 abweisen(`Der Prüfungstermin ist kein gültiges Datum: „${entschaerft(terminRoh)}“.`);
812 }
813 termin = terminRoh;
814 } else {
815 abweisen('Der Prüfungstermin muss ein ISO-Datum (JJJJ-MM-TT) oder null sein.');
816 }
817
818 const ausschlussRoh = anfrage['kapitelAusschluss'];
819 let ausschluss: string | undefined;
820 if (ausschlussRoh !== undefined) {
821 if (!Array.isArray(ausschlussRoh)) {
822 abweisen('Ungültige Anfrage: kapitelAusschluss muss eine Liste sein.');
823 }
824 const ids = ausschlussRoh.map((eintrag) => {
825 if (typeof eintrag !== 'string') {
826 abweisen('Ungültige Anfrage: kapitelAusschluss darf nur Zeichenketten enthalten.');
827 }
828 if (!this.kapitelIds.has(eintrag)) {
829 abweisen(`Unbekanntes Kapitel: „${entschaerft(eintrag)}“.`);
830 }
831 return eintrag;
832 });
833 /* Doppelte fallen heraus, die Reihenfolge folgt dem Katalog: Was
834 gespeichert wird, soll nicht davon abhängen, in welcher Reihenfolge
835 jemand geklickt hat. */
836 ausschluss = JSON.stringify(
837 this.katalog.kapitel.map((kapitel) => kapitel.id).filter((id) => ids.includes(id)),
838 );
839 }
840
841 const aendern = this.db.transaction(() => {
842 if (name !== undefined) {
843 /* Derselbe Maßstab wie beim Anlegen: Groß- und Kleinschreibung
844 unterscheidet nicht. Vorher stand hier ein buchstabengenauer
845 Vergleich – über das Umbenennen ließ sich also anlegen, was das
846 Anlegen abweist. */
847 if (this.namenskonflikt(name, id)) {
848 abweisen(`Es gibt bereits ein Profil mit dem Namen „${entschaerft(name)}“.`);
849 }
850 this.db.prepare<[string, number]>('UPDATE profil SET name = ? WHERE id = ?').run(name, id);
851 }
852 if (termin !== undefined) {
853 this.db
854 .prepare<[string | null, number]>('UPDATE profil SET pruefungstermin = ? WHERE id = ?')
855 .run(termin, id);
856 }
857 if (ausschluss !== undefined) {
858 this.db
859 .prepare<[string, number]>('UPDATE profil SET kapitel_ausschluss = ? WHERE id = ?')
860 .run(ausschluss, id);
861 }
862 });
863 aendern();
864
865 return this.profilLesen(id);
866 }
867
868 private profilLesen(id: number): Profil {
869 const zeile = this.db
870 .prepare<[number], ProfilZeile>(
871 'SELECT id, name, pruefungstermin, kapitel_ausschluss, erstellt_am FROM profil WHERE id = ?',
872 )
873 .get(id);
874 if (zeile === undefined) {
875 abweisen(`Unbekanntes Profil: ${String(id)}.`);
876 }
877 return this.zuProfil(zeile);
878 }
879
880 /**
881 * Volle Tage bis zum Prüfungstermin des Profils.
882 *
883 * `null`, wenn kein Termin gesetzt ist. Negativ, wenn er vorbei ist – das
884 * darf nicht verschluckt werden, sonst würde ein vergessener Termin still
885 * wie „heute“ behandelt und alle Intervalle auf das Maximum ziehen.
886 *
887 * Gerechnet wird auf Kalendertage, nicht auf Stunden: Der Termin ist ein
888 * Datum, keine Uhrzeit. Wer abends lernt und morgens geprüft wird, hat
889 * einen Tag Zeit – nicht null.
890 */
891 private tageBisTermin(profilId: number, jetzt: Date): number | null {
892 const zeile = this.db
893 .prepare<[number], { pruefungstermin: string | null }>(
894 'SELECT pruefungstermin FROM profil WHERE id = ?',
895 )
896 .get(profilId);
897
898 /* Auch beim Lesen prüfen: Ein Lernstand aus einer früheren Fassung kann
899 ein unmögliches Datum enthalten, das damals durchgelassen wurde. */
900 const termin = zeile?.pruefungstermin ?? null;
901 if (termin === null || !ISO_DATUM_MUSTER.test(termin) || !istEchtesDatum(termin)) {
902 return null;
903 }
904
905 const ziel = Date.parse(`${termin}T00:00:00Z`);
906
907 const heute = Date.parse(`${Lernstand.alsIsoDatum(jetzt)}T00:00:00Z`);
908 return Math.round((ziel - heute) / TAG_MS);
909 }
910
911 /** Lokales Kalenderdatum als ISO-Datum, ohne Zeitzonenversatz. */
912 private static alsIsoDatum(zeitpunkt: Date): string {
913 const jahr = String(zeitpunkt.getFullYear()).padStart(4, '0');
914 const monat = String(zeitpunkt.getMonth() + 1).padStart(2, '0');
915 const tag = String(zeitpunkt.getDate()).padStart(2, '0');
916 return `${jahr}-${monat}-${tag}`;
917 }
918
919 /**
920 * Prüft eine Profil-ID aus dem Renderer gegen die tatsächlich vorhandenen
921 * Profile. Öffentlich, weil die Prüfungssimulation dieselbe Prüfung
922 * braucht, bevor sie einen Lauf speichert.
923 */
924 profilIdPruefen(wert: unknown): number {
925 const id = ganzeZahl(wert, 'Die Profil-ID', 1, Number.MAX_SAFE_INTEGER);
926 const zeile = this.db
927 .prepare<[number], { id: number }>('SELECT id FROM profil WHERE id = ?')
928 .get(id);
929 if (zeile === undefined) {
930 abweisen(`Unbekanntes Profil: ${String(id)}.`);
931 }
932 return id;
933 }
934
935 // ── Stand einzelner Fragen ───────────────────────────────────────────
936
937 private static zuFrageStand(frageId: string, zeile: StandZeile | undefined): FrageStand {
938 if (zeile === undefined) {
939 return {
940 frageId,
941 versuche: 0,
942 richtige: 0,
943 zuletztBeantwortet: null,
944 faelligAb: null,
945 gemerkt: false,
946 letzteBewertung: null,
947 };
948 }
949 return {
950 frageId,
951 versuche: zeile.versuche,
952 richtige: zeile.richtige,
953 zuletztBeantwortet: zeile.zuletzt_beantwortet,
954 faelligAb: zeile.faellig_ab,
955 gemerkt: zeile.gemerkt === 1,
956 letzteBewertung: istBewertung(zeile.letzte_bewertung) ? zeile.letzte_bewertung : null,
957 };
958 }
959
960 /**
961 * Die hartnäckigen Fragen dieses Profils – wiederholt danebengegangen.
962 *
963 * **Warum aus `antwort_log` und nicht aus `frage_stand`.** Die Standtabelle
964 * ist eine Zusammenfassung; sie weiß, wie oft eine Frage richtig war, aber
965 * nicht **wann** sie danebenging. Genau darauf kommt es an: Drei
966 * Fehlschläge in einer Sitzung sind die gewöhnliche Wiedereinreihung, drei
967 * an drei verschiedenen Tagen sind ein Knoten.
968 *
969 * **Was ausgeschlossen wird.** `nur_historie = 1` steht an Zeilen, die eine
970 * Simulation für nie aufgeschlagene Fragen anlegt: Sie tragen `richtig = 0`,
971 * ohne dass jemand sie je gesehen hätte (siehe `schema.ts` und
972 * `entscheidung-motivation.md`). Sie hier mitzuzählen machte aus jedem
973 * abgebrochenen Prüfungslauf ein Dutzend „hartnäckiger“ Fragen.
974 *
975 * Gerechnet wird über den **Kalendertag der Ortszeit**: `date(zeitpunkt)`
976 * arbeitet auf der gespeicherten ISO-Zeit in UTC, deshalb die Umrechnung
977 * über `localtime`. Wer um ein Uhr nachts lernt, lernt an dem Tag, der auf
978 * seiner Uhr steht – dieselbe Regel wie in `tagesgrenzen`.
979 */
980 hartnaeckige(profilIdRoh: unknown): HartnaeckigeFrage[] {
981 const profilId = this.profilIdPruefen(profilIdRoh);
982
983 const zeilen = this.db
984 .prepare<[number, number], HartnaeckigZeile>(
985 `SELECT frage_id,
986 COUNT(*) AS fehlschlaege,
987 COUNT(DISTINCT date(zeitpunkt, 'localtime')) AS tage,
988 MAX(zeitpunkt) AS zuletzt
989 FROM antwort_log
990 WHERE profil_id = ? AND richtig = 0 AND nur_historie = 0
991 GROUP BY frage_id
992 HAVING tage >= ?
993 ORDER BY tage DESC, fehlschlaege DESC, zuletzt DESC`,
994 )
995 .all(profilId, HARTNAECKIG_AB_TAGEN);
996
997 /* Nur Fragen, die es im geladenen Katalog gibt und die zum Lernumfang
998 des Profils gehören: Nach einem Katalogwechsel stehen verwaiste Zeilen
999 im Protokoll, und abgewähltes Kapitel IV soll auch hier nicht
1000 auftauchen. */
1001 const umfang = new Set(this.lernfragen(profilId).map((frage) => frage.id));
1002
1003 return zeilen
1004 .filter((zeile) => umfang.has(zeile.frage_id))
1005 .map((zeile) => {
1006 const fehlwahl = this.haeufigsteFehlwahl(profilId, zeile.frage_id);
1007 return {
1008 frageId: zeile.frage_id,
1009 fehlschlaege: zeile.fehlschlaege,
1010 tage: zeile.tage,
1011 zuletzt: zeile.zuletzt,
1012 ...(fehlwahl === null ? {} : { haeufigsteFehlwahl: fehlwahl }),
1013 };
1014 });
1015 }
1016
1017 /**
1018 * Wie oft Wiederholungen mit Abstand wirklich saßen — die Ist-Quote.
1019 *
1020 * **Wozu.** Der Lernplan steuert auf eine Zielquote, die Reife-Ampel zeigt
1021 * modellierte Abrufwahrscheinlichkeiten. Wie oft der Lernende fällige
1022 * Wiederholungen **tatsächlich** trifft, stand bis 0.22.0 nirgends — obwohl
1023 * es im Protokoll steht und nur zu zählen war. Gemessene Vergangenheit,
1024 * keine Vorhersage.
1025 *
1026 * **Was ausgeschlossen wird, und zwar doppelt.** `nur_historie = 1` steht an
1027 * Zeilen, die eine Simulation für nie aufgeschlagene Fragen anlegt: Sie
1028 * tragen `richtig = 0`, ohne dass jemand sie gesehen hätte. Sie dürfen
1029 * weder als **gezählte Antwort** noch als **Vorgänger** durchgehen — als
1030 * Vorgänger verschöben sie den gemessenen Abstand, als Antwort brächten sie
1031 * erfundene Fehlschläge in die Quote. Das ist genau die Verunreinigung, die
1032 * `docs/entscheidung-reifegrad.md` Abschnitt 8 bei Simulationen benennt.
1033 *
1034 * **Warum der Vorgänger und nicht `frage_stand`.** Die Standtabelle kennt
1035 * nur die letzte Antwort. Der Abstand zwischen *jeder* Antwort und ihrer
1036 * Vorgängerin steht allein im Protokoll — und genau der entscheidet, ob
1037 * eine Antwort etwas über das Behalten aussagt oder bloß über das
1038 * Wiedererkennen.
1039 */
1040 behaltensquote(profilId: number): Behaltensquote | null {
1041 const zeile = this.db
1042 .prepare<[number, number], { gesamt: number; richtig: number }>(
1043 `WITH echte AS (
1044 SELECT frage_id, zeitpunkt, richtig,
1045 LAG(zeitpunkt) OVER (PARTITION BY frage_id ORDER BY id) AS vorher
1046 FROM antwort_log
1047 WHERE profil_id = ? AND nur_historie = 0
1048 )
1049 SELECT COUNT(*) AS gesamt, SUM(richtig) AS richtig
1050 FROM echte
1051 WHERE vorher IS NOT NULL
1052 AND julianday(zeitpunkt) - julianday(vorher) >= ?`,
1053 )
1054 .get(profilId, MINDESTABSTAND_TAGE);
1055
1056 if (zeile === undefined || zeile.gesamt < BEHALTENSQUOTE_MINDESTZAHL) {
1057 return null;
1058 }
1059 return { gesamt: zeile.gesamt, richtig: zeile.richtig };
1060 }
1061
1062 /**
1063 * Die am häufigsten gewählte falsche Antwort einer Frage.
1064 *
1065 * `null`, wenn es keine gibt oder keine heraussticht — bei offenen Fragen
1066 * ist die Spalte leer, und ein Gleichstand sagt nichts.
1067 *
1068 * Die Spalte `auswahl` wurde bis 0.22.0 geschrieben und von keiner Abfrage
1069 * gelesen. Sie unterscheidet zwei didaktisch verschiedene Fälle: immer
1070 * derselbe falsche Buchstabe heißt Verwechslung, gestreute Fehlgriffe
1071 * heißen Nichtwissen.
1072 */
1073 private haeufigsteFehlwahl(profilId: number, frageId: string): string | null {
1074 const zeilen = this.db
1075 .prepare<[number, string], { auswahl: string }>(
1076 `SELECT auswahl FROM antwort_log
1077 WHERE profil_id = ? AND frage_id = ? AND richtig = 0 AND nur_historie = 0`,
1078 )
1079 .all(profilId, frageId);
1080
1081 const zaehler = new Map<string, number>();
1082 for (const zeile of zeilen) {
1083 let labels: unknown;
1084 try {
1085 labels = JSON.parse(zeile.auswahl);
1086 } catch {
1087 continue;
1088 }
1089 if (!Array.isArray(labels) || labels.length === 0) {
1090 continue;
1091 }
1092 /* Die ganze Auswahl als Schlüssel, nicht die einzelnen Labels: „a und
1093 c“ ist ein anderer Fehlgriff als „a“ allein. */
1094 const schluessel = labels
1095 .filter((l) => typeof l === 'string')
1096 .sort()
1097 .join(', ');
1098 if (schluessel === '') {
1099 continue;
1100 }
1101 zaehler.set(schluessel, (zaehler.get(schluessel) ?? 0) + 1);
1102 }
1103
1104 const sortiert = [...zaehler.entries()].sort((a, b) => b[1] - a[1]);
1105 const erste = sortiert[0];
1106 const zweite = sortiert[1];
1107 if (erste === undefined || erste[1] < 2) {
1108 return null;
1109 }
1110 /* Ein Gleichstand ist kein Muster – dann lieber nichts sagen. */
1111 if (zweite?.[1] === erste[1]) {
1112 return null;
1113 }
1114 return erste[0];
1115 }
1116
1117 private standZeile(profilId: number, frageId: string): StandZeile | undefined {
1118 return this.db
1119 .prepare<[number, string], StandZeile>(
1120 `SELECT frage_id, versuche, richtige, zuletzt_beantwortet, faellig_ab,
1121 gemerkt, letzte_bewertung, intervall_tage, stabilitaet, schwierigkeit,
1122 bestaetigt
1123 FROM frage_stand WHERE profil_id = ? AND frage_id = ?`,
1124 )
1125 .get(profilId, frageId);
1126 }
1127
1128 private standKarte(profilId: number): ReadonlyMap<string, StandZeile> {
1129 const zeilen = this.db
1130 .prepare<[number], StandZeile>(
1131 `SELECT frage_id, versuche, richtige, zuletzt_beantwortet, faellig_ab,
1132 gemerkt, letzte_bewertung, intervall_tage, stabilitaet, schwierigkeit,
1133 bestaetigt
1134 FROM frage_stand WHERE profil_id = ?`,
1135 )
1136 .all(profilId);
1137 return new Map(zeilen.map((z) => [z.frage_id, z]));
1138 }
1139
1140 /**
1141 * Ergebnis der jeweils letzten Antwort je Frage.
1142 *
1143 * Quelle ist bewusst `antwort_log` und nicht `frage_stand`: das Protokoll
1144 * ist die Wahrheit, die Standtabelle nur eine Zusammenfassung.
1145 *
1146 * **Was ausgeschlossen wird.** `nur_historie = 1` steht an Zeilen, die ein
1147 * abgelaufener Prüfungsbogen für Fragen geschrieben hat, die nie
1148 * aufgeschlagen wurden (Schemafassung 8). Sie tragen `richtig = 0` und
1149 * hätten hier als „zuletzt falsch beantwortet“ gegolten – für den Filter
1150 * „nur Fehler“, für die Zahl auf dem Startbildschirm und, weil das
1151 * gedruckte Fehlerprotokoll seine Menge aus genau diesem Filter zieht, für
1152 * ein Blatt, das über nie gesehene Fragen behauptet, man habe sie falsch
1153 * beantwortet. Ein 80-Fragen-Bogen, bei dem die Zeit nach Frage 20 abläuft,
1154 * machte so aus 60 ungesehenen Fragen 60 Fehler.
1155 *
1156 * Der Ausschluss steht in derselben Form schon in {@link hartnaeckige},
1157 * {@link behaltensquote}, `haeufigsteFehlwahl` und `tagesbilanz`. Hier
1158 * fehlte er als einzige der sechs Abfragen über das Protokoll.
1159 */
1160 private letzteAntwortKarte(profilId: number): ReadonlyMap<string, boolean> {
1161 const zeilen = this.db
1162 .prepare<[number], LetzteAntwortZeile>(
1163 `SELECT l.frage_id AS frage_id, l.richtig AS richtig
1164 FROM antwort_log AS l
1165 JOIN (
1166 SELECT frage_id, MAX(id) AS letzte_id
1167 FROM antwort_log
1168 WHERE profil_id = ? AND nur_historie = 0
1169 GROUP BY frage_id
1170 ) AS m ON l.id = m.letzte_id`,
1171 )
1172 .all(profilId);
1173 return new Map(zeilen.map((z) => [z.frage_id, z.richtig === 1]));
1174 }
1175
1176 /**
1177 * Die zuletzt geschriebene Freitextantwort je Frage.
1178 *
1179 * Dieselbe Bauform wie {@link letzteAntwortKarte}: eine Abfrage für die
1180 * ganze Sitzung statt einer je Frage. Nachgemessen an einem Bestand mit
1181 * 4.600 Protokollzeilen kostet das rund eine Millisekunde – der vorhandene
1182 * Index `idx_antwort_log_profil_frage` trägt sie.
1183 *
1184 * Leere Einträge fallen hier schon heraus: Das Feld darf leer bleiben, und
1185 * ein leerer Kasten mit der Überschrift „Beim letzten Mal schrieben Sie“
1186 * wäre eine Vorhaltung ohne Inhalt.
1187 */
1188 private letzterFreitextKarte(profilId: number): ReadonlyMap<string, LetzterFreitext> {
1189 const zeilen = this.db
1190 .prepare<[number], { frage_id: string; freitext: string; zeitpunkt: string }>(
1191 `SELECT l.frage_id AS frage_id, l.freitext AS freitext, l.zeitpunkt AS zeitpunkt
1192 FROM antwort_log AS l
1193 JOIN (
1194 SELECT frage_id, MAX(id) AS letzte_id
1195 FROM antwort_log
1196 WHERE profil_id = ? AND freitext IS NOT NULL AND TRIM(freitext) <> ''
1197 GROUP BY frage_id
1198 ) AS m ON l.id = m.letzte_id`,
1199 )
1200 .all(profilId);
1201
1202 return new Map(zeilen.map((z) => [z.frage_id, { text: z.freitext, zeitpunkt: z.zeitpunkt }]));
1203 }
1204
1205 /**
1206 * Aktueller Stand einer einzelnen Frage.
1207 *
1208 * **Bis 0.22.0 rief das niemand.** `FrageStand` wanderte über die Brücke,
1209 * und die Oberfläche benutzte davon ausschließlich `gemerkt`: Wie oft
1210 * jemand eine Frage schon hatte und wie oft davon richtig, stand in der
1211 * Datenbank und nirgends auf dem Bildschirm — „Warum kommt die schon
1212 * wieder?“ blieb ohne Antwort. Seither hängt der Frage-Steckbrief daran
1213 * (`lernen:fragestand`).
1214 *
1215 * Eine nie beantwortete Frage liefert einen Nullstand und keinen Fehler;
1216 * `versuche === 0` ist der Unterschied, den der Steckbrief zeigt.
1217 */
1218 frageStand(profilIdRoh: unknown, frageIdRoh: unknown): FrageStand {
1219 const profilId = this.profilIdPruefen(profilIdRoh);
1220 const frage = this.fragePruefen(frageIdRoh);
1221 return Lernstand.zuFrageStand(frage.id, this.standZeile(profilId, frage.id));
1222 }
1223
1224 private fragePruefen(wert: unknown): Frage {
1225 if (typeof wert !== 'string' || wert.length === 0) {
1226 abweisen('Ungültige Anfrage: Die Frage-ID muss eine nicht-leere Zeichenkette sein.');
1227 }
1228 const frage = this.fragen.get(wert);
1229 if (frage === undefined) {
1230 abweisen(`Unbekannte Frage-ID: „${entschaerft(wert)}“.`);
1231 }
1232 return frage;
1233 }
1234
1235 // ── Antworten ────────────────────────────────────────────────────────
1236
1237 private protokollPruefen(wertRoh: unknown): GepruefterProtokoll {
1238 const roh = nutzlast(wertRoh, 'Das Antwortprotokoll');
1239 const frage = this.fragePruefen(roh['frageId']);
1240
1241 const bewertung = roh['bewertung'];
1242 if (!istBewertung(bewertung)) {
1243 abweisen(
1244 `Ungültige Anfrage: bewertung muss eine von ${BEWERTUNGEN.join(', ')} sein, war aber „${entschaerft(bewertung)}“.`,
1245 );
1246 }
1247
1248 /* Gekappt statt abgewiesen – dieselbe Regel wie in der Prüfung: Eine
1249 Sitzung, die über Nacht offen blieb, soll nicht ihren gesamten
1250 Lernfortschritt verlieren. */
1251 const dauerMs = Math.min(
1252 MAX_DAUER_MS,
1253 ganzeZahl(roh['dauerMs'] ?? 0, 'dauerMs', 0, Number.MAX_SAFE_INTEGER),
1254 );
1255
1256 // Auswahl: nur Labels, die es bei genau dieser Frage gibt. Doppelte
1257 // Einträge werden zusammengefasst, die Reihenfolge normalisiert.
1258 const erlaubteLabels = new Set((frage.optionen ?? []).map((o) => o.label));
1259 const auswahlRoh = textliste(roh['auswahl'], 'auswahl');
1260 for (const label of auswahlRoh) {
1261 if (!erlaubteLabels.has(label)) {
1262 abweisen(
1263 `Ungültige Anfrage: „${entschaerft(label)}“ ist keine Antwortoption der Frage „${frage.id}“.`,
1264 );
1265 }
1266 }
1267 const auswahl = [...new Set(auswahlRoh)].sort();
1268
1269 const freitextRoh = roh['freitext'];
1270 if (freitextRoh !== undefined && freitextRoh !== null && typeof freitextRoh !== 'string') {
1271 abweisen('Ungültige Anfrage: freitext muss eine Zeichenkette sein.');
1272 }
1273 const freitext =
1274 typeof freitextRoh === 'string' ? freitextRoh.slice(0, MAX_FREITEXT_LAENGE) : null;
1275
1276 // Bei Multiple Choice entscheidet nicht der Renderer, sondern der
1277 // Katalog: `richtig` wird hier neu berechnet. Bei offenen Fragen gibt
1278 // es keine maschinelle Wahrheit – dort zählt die Selbsteinschätzung.
1279 const gemeldetRichtig = roh['richtig'];
1280 if (typeof gemeldetRichtig !== 'boolean') {
1281 abweisen('Ungültige Anfrage: richtig muss ein Wahrheitswert sein.');
1282 }
1283 const richtig = frage.typ === 'mc' ? bewerteAuswahl(frage, auswahl).richtig : gemeldetRichtig;
1284
1285 return { frage, auswahl, freitext, richtig, bewertung, dauerMs };
1286 }
1287
1288 /**
1289 * Protokolliert eine Antwort, schreibt den Fragenstand fort und plant die
1290 * Wiedervorlage. Log-Eintrag und Standfortschreibung gehören zusammen und
1291 * laufen deshalb in einer Transaktion.
1292 */
1293 /**
1294 * Schreibt einen Eintrag in die Antworthistorie.
1295 *
1296 * Getrennt herausgezogen, weil nicht jede protokollierte Antwort die
1297 * Wiedervorlage fortschreiben darf – siehe {@link protokollieren}.
1298 */
1299 /**
1300 * Schreibt eine Zeile in die Historie.
1301 *
1302 * `nurHistorie` steht für Fragen, die in einem Prüfungsbogen standen, aber
1303 * nie aufgeschlagen wurden. Sie gehören in die Historie, sind aber keine
1304 * Antwort – die Tagesbilanz zählt sie deshalb nicht mit.
1305 */
1306 private logEintrag(
1307 profilId: number,
1308 p: GepruefterProtokoll,
1309 zeitpunkt: string,
1310 nurHistorie = false,
1311 ): void {
1312 this.db
1313 .prepare<{
1314 profil_id: number;
1315 frage_id: string;
1316 zeitpunkt: string;
1317 richtig: number;
1318 bewertung: string;
1319 dauer_ms: number;
1320 auswahl: string;
1321 freitext: string | null;
1322 nur_historie: number;
1323 }>(
1324 `INSERT INTO antwort_log
1325 (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms, auswahl, freitext,
1326 nur_historie)
1327 VALUES
1328 (@profil_id, @frage_id, @zeitpunkt, @richtig, @bewertung, @dauer_ms, @auswahl,
1329 @freitext, @nur_historie)`,
1330 )
1331 .run({
1332 profil_id: profilId,
1333 frage_id: p.frage.id,
1334 zeitpunkt,
1335 nur_historie: nurHistorie ? 1 : 0,
1336 richtig: p.richtig ? 1 : 0,
1337 bewertung: p.bewertung,
1338 dauer_ms: p.dauerMs,
1339 auswahl: JSON.stringify(p.auswahl),
1340 freitext: p.freitext,
1341 });
1342 }
1343
1344 /**
1345 * Protokolliert eine Antwort, **ohne** die Wiedervorlage fortzuschreiben.
1346 *
1347 * Gedacht für Fragen, die in einer Prüfungssimulation nie aufgeschlagen
1348 * wurden. Sie gehören in die Historie – der Bogen enthielt sie ja –, dürfen
1349 * aber den Lernstand nicht zurückstufen: Eine Frage, die viermal sicher
1350 * gewusst wurde, wäre sonst nach einem abgelaufenen Prüfungslauf wieder
1351 * sofort fällig, obwohl sie nie gezeigt wurde.
1352 */
1353 protokollieren(profilIdRoh: unknown, protokollRoh: unknown): void {
1354 const profilId = this.profilIdPruefen(profilIdRoh);
1355 const p = this.protokollPruefen(protokollRoh);
1356 this.logEintrag(profilId, p, this.jetzt().toISOString(), true);
1357 }
1358
1359 antworten(profilIdRoh: unknown, protokollRoh: unknown): FrageStand {
1360 const profilId = this.profilIdPruefen(profilIdRoh);
1361 const p = this.protokollPruefen(protokollRoh);
1362
1363 const jetzt = this.jetzt();
1364 const zeitpunkt = jetzt.toISOString();
1365
1366 const schreiben = this.db.transaction(() => {
1367 this.logEintrag(profilId, p, zeitpunkt);
1368
1369 const zeile = this.standZeile(profilId, p.frage.id);
1370 const abstandTage = tageZwischen(zeile?.zuletzt_beantwortet ?? null, jetzt);
1371 const vorlage = wiedervorlageBerechnen(
1372 gedaechtnisstandAus(zeile),
1373 FSRS_GRAD[p.bewertung],
1374 abstandTage,
1375 this.tageBisTermin(profilId, jetzt),
1376 /* Seit 0.26.6: In einem Bereich mit K.-o.-Kriterium hält der Planer
1377 eine höhere Zielquote. Die Ampel deckelt daran seit 0.22.0 ihr
1378 Gesamturteil – bis hierher wusste die Terminierung nichts davon. */
1379 istKoFrage(p.frage.kapitel, p.frage.abschnitt),
1380 );
1381
1382 /*
1383 Der Beleg des Abrufs – die Regel steht in `shared/reife.ts`.
1384
1385 Drei Fälle, und der dritte ist der, den man leicht übersieht: Eine
1386 falsche Antwort nimmt den Beleg weg, gleich wie lange die Frage vorher
1387 saß. Eine richtige Antwort nach mindestens einem Tag setzt ihn. Eine
1388 richtige Antwort am selben Tag lässt ihn, wie er ist – sie beweist
1389 nichts über das Behalten, spricht aber auch nicht dagegen.
1390
1391 Die erste Antwort auf eine Frage hat definitionsgemäß den Abstand 0
1392 und belegt deshalb nie. Das ist keine Härte, sondern der Punkt:
1393 Wiedererkennen ist kein Erinnern.
1394
1395 **Seit 0.22.0 zählt auch die Bewertung mit.** Bis dahin hing der Beleg
1396 allein an `richtig` — und `richtig` rechnet der Kern aus der Auswahl
1397 nach, ohne zu wissen, ob jemand die Antwort wusste oder traf. Wer über
1398 „Ich hatte geraten“ die Selbsteinschätzung nachreicht, sagt genau das:
1399 angekreuzt war das Richtige, gewusst war es nicht. Ein Beleg dafür
1400 wäre eine Reifezahl auf einem Zufallstreffer — gemessen an einem
1401 nachgestellten Rater fiel er an 40 Prozent der Tage zu Unrecht
1402 (`docs/entscheidung-ratewahrscheinlichkeit.md`). `giltAlsRichtig`
1403 zieht dieselbe Grenze, nach der auch die Wiedervorlage bucht.
1404 */
1405 const belegtDieseAntwort = p.richtig && giltAlsRichtig(p.bewertung);
1406 const bestaetigt = !belegtDieseAntwort
1407 ? p.richtig
1408 ? (zeile?.bestaetigt ?? 0)
1409 : 0
1410 : abstandTage >= MINDESTABSTAND_TAGE
1411 ? 1
1412 : (zeile?.bestaetigt ?? 0);
1413 /* Der Termin geht auf den am wenigsten belasteten Tag des ohnehin
1414 zulässigen Fensters – siehe `ausgeglichenesIntervall`. Ohne Last
1415 kommt genau der gestreute Wert heraus wie bisher. */
1416 const intervall = ausgeglichenesIntervall(
1417 vorlage.intervallTage,
1418 p.frage.id,
1419 this.lastJeTag(profilId, jetzt, p.frage.id),
1420 );
1421 const faelligAb = new Date(jetzt.getTime() + intervall * TAG_MS).toISOString();
1422
1423 this.db
1424 .prepare<{
1425 profil_id: number;
1426 frage_id: string;
1427 richtig: number;
1428 zeitpunkt: string;
1429 faellig_ab: string;
1430 bewertung: string;
1431 intervall: number;
1432 stabilitaet: number;
1433 schwierigkeit: number;
1434 bestaetigt: number;
1435 }>(
1436 `INSERT INTO frage_stand
1437 (profil_id, frage_id, versuche, richtige, zuletzt_beantwortet,
1438 faellig_ab, gemerkt, letzte_bewertung, intervall_tage,
1439 stabilitaet, schwierigkeit, bestaetigt)
1440 VALUES
1441 (@profil_id, @frage_id, 1, @richtig, @zeitpunkt,
1442 @faellig_ab, 0, @bewertung, @intervall,
1443 @stabilitaet, @schwierigkeit, @bestaetigt)
1444 ON CONFLICT (profil_id, frage_id) DO UPDATE SET
1445 versuche = versuche + 1,
1446 richtige = richtige + @richtig,
1447 zuletzt_beantwortet = @zeitpunkt,
1448 faellig_ab = @faellig_ab,
1449 letzte_bewertung = @bewertung,
1450 intervall_tage = @intervall,
1451 stabilitaet = @stabilitaet,
1452 schwierigkeit = @schwierigkeit,
1453 bestaetigt = @bestaetigt`,
1454 )
1455 .run({
1456 profil_id: profilId,
1457 frage_id: p.frage.id,
1458 richtig: p.richtig ? 1 : 0,
1459 zeitpunkt,
1460 faellig_ab: faelligAb,
1461 bewertung: p.bewertung,
1462 intervall,
1463 stabilitaet: vorlage.stabilitaet,
1464 schwierigkeit: vorlage.schwierigkeit,
1465 bestaetigt,
1466 });
1467 });
1468 schreiben();
1469
1470 return Lernstand.zuFrageStand(p.frage.id, this.standZeile(profilId, p.frage.id));
1471 }
1472
1473 // ── Merkliste ────────────────────────────────────────────────────────
1474
1475 merken(profilIdRoh: unknown, frageIdRoh: unknown, gemerktRoh: unknown): FrageStand {
1476 const profilId = this.profilIdPruefen(profilIdRoh);
1477 const frage = this.fragePruefen(frageIdRoh);
1478 if (typeof gemerktRoh !== 'boolean') {
1479 abweisen('Ungültige Anfrage: gemerkt muss ein Wahrheitswert sein.');
1480 }
1481
1482 this.db
1483 .prepare<[number, string, number]>(
1484 `INSERT INTO frage_stand (profil_id, frage_id, gemerkt)
1485 VALUES (?, ?, ?)
1486 ON CONFLICT (profil_id, frage_id) DO UPDATE SET gemerkt = excluded.gemerkt`,
1487 )
1488 .run(profilId, frage.id, gemerktRoh ? 1 : 0);
1489
1490 return Lernstand.zuFrageStand(frage.id, this.standZeile(profilId, frage.id));
1491 }
1492
1493 // ── Sitzung ──────────────────────────────────────────────────────────
1494
1495 private filterPruefen(wertRoh: unknown): GepruefterFilter {
1496 const roh = wertRoh === undefined || wertRoh === null ? {} : nutzlast(wertRoh, 'Der Filter');
1497
1498 const kapitel = textliste(roh['kapitel'], 'kapitel');
1499 for (const id of kapitel) {
1500 if (!this.kapitelIds.has(id)) {
1501 abweisen(`Unbekanntes Kapitel: „${entschaerft(id)}“.`);
1502 }
1503 }
1504
1505 const abschnitte = textliste(roh['abschnitte'], 'abschnitte');
1506 for (const id of abschnitte) {
1507 if (!this.abschnittIds.has(id)) {
1508 abweisen(`Unbekannter Abschnitt: „${entschaerft(id)}“.`);
1509 }
1510 }
1511
1512 // `anzahl` ist laut Vertrag eine Höchstzahl. Fehlt sie, wird nichts
1513 // abgeschnitten – die Sitzung umfasst dann alle passenden Fragen.
1514 const anzahlRoh = roh['anzahl'];
1515 const anzahl =
1516 anzahlRoh === undefined || anzahlRoh === null
1517 ? this.katalog.fragen.length
1518 : ganzeZahl(anzahlRoh, 'anzahl', 1, MAX_SITZUNGSGROESSE);
1519
1520 const mischen = wahrheitswert(roh['mischen'], 'mischen', true);
1521
1522 return {
1523 kapitel,
1524 abschnitte,
1525 nurGemerkte: wahrheitswert(roh['nurGemerkte'], 'nurGemerkte', false),
1526 nurFehler: wahrheitswert(roh['nurFehler'], 'nurFehler', false),
1527 nurHartnaeckige: wahrheitswert(roh['nurHartnaeckige'], 'nurHartnaeckige', false),
1528 nurNeue: wahrheitswert(roh['nurNeue'], 'nurNeue', false),
1529 nurOffene: wahrheitswert(roh['nurOffene'], 'nurOffene', false),
1530 anzahl,
1531 mischen,
1532 /* Ohne eigene Angabe bleiben die Optionen in Katalogreihenfolge – und
1533 zwar unabhängig von `mischen`. Die frühere Kopplung an die
1534 Fragenreihenfolge war die eigentliche Ursache des Problems: Wer nur
1535 „Fragen mischen" sagte, mischte die Antworten stillschweigend mit.
1536 Beides leistet Unterschiedliches. Die Fragenreihenfolge zu mischen
1537 ist folgenlos; die Optionen zu mischen kostet den Gleichlauf mit dem
1538 amtlichen Katalog, in dem der Lernende nachschlägt, und hat keinen
1539 belegten Lernnutzen. Das darf nur auf ausdrücklichen Wunsch
1540 geschehen (siehe `docs/entscheidung-antwortreihenfolge.md`). */
1541 optionenMischen: wahrheitswert(roh['optionenMischen'], 'optionenMischen', false),
1542 };
1543 }
1544
1545 private optionsReihenfolge(frage: Frage, mischen: boolean): string[] {
1546 const labels = (frage.optionen ?? []).map((o) => o.label);
1547 return mischen ? gemischt(labels, this.zufall) : labels;
1548 }
1549
1550 /**
1551 * Stellt die Fragen einer Lernsitzung zusammen.
1552 *
1553 * Reihenfolge in vier Gruppen: zuerst die fälligen Wiederholungen, dann
1554 * neue Fragen bis zur Einführungsrate des Tages, dann bereits beantwortete,
1555 * noch nicht fällige Fragen, ganz hinten die übrigen neuen. Innerhalb jeder
1556 * Gruppe entscheidet `mischen` zwischen Zufall und Katalogreihenfolge.
1557 *
1558 * Die Rate ist **dieselbe Rechnung** wie das „neu“ im angezeigten
1559 * Tagespensum ({@link einfuehrungsrate}, gerechnet über den ganzen
1560 * Lernumfang, nicht über den gefilterten Ausschnitt – sonst wäre sie eine
1561 * zweite, andere Zahl): Was der Einstieg „9 neue“ nennt, ist auch das, was
1562 * eine Sitzung höchstens an Neuem vorlegt. Ohne Prüfungstermin liegt die
1563 * Rate beim Sitzungsumfang – für die übliche 20er-Sitzung also kein
1564 * Deckel, das bisherige Verhalten.
1565 *
1566 * Der Deckel ordnet, er versteckt nicht: Überzählige neue Fragen stehen am
1567 * Ende statt zu fehlen. Eine Anfrage ohne `anzahl` umfasst damit weiterhin
1568 * alle passenden Fragen, und wer ausdrücklich „nur Neue“ wählt, bekommt
1569 * sie auch am Prüfungstag – dort ist die Rate 0, und alles Neue steht in
1570 * der letzten Gruppe.
1571 *
1572 * Kapitel- und Abschnittsfilter wirken additiv (UND): sind beide gesetzt,
1573 * muss eine Frage beide Bedingungen erfüllen.
1574 */
1575 sitzung(profilIdRoh: unknown, filterRoh: unknown): SitzungsFrage[] {
1576 const profilId = this.profilIdPruefen(profilIdRoh);
1577 const filter = this.filterPruefen(filterRoh);
1578
1579 const stand = this.standKarte(profilId);
1580 const letzteAntwort = this.letzteAntwortKarte(profilId);
1581 /* Nur geholt, wenn wirklich danach gefiltert wird – die Abfrage geht
1582 über das ganze Protokoll. */
1583 const hartnaeckigeIds = filter.nurHartnaeckige
1584 ? new Set(this.hartnaeckige(profilId).map((eintrag) => eintrag.frageId))
1585 : new Set<string>();
1586 const jetzt = this.jetzt();
1587 const jetztIso = jetzt.toISOString();
1588
1589 const faellige: Frage[] = [];
1590 const neue: Frage[] = [];
1591 const rest: Frage[] = [];
1592
1593 /* Die Quelle ist der Lernumfang des Profils, nicht der ganze Katalog –
1594 damit sind Weiterlernen, Kapitelwahl, „nur Fehler“, „nur Gemerkte“ und
1595 „nur Neue“ mit einem Eingriff richtig. Besonders „nur Fehler“ braucht
1596 das: Seine Quelle ist `antwort_log`, und dort stehen auch Antworten aus
1597 Prüfungsläufen, die Kapitel IV enthielten. Ohne die Vorfilterung legte
1598 ausgerechnet dieser Weg die abgewählten Fragen wieder vor. */
1599 for (const frage of this.lernfragen(profilId)) {
1600 if (filter.kapitel.length > 0 && !filter.kapitel.includes(frage.kapitel)) {
1601 continue;
1602 }
1603 if (
1604 filter.abschnitte.length > 0 &&
1605 (frage.abschnitt === null || !filter.abschnitte.includes(frage.abschnitt))
1606 ) {
1607 continue;
1608 }
1609
1610 const zeile = stand.get(frage.id);
1611 const versuche = zeile?.versuche ?? 0;
1612
1613 if (filter.nurGemerkte && (zeile?.gemerkt ?? 0) !== 1) {
1614 continue;
1615 }
1616 if (filter.nurNeue && versuche > 0) {
1617 continue;
1618 }
1619 if (filter.nurFehler && letzteAntwort.get(frage.id) !== false) {
1620 continue;
1621 }
1622 /* Neben „nur Fehler“ und nicht an seiner Stelle: Jener fragt die
1623 letzte Antwort, dieser die Historie. Eine Frage, die viermal
1624 durchfiel und gestern zufällig saß, steht nur hier. */
1625 if (filter.nurHartnaeckige && !hartnaeckigeIds.has(frage.id)) {
1626 continue;
1627 }
1628 /* Als einziger Filter aus dem Katalog statt aus dem Lernstand – der
1629 Fragetyp ist eine Eigenschaft der Frage, keine des Lernenden. */
1630 if (filter.nurOffene && frage.typ === 'mc') {
1631 continue;
1632 }
1633
1634 const faellig = zeile?.faellig_ab != null && zeile.faellig_ab <= jetztIso;
1635 if (versuche === 0) {
1636 neue.push(frage);
1637 } else if (faellig) {
1638 faellige.push(frage);
1639 } else {
1640 rest.push(frage);
1641 }
1642 }
1643
1644 /* Die Rate rechnet über den ganzen Lernumfang – dieselbe Grundmenge, aus
1645 der `lernplan()` das angezeigte Pensum bildet. Sie ist bewusst keine
1646 Tagesmenge mit Gedächtnis, sondern wird je Sitzung neu gebildet; das
1647 ist dieselbe Entscheidung, die der Einstieg als „Rate, die sich
1648 nachfüllt“ dokumentiert (`Einstieg.tsx`). */
1649 let nieBeantwortetGesamt = 0;
1650 for (const frage of this.lernfragen(profilId)) {
1651 if ((stand.get(frage.id)?.versuche ?? 0) === 0) {
1652 nieBeantwortetGesamt += 1;
1653 }
1654 }
1655 const rate = einfuehrungsrate(nieBeantwortetGesamt, this.tageBisTermin(profilId, jetzt));
1656
1657 /* Bei Rückstand entscheidet nicht mehr der Zufall, welche fälligen Fragen
1658 in die Sitzung kommen.
1659
1660 Der Fall: 100 Fragen sind fällig, die Sitzung fasst 20. Bis 0.26.6
1661 wurde die fällige Gruppe gemischt und danach abgeschnitten – welche 20
1662 vorgelegt wurden, war Los. Die am stärksten vergessene Frage konnte
1663 Tag um Tag hinten bleiben, während dieselbe Menge Zeit auf gerade erst
1664 fällig gewordene ging.
1665
1666 Sortiert wird nach der Abrufwahrscheinlichkeit, aufsteigend: zuerst
1667 das, was am wahrscheinlichsten schon weg ist. Bewusst nicht nach
1668 „am längsten überfällig“ – eine Frage mit kleiner Stabilität ist nach
1669 einem Tag schon verloren, eine gefestigte nach dreißig noch da. Die
1670 Überfälligkeit allein misst also das Falsche; `abrufwahrscheinlichkeit`
1671 rechnet beides zusammen.
1672
1673 Ohne Gedächtnisstand (Zeilen aus der Zeit vor FSRS) steht die Frage
1674 vorn: Was sich nicht einschätzen lässt, wird vorgelegt statt
1675 weggeworfen. */
1676 const abrufJetzt = (frage: Frage): number => {
1677 const zeile = stand.get(frage.id);
1678 if (zeile?.stabilitaet == null || !Number.isFinite(zeile.stabilitaet)) {
1679 return 0;
1680 }
1681 return abrufwahrscheinlichkeit(
1682 zeile.stabilitaet,
1683 tageZwischen(zeile.zuletzt_beantwortet ?? null, jetzt),
1684 );
1685 };
1686 const nachDringlichkeit = (gruppe: Frage[]): Frage[] =>
1687 [...gruppe].sort((a, b) => abrufJetzt(a) - abrufJetzt(b));
1688
1689 // Innerhalb der Gruppen mischen, nie über die Gruppengrenze hinweg:
1690 // Fälliges bleibt vor Neuem, Neues im Pensum vor dem Rest.
1691 const geordnet = (gruppe: Frage[]): Frage[] =>
1692 filter.mischen ? gemischt(gruppe, this.zufall) : gruppe;
1693 const neueGeordnet = geordnet(neue);
1694 const reihenfolge = [
1695 ...(filter.mischen ? nachDringlichkeit(faellige) : faellige),
1696 ...neueGeordnet.slice(0, rate),
1697 ...geordnet(rest),
1698 ...neueGeordnet.slice(rate),
1699 ];
1700
1701 /* Nur holen, wenn die Sitzung überhaupt eine offene Frage enthält –
1702 sonst kostet die Abfrage etwas für nichts. */
1703 const gewaehlt = reihenfolge.slice(0, filter.anzahl);
1704 const freitexte = gewaehlt.some((frage) => frage.typ !== 'mc')
1705 ? this.letzterFreitextKarte(profilId)
1706 : new Map<string, LetzterFreitext>();
1707
1708 return gewaehlt.map((frage) => {
1709 const letzter = frage.typ === 'mc' ? undefined : freitexte.get(frage.id);
1710 return {
1711 frageId: frage.id,
1712 optionsReihenfolge: this.optionsReihenfolge(frage, filter.optionenMischen),
1713 /* Aus derselben Karte, aus der 80 Zeilen weiter oben der Filter
1714 „nur Gemerkte“ liest. Ohne diese Angabe begann die Oberfläche jede
1715 Sitzung mit einer leeren Merkliste. */
1716 gemerkt: (stand.get(frage.id)?.gemerkt ?? 0) === 1,
1717 /* `exactOptionalPropertyTypes`: das Feld nur setzen, wenn es wirklich
1718 einen Wert hat – und nur bei offenen Fragen. */
1719 ...(letzter === undefined ? {} : { letzterFreitext: letzter }),
1720 };
1721 });
1722 }
1723
1724 // ── Übersicht ────────────────────────────────────────────────────────
1725
1726 private tagesbilanz(profilId: number): TagesbilanzZeile {
1727 const jetzt = this.jetzt();
1728 // Kalendertag in der Zeitzone des Geräts – gespeichert wird UTC, deshalb
1729 // werden die Grenzen umgerechnet.
1730 const { von, bis } = tagesgrenzen(jetzt);
1731
1732 const zeile = this.db
1733 .prepare<[number, string, string], TagesbilanzZeile>(
1734 `SELECT
1735 COALESCE(SUM(CASE WHEN richtig = 1 THEN 1 ELSE 0 END), 0) AS richtig,
1736 COALESCE(SUM(CASE WHEN richtig = 0 THEN 1 ELSE 0 END), 0) AS falsch
1737 FROM antwort_log
1738 WHERE profil_id = ? AND zeitpunkt >= ? AND zeitpunkt < ?
1739 AND nur_historie = 0`,
1740 )
1741 .get(profilId, von, bis);
1742
1743 return zeile ?? { richtig: 0, falsch: 0 };
1744 }
1745
1746 /**
1747 * Bereichsstatistik: in Kapiteln mit Abschnitten je Abschnitt, sonst je
1748 * Kapitel. Die Reihenfolge folgt dem Katalog.
1749 *
1750 * Rechnet aus derselben Zeilenform wie Gesamtzahl und Lernplan. Die
1751 * Bereichswerte und der Gesamtwert sind damit nicht bloß aufeinander
1752 * abgestimmt, sondern dieselbe Summe, nur anders gruppiert.
1753 */
1754 private bereiche(
1755 fragen: readonly PlanFrage[],
1756 stand: ReadonlyMap<string, StandZeile>,
1757 idsJeBereich: ReadonlyMap<string, readonly string[]>,
1758 ): BereichStatistik[] {
1759 const titel = new Map<string, string>();
1760 for (const kapitel of this.katalog.kapitel) {
1761 titel.set(kapitel.id, kapitel.titel);
1762 for (const abschnitt of kapitel.abschnitte) {
1763 titel.set(abschnitt.id, abschnitt.titel);
1764 }
1765 }
1766
1767 const eimer = new Map<string, PlanFrage[]>();
1768 /* Ein abgewähltes Kapitel verschwindet aus der Aufschlüsselung, statt
1769 mit 0 dazustehen: Sonst summierten sich die Bereiche zu 575, während
1770 `fragenGesamt` 486 sagt – zwei Zahlen auf einem Bildschirm, die
1771 einander widersprechen. */
1772 for (const frage of fragen) {
1773 const liste = eimer.get(frage.bereich);
1774 if (liste === undefined) {
1775 eimer.set(frage.bereich, [frage]);
1776 } else {
1777 liste.push(frage);
1778 }
1779 }
1780
1781 return [...eimer.entries()].map(([id, gruppe]) => {
1782 const ids = idsJeBereich.get(id) ?? [];
1783 const beantwortet = ids.filter((frageId) => (stand.get(frageId)?.versuche ?? 0) > 0).length;
1784 const reifegrad = reifegradVon(gruppe);
1785 const belegt = belegteFragen(reifegrad, gruppe.length);
1786 return {
1787 id,
1788 titel: titel.get(id) ?? id,
1789 fragenGesamt: gruppe.length,
1790 beantwortet,
1791 belegt,
1792 reifegrad,
1793 stufe: stufeFuer(belegt, gruppe.length),
1794 };
1795 });
1796 }
1797
1798 /**
1799 * Dieselbe Rechnung für die 29 Themengruppen der Kapitel II bis IV.
1800 *
1801 * **Warum eine eigene Methode und nicht ein Parameter an `bereiche()`.**
1802 * Die Eimer entstehen hier anders: nicht aus `frage.abschnitt ?? kapitel`,
1803 * sondern aus einer Zuordnungstabelle. Und der Rückfall ist ein anderer –
1804 * eine Frage, die in keiner Gruppe steht, fällt einfach weg, statt einen
1805 * Eimer „ohne Gruppe“ zu eröffnen. Nachgemessen am Katalogstand
1806 * 16.12.2024 gibt es sie nicht; ein Eimer für den leeren Fall wäre eine
1807 * Zeile auf dem Bildschirm für einen Zustand, den es nicht gibt.
1808 *
1809 * Abgewählte Kapitel fallen wie in `bereiche()` heraus – `fragen` enthält
1810 * nur den Lernumfang. Eine Gruppe ohne Frage darin erscheint nicht.
1811 */
1812 private themengruppen(
1813 /** Reifezeilen **mit** ihrer Kennung – siehe `reifefragenMitKennung`. */
1814 paare: readonly { readonly id: string; readonly frage: PlanFrage }[],
1815 stand: ReadonlyMap<string, StandZeile>,
1816 ): Themengruppenstatistik[] {
1817 if (this.themen.gruppen.length === 0) {
1818 return [];
1819 }
1820
1821 /* Von der Frage zur Gruppe, einmal gebildet: Die andere Richtung wäre je
1822 Gruppe ein Durchlauf durch alle Fragen. */
1823 const gruppeJeFrage = new Map<string, string>();
1824 for (const gruppe of this.themen.gruppen) {
1825 for (const frageId of gruppe.fragen) {
1826 gruppeJeFrage.set(frageId, gruppe.id);
1827 }
1828 }
1829
1830 const eimer = new Map<string, { id: string; frage: PlanFrage }[]>();
1831 for (const paar of paare) {
1832 const gruppenId = gruppeJeFrage.get(paar.id);
1833 if (gruppenId === undefined) {
1834 continue;
1835 }
1836 const liste = eimer.get(gruppenId);
1837 if (liste === undefined) {
1838 eimer.set(gruppenId, [paar]);
1839 } else {
1840 liste.push(paar);
1841 }
1842 }
1843
1844 /* In der Reihenfolge der Datei, nicht in der der Eimer: Die Gruppen sind
1845 nach Kapitel und Sachzusammenhang sortiert; eine Liste in der
1846 Reihenfolge, in der zufällig die erste Frage kam, wäre eine andere
1847 Reihenfolge bei jedem Profil. */
1848 return this.themen.gruppen.flatMap((gruppe) => {
1849 const gefunden = eimer.get(gruppe.id);
1850 if (gefunden === undefined) {
1851 return [];
1852 }
1853 const beantwortet = gefunden.filter((paar) => (stand.get(paar.id)?.versuche ?? 0) > 0).length;
1854 const reifegrad = reifegradVon(gefunden.map((paar) => paar.frage));
1855 const belegt = belegteFragen(reifegrad, gefunden.length);
1856 return [
1857 {
1858 id: gruppe.id,
1859 kapitel: gruppe.kapitel,
1860 titel: gruppe.titel,
1861 fragenGesamt: gefunden.length,
1862 beantwortet,
1863 belegt,
1864 reifegrad,
1865 stufe: stufeFuer(belegt, gefunden.length),
1866 },
1867 ];
1868 });
1869 }
1870
1871 /**
1872 * Die gemeinsame Zeilenform für Lernplan **und** Reifegrad.
1873 *
1874 * Es gibt sie genau einmal, und das ist der Kern dieses Schrittes: Vorher
1875 * baute `lernplan()` seine Sicht auf den Lernstand und `uebersicht()` eine
1876 * zweite, die etwas anderes bedeutete. Zwei Zahlen auf einem Bildschirm,
1877 * die dasselbe zu sagen schienen und es nicht taten – der Befund in
1878 * `docs/stand.md` 7.1.
1879 */
1880 private reifefragen(
1881 profilId: number,
1882 stand: ReadonlyMap<string, StandZeile>,
1883 jetzt: Date,
1884 ): readonly PlanFrage[] {
1885 return this.reifefragenMitKennung(profilId, stand, jetzt).map((paar) => paar.frage);
1886 }
1887
1888 /**
1889 * Dasselbe, aber mit der Kennung neben jeder Zeile.
1890 *
1891 * `PlanFrage` trägt bewusst keine Kennung: Planung und Reifegrad rechnen
1892 * auf einer Zeilenform, die nur Reifewerte enthält. Wer eine Zeile einer
1893 * **Frage** zuordnen muss – die Themengruppen tun das –, braucht die
1894 * Kennung trotzdem. Sie hier mitzuführen ist der einzige Weg, der ohne
1895 * eine stillschweigende Annahme über Reihenfolgen auskommt; eine zweite
1896 * Liste daneben wäre genau die Annahme, die später bricht.
1897 */
1898 private reifefragenMitKennung(
1899 profilId: number,
1900 stand: ReadonlyMap<string, StandZeile>,
1901 jetzt: Date,
1902 ): readonly { readonly id: string; readonly frage: PlanFrage }[] {
1903 return this.lernfragen(profilId).map((frage) => {
1904 const zeile = stand.get(frage.id);
1905 const beantwortet = zeile?.zuletzt_beantwortet ?? null;
1906 return {
1907 id: frage.id,
1908 frage: {
1909 bereich: frage.abschnitt ?? frage.kapitel,
1910 stabilitaet: beantwortet === null ? null : (zeile?.stabilitaet ?? null),
1911 tageSeitAntwort: tageZwischen(beantwortet, jetzt),
1912 bestaetigt: (zeile?.bestaetigt ?? 0) === 1,
1913 /* Aus derselben Spalte, aus der `sitzung()` die neuen Fragen
1914 erkennt. Nicht aus `stabilitaet`: Eine Zeile von vor der
1915 FSRS-Umstellung hat Versuche und keine Stabilität, und die
1916 beiden Wege sollen dieselbe Rechnung sein. */
1917 nieBeantwortet: (zeile?.versuche ?? 0) === 0,
1918 /* Nie beantwortete Fragen sind nicht „fällig“, sondern neu. Sonst
1919 stünden sie in beiden Zahlen des Pensums. */
1920 faellig:
1921 beantwortet !== null &&
1922 zeile?.faellig_ab != null &&
1923 Date.parse(zeile.faellig_ab) <= jetzt.getTime(),
1924 /* Für die Arbeitslast-Vorschau: an welchem Tag diese Frage ansteht.
1925 `null` für nie beantwortete – sie haben keinen Termin, sondern
1926 warten auf die Einführungsrate.
1927
1928 In **Kalendertagen**, nicht in Vierundzwanzig-Stunden-Blöcken: Die
1929 Vorschau beschriftet die Werte als „heute“, „morgen“ und danach mit
1930 Wochentag und Datum (`Lernplanung.tsx`). Bis Fassung 0.24.1 stand
1931 hier `Math.ceil(differenz / TAG_MS)`, und wer abends lernte und
1932 morgens plante, sah jeden Tag die Last des Vortages: Eine Montag um
1933 20 Uhr beantwortete Frage mit Intervall 1 wird Dienstag um 20 Uhr
1934 fällig – am Dienstagmorgen ergab die alte Rechnung `ceil(12/24) = 1`
1935 und stellte sie unter „morgen“. Dieselbe Regel wie in
1936 `kalendertageSeit()`. */
1937 faelligInTagen:
1938 beantwortet === null || zeile?.faellig_ab == null
1939 ? null
1940 : kalendertageBis(new Date(Date.parse(zeile.faellig_ab)), jetzt),
1941 },
1942 };
1943 });
1944 }
1945
1946 /**
1947 * Wie viele Fragen an welchem der kommenden Tage bereits fällig sind.
1948 *
1949 * Gezählt in **Kalendertagen** ab heute, mit derselben Rechnung wie die
1950 * Arbeitslast-Vorschau (`kalendertageBis`) – sonst zeigte die Vorschau
1951 * andere Berge, als der Ausgleich abzutragen versucht.
1952 *
1953 * Die gerade beantwortete Frage bleibt draußen: Ihr alter Termin wird in
1954 * derselben Transaktion überschrieben und ist keine Last mehr.
1955 *
1956 * Das Fenster reicht so weit wie das größtmögliche Streufenster. Weiter zu
1957 * zählen kostete nur Zeit; der Ausgleich greift nie darüber hinaus.
1958 *
1959 * **Nur der Lernumfang zählt.** Bis 0.27.2 ging die Abfrage ungefiltert auf
1960 * `frage_stand`, und ein dauerhaft abgewähltes Kapitel behält seine
1961 * Termine — `profilAktualisieren` rührt die Tabelle nicht an. Der Ausgleich
1962 * wich damit Bergen aus, die die Vorschau desselben Profils gar nicht
1963 * kennt: gemessen ein Tag Verschiebung auf eine Last von null. Gefiltert
1964 * wird über dieselbe Menge wie dort, `lernfragen(profilId)` — genau das
1965 * meint die Zusage oben.
1966 */
1967 private lastJeTag(
1968 profilId: number,
1969 jetzt: Date,
1970 ausgenommen: string,
1971 ): ReadonlyMap<number, number> {
1972 const grenze = new Date(jetzt.getTime() + (MAX_INTERVALL_TAGE + 1) * TAG_MS).toISOString();
1973 const zeilen = this.db
1974 .prepare<[number, string, string, string], { frage_id: string; faellig_ab: string }>(
1975 `SELECT frage_id, faellig_ab FROM frage_stand
1976 WHERE profil_id = ? AND frage_id <> ? AND faellig_ab IS NOT NULL
1977 AND faellig_ab >= ? AND faellig_ab <= ?`,
1978 )
1979 .all(profilId, ausgenommen, jetzt.toISOString(), grenze);
1980
1981 /* Dieselbe Menge wie die Vorschau. Gefiltert wird hier und nicht in SQL:
1982 Der Lernumfang steht als Fragenliste da, und 575 gebundene Parameter
1983 wären teurer als ein Durchlauf über höchstens ebenso viele Zeilen —
1984 dieselbe Bauform wie in `hartnaeckige()`. */
1985 const umfang = new Set(this.lernfragen(profilId).map((frage) => frage.id));
1986
1987 const last = new Map<number, number>();
1988 for (const zeile of zeilen) {
1989 if (!umfang.has(zeile.frage_id)) {
1990 continue;
1991 }
1992 const zeitpunkt = Date.parse(zeile.faellig_ab);
1993 if (Number.isNaN(zeitpunkt)) {
1994 continue;
1995 }
1996 const tag = kalendertageBis(new Date(zeitpunkt), jetzt);
1997 last.set(tag, (last.get(tag) ?? 0) + 1);
1998 }
1999 return last;
2000 }
2001
2002 // ── Lernplan ─────────────────────────────────────────────────────────
2003
2004 /**
2005 * Mittlere Bearbeitungsdauer je Frage in Sekunden.
2006 *
2007 * Der **Median** der letzten Antworten, nicht das arithmetische Mittel:
2008 * Eine einzige Sitzung, bei der jemand zwischendurch Kaffee holt, würde
2009 * einen Mittelwert um Minuten verschieben und die Zeitschätzung unbrauchbar
2010 * machen. Der Median stört sich daran nicht.
2011 *
2012 * `null`, solange zu wenige Antworten vorliegen – dann greift der
2013 * Vorgabewert aus `shared/lernplan.ts` statt einer Schätzung aus drei
2014 * Datenpunkten.
2015 *
2016 * **Ohne die Historienzeilen.** Ein abgelaufener Prüfungsbogen schreibt für
2017 * jede nie aufgeschlagene Frage `dauer_ms = Gesamtdauer / Fragenzahl`
2018 * (`pruefung.ts`) – eine gleichmäßig verteilte Rechengröße für etwas, das
2019 * niemand gelesen hat. Nach einem 80-Fragen-Bogen mit 60 ungesehenen Fragen
2020 * bestanden bis Fassung 0.24.1 sechzig der zweihundert Stichprobenwerte
2021 * daraus, und der Median – und mit ihm die Zeitschätzung des Tagespensums –
2022 * verschob sich auf Zahlen, die keine gemessene Bearbeitungszeit sind.
2023 */
2024 private sekundenProFrage(profilId: number): number | null {
2025 const zeilen = this.db
2026 .prepare<[number, number], { dauer_ms: number }>(
2027 `SELECT dauer_ms FROM antwort_log
2028 WHERE profil_id = ? AND dauer_ms > 0 AND nur_historie = 0
2029 ORDER BY id DESC LIMIT ?`,
2030 )
2031 .all(profilId, TEMPO_STICHPROBE);
2032
2033 if (zeilen.length < TEMPO_MINDESTZAHL) {
2034 return null;
2035 }
2036
2037 const sortiert = zeilen.map((z) => z.dauer_ms).sort((a, b) => a - b);
2038 const mitte = Math.floor(sortiert.length / 2);
2039 const median =
2040 sortiert.length % 2 === 1
2041 ? (sortiert[mitte] ?? 0)
2042 : ((sortiert[mitte - 1] ?? 0) + (sortiert[mitte] ?? 0)) / 2;
2043
2044 return median > 0 ? median / 1000 : null;
2045 }
2046
2047 /**
2048 * Stellt den Lernplan zusammen: Prognose, Tagespensum, Machbarkeit.
2049 *
2050 * Der Lernstand liefert hier ausschließlich Fakten aus der Datenbank; die
2051 * Bewertung findet in `shared/lernplan.ts` statt und ist dort ohne
2052 * Datenbank prüfbar.
2053 */
2054 lernplan(profilIdRoh: unknown): Lernplan {
2055 const profilId = this.profilIdPruefen(profilIdRoh);
2056 const jetzt = this.jetzt();
2057
2058 return lernplanBerechnen({
2059 termin: this.profilLesen(profilId).pruefungstermin,
2060 tageBisTermin: this.tageBisTermin(profilId, jetzt),
2061 fragen: this.reifefragen(profilId, this.standKarte(profilId), jetzt),
2062 sekundenProFrage: this.sekundenProFrage(profilId),
2063 });
2064 }
2065
2066 uebersicht(profilIdRoh: unknown): Lernuebersicht {
2067 const profilId = this.profilIdPruefen(profilIdRoh);
2068 return this.uebersichtIntern(profilId);
2069 }
2070
2071 private uebersichtIntern(profilId: number): Lernuebersicht {
2072 const stand = this.standKarte(profilId);
2073 const jetzt = this.jetzt();
2074 const jetztIso = jetzt.toISOString();
2075 const lernfragen = this.lernfragen(profilId);
2076
2077 let beantwortet = 0;
2078 let faellig = 0;
2079 let gemerkt = 0;
2080 let heuteBearbeitet = 0;
2081 let juengste = 0;
2082
2083 /* Dieselben lokalen Mitternachtsgrenzen wie in `tagesbilanz()`. Ein Tag
2084 ist der Kalendertag des Nutzers, nicht der von UTC – wer um ein Uhr
2085 nachts lernt, lernt an dem Tag, der auf seiner Uhr steht. */
2086 const tagesbeginn = tagesgrenzen(jetzt).von;
2087
2088 const idsJeBereich = new Map<string, string[]>();
2089 for (const frage of lernfragen) {
2090 const bereich = frage.abschnitt ?? frage.kapitel;
2091 const liste = idsJeBereich.get(bereich);
2092 if (liste === undefined) {
2093 idsJeBereich.set(bereich, [frage.id]);
2094 } else {
2095 liste.push(frage.id);
2096 }
2097
2098 const zeile = stand.get(frage.id);
2099 if (zeile === undefined) {
2100 continue;
2101 }
2102 if (zeile.versuche > 0) {
2103 beantwortet += 1;
2104 if (zeile.faellig_ab != null && zeile.faellig_ab <= jetztIso) {
2105 faellig += 1;
2106 }
2107 }
2108 if (zeile.gemerkt === 1) {
2109 gemerkt += 1;
2110 }
2111 if (zeile.zuletzt_beantwortet !== null) {
2112 if (zeile.zuletzt_beantwortet >= tagesbeginn) {
2113 heuteBearbeitet += 1;
2114 }
2115 const zeitpunkt = Date.parse(zeile.zuletzt_beantwortet);
2116 if (!Number.isNaN(zeitpunkt) && zeitpunkt > juengste) {
2117 juengste = zeitpunkt;
2118 }
2119 }
2120 }
2121
2122 const fragen = this.reifefragen(profilId, stand, jetzt);
2123 const letzteAntwort = this.letzteAntwortKarte(profilId);
2124 const reifegrad = reifegradVon(fragen);
2125 const belegt = belegteFragen(reifegrad, fragen.length);
2126 const bereiche = this.bereiche(fragen, stand, idsJeBereich);
2127 const themengruppen = this.themengruppen(
2128 this.reifefragenMitKennung(profilId, stand, jetzt),
2129 stand,
2130 );
2131 const bilanz = this.tagesbilanz(profilId);
2132
2133 /* Manche Prüfungsordnungen lassen in Notwehr und Notstand höchstens zwei
2134 Fehler zu, gleich wie gut der Rest ist. Wer insgesamt gut dasteht und
2135 dort zurückliegt, fällt sicher durch – eine Ampel namens
2136 „Prüfungsreife“, die das verschweigt, wäre gefährlicher als gar keine.
2137 Deshalb deckelt der schwächste K.-o.-Bereich das Gesamturteil, und die
2138 Oberfläche bekommt seinen Namen mit, statt nur eine Stufe tiefer zu
2139 zeigen. */
2140 const koBereiche = bereiche.filter((bereich) => KO_BEREICHE.includes(bereich.id));
2141
2142 return {
2143 fragenGesamt: fragen.length,
2144 beantwortet,
2145 belegt,
2146 reifegrad,
2147 stufe: gesamtstufeMitDeckel(
2148 stufeFuer(belegt, fragen.length),
2149 koBereiche.map((bereich) => bereich.stufe),
2150 ),
2151 deckelnd: koBereiche.filter((b) => b.stufe !== 'reif').map((b) => `${b.id} – ${b.titel}`),
2152 faellig,
2153 gemerkt,
2154 offen: this.lernfragen(profilId).filter((frage) => frage.typ !== 'mc').length,
2155 /* Aus derselben Karte wie der Filter „nur Fehler“, damit Einstieg,
2156 Zahl und gedrucktes Protokoll nicht auseinanderlaufen können. */
2157 fehler: this.lernfragen(profilId).filter((frage) => letzteAntwort.get(frage.id) === false)
2158 .length,
2159 heuteRichtig: bilanz.richtig,
2160 heuteFalsch: bilanz.falsch,
2161 heuteBearbeitet,
2162 tageSeitLetzterAntwort: juengste === 0 ? null : kalendertageSeit(new Date(juengste), jetzt),
2163 behaltensquote: this.behaltensquote(profilId),
2164 bereiche,
2165 themengruppen,
2166 };
2167 }
2168
2169 /**
2170 * Der Reifegrad der letzten Tage, aus dem Antwortprotokoll nachgerechnet.
2171 *
2172 * Warum nachgerechnet und nicht mitgeschrieben, steht im Kopf von
2173 * `shared/reifeverlauf.ts`. Hier steht nur, was der Anwendungskern dazu
2174 * beisteuert: das Protokoll in der richtigen Reihenfolge und der heutige
2175 * Lernumfang.
2176 *
2177 * **Ohne die reinen Historienzeilen.** Ein abgelaufener Prüfungsbogen
2178 * schreibt für jede nie aufgeschlagene Frage eine Zeile mit
2179 * `nur_historie = 1`. Sie stehen im Protokoll, sind aber keine Antwort
2180 * eines Menschen und haben den Gedächtnisstand nie verändert; im Verlauf
2181 * würden sie den Reifegrad senken, ohne dass jemand etwas vergessen hätte.
2182 *
2183 * **Geordnet nach `id`, nicht nach `zeitpunkt`.** Beides fällt fast immer
2184 * zusammen; wo nicht, ist die Einfügereihenfolge die maßgebliche, denn
2185 * genau in ihr hat `antworten()` den Gedächtnisstand fortgeschrieben. Bis
2186 * 0.27.2 stand hier `ORDER BY zeitpunkt` — bei vertauschten Zeitpunkten
2187 * rechnete die Wiedergabe deshalb eine andere Kette als der Lernstand und
2188 * zeigte am Ende eine andere Zahl als die Ampel daneben.
2189 */
2190 reifeverlauf(profilIdRoh: unknown, tageRoh?: unknown): Verlaufspunkt[] {
2191 const profilId = this.profilIdPruefen(profilIdRoh);
2192 /* Ein Wert aus dem Renderer wird geprüft, nicht geglaubt: Die Zahl geht
2193 in eine Schleife über Stichtage mal Fragen. Ohne Obergrenze wäre sie
2194 der einzige Weg, den Anwendungskern von der Oberfläche aus lahmzulegen. */
2195 const tage =
2196 tageRoh === undefined ? VERLAUF_TAGE : ganzeZahl(tageRoh, 'tage', 1, VERLAUF_TAGE_MAX);
2197
2198 const zeilen = this.db
2199 .prepare<
2200 [number],
2201 { frage_id: string; zeitpunkt: string; richtig: number; bewertung: string }
2202 >(
2203 `SELECT frage_id, zeitpunkt, richtig, bewertung
2204 FROM antwort_log
2205 WHERE profil_id = ? AND nur_historie = 0
2206 ORDER BY id ASC`,
2207 )
2208 .all(profilId);
2209
2210 return reifeverlauf(
2211 zeilen.map((zeile) => ({
2212 frageId: zeile.frage_id,
2213 zeitpunkt: zeile.zeitpunkt,
2214 richtig: zeile.richtig === 1,
2215 bewertung: zeile.bewertung as Bewertung,
2216 })),
2217 this.lernfragen(profilId).map((frage) => frage.id),
2218 this.jetzt(),
2219 tage,
2220 );
2221 }
2222
2223 // ── Zurücksetzen ─────────────────────────────────────────────────────
2224
2225 /**
2226 * Löscht den Lernstand – wahlweise nur den eines Kapitels.
2227 *
2228 * Bewusst ohne jede Begrenzung: Zurücksetzen ist eine legitime Handlung
2229 * und darf beliebig oft geschehen. Historie und Stand werden gemeinsam
2230 * entfernt, damit keine widersprüchlichen Daten zurückbleiben.
2231 *
2232 * **Ein Unterschied zwischen beiden Wegen.** Vollständig heißt vollständig:
2233 * Antworten, Fortschritt, Merkliste, Prüfungsverlauf. Kapitelweise bleibt
2234 * die Merkliste stehen. Wer eine Frage als schwer markiert hat, will sie
2235 * wiederfinden – gerade dann, wenn er das Kapitel noch einmal von vorn
2236 * lernt. Dass beides in derselben Tabelle steht, ist eine Eigenheit der
2237 * Speicherung und darf keine Bedeutung bekommen.
2238 */
2239 zuruecksetzen(profilIdRoh: unknown, kapitelRoh: unknown): Lernuebersicht {
2240 const profilId = this.profilIdPruefen(profilIdRoh);
2241
2242 let frageIds: string[] | null = null;
2243 if (kapitelRoh !== undefined && kapitelRoh !== null) {
2244 if (typeof kapitelRoh !== 'string') {
2245 abweisen('Ungültige Anfrage: kapitel muss eine Zeichenkette oder null sein.');
2246 }
2247 if (!this.kapitelIds.has(kapitelRoh)) {
2248 abweisen(`Unbekanntes Kapitel: „${entschaerft(kapitelRoh)}“.`);
2249 }
2250 frageIds = this.katalog.fragen.filter((f) => f.kapitel === kapitelRoh).map((f) => f.id);
2251 }
2252
2253 const loeschen = this.db.transaction(() => {
2254 if (frageIds === null) {
2255 this.db.prepare<[number]>('DELETE FROM antwort_log WHERE profil_id = ?').run(profilId);
2256 this.db.prepare<[number]>('DELETE FROM frage_stand WHERE profil_id = ?').run(profilId);
2257 /* Auch der Prüfungsverlauf. Wer von vorn anfangen will, meint von
2258 vorn – alte Simulationsergebnisse stünden sonst weiter in der
2259 Auswertung und im Lernbericht, während der Lernstand bei null ist.
2260 Beim Zurücksetzen eines einzelnen Kapitels bleibt er dagegen: Ein
2261 Lauf geht über den ganzen Bogen und lässt sich nicht kapitelweise
2262 herausrechnen. */
2263 this.db.prepare<[number]>('DELETE FROM pruefung_lauf WHERE profil_id = ?').run(profilId);
2264 /* Auch ein unterbrochener Bogen. „Von vorn" und „aber die halb
2265 bearbeitete Prüfung von gestern liegt noch da" passen nicht
2266 zusammen; beim nächsten Start böte die Anwendung sie sonst zum
2267 Fortsetzen an, während der Lernstand bei null steht. Kapitelweise
2268 bleibt sie stehen – ein Bogen geht über den ganzen Katalog und
2269 lässt sich nicht kapitelweise herausrechnen. */
2270 this.db.prepare<[number]>('DELETE FROM pruefung_offen WHERE profil_id = ?').run(profilId);
2271 return;
2272 }
2273
2274 // In Stapeln, damit die Zahl der gebundenen Parameter klein bleibt.
2275 for (let i = 0; i < frageIds.length; i += STAPELGROESSE) {
2276 const stapel = frageIds.slice(i, i + STAPELGROESSE);
2277 const platzhalter = stapel.map(() => '?').join(', ');
2278 this.db
2279 .prepare(`DELETE FROM antwort_log WHERE profil_id = ? AND frage_id IN (${platzhalter})`)
2280 .run(profilId, ...stapel);
2281
2282 /*
2283 Zwei Anweisungen statt einer, weil `gemerkt` in derselben Tabelle
2284 steht wie der Fortschritt. Was nicht gemerkt ist, fällt ganz weg;
2285 was gemerkt ist, bleibt stehen und wird auf null gesetzt.
2286
2287 `gemerkt` ist NOT NULL mit CHECK (0, 1) – die beiden Bedingungen
2288 decken also jede Zeile ab, keine bleibt ungenullt zurück.
2289
2290 Was danach steht, ist kein neuer Zustand: Genau diese Zeile legt
2291 `merken()` an, wenn jemand eine nie beantwortete Frage markiert.
2292 Jeder Verbraucher kennt sie deshalb längst.
2293
2294 Die SET-Liste nennt jede Spalte von `frage_stand` außer den
2295 Schlüsseln und dem absichtlich erhaltenen `gemerkt`. Kommt eine
2296 Spalte hinzu, gehört sie hierher – der Test „zurückgesetzt sieht
2297 aus wie nie beantwortet" vergleicht mit SELECT * und merkt es.
2298 */
2299 this.db
2300 .prepare(
2301 `DELETE FROM frage_stand
2302 WHERE profil_id = ? AND gemerkt = 0 AND frage_id IN (${platzhalter})`,
2303 )
2304 .run(profilId, ...stapel);
2305 this.db
2306 .prepare(
2307 `UPDATE frage_stand
2308 SET versuche = 0,
2309 richtige = 0,
2310 zuletzt_beantwortet = NULL,
2311 faellig_ab = NULL,
2312 letzte_bewertung = NULL,
2313 intervall_tage = 0,
2314 stabilitaet = NULL,
2315 schwierigkeit = NULL,
2316 bestaetigt = 0
2317 WHERE profil_id = ? AND gemerkt = 1 AND frage_id IN (${platzhalter})`,
2318 )
2319 .run(profilId, ...stapel);
2320 }
2321 });
2322 loeschen();
2323
2324 return this.uebersichtIntern(profilId);
2325 }
2326 }
2327
2328 // ─── Instanz für den Main-Prozess ───────────────────────────────────────────
2329
2330 let instanz: Lernstand | null = null;
2331
2332 /**
2333 * Der Konstruktor von better-sqlite3.
2334 *
2335 * Absichtlich `require` statt eines statischen Imports – wie in
2336 * `datenbank.ts`: better-sqlite3 ist ein natives CommonJS-Modul, das nicht
2337 * mitgebündelt wird, und ein Ladefehler soll beim ersten Zugriff auftreten
2338 * und nicht schon beim Start des Main-Prozesses.
2339 *
2340 * Herausgegeben, weil die Sicherung denselben Konstruktor braucht: Sie öffnet
2341 * fremde Dateien nur lesend. Zweimal geladen wäre es zweimal dasselbe native
2342 * Modul – und die Sicherung müsste die Begründung oben wiederholen.
2343 */
2344 export function datenbankKonstruktor(): DatenbankKonstruktor {
2345 // eslint-disable-next-line @typescript-eslint/no-require-imports
2346 return require('better-sqlite3') as DatenbankKonstruktor;
2347 }
2348
2349 function datenbankOeffnen(pfad: string): BetterSqlite3.Database {
2350 mkdirSync(dirname(pfad), { recursive: true });
2351 return new (datenbankKonstruktor())(pfad);
2352 }
2353
2354 /**
2355 * Öffnet den Lernstand einmalig und liefert danach dieselbe Instanz.
2356 * Der Pfad kommt vom Aufrufer, damit dieses Modul Electron nicht kennen muss.
2357 */
2358 export function lernstandInstanz(
2359 datenbankPfad: string,
2360 katalog: Katalog,
2361 /** Die Feingliederung; ohne sie bleibt `themengruppen` leer. */
2362 themen?: Themen,
2363 ): Lernstand {
2364 if (instanz !== null) {
2365 return instanz;
2366 }
2367
2368 /* Scheitert der Konstruktor – etwa bei einem Lernstand aus einer neueren
2369 Programmversion –, muss die eben geöffnete Verbindung wieder zu. Sonst
2370 bliebe bei jedem weiteren Versuch ein Handle liegen, und die Oberfläche
2371 versucht es nach jeder Sitzung erneut. */
2372 const datenbank = datenbankOeffnen(datenbankPfad);
2373 try {
2374 instanz = new Lernstand(datenbank, katalog, themen === undefined ? {} : { themen });
2375 } catch (fehler: unknown) {
2376 datenbank.close();
2377 throw fehler;
2378 }
2379
2380 return instanz;
2381 }
2382
2383 /**
2384 * Der bereits geöffnete Lernstand, oder `null`.
2385 *
2386 * Ausdrücklich ohne zu öffnen: Wer das Programm startet und gleich wieder
2387 * beendet, soll keine Datenbank anlegen lassen, nur damit beim Herunterfahren
2388 * jemand nachsieht, ob eine da ist.
2389 */
2390 export function lernstandOffen(): Lernstand | null {
2391 return instanz;
2392 }
2393
2394 /** Schließt den Lernstand – beim Beenden der Anwendung und in Tests. */
2395 export function lernstandSchliessen(): void {
2396 instanz?.schliessen();
2397 instanz = null;
2398 }
2399
2400 /**
2401 * Legt eine beschädigte Lernstandsdatei beiseite und gibt ihren neuen Namen
2402 * zurück.
2403 *
2404 * **Beiseitelegen, nicht löschen.** Aus einer beschädigten SQLite-Datei ist
2405 * oft noch etwas zu holen – mit `.recover` der Kommandozeile, notfalls von
2406 * fremder Hand. Gelöscht ist sie dagegen endgültig weg, und der Lernstand ist
2407 * das Einzige, was der Anwendung anvertraut wurde. Der Name trägt einen
2408 * Zeitstempel, damit ein zweiter Fall den ersten nicht überschreibt.
2409 *
2410 * Die Nebendateien `-wal` und `-shm` wandern mit: Bleiben sie liegen, findet
2411 * die frisch angelegte Datenbank ein Schreibprotokoll vor, das nicht zu ihr
2412 * gehört – derselbe Fehler, den `aufraeumen()` beim Einspielen schon einmal
2413 * gemacht hat (docs/stand.md 7.8).
2414 */
2415 export function lernstandBeiseitelegen(pfad: string, jetzt: Date = new Date()): string {
2416 const stempel = jetzt.toISOString().replace(/[:.]/gu, '-');
2417 const ziel = `${pfad}.beschaedigt-${stempel}`;
2418
2419 renameSync(pfad, ziel);
2420 for (const anhang of ['-wal', '-shm']) {
2421 if (existsSync(`${pfad}${anhang}`)) {
2422 renameSync(`${pfad}${anhang}`, `${ziel}${anhang}`);
2423 }
2424 }
2425
2426 return ziel;
2427 }
2428
2429 /**
2430 * Anfang und Ende des Kalendertages, in dem `jetzt` liegt – als ISO-Zeit.
2431 *
2432 * Ein Tag ist der Kalendertag des Nutzers, nicht der von UTC: Wer um ein Uhr
2433 * nachts lernt, lernt an dem Tag, der auf seiner Uhr steht. `new Date(Jahr,
2434 * Monat, Tag)` baut örtliche Mitternacht, `toISOString()` rechnet sie in die
2435 * Form um, in der die Zeitpunkte gespeichert sind.
2436 */
2437 function tagesgrenzen(jetzt: Date): { von: string; bis: string } {
2438 return {
2439 von: new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()).toISOString(),
2440 bis: new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate() + 1).toISOString(),
2441 };
2442 }
2443
2444 /**
2445 * Volle Kalendertage zwischen zwei Zeitpunkten.
2446 *
2447 * Über Kalendertage und nicht über Stunden: „gestern“ soll auch dann
2448 * „gestern“ heissen, wenn zwischen beiden Zeitpunkten dreissig Stunden
2449 * liegen. Wer gestern abend und heute früh lernt, hat nicht zwei Tage
2450 * Abstand.
2451 */
2452 function kalendertageSeit(frueher: Date, jetzt: Date): number {
2453 const a = new Date(frueher.getFullYear(), frueher.getMonth(), frueher.getDate()).getTime();
2454 const b = new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()).getTime();
2455 return Math.max(0, Math.round((b - a) / TAG_MS));
2456 }
2457
2458 /**
2459 * Kalendertage bis zu einem künftigen Termin – die Gegenrichtung.
2460 *
2461 * Getrennt von {@link kalendertageSeit}, weil beide Enden gedeckelt sind: Ein
2462 * Termin in der Vergangenheit ist „heute“ (0) und nicht „minus drei“.
2463 */
2464 function kalendertageBis(termin: Date, jetzt: Date): number {
2465 const a = new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()).getTime();
2466 const b = new Date(termin.getFullYear(), termin.getMonth(), termin.getDate()).getTime();
2467 return Math.max(0, Math.round((b - a) / TAG_MS));
2468 }