waffensachkunde

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

/ app src shared reifeverlauf.ts

9,0 KB Rohdatei
app/src/shared/reifeverlauf.ts — 232 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 /**
52 * ISO-Zeitpunkt der Antwort.
53 *
54 * Die Liste kommt in der Reihenfolge, in der der Lernstand geschrieben
55 * hat — nach `id`, nicht nach diesem Feld. Beides fällt fast immer
56 * zusammen; wo nicht, ist die Schreibreihenfolge die maßgebliche, denn
57 * genau in ihr hat der Lernstand seinen Gedächtnisstand fortgeschrieben.
58 */
59 readonly zeitpunkt: string;
60 readonly richtig: boolean;
61 readonly bewertung: Bewertung;
62 }
63
64 /** Ein Stichtag des Verlaufs. */
65 export interface Verlaufspunkt {
66 /** Kalendertag als ISO-Datum in Ortszeit. */
67 readonly tag: string;
68 /** Reifegrad an jenem Tag, 0 bis 1. */
69 readonly reifegrad: number;
70 /** Auf ganze Fragen gerundet, wie in der Ampel. */
71 readonly belegt: number;
72 /** Verschiedene Fragen des Lernumfangs, die bis dahin drankamen. */
73 readonly beantwortet: number;
74 }
75
76 /** Der Stand einer Frage während des Wiederaufbaus. */
77 interface Zwischenstand {
78 gedaechtnis: Gedaechtnisstand | null;
79 zuletzt: number | null;
80 bestaetigt: boolean;
81 }
82
83 /** Kalendertag in Ortszeit, als ISO-Datum. */
84 function tagesschluessel(zeitpunkt: Date): string {
85 const jahr = String(zeitpunkt.getFullYear()).padStart(4, '0');
86 const monat = String(zeitpunkt.getMonth() + 1).padStart(2, '0');
87 const tag = String(zeitpunkt.getDate()).padStart(2, '0');
88 return jahr + '-' + monat + '-' + tag;
89 }
90
91 /**
92 * Der Abstand in Tagen zwischen zwei Zeitpunkten, als Bruchzahl.
93 *
94 * Dieselbe Rechnung wie `tageZwischen` in `main/lernstand.ts`; sie steht hier
95 * ein zweites Mal, weil jene Datei den Anwendungskern und better-sqlite3
96 * mitbrächte. Zwei Zeilen, und `tests/reifeverlauf.test.ts` prüft, dass der
97 * nachgerechnete Endpunkt mit dem laufenden Lernstand übereinstimmt – wäre
98 * die Rechnung eine andere, fiele genau das auf.
99 */
100 function abstandTage(von: number | null, bis: number): number {
101 return von === null ? 0 : Math.max(0, (bis - von) / TAG_MS);
102 }
103
104 /**
105 * Rechnet den Reifegrad für die letzten Tage nach.
106 *
107 * @param antworten Das Protokoll, aufsteigend nach Zeitpunkt.
108 * @param fragen Der **heutige** Lernumfang. Antworten auf Fragen, die nicht
109 * darin stehen, werden übergangen – sonst zählte ein abgewähltes Kapitel im
110 * Verlauf mit und in der Ampel nicht.
111 * @param jetzt Der Zeitpunkt, an dem der letzte Punkt steht.
112 * @param tage Wie viele Kalendertage zurück; der letzte Punkt ist heute.
113 */
114 export function reifeverlauf(
115 antworten: readonly Verlaufsantwort[],
116 fragen: readonly string[],
117 jetzt: Date,
118 tage: number,
119 ): Verlaufspunkt[] {
120 if (fragen.length === 0 || tage < 1) {
121 return [];
122 }
123
124 const imUmfang = new Set(fragen);
125 const stand = new Map<string, Zwischenstand>();
126
127 /* Die Stichtage: von hinten nach vorn aufgebaut, jeder um Mitternacht
128 Ortszeit **am Ende** des Tages – der Reifegrad eines Tages ist der, mit
129 dem man abends dasteht. Ein Stichtag am Morgen zeigte die Arbeit des
130 Vortages als die von heute. */
131 const stichtage: Date[] = [];
132 for (let zurueck = tage - 1; zurueck >= 0; zurueck -= 1) {
133 const tagesende = new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate() - zurueck);
134 tagesende.setHours(23, 59, 59, 999);
135 /* Der heutige Stichtag ist **jetzt**, nicht heute Nacht: Sonst rechnete
136 der letzte Punkt Stunden in die Zukunft und stünde neben der Ampel mit
137 einer anderen Zahl. */
138 stichtage.push(zurueck === 0 ? jetzt : tagesende);
139 }
140
141 const punkte: Verlaufspunkt[] = [];
142 let gelesen = 0;
143 let beantwortet = 0;
144
145 stichtage.forEach((stichtag, nummer) => {
146 /* Der letzte Stichtag nimmt **alles**, was noch übrig ist.
147
148 Bis 0.27.2 brach die Schleife auch hier an der ersten Zeile ab, deren
149 Zeitpunkt hinter dem Stichtag liegt — und ließ sie damit für immer
150 liegen. Die Ampel las derweil `frage_stand` und kannte sie: zwei Zahlen
151 auf einem Bildschirm, die einander widersprechen, also genau das, was
152 der Kopf dieser Datei ausschließt.
153
154 Zeilen aus der Zukunft sind kein Sonderfall am Rande:
155 `profil-uebernehmen.ts` kopiert `antwort_log` samt Zeitpunkt wörtlich
156 aus einer fremden Datenbank. Ein übernommenes Profil von einem Gerät
157 mit vorgehender Uhr genügt. */
158 const letzter = nummer === stichtage.length - 1;
159
160 /* Alle Antworten bis zu diesem Stichtag einarbeiten. Das Protokoll wird
161 genau einmal durchlaufen – die Stichtage stehen aufsteigend. */
162 while (gelesen < antworten.length) {
163 const antwort = antworten[gelesen];
164 if (antwort === undefined) {
165 break;
166 }
167 const zeitpunkt = Date.parse(antwort.zeitpunkt);
168 if (!letzter && (Number.isNaN(zeitpunkt) || zeitpunkt > stichtag.getTime())) {
169 break;
170 }
171 gelesen += 1;
172 if (!imUmfang.has(antwort.frageId)) {
173 continue;
174 }
175
176 const bisher = stand.get(antwort.frageId);
177 if (bisher === undefined) {
178 beantwortet += 1;
179 }
180 const vorher: Zwischenstand = bisher ?? {
181 gedaechtnis: null,
182 zuletzt: null,
183 bestaetigt: false,
184 };
185 /* `Number.isNaN` kann hier nur im letzten Stichtag ankommen; eine
186 unlesbare Zeit gilt dann wie kein Abstand – dieselbe Antwort, die
187 `tageZwischen` im Anwendungskern gibt. */
188 const abstand = Number.isNaN(zeitpunkt) ? 0 : abstandTage(vorher.zuletzt, zeitpunkt);
189 const gedaechtnis = naechsterStand(vorher.gedaechtnis, FSRS_GRAD[antwort.bewertung], abstand);
190
191 /* Wortgleich die Regel aus `main/lernstand.ts`: Eine falsche Antwort
192 nimmt den Beleg weg. Eine richtige nach mindestens einem Tag setzt
193 ihn. Eine richtige am selben Tag lässt ihn, wie er ist. */
194 const belegtDieseAntwort = antwort.richtig && giltAlsRichtig(antwort.bewertung);
195 const bestaetigt = !belegtDieseAntwort
196 ? antwort.richtig
197 ? vorher.bestaetigt
198 : false
199 : abstand >= MINDESTABSTAND_TAGE
200 ? true
201 : vorher.bestaetigt;
202
203 stand.set(antwort.frageId, {
204 gedaechtnis,
205 zuletzt: Number.isNaN(zeitpunkt) ? vorher.zuletzt : zeitpunkt,
206 bestaetigt,
207 });
208 }
209
210 const zeilen: ReifeFrage[] = fragen.map((frageId) => {
211 const zwischen = stand.get(frageId);
212 return {
213 /* Der Bereich spielt für den Gesamtreifegrad keine Rolle; er steht in
214 `ReifeFrage`, weil dieselbe Zeilenform die Aufschlüsselung trägt. */
215 bereich: '',
216 stabilitaet: zwischen?.gedaechtnis?.stabilitaet ?? null,
217 tageSeitAntwort: abstandTage(zwischen?.zuletzt ?? null, stichtag.getTime()),
218 bestaetigt: zwischen?.bestaetigt ?? false,
219 };
220 });
221
222 const grad = reifegradVon(zeilen);
223 punkte.push({
224 tag: tagesschluessel(stichtag),
225 reifegrad: grad,
226 belegt: Math.round(grad * fragen.length),
227 beantwortet,
228 });
229 });
230
231 return punkte;
232 }