waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | * **Was der Beleg allein noch nicht bewirkt.** Auf einer Datenbank, die vor |
| 365 | * Schemafassung 3 stehengeblieben ist, hat `frage_stand.stabilitaet` keinen |
| 366 | * Wert – Migration 3 setzt bewusst keinen, aus demselben Grund wie hier: Es |
| 367 | * gäbe nichts zu setzen außer einer Schätzung. `abrufFuer` |
| 368 | * (`shared/reife.ts`) liefert ohne Stabilität ausnahmslos 0, auch mit |
| 369 | * gesetztem Beleg. Die Ampel zeigt für solche Fragen deshalb weiterhin |
| 370 | * nichts, bis sie einmal wieder beantwortet werden; der nachgerechnete |
| 371 | * Verlauf, der den Gedächtnisstand aus `antwort_log` neu bildet, zeigt sie |
| 372 | * schon. Zwei Zahlen, die auseinandergehen – gemessen 0 gegen 109 – und die |
| 373 | * einzige Stelle im Programm, an der das so ist. **Betroffen ist niemand:** |
| 374 | * Die erste veröffentlichte Fassung 0.7.0 stand bereits auf Schemafassung 4; |
| 375 | * eine Datei auf Fassung 1 oder 2 gibt es nur auf einem Entwicklungsrechner. |
| 376 | * Aufgeschrieben steht es hier, weil das Projekt den Migrationsweg pflegt und |
| 377 | * prüft – und weil die Zusage darüber sonst mehr verspricht, als sie hält. |
| 378 | * |
| 379 | * Die Schleife läuft über alle Zeilen aller Profile. Bei 575 Fragen und |
| 380 | * einigen tausend Antworten ist das eine Sache von Millisekunden, und sie |
| 381 | * geschieht genau einmal. |
| 382 | */ |
| 383 | function belegSpalte(db: BetterSqlite3.Database): void { |
| 384 | spalteErgaenzen(db, 'frage_stand', 'bestaetigt', 'INTEGER NOT NULL DEFAULT 0'); |
| 385 | |
| 386 | const antworten = db |
| 387 | .prepare<[], { profil_id: number; frage_id: string; zeitpunkt: string; richtig: number }>( |
| 388 | `SELECT profil_id, frage_id, zeitpunkt, richtig |
| 389 | FROM antwort_log |
| 390 | ORDER BY profil_id, frage_id, id`, |
| 391 | ) |
| 392 | .all(); |
| 393 | |
| 394 | /* Verschachtelt statt zusammengesetzter Schluessel: Ein Trennzeichen |
| 395 | muesste garantiert in keiner Frage-ID vorkommen, und die naheliegende |
| 396 | Wahl - das Nullzeichen - hat in diesem Projekt schon einmal eine |
| 397 | Quelldatei fuer git zur Binaerdatei gemacht. */ |
| 398 | const beleg = new Map<number, Map<string, boolean>>(); |
| 399 | let laufendesProfil = -1; |
| 400 | let laufendeFrage = ''; |
| 401 | let vorigerZeitpunkt = Number.NaN; |
| 402 | |
| 403 | for (const zeile of antworten) { |
| 404 | if (zeile.profil_id !== laufendesProfil || zeile.frage_id !== laufendeFrage) { |
| 405 | laufendesProfil = zeile.profil_id; |
| 406 | laufendeFrage = zeile.frage_id; |
| 407 | vorigerZeitpunkt = Number.NaN; |
| 408 | } |
| 409 | |
| 410 | const jetzt = Date.parse(zeile.zeitpunkt); |
| 411 | const abstandTage = Number.isNaN(vorigerZeitpunkt) ? 0 : (jetzt - vorigerZeitpunkt) / TAG_MS; |
| 412 | |
| 413 | let jeProfil = beleg.get(zeile.profil_id); |
| 414 | if (jeProfil === undefined) { |
| 415 | jeProfil = new Map<string, boolean>(); |
| 416 | beleg.set(zeile.profil_id, jeProfil); |
| 417 | } |
| 418 | |
| 419 | if (zeile.richtig === 0) { |
| 420 | jeProfil.set(zeile.frage_id, false); |
| 421 | } else if (abstandTage >= 1) { |
| 422 | jeProfil.set(zeile.frage_id, true); |
| 423 | } |
| 424 | |
| 425 | if (!Number.isNaN(jetzt)) { |
| 426 | vorigerZeitpunkt = jetzt; |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | const setzen = db.prepare<[number, string]>( |
| 431 | 'UPDATE frage_stand SET bestaetigt = 1 WHERE profil_id = ? AND frage_id = ?', |
| 432 | ); |
| 433 | for (const [profilId, jeProfil] of beleg) { |
| 434 | for (const [frageId, belegt] of jeProfil) { |
| 435 | if (belegt) { |
| 436 | setzen.run(profilId, frageId); |
| 437 | } |
| 438 | } |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | /** |
| 443 | * Schema-Version 8: Zeilen, die nur in die Historie gehören. |
| 444 | * |
| 445 | * Läuft in der Prüfungssimulation die Zeit ab, kommen Fragen im Bogen vor, die |
| 446 | * nie aufgeschlagen wurden. Sie gehören in die Historie – der Bogen enthielt |
| 447 | * sie ja –, dürfen den Lernstand aber nicht zurückstufen. Deshalb schreibt |
| 448 | * `protokollieren()` sie in `antwort_log`, ohne `frage_stand` anzufassen. |
| 449 | * |
| 450 | * Die Tagesbilanz zählte sie trotzdem mit, und zwar als falsch beantwortet: |
| 451 | * „Heute beantwortet: 16 richtig, 40 falsch“ nach einem abgelaufenen Bogen, |
| 452 | * von dem jemand 16 Fragen gesehen hatte. Unterscheiden liess sich das an |
| 453 | * nichts – eine falsch beantwortete offene Frage sieht in jeder Spalte |
| 454 | * genauso aus. |
| 455 | * |
| 456 | * Bestehende Zeilen bekommen 0 und zählen weiter mit. Rückwirkend liesse es |
| 457 | * sich nicht ermitteln, und zu raten wäre schlechter, als es stehen zu lassen |
| 458 | * und zu sagen. |
| 459 | */ |
| 460 | function nurHistorieSpalte(db: BetterSqlite3.Database): void { |
| 461 | spalteErgaenzen(db, 'antwort_log', 'nur_historie', 'INTEGER NOT NULL DEFAULT 0'); |
| 462 | } |
| 463 | |
| 464 | /** |
| 465 | * Schema-Version 10: was ein Simulationslauf über sein Ergebnis hinaus sagt. |
| 466 | * |
| 467 | * Ein gespeicherter Lauf trug bis Fassung 9 nur Zahl, Treffer, Quote und |
| 468 | * Urteil. Das genügt für eine Liste, aber nicht für einen **ehrlichen** |
| 469 | * Vergleich zweier Läufe: |
| 470 | * |
| 471 | * - `quote` zählt unbeantwortete Fragen wie falsch beantwortete. Ein Lauf, in |
| 472 | * dem die Zeit ablief, sieht darin wie ein Wissenseinbruch aus. Ohne |
| 473 | * `unbeantwortet` und `zeit_abgelaufen` kann die Anwendung das nicht sagen – |
| 474 | * und ein Vergleichssatz, der es verschweigt, ist schlimmer als keiner. |
| 475 | * - Die Themenanalyse gab es nur je Einzellauf und nur im Arbeitsspeicher. |
| 476 | * Dass ein Bereich in drei von vier Simulationen unter der Grenze lag, war |
| 477 | * nirgends zu sehen. `bereiche` hält sie als JSON fest, so wie sie in der |
| 478 | * Auswertung stand. |
| 479 | * - `bestehens_quote` ist der Maßstab, an dem ein Bereich gemessen wird. Er |
| 480 | * muss mitgespeichert werden, weil er sich später nicht mehr ermitteln |
| 481 | * lässt: Beim frei eingestellten Profil sind die gewählten Werte nach dem |
| 482 | * Lauf fort, und ein Fehlerpunkte-Profil hat gar keine Quote, sondern eine |
| 483 | * umgerechnete (siehe `grenzquote` in `shared/pruefung.ts`). |
| 484 | * |
| 485 | * **Alle vier Spalten sind bewusst `NULL`-fähig.** Bestehende Läufe bekommen |
| 486 | * keinen Ersatzwert. `unbeantwortet = 0` hieße „es blieb nichts offen“ – eine |
| 487 | * Behauptung über Läufe, von denen niemand weiß, wie sie endeten. `NULL` heißt |
| 488 | * „nicht festgehalten“, und die Oberfläche sagt genau das. Aus `antwort_log` |
| 489 | * ließe es sich auch nicht zurückrechnen: Dort steht nicht, welcher Lauf |
| 490 | * welche Zeile geschrieben hat. |
| 491 | */ |
| 492 | function laufKennzahlenSpalten(db: BetterSqlite3.Database): void { |
| 493 | spalteErgaenzen(db, 'pruefung_lauf', 'unbeantwortet', 'INTEGER'); |
| 494 | spalteErgaenzen(db, 'pruefung_lauf', 'zeit_abgelaufen', 'INTEGER'); |
| 495 | spalteErgaenzen(db, 'pruefung_lauf', 'bereiche', 'TEXT'); |
| 496 | spalteErgaenzen(db, 'pruefung_lauf', 'bestehens_quote', 'REAL'); |
| 497 | } |
| 498 | |
| 499 | export const MIGRATIONEN: Readonly<Record<number, Migration>> = Object.freeze({ |
| 500 | 2: PRUEFUNG_LAUF_SCHEMA, |
| 501 | 3: fsrsSpalten, |
| 502 | 4: zeitmodusSpalte, |
| 503 | 5: PRUEFUNG_OFFEN_SCHEMA, |
| 504 | 6: kapitelAusschlussSpalte, |
| 505 | 7: belegSpalte, |
| 506 | 8: nurHistorieSpalte, |
| 507 | 9: KATALOG_STAND_SCHEMA, |
| 508 | 10: laufKennzahlenSpalten, |
| 509 | }); |