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