waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared reifeverlauf.ts
| 1 | /** |
| 2 | * Der Reifegrad rückwärts durch die Zeit – aus dem Antwortprotokoll. |
| 3 | * |
| 4 | * ## Was diese Datei beantwortet |
| 5 | * |
| 6 | * „Komme ich voran?“ Die Ampel sagt, wo jemand **heute** steht. Ob die Zahl |
| 7 | * seit zwei Wochen steigt, steht oder fällt, stand nirgends – und genau das |
| 8 | * ist die Frage, die jemand stellt, der jeden Tag lernt und nicht weiß, ob es |
| 9 | * etwas nützt. |
| 10 | * |
| 11 | * ## Warum nachgerechnet und nicht mitgeschrieben |
| 12 | * |
| 13 | * Der naheliegende Weg wäre eine Tabelle, in die täglich der Reifegrad |
| 14 | * geschrieben wird. Sie hätte einen Fehler, der sie fast wertlos machte: |
| 15 | * **Sie fängt heute an.** Wer seit sechs Wochen lernt, sähe einen leeren |
| 16 | * Verlauf und müsste sechs Wochen warten, bis die Anzeige etwas sagt. |
| 17 | * |
| 18 | * `antwort_log` enthält jede Antwort mit Zeitpunkt, Richtigkeit und |
| 19 | * Bewertung – also alles, was der Gedächtnisstand braucht. Der Verlauf wird |
| 20 | * deshalb **nachgerechnet**: einmal durch das Protokoll, und an jedem |
| 21 | * Stichtag steht der Reifegrad, der an jenem Tag gegolten hätte. |
| 22 | * |
| 23 | * ## Die Zusicherung, an der alles hängt |
| 24 | * |
| 25 | * Ein nachgerechneter Verlauf, dessen letzter Punkt nicht die Zahl der Ampel |
| 26 | * ist, wäre schlimmer als keiner: zwei Zahlen auf einem Bildschirm, die |
| 27 | * einander widersprechen. Deshalb rechnet diese Datei mit **denselben** |
| 28 | * Bausteinen wie `main/lernstand.ts` – `naechsterStand`, `giltAlsRichtig`, |
| 29 | * `MINDESTABSTAND_TAGE`, `reifegradVon` – und `tests/reifeverlauf.test.ts` |
| 30 | * prüft die Gleichheit gegen den laufenden Lernstand. |
| 31 | * |
| 32 | * ## Was der Verlauf nicht kann |
| 33 | * |
| 34 | * Er kennt den **heutigen** Lernumfang. Wer gestern ein Kapitel abgewählt |
| 35 | * hat, sieht auch die Vergangenheit ohne dieses Kapitel gerechnet. Das ist |
| 36 | * die ehrlichere von zwei Unvollkommenheiten: Die Alternative wäre ein |
| 37 | * Verlauf, der an dem Tag springt, an dem jemand eine Einstellung ändert, |
| 38 | * ohne dass er etwas gelernt oder vergessen hätte. |
| 39 | */ |
| 40 | |
| 41 | import { FSRS_GRAD, naechsterStand, type Gedaechtnisstand } from './fsrs'; |
| 42 | import { giltAlsRichtig, type Bewertung } from './lernstand'; |
| 43 | import { MINDESTABSTAND_TAGE, reifegradVon, type ReifeFrage } from './reife'; |
| 44 | |
| 45 | /** Millisekunden eines Tages. */ |
| 46 | const TAG_MS = 86_400_000; |
| 47 | |
| 48 | /** Eine Zeile des Antwortprotokolls, so weit der Verlauf sie braucht. */ |
| 49 | export interface Verlaufsantwort { |
| 50 | readonly frageId: string; |
| 51 | /** ISO-Zeitpunkt; die Liste wird aufsteigend sortiert übergeben. */ |
| 52 | readonly zeitpunkt: string; |
| 53 | readonly richtig: boolean; |
| 54 | readonly bewertung: Bewertung; |
| 55 | } |
| 56 | |
| 57 | /** Ein Stichtag des Verlaufs. */ |
| 58 | export interface Verlaufspunkt { |
| 59 | /** Kalendertag als ISO-Datum in Ortszeit. */ |
| 60 | readonly tag: string; |
| 61 | /** Reifegrad an jenem Tag, 0 bis 1. */ |
| 62 | readonly reifegrad: number; |
| 63 | /** Auf ganze Fragen gerundet, wie in der Ampel. */ |
| 64 | readonly belegt: number; |
| 65 | /** Verschiedene Fragen des Lernumfangs, die bis dahin drankamen. */ |
| 66 | readonly beantwortet: number; |
| 67 | } |
| 68 | |
| 69 | /** Der Stand einer Frage während des Wiederaufbaus. */ |
| 70 | interface Zwischenstand { |
| 71 | gedaechtnis: Gedaechtnisstand | null; |
| 72 | zuletzt: number | null; |
| 73 | bestaetigt: boolean; |
| 74 | } |
| 75 | |
| 76 | /** Kalendertag in Ortszeit, als ISO-Datum. */ |
| 77 | function tagesschluessel(zeitpunkt: Date): string { |
| 78 | const jahr = String(zeitpunkt.getFullYear()).padStart(4, '0'); |
| 79 | const monat = String(zeitpunkt.getMonth() + 1).padStart(2, '0'); |
| 80 | const tag = String(zeitpunkt.getDate()).padStart(2, '0'); |
| 81 | return jahr + '-' + monat + '-' + tag; |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Der Abstand in Tagen zwischen zwei Zeitpunkten, als Bruchzahl. |
| 86 | * |
| 87 | * Dieselbe Rechnung wie `tageZwischen` in `main/lernstand.ts`; sie steht hier |
| 88 | * ein zweites Mal, weil jene Datei den Anwendungskern und better-sqlite3 |
| 89 | * mitbrächte. Zwei Zeilen, und `tests/reifeverlauf.test.ts` prüft, dass der |
| 90 | * nachgerechnete Endpunkt mit dem laufenden Lernstand übereinstimmt – wäre |
| 91 | * die Rechnung eine andere, fiele genau das auf. |
| 92 | */ |
| 93 | function abstandTage(von: number | null, bis: number): number { |
| 94 | return von === null ? 0 : Math.max(0, (bis - von) / TAG_MS); |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Rechnet den Reifegrad für die letzten Tage nach. |
| 99 | * |
| 100 | * @param antworten Das Protokoll, aufsteigend nach Zeitpunkt. |
| 101 | * @param fragen Der **heutige** Lernumfang. Antworten auf Fragen, die nicht |
| 102 | * darin stehen, werden übergangen – sonst zählte ein abgewähltes Kapitel im |
| 103 | * Verlauf mit und in der Ampel nicht. |
| 104 | * @param jetzt Der Zeitpunkt, an dem der letzte Punkt steht. |
| 105 | * @param tage Wie viele Kalendertage zurück; der letzte Punkt ist heute. |
| 106 | */ |
| 107 | export function reifeverlauf( |
| 108 | antworten: readonly Verlaufsantwort[], |
| 109 | fragen: readonly string[], |
| 110 | jetzt: Date, |
| 111 | tage: number, |
| 112 | ): Verlaufspunkt[] { |
| 113 | if (fragen.length === 0 || tage < 1) { |
| 114 | return []; |
| 115 | } |
| 116 | |
| 117 | const imUmfang = new Set(fragen); |
| 118 | const stand = new Map<string, Zwischenstand>(); |
| 119 | |
| 120 | /* Die Stichtage: von hinten nach vorn aufgebaut, jeder um Mitternacht |
| 121 | Ortszeit **am Ende** des Tages – der Reifegrad eines Tages ist der, mit |
| 122 | dem man abends dasteht. Ein Stichtag am Morgen zeigte die Arbeit des |
| 123 | Vortages als die von heute. */ |
| 124 | const stichtage: Date[] = []; |
| 125 | for (let zurueck = tage - 1; zurueck >= 0; zurueck -= 1) { |
| 126 | const tagesende = new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate() - zurueck); |
| 127 | tagesende.setHours(23, 59, 59, 999); |
| 128 | /* Der heutige Stichtag ist **jetzt**, nicht heute Nacht: Sonst rechnete |
| 129 | der letzte Punkt Stunden in die Zukunft und stünde neben der Ampel mit |
| 130 | einer anderen Zahl. */ |
| 131 | stichtage.push(zurueck === 0 ? jetzt : tagesende); |
| 132 | } |
| 133 | |
| 134 | const punkte: Verlaufspunkt[] = []; |
| 135 | let gelesen = 0; |
| 136 | let beantwortet = 0; |
| 137 | |
| 138 | for (const stichtag of stichtage) { |
| 139 | /* Alle Antworten bis zu diesem Stichtag einarbeiten. Das Protokoll wird |
| 140 | genau einmal durchlaufen – die Stichtage stehen aufsteigend. */ |
| 141 | while (gelesen < antworten.length) { |
| 142 | const antwort = antworten[gelesen]; |
| 143 | if (antwort === undefined) { |
| 144 | break; |
| 145 | } |
| 146 | const zeitpunkt = Date.parse(antwort.zeitpunkt); |
| 147 | if (Number.isNaN(zeitpunkt) || zeitpunkt > stichtag.getTime()) { |
| 148 | break; |
| 149 | } |
| 150 | gelesen += 1; |
| 151 | if (!imUmfang.has(antwort.frageId)) { |
| 152 | continue; |
| 153 | } |
| 154 | |
| 155 | const bisher = stand.get(antwort.frageId); |
| 156 | if (bisher === undefined) { |
| 157 | beantwortet += 1; |
| 158 | } |
| 159 | const vorher: Zwischenstand = bisher ?? { |
| 160 | gedaechtnis: null, |
| 161 | zuletzt: null, |
| 162 | bestaetigt: false, |
| 163 | }; |
| 164 | const abstand = abstandTage(vorher.zuletzt, zeitpunkt); |
| 165 | const gedaechtnis = naechsterStand(vorher.gedaechtnis, FSRS_GRAD[antwort.bewertung], abstand); |
| 166 | |
| 167 | /* Wortgleich die Regel aus `main/lernstand.ts`: Eine falsche Antwort |
| 168 | nimmt den Beleg weg. Eine richtige nach mindestens einem Tag setzt |
| 169 | ihn. Eine richtige am selben Tag lässt ihn, wie er ist. */ |
| 170 | const belegtDieseAntwort = antwort.richtig && giltAlsRichtig(antwort.bewertung); |
| 171 | const bestaetigt = !belegtDieseAntwort |
| 172 | ? antwort.richtig |
| 173 | ? vorher.bestaetigt |
| 174 | : false |
| 175 | : abstand >= MINDESTABSTAND_TAGE |
| 176 | ? true |
| 177 | : vorher.bestaetigt; |
| 178 | |
| 179 | stand.set(antwort.frageId, { gedaechtnis, zuletzt: zeitpunkt, bestaetigt }); |
| 180 | } |
| 181 | |
| 182 | const zeilen: ReifeFrage[] = fragen.map((frageId) => { |
| 183 | const zwischen = stand.get(frageId); |
| 184 | return { |
| 185 | /* Der Bereich spielt für den Gesamtreifegrad keine Rolle; er steht in |
| 186 | `ReifeFrage`, weil dieselbe Zeilenform die Aufschlüsselung trägt. */ |
| 187 | bereich: '', |
| 188 | stabilitaet: zwischen?.gedaechtnis?.stabilitaet ?? null, |
| 189 | tageSeitAntwort: abstandTage(zwischen?.zuletzt ?? null, stichtag.getTime()), |
| 190 | bestaetigt: zwischen?.bestaetigt ?? false, |
| 191 | }; |
| 192 | }); |
| 193 | |
| 194 | const grad = reifegradVon(zeilen); |
| 195 | punkte.push({ |
| 196 | tag: tagesschluessel(stichtag), |
| 197 | reifegrad: grad, |
| 198 | belegt: Math.round(grad * fragen.length), |
| 199 | beantwortet, |
| 200 | }); |
| 201 | } |
| 202 | |
| 203 | return punkte; |
| 204 | } |