waffensachkunde

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

/ app src main lernstand.ts

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