waffensachkunde

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

/ app src main schema.ts

21,8 KB Rohdatei
app/src/main/schema.ts — 494 Zeilen
1 import type BetterSqlite3 from 'better-sqlite3';
2
3 /**
4 * Datenbankschema des Lernstands.
5 *
6 * Bewusst als TypeScript-Konstante und nicht als `.sql`-Datei: der
7 * Main-Prozess wird von electron-vite gebündelt, eine Textdatei müsste
8 * zusätzlich als Ressource mitgepackt und zur Laufzeit gefunden werden.
9 * Eine Konstante ist Teil des Bundles und kann nicht fehlen.
10 *
11 * Alle Anweisungen sind idempotent (`IF NOT EXISTS`) – das Schema wird bei
12 * jedem Öffnen der Datenbank angewandt. Echte Migrationen (Spalten ändern,
13 * Daten umschreiben) laufen später über {@link MIGRATIONEN}.
14 */
15
16 /** Aktueller Stand des Schemas. Wird in `schema_version` festgehalten. */
17 export const SCHEMA_VERSION = 10;
18
19 /**
20 * Ein Tag in Millisekunden.
21 *
22 * Steht hier und nicht in `lernstand.ts`, weil die Migration ihn braucht und
23 * `schema.ts` nichts aus `lernstand.ts` importieren darf – die Abhängigkeit
24 * läuft genau andersherum.
25 */
26 export const TAG_MS = 86_400_000;
27
28 /**
29 * Schema-Version 2: gespeicherte Läufe der Prüfungssimulation.
30 *
31 * Steht als eigene Konstante, weil dieselbe Anweisung an zwei Stellen
32 * gebraucht wird – im Grundschema für neue Datenbanken und als
33 * Migrationsschritt für bestehende. Ausschließlich `CREATE … IF NOT EXISTS`:
34 * ein vorhandener Lernstand wird dabei nicht angefasst.
35 *
36 * Die Einzelantworten stehen weiterhin in `antwort_log`; hier liegt nur die
37 * Zusammenfassung eines Laufs, die der Verlauf anzeigt.
38 */
39 const PRUEFUNG_LAUF_SCHEMA = `
40 CREATE TABLE IF NOT EXISTS pruefung_lauf (
41 id INTEGER PRIMARY KEY AUTOINCREMENT,
42 profil_id INTEGER NOT NULL REFERENCES profil(id) ON DELETE CASCADE,
43 -- ID des Prüfungsprofils aus src/shared/pruefung.ts, z. B. 'dsb'.
44 -- Bewusst als Text und ohne Fremdschlüssel: die Profile stehen im Quelltext,
45 -- nicht in der Datenbank, und ein alter Lauf soll auch dann lesbar bleiben,
46 -- wenn ein Profil später umbenannt oder entfernt wird.
47 pruefungsprofil TEXT NOT NULL,
48 zeitpunkt TEXT NOT NULL,
49 gesamt INTEGER NOT NULL CHECK (gesamt >= 0),
50 richtig INTEGER NOT NULL CHECK (richtig >= 0),
51 quote REAL NOT NULL CHECK (quote >= 0 AND quote <= 1),
52 urteil TEXT NOT NULL CHECK (
53 urteil IN ('bestanden', 'nachpruefung', 'nicht_bestanden')
54 ),
55 dauer_ms INTEGER NOT NULL CHECK (dauer_ms >= 0)
56 );
57
58 CREATE INDEX IF NOT EXISTS idx_pruefung_lauf_profil_zeit
59 ON pruefung_lauf (profil_id, zeitpunkt);
60 `;
61
62 /**
63 * Schema-Version 5: der laufende, noch nicht abgegebene Prüfungsbogen.
64 *
65 * **Warum das in der Datenbank steht.** Ein Bogen läuft bis zu zwei Stunden.
66 * Er lag bisher ausschließlich im Arbeitsspeicher des Renderers; wer bei
67 * Minute 90 das Fenster schloss, verlor alles ohne Rückfrage. Das trifft
68 * gerade die Gruppe, die für denselben Bogen länger braucht, und widerspricht
69 * der eigenen Zusage zu WCAG 2.2.6.
70 *
71 * **Warum der Kern die Zeile schreibt und nicht der Renderer.** `bogen`,
72 * `auftrag` und `profil` stammen aus `Pruefung.starten` – der Renderer bekommt
73 * sie, schickt sie aber nie zurück. Andernfalls wäre die einzige Prüfung der
74 * eingereichten Antwortliste dahin: Wer den Bogen selbst mitbringt, kann ihn
75 * auch erfinden. Der Renderer sichert nur, was ihm gehört: Eingaben,
76 * Position, Phase und die verbrauchte Zeit.
77 *
78 * **Warum `lauf_id`.** Das Sichern ist entprellt. Ohne eine Kennung könnte
79 * ein verspäteter Nachzügler eine Zeile wiederauferstehen lassen, die die
80 * Auswertung gerade gelöscht hat – und dieselbe Prüfung ließe sich ein
81 * zweites Mal abgeben. Gesichert wird deshalb ausschließlich per `UPDATE`
82 * mit `lauf_id`; findet es keine Zeile, ist der Lauf vorbei und die Sicherung
83 * verfällt still.
84 *
85 * **Eine Zeile je Profil.** Mehr als ein offener Bogen gleichzeitig wäre
86 * keine Simulation, sondern eine Ablage.
87 *
88 * `profil` hält das *wirksame* Prüfungsprofil als JSON, also mit den
89 * tatsächlich gewählten Werten für Zeit, Bestehensgrenze und Fehlergrenze.
90 * Aus demselben Grund wie bei `pruefung_lauf.pruefungsprofil`: Die Profile
91 * stehen im Quelltext, nicht in der Datenbank, und ein fortgesetzter Lauf
92 * muss unter denselben Vorgaben zu Ende gehen, unter denen er begonnen hat.
93 */
94 const PRUEFUNG_OFFEN_SCHEMA = `
95 CREATE TABLE IF NOT EXISTS pruefung_offen (
96 profil_id INTEGER PRIMARY KEY REFERENCES profil(id) ON DELETE CASCADE,
97 -- Kennung dieses Laufs; schuetzt gegen verspaetete Sicherungen.
98 lauf_id TEXT NOT NULL,
99 -- JSON, vom Kern geschrieben: Auftrag, wirksames Profil, gezogener Bogen.
100 auftrag TEXT NOT NULL,
101 profil TEXT NOT NULL,
102 bogen TEXT NOT NULL,
103 -- JSON, vom Renderer gesichert: je Frage Auswahl, Freitext, Selbstbewertung.
104 eingaben TEXT NOT NULL DEFAULT '{}',
105 position INTEGER NOT NULL DEFAULT 0 CHECK (position >= 0),
106 phase TEXT NOT NULL DEFAULT 'bearbeiten' CHECK (
107 phase IN ('bearbeiten', 'nachbewertung')
108 ),
109 -- Ob die Zeit bereits abgelaufen war. Ohne diese Spalte meldete eine
110 -- wiederhergestellte Nachbewertung faelschlich einen Lauf ohne Zeitablauf.
111 zeit_abgelaufen INTEGER NOT NULL DEFAULT 0 CHECK (zeit_abgelaufen IN (0, 1)),
112 -- Verbrauchte Bearbeitungszeit. Waehrend das Programm zu ist, steht die
113 -- Uhr still; beim Fortsetzen wird der Zeitursprung um diesen Wert
114 -- zurueckdatiert, damit Restzeit und Bearbeitungsdauer aus derselben
115 -- Rechnung stammen.
116 verbraucht_ms INTEGER NOT NULL DEFAULT 0 CHECK (verbraucht_ms >= 0),
117 begonnen_am TEXT NOT NULL,
118 gesichert_am TEXT NOT NULL
119 );
120 `;
121
122 /**
123 * Schema-Version 9: der Katalogstand, unter dem dieser Lernstand geführt wird.
124 *
125 * `frage_stand` und `antwort_log` verweisen mit Frage-IDs wie „I.1-01“ auf den
126 * Katalog – bis Fassung 8 ohne Vermerk, gegen welchen BVA-Stand sie
127 * entstanden. Veröffentlicht das Bundesverwaltungsamt eine Fassung mit
128 * geänderter Nummerierung, zeigen Zeilen zu verschwundenen IDs still ins
129 * Leere. Dieser Vermerk macht das erkennbar: Beim Öffnen vergleicht der
130 * Lernstand den gespeicherten mit dem geladenen Stand und meldet eine
131 * Abweichung, statt zu schweigen (`katalogstandAbgleichen` in `lernstand.ts`).
132 *
133 * Wie `schema_version` eine Fortschreibung: Jeder übernommene Stand bekommt
134 * eine Zeile, maßgeblich ist die zuletzt eingetragene. Bewusst kein
135 * Schlüssel auf `stand`: Wer zu einer älteren Programmfassung zurückkehrt,
136 * kehrt zu einem schon dagewesenen Stand zurück – auch das ist ein Wechsel
137 * und braucht eine neue Zeile.
138 *
139 * Die Migration legt die Tabelle nur an, und zwar **leer**. Den Ausgangswert
140 * trägt der Lernstand beim Öffnen ein, denn die Migration kennt den geladenen
141 * Katalog nicht – und ehrlich wäre ein anderer Wert ohnehin nicht zu haben:
142 * Gegen welchen Stand die vorhandenen Zeilen wirklich entstanden, wurde nie
143 * festgehalten und lässt sich nicht rekonstruieren. Der beim ersten Öffnen
144 * geladene Stand ist die beste verfügbare Annahme; ab dann steht jeder
145 * Wechsel schwarz auf weiß.
146 */
147 const KATALOG_STAND_SCHEMA = `
148 CREATE TABLE IF NOT EXISTS katalog_stand (
149 id INTEGER PRIMARY KEY AUTOINCREMENT,
150 -- ISO-Datum aus meta.stand des Katalogs, z. B. '2024-12-16'.
151 stand TEXT NOT NULL,
152 vermerkt_am TEXT NOT NULL
153 );
154 `;
155
156 /**
157 * Ergänzt eine Spalte, falls sie noch fehlt.
158 *
159 * SQLite kennt kein `ADD COLUMN IF NOT EXISTS`. Der Migrationslauf allein
160 * würde genügen – er führt jeden Schritt genau einmal aus –, verlässt sich
161 * dabei aber darauf, dass `schema_version` und die tatsächliche Tabelle
162 * zusammenpassen. Tun sie das einmal nicht, scheitert der Schritt beim
163 * Öffnen, und die Anwendung startet überhaupt nicht mehr.
164 *
165 * Der Blick in `PRAGMA table_info` kostet nichts und macht daraus einen
166 * Schritt, der beliebig oft laufen darf.
167 */
168 function spalteErgaenzen(
169 db: BetterSqlite3.Database,
170 tabelle: string,
171 spalte: string,
172 typ: string,
173 ): void {
174 /* Tabellen- und Spaltennamen lassen sich nicht als Parameter binden. Beide
175 stammen hier ausschließlich aus Konstanten dieser Datei, nie aus einer
176 Eingabe – die Prüfung hält das fest, damit es so bleibt. */
177 if (!/^[a-z_][a-z0-9_]*$/u.test(tabelle) || !/^[a-z_][a-z0-9_]*$/u.test(spalte)) {
178 throw new Error(`Ungültiger Bezeichner in der Migration: ${tabelle}.${spalte}`);
179 }
180
181 const vorhanden = db
182 .prepare<[], { name: string }>(`PRAGMA table_info(${tabelle})`)
183 .all()
184 .some((zeile) => zeile.name === spalte);
185
186 if (!vorhanden) {
187 db.exec(`ALTER TABLE ${tabelle} ADD COLUMN ${spalte} ${typ}`);
188 }
189 }
190
191 /**
192 * Schema-Version 3: Gedächtnisstand nach FSRS.
193 *
194 * `frage_stand` bekommt zwei Spalten, die das Gedächtnismodell aus
195 * `shared/fsrs.ts` fortschreibt: Stabilität in Tagen und Schwierigkeit
196 * zwischen 1 und 10. Beide sind `NULL`, solange die Frage nie beantwortet
197 * wurde – das ist die Unterscheidung zwischen „noch nie gesehen“ und
198 * „gesehen, aber vergessen“.
199 *
200 * Als Migration und nicht im Grundschema: Eine frisch angelegte Datenbank
201 * startet ebenfalls bei Version 0 und durchläuft alle Schritte. Stünden die
202 * Spalten zusätzlich im Grundschema, liefe dieser Schritt in eine bereits
203 * vorhandene Spalte.
204 *
205 * Bestehende Lernstände verlieren nichts. Ihre Fragen starten ohne
206 * Gedächtnisstand und bekommen ihn bei der nächsten Antwort; bis dahin bleibt
207 * `faellig_ab` unverändert gültig.
208 */
209 function fsrsSpalten(db: BetterSqlite3.Database): void {
210 spalteErgaenzen(db, 'frage_stand', 'stabilitaet', 'REAL');
211 spalteErgaenzen(db, 'frage_stand', 'schwierigkeit', 'REAL');
212 }
213
214 /**
215 * Schema-Version 4: die gewählte Zeitstufe eines Simulationslaufs.
216 *
217 * Ohne sie stehen ein Lauf ohne Uhr und ein Lauf unter Zeitdruck in der
218 * Verlaufstabelle mit identischen Spalten nebeneinander – ausdrücklich zum
219 * Vergleich eingeladen, obwohl sie nicht vergleichbar sind.
220 *
221 * Bestehende Läufe bekommen `NULL`: Ihre Zeitstufe ist nicht mehr zu
222 * ermitteln, und sie zu raten wäre schlechter, als sie offen zu lassen.
223 */
224 function zeitmodusSpalte(db: BetterSqlite3.Database): void {
225 spalteErgaenzen(db, 'pruefung_lauf', 'zeitmodus', 'TEXT');
226 }
227
228 /**
229 * Schema-Version 6: Welche Kapitel ein Profil dauerhaft abwählt.
230 *
231 * Kapitel IV („Not- und Seenotsignalmittel“) prüft nicht jede Prüfungsstelle.
232 * Wer es nie braucht, schleppte bisher 89 der 575 Fragen durch jede Zahl der
233 * Anwendung – Fortschritt, Tagespensum, Prognose, Prüfungsreife waren für ihn
234 * dauerhaft falsch. Abwählen ging nur je Simulationslauf und wirkte nur auf
235 * den gezogenen Bogen.
236 *
237 * **Warum die Spalte hier steht und nicht im Grundschema.** Weil sie sonst
238 * auf bestehenden Lernständen niemals entstünde: `CREATE TABLE IF NOT EXISTS`
239 * ist dort ein reiner Leerlauf, und das erste `SELECT` auf die Spalte
240 * scheiterte mit „no such column“. Kein Test im Projekt bemerkte das – alle
241 * legen frische Datenbanken an. `pruefungstermin` steht seit dem ersten
242 * Commit im `CREATE TABLE` und ist deshalb **kein** Vorbild; das Vorbild ist
243 * `zeitmodusSpalte` eine Ebene darüber.
244 *
245 * **Warum `TEXT NOT NULL DEFAULT '[]'`.** Der Vorgabewert füllt bestehende
246 * Zeilen beim `ADD COLUMN` auf und bedient zugleich `profilEinfuegen`, das
247 * seine Spalten einzeln nennt und die neue nicht kennt. `NOT NULL` ohne
248 * Vorgabe verbietet SQLite bei `ADD COLUMN` ohnehin.
249 *
250 * **Warum eine Liste und kein Wahrheitswert.** Heute ist nur Kapitel IV
251 * abwählbar. Ein `boolean` müsste beim nächsten Kapitel wieder migriert
252 * werden; eine Liste von Kapitel-IDs trägt den Fall ohne Schemaschritt mit.
253 */
254 function kapitelAusschlussSpalte(db: BetterSqlite3.Database): void {
255 spalteErgaenzen(db, 'profil', 'kapitel_ausschluss', "TEXT NOT NULL DEFAULT '[]'");
256 }
257
258 /** Ein Migrationsschritt: SQL oder eine Funktion auf der Datenbank. */
259 export type Migration = string | ((db: BetterSqlite3.Database) => void);
260
261 export const LERNSTAND_SCHEMA = `
262 -- Fortschreibung des Schemas. Jede angewandte Version bekommt eine Zeile,
263 -- sodass sich später nachvollziehen lässt, wann was migriert wurde.
264 CREATE TABLE IF NOT EXISTS schema_version (
265 version INTEGER NOT NULL PRIMARY KEY,
266 angewendet_am TEXT NOT NULL
267 );
268
269 -- Lernprofile. Mehrere Personen teilen sich ein Gerät, ohne dass die
270 -- Lernstände sich vermischen.
271 CREATE TABLE IF NOT EXISTS profil (
272 id INTEGER PRIMARY KEY AUTOINCREMENT,
273 name TEXT NOT NULL UNIQUE,
274 pruefungstermin TEXT,
275 erstellt_am TEXT NOT NULL
276 );
277
278 -- Aktueller Stand je Frage und Profil. Diese Tabelle ist eine
279 -- Zusammenfassung; die Wahrheit steht in antwort_log.
280 CREATE TABLE IF NOT EXISTS frage_stand (
281 profil_id INTEGER NOT NULL REFERENCES profil(id) ON DELETE CASCADE,
282 frage_id TEXT NOT NULL,
283 versuche INTEGER NOT NULL DEFAULT 0,
284 richtige INTEGER NOT NULL DEFAULT 0,
285 zuletzt_beantwortet TEXT,
286 faellig_ab TEXT,
287 gemerkt INTEGER NOT NULL DEFAULT 0 CHECK (gemerkt IN (0, 1)),
288 letzte_bewertung TEXT CHECK (
289 letzte_bewertung IS NULL
290 OR letzte_bewertung IN ('nochmal', 'schwer', 'gut', 'leicht')
291 ),
292 -- Zuletzt vergebenes Wiedervorlage-Intervall in Tagen. Ergebnis der
293 -- FSRS-Rechnung, nur zur Anzeige; maßgeblich ist faellig_ab.
294 intervall_tage REAL NOT NULL DEFAULT 0,
295 PRIMARY KEY (profil_id, frage_id)
296 );
297
298 -- Vollständige Historie. Wird niemals überschrieben und ist die Grundlage
299 -- für Statistik und für ein späteres Nachtrainieren der FSRS-Parameter.
300 CREATE TABLE IF NOT EXISTS antwort_log (
301 id INTEGER PRIMARY KEY AUTOINCREMENT,
302 profil_id INTEGER NOT NULL REFERENCES profil(id) ON DELETE CASCADE,
303 frage_id TEXT NOT NULL,
304 zeitpunkt TEXT NOT NULL,
305 richtig INTEGER NOT NULL CHECK (richtig IN (0, 1)),
306 bewertung TEXT NOT NULL CHECK (bewertung IN ('nochmal', 'schwer', 'gut', 'leicht')),
307 dauer_ms INTEGER NOT NULL CHECK (dauer_ms >= 0),
308 -- JSON-Array der gewählten Antwortlabels, z. B. ["a","c"].
309 auswahl TEXT NOT NULL DEFAULT '[]',
310 freitext TEXT
311 );
312
313 CREATE INDEX IF NOT EXISTS idx_antwort_log_profil_zeit
314 ON antwort_log (profil_id, zeitpunkt);
315 CREATE INDEX IF NOT EXISTS idx_antwort_log_profil_frage
316 ON antwort_log (profil_id, frage_id, id);
317 CREATE INDEX IF NOT EXISTS idx_frage_stand_faellig
318 ON frage_stand (profil_id, faellig_ab);
319 CREATE INDEX IF NOT EXISTS idx_frage_stand_gemerkt
320 ON frage_stand (profil_id, gemerkt);
321 ${PRUEFUNG_LAUF_SCHEMA}${PRUEFUNG_OFFEN_SCHEMA}${KATALOG_STAND_SCHEMA}`;
322
323 /**
324 * Migrationsschritte oberhalb von Version 1.
325 *
326 * Der Schlüssel ist die Zielversion, der Wert entweder auszuführendes SQL
327 * oder eine Funktion – Letzteres für Schritte, die vorher etwas nachsehen
328 * müssen. Beim Öffnen werden alle Schritte oberhalb der gespeicherten Version
329 * der Reihe nach in einer Transaktion angewandt.
330 *
331 * Jeder Schritt muss so geschrieben sein, dass er auch dann durchläuft, wenn
332 * sein Ergebnis bereits vorliegt. Sonst wäre eine einmal aus dem Tritt
333 * geratene Datenbank dauerhaft nicht mehr zu öffnen.
334 *
335 * Version 2 legt `pruefung_lauf` an, Version 3 ergänzt `frage_stand` um den
336 * Gedächtnisstand, Version 4 die Zeitstufe eines Laufs, Version 5 den
337 * unterbrochenen Bogen, Version 6 die dauerhaft abgewählten Kapitel eines
338 * Profils, Version 7 den belegten Abruf je Frage, Version 8 die Zeilen,
339 * die nur in die Historie gehören, Version 9 den Vermerk des Katalogstands,
340 * Version 10 die Kennzahlen, ohne die sich zwei Läufe nicht ehrlich
341 * vergleichen lassen. Bestehende Lernstände behalten dabei alle Daten: es
342 * kommen nur Tabellen und Spalten hinzu, nichts wird geändert oder gelöscht.
343 *
344 * **Tabellen dürfen doppelt stehen, Spalten nicht.** Version 2, 5 und 9 legen
345 * Tabellen an und erscheinen deshalb auch im Grundschema – `CREATE TABLE IF
346 * NOT EXISTS` tut auf einer Bestandsdatenbank noch etwas. Für eine Spalte
347 * gilt das nicht: Sie gehört ausschließlich hierher.
348 */
349 /**
350 * Schema-Version 7: der belegte Abruf je Frage.
351 *
352 * Siehe `shared/reife.ts` zur Regel selbst. Hier zählt nur, dass sie sich für
353 * bestehende Lernstände **exakt nachbilden** lässt: `antwort_log` ist die
354 * vollständige Historie und wird nie überschrieben – der Kommentar über
355 * `frage_stand` sagt es selbst, dort stehe nur die Zusammenfassung, „die
356 * Wahrheit steht in antwort_log“.
357 *
358 * Nachgespielt wird je Frage in Antwortreihenfolge, mit derselben Regel, die
359 * künftig beim Antworten gilt: Eine richtige Antwort mit mindestens einem Tag
360 * Abstand setzt den Beleg, eine falsche nimmt ihn weg, eine richtige am
361 * selben Tag lässt ihn stehen. Kein Schätzen, kein Vorgabewert – wer seinen
362 * Stand ehrlich erarbeitet hat, behält ihn auf die Frage genau.
363 *
364 * Die Schleife läuft über alle Zeilen aller Profile. Bei 575 Fragen und
365 * einigen tausend Antworten ist das eine Sache von Millisekunden, und sie
366 * geschieht genau einmal.
367 */
368 function belegSpalte(db: BetterSqlite3.Database): void {
369 spalteErgaenzen(db, 'frage_stand', 'bestaetigt', 'INTEGER NOT NULL DEFAULT 0');
370
371 const antworten = db
372 .prepare<[], { profil_id: number; frage_id: string; zeitpunkt: string; richtig: number }>(
373 `SELECT profil_id, frage_id, zeitpunkt, richtig
374 FROM antwort_log
375 ORDER BY profil_id, frage_id, id`,
376 )
377 .all();
378
379 /* Verschachtelt statt zusammengesetzter Schluessel: Ein Trennzeichen
380 muesste garantiert in keiner Frage-ID vorkommen, und die naheliegende
381 Wahl - das Nullzeichen - hat in diesem Projekt schon einmal eine
382 Quelldatei fuer git zur Binaerdatei gemacht. */
383 const beleg = new Map<number, Map<string, boolean>>();
384 let laufendesProfil = -1;
385 let laufendeFrage = '';
386 let vorigerZeitpunkt = Number.NaN;
387
388 for (const zeile of antworten) {
389 if (zeile.profil_id !== laufendesProfil || zeile.frage_id !== laufendeFrage) {
390 laufendesProfil = zeile.profil_id;
391 laufendeFrage = zeile.frage_id;
392 vorigerZeitpunkt = Number.NaN;
393 }
394
395 const jetzt = Date.parse(zeile.zeitpunkt);
396 const abstandTage = Number.isNaN(vorigerZeitpunkt) ? 0 : (jetzt - vorigerZeitpunkt) / TAG_MS;
397
398 let jeProfil = beleg.get(zeile.profil_id);
399 if (jeProfil === undefined) {
400 jeProfil = new Map<string, boolean>();
401 beleg.set(zeile.profil_id, jeProfil);
402 }
403
404 if (zeile.richtig === 0) {
405 jeProfil.set(zeile.frage_id, false);
406 } else if (abstandTage >= 1) {
407 jeProfil.set(zeile.frage_id, true);
408 }
409
410 if (!Number.isNaN(jetzt)) {
411 vorigerZeitpunkt = jetzt;
412 }
413 }
414
415 const setzen = db.prepare<[number, string]>(
416 'UPDATE frage_stand SET bestaetigt = 1 WHERE profil_id = ? AND frage_id = ?',
417 );
418 for (const [profilId, jeProfil] of beleg) {
419 for (const [frageId, belegt] of jeProfil) {
420 if (belegt) {
421 setzen.run(profilId, frageId);
422 }
423 }
424 }
425 }
426
427 /**
428 * Schema-Version 8: Zeilen, die nur in die Historie gehören.
429 *
430 * Läuft in der Prüfungssimulation die Zeit ab, kommen Fragen im Bogen vor, die
431 * nie aufgeschlagen wurden. Sie gehören in die Historie – der Bogen enthielt
432 * sie ja –, dürfen den Lernstand aber nicht zurückstufen. Deshalb schreibt
433 * `protokollieren()` sie in `antwort_log`, ohne `frage_stand` anzufassen.
434 *
435 * Die Tagesbilanz zählte sie trotzdem mit, und zwar als falsch beantwortet:
436 * „Heute beantwortet: 16 richtig, 40 falsch“ nach einem abgelaufenen Bogen,
437 * von dem jemand 16 Fragen gesehen hatte. Unterscheiden liess sich das an
438 * nichts – eine falsch beantwortete offene Frage sieht in jeder Spalte
439 * genauso aus.
440 *
441 * Bestehende Zeilen bekommen 0 und zählen weiter mit. Rückwirkend liesse es
442 * sich nicht ermitteln, und zu raten wäre schlechter, als es stehen zu lassen
443 * und zu sagen.
444 */
445 function nurHistorieSpalte(db: BetterSqlite3.Database): void {
446 spalteErgaenzen(db, 'antwort_log', 'nur_historie', 'INTEGER NOT NULL DEFAULT 0');
447 }
448
449 /**
450 * Schema-Version 10: was ein Simulationslauf über sein Ergebnis hinaus sagt.
451 *
452 * Ein gespeicherter Lauf trug bis Fassung 9 nur Zahl, Treffer, Quote und
453 * Urteil. Das genügt für eine Liste, aber nicht für einen **ehrlichen**
454 * Vergleich zweier Läufe:
455 *
456 * - `quote` zählt unbeantwortete Fragen wie falsch beantwortete. Ein Lauf, in
457 * dem die Zeit ablief, sieht darin wie ein Wissenseinbruch aus. Ohne
458 * `unbeantwortet` und `zeit_abgelaufen` kann die Anwendung das nicht sagen –
459 * und ein Vergleichssatz, der es verschweigt, ist schlimmer als keiner.
460 * - Die Themenanalyse gab es nur je Einzellauf und nur im Arbeitsspeicher.
461 * Dass ein Bereich in drei von vier Simulationen unter der Grenze lag, war
462 * nirgends zu sehen. `bereiche` hält sie als JSON fest, so wie sie in der
463 * Auswertung stand.
464 * - `bestehens_quote` ist der Maßstab, an dem ein Bereich gemessen wird. Er
465 * muss mitgespeichert werden, weil er sich später nicht mehr ermitteln
466 * lässt: Beim frei eingestellten Profil sind die gewählten Werte nach dem
467 * Lauf fort, und ein Fehlerpunkte-Profil hat gar keine Quote, sondern eine
468 * umgerechnete (siehe `grenzquote` in `shared/pruefung.ts`).
469 *
470 * **Alle vier Spalten sind bewusst `NULL`-fähig.** Bestehende Läufe bekommen
471 * keinen Ersatzwert. `unbeantwortet = 0` hieße „es blieb nichts offen“ – eine
472 * Behauptung über Läufe, von denen niemand weiß, wie sie endeten. `NULL` heißt
473 * „nicht festgehalten“, und die Oberfläche sagt genau das. Aus `antwort_log`
474 * ließe es sich auch nicht zurückrechnen: Dort steht nicht, welcher Lauf
475 * welche Zeile geschrieben hat.
476 */
477 function laufKennzahlenSpalten(db: BetterSqlite3.Database): void {
478 spalteErgaenzen(db, 'pruefung_lauf', 'unbeantwortet', 'INTEGER');
479 spalteErgaenzen(db, 'pruefung_lauf', 'zeit_abgelaufen', 'INTEGER');
480 spalteErgaenzen(db, 'pruefung_lauf', 'bereiche', 'TEXT');
481 spalteErgaenzen(db, 'pruefung_lauf', 'bestehens_quote', 'REAL');
482 }
483
484 export const MIGRATIONEN: Readonly<Record<number, Migration>> = Object.freeze({
485 2: PRUEFUNG_LAUF_SCHEMA,
486 3: fsrsSpalten,
487 4: zeitmodusSpalte,
488 5: PRUEFUNG_OFFEN_SCHEMA,
489 6: kapitelAusschlussSpalte,
490 7: belegSpalte,
491 8: nurHistorieSpalte,
492 9: KATALOG_STAND_SCHEMA,
493 10: laufKennzahlenSpalten,
494 });