/** * Der Reifegrad rückwärts durch die Zeit – aus dem Antwortprotokoll. * * ## Was diese Datei beantwortet * * „Komme ich voran?“ Die Ampel sagt, wo jemand **heute** steht. Ob die Zahl * seit zwei Wochen steigt, steht oder fällt, stand nirgends – und genau das * ist die Frage, die jemand stellt, der jeden Tag lernt und nicht weiß, ob es * etwas nützt. * * ## Warum nachgerechnet und nicht mitgeschrieben * * Der naheliegende Weg wäre eine Tabelle, in die täglich der Reifegrad * geschrieben wird. Sie hätte einen Fehler, der sie fast wertlos machte: * **Sie fängt heute an.** Wer seit sechs Wochen lernt, sähe einen leeren * Verlauf und müsste sechs Wochen warten, bis die Anzeige etwas sagt. * * `antwort_log` enthält jede Antwort mit Zeitpunkt, Richtigkeit und * Bewertung – also alles, was der Gedächtnisstand braucht. Der Verlauf wird * deshalb **nachgerechnet**: einmal durch das Protokoll, und an jedem * Stichtag steht der Reifegrad, der an jenem Tag gegolten hätte. * * ## Die Zusicherung, an der alles hängt * * Ein nachgerechneter Verlauf, dessen letzter Punkt nicht die Zahl der Ampel * ist, wäre schlimmer als keiner: zwei Zahlen auf einem Bildschirm, die * einander widersprechen. Deshalb rechnet diese Datei mit **denselben** * Bausteinen wie `main/lernstand.ts` – `naechsterStand`, `giltAlsRichtig`, * `MINDESTABSTAND_TAGE`, `reifegradVon` – und `tests/reifeverlauf.test.ts` * prüft die Gleichheit gegen den laufenden Lernstand. * * ## Was der Verlauf nicht kann * * Er kennt den **heutigen** Lernumfang. Wer gestern ein Kapitel abgewählt * hat, sieht auch die Vergangenheit ohne dieses Kapitel gerechnet. Das ist * die ehrlichere von zwei Unvollkommenheiten: Die Alternative wäre ein * Verlauf, der an dem Tag springt, an dem jemand eine Einstellung ändert, * ohne dass er etwas gelernt oder vergessen hätte. */ import { FSRS_GRAD, naechsterStand, type Gedaechtnisstand } from './fsrs'; import { giltAlsRichtig, type Bewertung } from './lernstand'; import { MINDESTABSTAND_TAGE, reifegradVon, type ReifeFrage } from './reife'; /** Millisekunden eines Tages. */ const TAG_MS = 86_400_000; /** Eine Zeile des Antwortprotokolls, so weit der Verlauf sie braucht. */ export interface Verlaufsantwort { readonly frageId: string; /** ISO-Zeitpunkt; die Liste wird aufsteigend sortiert übergeben. */ readonly zeitpunkt: string; readonly richtig: boolean; readonly bewertung: Bewertung; } /** Ein Stichtag des Verlaufs. */ export interface Verlaufspunkt { /** Kalendertag als ISO-Datum in Ortszeit. */ readonly tag: string; /** Reifegrad an jenem Tag, 0 bis 1. */ readonly reifegrad: number; /** Auf ganze Fragen gerundet, wie in der Ampel. */ readonly belegt: number; /** Verschiedene Fragen des Lernumfangs, die bis dahin drankamen. */ readonly beantwortet: number; } /** Der Stand einer Frage während des Wiederaufbaus. */ interface Zwischenstand { gedaechtnis: Gedaechtnisstand | null; zuletzt: number | null; bestaetigt: boolean; } /** Kalendertag in Ortszeit, als ISO-Datum. */ function tagesschluessel(zeitpunkt: Date): string { const jahr = String(zeitpunkt.getFullYear()).padStart(4, '0'); const monat = String(zeitpunkt.getMonth() + 1).padStart(2, '0'); const tag = String(zeitpunkt.getDate()).padStart(2, '0'); return jahr + '-' + monat + '-' + tag; } /** * Der Abstand in Tagen zwischen zwei Zeitpunkten, als Bruchzahl. * * Dieselbe Rechnung wie `tageZwischen` in `main/lernstand.ts`; sie steht hier * ein zweites Mal, weil jene Datei den Anwendungskern und better-sqlite3 * mitbrächte. Zwei Zeilen, und `tests/reifeverlauf.test.ts` prüft, dass der * nachgerechnete Endpunkt mit dem laufenden Lernstand übereinstimmt – wäre * die Rechnung eine andere, fiele genau das auf. */ function abstandTage(von: number | null, bis: number): number { return von === null ? 0 : Math.max(0, (bis - von) / TAG_MS); } /** * Rechnet den Reifegrad für die letzten Tage nach. * * @param antworten Das Protokoll, aufsteigend nach Zeitpunkt. * @param fragen Der **heutige** Lernumfang. Antworten auf Fragen, die nicht * darin stehen, werden übergangen – sonst zählte ein abgewähltes Kapitel im * Verlauf mit und in der Ampel nicht. * @param jetzt Der Zeitpunkt, an dem der letzte Punkt steht. * @param tage Wie viele Kalendertage zurück; der letzte Punkt ist heute. */ export function reifeverlauf( antworten: readonly Verlaufsantwort[], fragen: readonly string[], jetzt: Date, tage: number, ): Verlaufspunkt[] { if (fragen.length === 0 || tage < 1) { return []; } const imUmfang = new Set(fragen); const stand = new Map(); /* Die Stichtage: von hinten nach vorn aufgebaut, jeder um Mitternacht Ortszeit **am Ende** des Tages – der Reifegrad eines Tages ist der, mit dem man abends dasteht. Ein Stichtag am Morgen zeigte die Arbeit des Vortages als die von heute. */ const stichtage: Date[] = []; for (let zurueck = tage - 1; zurueck >= 0; zurueck -= 1) { const tagesende = new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate() - zurueck); tagesende.setHours(23, 59, 59, 999); /* Der heutige Stichtag ist **jetzt**, nicht heute Nacht: Sonst rechnete der letzte Punkt Stunden in die Zukunft und stünde neben der Ampel mit einer anderen Zahl. */ stichtage.push(zurueck === 0 ? jetzt : tagesende); } const punkte: Verlaufspunkt[] = []; let gelesen = 0; let beantwortet = 0; for (const stichtag of stichtage) { /* Alle Antworten bis zu diesem Stichtag einarbeiten. Das Protokoll wird genau einmal durchlaufen – die Stichtage stehen aufsteigend. */ while (gelesen < antworten.length) { const antwort = antworten[gelesen]; if (antwort === undefined) { break; } const zeitpunkt = Date.parse(antwort.zeitpunkt); if (Number.isNaN(zeitpunkt) || zeitpunkt > stichtag.getTime()) { break; } gelesen += 1; if (!imUmfang.has(antwort.frageId)) { continue; } const bisher = stand.get(antwort.frageId); if (bisher === undefined) { beantwortet += 1; } const vorher: Zwischenstand = bisher ?? { gedaechtnis: null, zuletzt: null, bestaetigt: false, }; const abstand = abstandTage(vorher.zuletzt, zeitpunkt); const gedaechtnis = naechsterStand(vorher.gedaechtnis, FSRS_GRAD[antwort.bewertung], abstand); /* Wortgleich die Regel aus `main/lernstand.ts`: Eine falsche Antwort nimmt den Beleg weg. Eine richtige nach mindestens einem Tag setzt ihn. Eine richtige am selben Tag lässt ihn, wie er ist. */ const belegtDieseAntwort = antwort.richtig && giltAlsRichtig(antwort.bewertung); const bestaetigt = !belegtDieseAntwort ? antwort.richtig ? vorher.bestaetigt : false : abstand >= MINDESTABSTAND_TAGE ? true : vorher.bestaetigt; stand.set(antwort.frageId, { gedaechtnis, zuletzt: zeitpunkt, bestaetigt }); } const zeilen: ReifeFrage[] = fragen.map((frageId) => { const zwischen = stand.get(frageId); return { /* Der Bereich spielt für den Gesamtreifegrad keine Rolle; er steht in `ReifeFrage`, weil dieselbe Zeilenform die Aufschlüsselung trägt. */ bereich: '', stabilitaet: zwischen?.gedaechtnis?.stabilitaet ?? null, tageSeitAntwort: abstandTage(zwischen?.zuletzt ?? null, stichtag.getTime()), bestaetigt: zwischen?.bestaetigt ?? false, }; }); const grad = reifegradVon(zeilen); punkte.push({ tag: tagesschluessel(stichtag), reifegrad: grad, belegt: Math.round(grad * fragen.length), beantwortet, }); } return punkte; }