waffensachkunde

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

/ app src shared reifeverlauf.ts

7,7 KB Rohdatei
app/src/shared/reifeverlauf.ts — 204 Zeilen
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 }