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