waffensachkunde

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

/ app src main lernstand.ts

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