/** * Lernplan – FSRS auf einen Prüfungstermin hin. * * FSRS (siehe `fsrs.ts`) plant für dauerhaftes Behalten: Es hält die * Abrufwahrscheinlichkeit jeder Frage bei rund 90 % und dehnt die Abstände * dabei immer weiter. Für eine Prüfung an einem festen Tag ist das nicht ganz * das Richtige. Hier zählt ein anderes Ziel: **an genau diesem einen Tag soll * möglichst viel abrufbar sein.** * * Dieses Modul ist die Anpassung daran. Es steht bewusst getrennt von * `fsrs.ts`, damit erkennbar bleibt, was belegtes Referenzverfahren ist und * was eigene Entscheidung. Alles hier ist eigene Entscheidung. * * ## Die beiden Regeln * * **1. Die Zielquote steigt, je näher der Termin rückt.** * Weit vor der Prüfung genügen 90 % – wer zu früh zu oft wiederholt, * verschwendet Zeit, die für neue Fragen fehlt. In den letzten Wochen wird * die Zielquote schrittweise auf 97 % angehoben. Dadurch rücken die Termine * von selbst zusammen und der Stoff ist am Prüfungstag frisch. * * **2. Es wird nicht auf den Termin gedeckelt.** * Nachgerechnet und bewusst so gelassen – siehe * `docs/entscheidung-lernphasen.md`. Jede geprüfte Kappung schiebt alles, was * über den Termin hinausginge, auf den Prüfungstag selbst; gemessen 432 von * 486 Fragen an einem Tag. * Naheliegend wäre, jede Frage vor der Prüfung noch einmal vorzulegen. Das * wäre aber verschwendete Zeit: Eine Frage mit 60 Tagen Stabilität sitzt zehn * Tage vor der Prüfung noch zu 97,7 % – sie braucht keine Wiederholung. Genau * diese Fragen filtert Regel 1 bereits heraus, ohne dass eine zweite Regel * nötig ist. Wer wirklich alles noch einmal sehen will, hat dafür die * Prüfungssimulation. * * ## Ehrlichkeit der Prognose * * Die Prognose ist der Mittelwert der Abrufwahrscheinlichkeiten über den * **gesamten** Katalog. Eine nie beantwortete Frage geht mit 0 ein. Das ist * bewusst streng: Geraten wird nicht eingerechnet, und ungesehene Fragen * gelten als nicht gekonnt. Die Zahl ist damit eher zu niedrig als zu hoch – * in dieser Richtung schadet ein Fehler niemandem. */ import { intervallTage, naechsterStand, type FsrsGrad, type Gedaechtnisstand } from './fsrs'; import { reifegradVon, type ReifeFrage } from './reife'; /** Zielquote ohne Prüfungstermin – der Vorgabewert von FSRS. */ export const ZIELQUOTE_GRUND = 0.9; /** Zielquote am Prüfungstag. */ export const ZIELQUOTE_TERMIN = 0.97; /** * Ab dieser Zahl von Tagen vor dem Termin greift die Anhebung. * * Vier Wochen: kurz genug, dass die zusätzlichen Wiederholungen nicht die * ganze Vorbereitungszeit auffressen, lang genug, dass die Anhebung sanft * verläuft und nicht als plötzlicher Sprung erlebt wird. */ export const ANHEBUNG_AB_TAGEN = 28; /** * Umfang einer Lernsitzung – zugleich der Tagesvorschlag ohne Prüfungstermin. * * Steht hier und nicht in der Oberfläche, damit der vorgeschlagene Tagesplan * und die Schaltfläche „Weiterlernen“ nicht auseinanderlaufen können. */ export const SITZUNGSUMFANG = 20; /** Angenommene Bearbeitungsdauer, solange keine eigene Historie vorliegt. */ export const SEKUNDEN_PRO_FRAGE_VORGABE = 25; /** * Wie viele Tage die Einführung neuer Fragen nach vorn gezogen wird. * * Eine Frage, die zum ersten Mal am Vorabend auftaucht, ist am Prüfungstag * nicht gefestigt. Der Puffer zieht die Einführung deshalb nach vorn, damit * möglichst jede Frage wenigstens eine Wiederholung erlebt. Er wächst nicht * über drei Tage hinaus – bei kurzer Vorbereitungszeit wäre das sonst der * größte Teil davon. * * **Er stoppt die Einführung nicht.** Der Kommentar behauptete das bis * Fassung 0.16.0 („Tage, an denen keine neuen Fragen mehr eingeführt * werden“), und der Code leistete es nie: Der Puffer ist ein kleinerer * Divisor, ab vier Tagen Rest ist er null, und die Rate wird täglich neu * gebildet. Wer zurückliegt, bekommt bis zum Vortag neue Fragen angetragen – * und dazu von {@link machbarkeitFuer} die Auskunft, dass das nicht mehr zu * schaffen ist. Am Prüfungstag selbst kommen keine mehr dazu, weil ein Beleg * an diesem Tag nicht mehr entstehen kann. */ export const PUFFER_TAGE_MAX = 3; /** Einschätzung, ob das Pensum in der verbleibenden Zeit zu schaffen ist. */ export type Machbarkeit = 'kein_termin' | 'termin_vorbei' | 'entspannt' | 'machbar' | 'knapp' | 'zu_wenig_zeit'; /** Empfohlenes Tagespensum. */ export interface Tagespensum { /** Neue, noch nie beantwortete Fragen. */ readonly neu: number; /** Heute zur Wiederholung fällige Fragen. */ readonly wiederholung: number; /** Summe aus beidem. */ readonly gesamt: number; /** Geschätzte Dauer in Minuten, auf Basis des eigenen bisherigen Tempos. */ readonly minuten: number; } /** Der vollständige Lernplan, wie ihn die Oberfläche anzeigt. */ export interface Lernplan { /** Prüfungstermin als ISO-Datum, oder `null`, wenn keiner gesetzt ist. */ readonly termin: string | null; /** Volle Tage bis zum Termin. 0 = heute, negativ = vorbei. */ readonly tageBisTermin: number | null; readonly gesamtFragen: number; readonly nieBeantwortet: number; readonly faellig: number; /** Aktuell angesetzte Zielquote zwischen {@link ZIELQUOTE_GRUND} und {@link ZIELQUOTE_TERMIN}. */ readonly zielquote: number; /** Geschätzte Trefferquote, wenn heute geprüft würde. */ readonly prognoseHeute: number; /** * Geschätzte Trefferquote am Prüfungstag, **wenn ab jetzt nicht mehr * gelernt wird**. Die Differenz zu {@link prognoseHeute} zeigt, was * Nichtstun kostet. */ readonly prognoseAmTermin: number | null; readonly pensum: Tagespensum; readonly machbarkeit: Machbarkeit; /** Sekunden je Frage, die der Schätzung zugrunde liegen. */ readonly sekundenProFrage: number; /** * Bereits angesetzte Wiederholungen je Tag, beginnend mit heute. * * Eine **Untergrenze**, keine Prognose: Was hier steht, ist der heutige * Kalenderstand; jede Antwort von heute setzt neue Termine. Die neuen * Fragen bleiben draußen – sie kommen mit der Einführungsrate, die als * eigene, planbare Größe daneben steht. * * Optional geführt wie die übrigen jüngeren Erweiterungen, damit die * Attrappen der Oberflächentests mit dem bisherigen Zuschnitt gültig * bleiben. */ readonly vorschau?: readonly number[]; } /** * Stand einer einzelnen Frage, wie ihn die Planung braucht. * * Erbt bewusst von {@link ReifeFrage}: Planung und Reifegrad rechnen auf * **derselben** Zeilenform, damit die Prognose des Lernplans und die Zahl der * Ampel nicht nur zufällig übereinstimmen, sondern dieselbe Rechnung sind. * Genau das Auseinanderlaufen war der Befund in `docs/stand.md` 7.1. */ export interface PlanFrage extends ReifeFrage { /** Ob die Frage heute zur Wiederholung ansteht. */ readonly faellig: boolean; /** * Ob die Frage noch **nie** beantwortet wurde. * * Bis 0.27.2 leitete der Plan das aus `stabilitaet === null` ab. Das ist * fast immer dasselbe, aber nicht immer: Eine Zeile aus der Zeit vor der * FSRS-Umstellung trägt Versuche und keine Stabilität, und `lernstand.ts` * sichert ausdrücklich zu, „der bisherige Fortschritt in `versuche`, * `richtige` und `faellig_ab` bleibt davon unberührt“. Der Plan meldete * sie trotzdem als neu — während die Sitzung, die über `versuche` geht, * keine einzige neue Frage vorlegte. `einfuehrungsrate` sagt aber zu: * „Anzeige und Sitzung sind dieselbe Rechnung.“ * * Wahlfrei geführt, damit die Attrappen der Oberflächentests mit dem * bisherigen Zuschnitt gültig bleiben; fehlt das Feld, gilt der alte * Rückschluss aus der Stabilität. */ readonly nieBeantwortet?: boolean; /** * In wie vielen Tagen die Frage ansteht; 0 heißt heute oder überfällig. * * `null` für nie beantwortete Fragen: Sie haben keinen Termin, sondern * warten auf die Einführungsrate. Gebraucht wird der Wert für die * Arbeitslast-Vorschau (siehe {@link Lernplan.vorschau}). */ readonly faelligInTagen?: number | null; } /** Alles, was die Planung an Fakten braucht. Kommt aus der Datenbank. */ export interface Planungsdaten { readonly termin: string | null; readonly tageBisTermin: number | null; readonly fragen: readonly PlanFrage[]; /** Bisheriges Tempo; `null`, solange zu wenig Historie vorliegt. */ readonly sekundenProFrage: number | null; } /** * Untergrenze der Zielquote in einem Bereich mit K.-o.-Kriterium. * * **Warum es sie gibt.** Manche Prüfungsordnungen lassen in Notwehr und * Notstand höchstens zwei Fehler zu, gleich wie gut der Rest sitzt. Die * Reifeampel weiß das seit 0.22.0 und deckelt daran ihr Gesamturteil * ({@link gesamtstufeMitDeckel}) – der Planer wusste es nicht und behandelte * die 43 Fragen aus I.5 wie jede andere. Die Ampel warnte also, und die * Wiedervorlage handelte nicht danach. * * **Warum 0,95 und nicht 0,97.** Nachgerechnet mit den Parametern dieses * Projekts (Abfall 0,1542): Bei einer Stabilität von 60 Tagen ergibt 0,90 ein * Intervall von 60 Tagen, 0,95 eines von 24 und 0,97 eines von 13. Die * Terminquote dauerhaft anzulegen hieße, diese Fragen viereinhalbmal so oft * vorzulegen wie alle anderen – auch ein Jahr vor der Prüfung. 0,95 hält sie * spürbar enger, ohne die Sitzung zu füllen; und sobald der Termin naht, * steigt die allgemeine Quote ohnehin darüber und die Untergrenze greift * nicht mehr. */ export const ZIELQUOTE_KO = 0.95; /** * Zielquote für den heutigen Tag. * * Ohne Termin bleibt es bei {@link ZIELQUOTE_GRUND}. Sonst steigt sie linear * an, sobald weniger als {@link ANHEBUNG_AB_TAGEN} Tage verbleiben, und * erreicht am Termin selbst {@link ZIELQUOTE_TERMIN}. */ export function zielquoteFuer(tageBisTermin: number | null, istKoBereich = false): number { /* Die Untergrenze steht am Ende und nicht als eigener Zweig: Sie soll die Anhebung zum Termin nicht ersetzen, sondern nur verhindern, dass ein K.-o.-Bereich fern vom Termin auf die Grundquote zurückfällt. */ const untergrenze = istKoBereich ? ZIELQUOTE_KO : 0; if (tageBisTermin === null || !Number.isFinite(tageBisTermin)) { return Math.max(ZIELQUOTE_GRUND, untergrenze); } if (tageBisTermin >= ANHEBUNG_AB_TAGEN) { return Math.max(ZIELQUOTE_GRUND, untergrenze); } /* Nach dem Termin bleibt die Zielquote oben: Wer den Termin verstreichen lässt, ohne ihn zu ändern, lernt vermutlich für einen Nachholtermin. */ const naehe = 1 - Math.max(0, tageBisTermin) / ANHEBUNG_AB_TAGEN; return Math.max(ZIELQUOTE_GRUND + (ZIELQUOTE_TERMIN - ZIELQUOTE_GRUND) * naehe, untergrenze); } /** * Tage, an denen noch neue Fragen eingeführt werden können. * * @see PUFFER_TAGE_MAX zur Begründung des Abzugs. */ export function einfuehrungstage(tageBisTermin: number): number { const puffer = Math.min(PUFFER_TAGE_MAX, Math.floor(tageBisTermin / 5)); return Math.max(1, tageBisTermin - puffer); } /** * Neue Fragen für heute – das „neu“ des Tagespensums. * * Eine Funktion, zwei Verwender, und genau das ist der Punkt: * {@link lernplanBerechnen} zeigt die Zahl an, `Lernstand.sitzung()` deckelt * die neuen Fragen einer Sitzung damit. Anzeige und Sitzung sind dieselbe * Rechnung. Bis Fassung 0.20.0 war die Rate reine Anzeige – die Sitzung * füllte unabhängig davon mit neuen Fragen auf (`docs/stand.md` 7.11). * * Mit Termin werden die ungesehenen Fragen gleichmäßig über die * verbleibenden {@link einfuehrungstage} verteilt. Die Rate ist keine Menge, * die sich abarbeitet, sondern wird täglich neu gebildet – wer Tage * auslässt, bekommt danach von selbst mehr. * * Ohne Termin gibt es nichts zu verteilen – dann gilt {@link SITZUNGSUMFANG}, * damit der Plan nicht „nichts zu tun“ meldet, während der halbe Katalog * ungesehen ist. Für die übliche Sitzung von 20 Fragen ist das zugleich das * bisherige Verhalten: Der Deckel liegt auf der Sitzungsgröße und greift * nicht. Nach dem Termin bleibt es bei 0: Wer den Termin nicht nachträgt, * bekommt keinen erfundenen Plan. * * **Am Prüfungstag selbst ebenfalls 0**, und der Grund ist keine Schonung, * sondern eine Ableitung aus der Belegregel: Eine Frage, die heute zum ersten * Mal auftaucht, kann nicht mehr belegt werden. Der Beleg verlangt eine * richtige Antwort nach mindestens einem Tag Abstand * (`MINDESTABSTAND_TAGE` in `reife.ts`), und diesen Tag gibt es nicht mehr. * Neue Fragen sind heute nicht knapp, sondern wirkungslos. * * Vorher stand hier `< 0`. Da `einfuehrungstage(0)` auf 1 zurückfällt, ergab * das am Prüfungstag `ceil(n / 1)` – der ganze Rest. Nachgerechnet: 300 * ungesehene Fragen und 20 fällige ergaben am Morgen der Prüfung „Heute 320 * Fragen, etwa 133 Minuten“. * * Der Vortag bleibt ausdrücklich, wie er ist: Dort ist der Beleg noch * erreichbar, mehr Tage gibt es nicht, und `machbarkeitFuer` sagt von selbst, * wenn das nicht mehr zu schaffen ist. Ein harter Einführungsstopp über * mehrere Tage wäre schlechter – er verstecke dreizehn ungesehene Fragen bei * drei Tagen Rest ganz. */ export function einfuehrungsrate(nieBeantwortet: number, tageBisTermin: number | null): number { if (tageBisTermin === null) { return Math.min(nieBeantwortet, SITZUNGSUMFANG); } if (tageBisTermin <= 0) { return 0; } return Math.min(nieBeantwortet, Math.ceil(nieBeantwortet / einfuehrungstage(tageBisTermin))); } /** * Einschätzung des Tagespensums. * * Bewertet wird die **Zeit**, nicht die Anzahl: Eine Stunde täglich ist eine * Aussage, die jeder einordnen kann; „62 Fragen“ ist es nicht. */ export function machbarkeitFuer(minuten: number, tageBisTermin: number | null): Machbarkeit { if (tageBisTermin === null) { return 'kein_termin'; } if (tageBisTermin < 0) { return 'termin_vorbei'; } if (minuten <= 20) { return 'entspannt'; } if (minuten <= 45) { return 'machbar'; } if (minuten <= 90) { return 'knapp'; } return 'zu_wenig_zeit'; } /** * Obergrenze der Wiedervorlage in Tagen. * * FSRS kann bei gut sitzendem Stoff Intervalle von Jahren berechnen. Für eine * Prüfungsvorbereitung ist das ohne Nutzen: Ein halbes Jahr ist länger als * jede realistische Vorbereitungszeit, und eine Frage nach zwei Jahren wieder * vorzulegen hilft niemandem, der in acht Wochen geprüft wird. */ export const MAX_INTERVALL_TAGE = 180; /** Ergebnis der Terminberechnung nach einer Antwort. */ export interface Wiedervorlage extends Gedaechtnisstand { /** * Abstand bis zur nächsten Vorlage in ganzen Tagen. * * 0 bedeutet: sofort wieder fällig – spätestens die nächste Sitzung legt * die Frage wieder vorn vor. */ readonly intervallTage: number; } /** * Schreibt den Gedächtnisstand fort und bestimmt daraus den nächsten Termin. * * Die einzige Stelle, an der FSRS und Prüfungstermin zusammenkommen. * * Ein „nochmal“ führt bewusst zu Intervall 0 statt zu dem, was FSRS * ausrechnen würde (rund fünf Stunden): Die Frage ist sofort wieder fällig * und steht in der nächsten Sitzung in der Vorranggruppe vorn. Dass sie * schon in der **laufenden** Sitzung wiederkommt, leistet nicht dieses * Intervall, sondern die Oberfläche: `useSitzung` reiht eine falsch * beantwortete Frage bis zu zweimal ans Sitzungsende, bis sie einmal richtig * beantwortet ist. (Bis Fassung 0.20.0 behauptete dieser Kommentar das * Wiedersehen in derselben Sitzung, und niemand leistete es.) Der * Gedächtnisstand wird davon nicht berührt: Er wird ganz normal nach FSRS * fortgeschrieben und bestimmt weiterhin, wie es danach weitergeht. */ /** * Streut ein Wiedervorlage-Intervall, damit nicht ganze Tagesjahrgänge im * Gleichschritt marschieren. * * **Das Problem.** Ohne Streuung ist der Termin punktgenau `jetzt + n Tage`. * Wer an einem Abend zwanzig neue Fragen mit „gut“ beantwortet, bekommt für * alle zwanzig dasselbe Intervall — und damit denselben Termin, und zwei * Termine später wieder. Die Arbeitslastvorschau macht diese Berge sichtbar; * geglättet hat sie sie nie. * * **Warum aus der Frage-ID und nicht aus einer Zufallsquelle.** Der erste * Entwurf nahm `this.zufall` des Lernstands. Er fiel sofort auf: Jeder Test, * der einen Abstand nachrechnet, ohne die Quelle festzunageln, wurde damit * zufällig — zwei Zusicherungen wurden auf der Stelle rot, eine davon ohne * jede Berührung. Ein Verfahren, das reproduzierbare Zahlen liefert, darf * nicht an einer Quelle hängen, die es nicht kennt. Derselbe Versatz für * dieselbe Frage ist zudem kein Nachteil: Verschieden sind die **Fragen** * untereinander, und genau darum geht es. * * **Die Regeln.** Höchstens 15 Prozent, gedeckelt auf sieben Tage, und erst * ab drei Tagen: Darunter ist ein Tag Versatz keine Streuung mehr, sondern * eine andere Antwort — bei Intervall 1 hieße „ein Tag früher“ noch heute. */ export function gestreutesIntervall(tage: number, frageId: string): number { if (tage < 3) { return tage; } const spanne = Math.min(7, Math.max(1, Math.round(tage * 0.15))); /* Ein einfacher, stabiler Streuwert aus der Kennung. Keine Kryptographie – gebraucht wird nur, dass benachbarte Fragen verschieden herauskommen. */ let summe = 0; for (let i = 0; i < frageId.length; i += 1) { summe = (summe * 31 + frageId.charCodeAt(i)) % 100_003; } const versatz = (summe % (2 * spanne + 1)) - spanne; return Math.min(MAX_INTERVALL_TAGE, Math.max(1, tage + versatz)); } /** * Wie {@link gestreutesIntervall}, aber mit Blick auf den Kalender. * * **Was die Streuung allein nicht kann.** Sie verteilt Fragen gleichmäßig * über ein Fenster – aber blind. Sie weiß nicht, dass am Donnerstag schon * neunzig Wiederholungen stehen und am Freitag vier. Wer an drei Abenden * hintereinander lernt, baut sich Berge, die die Streuung nur ein wenig * verwischt. * * **Was hier dazukommt.** Aus demselben Fenster wird der Tag mit der * **geringsten** bereits angesetzten Last gewählt. Das Fenster ist unverändert * das der Streuung: höchstens 15 Prozent, gedeckelt auf sieben Tage, erst ab * drei Tagen Intervall. Es wird also nichts verschoben, was nicht ohnehin * verschoben würde – die Genauigkeit des Modells bleibt, wo sie war. * * **Die Eigenschaft, auf die es ankommt.** Sind alle Tage des Fensters gleich * belastet – der Regelfall bei einem leeren Kalender –, kommt **genau** das * heraus, was die Streuung liefert. Der Gleichstand entscheidet nach dem * Abstand zum gestreuten Wert. Der Ausgleich ist damit eine Verfeinerung und * keine Ablösung: Ohne Last ändert sich nichts, mit Last wird geglättet. * * @param lastJeTag Wie viele Fragen an Tag `n` (von heute aus gezählt) bereits * fällig sind. `null`, wenn es keine Auskunft gibt – dann wird gestreut wie * bisher. */ export function ausgeglichenesIntervall( tage: number, frageId: string, lastJeTag: ReadonlyMap | null, ): number { const gestreut = gestreutesIntervall(tage, frageId); if (lastJeTag === null || tage < 3) { return gestreut; } const spanne = Math.min(7, Math.max(1, Math.round(tage * 0.15))); const von = Math.max(1, tage - spanne); const bis = Math.min(MAX_INTERVALL_TAGE, tage + spanne); let bester = gestreut; let besteLast = Number.POSITIVE_INFINITY; let besterAbstand = Number.POSITIVE_INFINITY; for (let kandidat = von; kandidat <= bis; kandidat += 1) { const last = lastJeTag.get(kandidat) ?? 0; const abstand = Math.abs(kandidat - gestreut); if (last < besteLast || (last === besteLast && abstand < besterAbstand)) { bester = kandidat; besteLast = last; besterAbstand = abstand; } } return bester; } export function wiedervorlageBerechnen( bisher: Gedaechtnisstand | null, grad: FsrsGrad, abstandTage: number, tageBisTermin: number | null, istKoBereich = false, ): Wiedervorlage { const stand = naechsterStand(bisher, grad, abstandTage); if (grad === 1) { return { ...stand, intervallTage: 0 }; } const roh = intervallTage(stand.stabilitaet, zielquoteFuer(tageBisTermin, istKoBereich)); const tage = Math.min(MAX_INTERVALL_TAGE, Math.max(1, Math.round(roh))); return { ...stand, intervallTage: tage }; } /** So viele Tage weit reicht die Arbeitslast-Vorschau höchstens. */ export const VORSCHAU_TAGE = 14; /** * Die bereits angesetzten Wiederholungen der nächsten Tage. * * **Wozu.** Der Plan kannte bis 0.22.0 nur „heute“. Weil die Zielquote ab * {@link ANHEBUNG_AB_TAGEN} Tagen vor dem Termin steigt und die Intervalle * dadurch zusammenrücken, türmt sich vor der Prüfung ein Wiederholungsberg, * den der Lernende erst am jeweiligen Morgen erfährt. Wer Schichtdienst, * Familie oder einen vollen Kalender hat, kann Lerntage aber nur planen, wenn * er die kommende Last kennt. * * **Was das ist und was nicht.** Eine reine Bestandsauskunft: So viele Fragen * stehen an diesem Tag **jetzt schon** im Kalender. Keine Prognose — und * ausdrücklich eine **Untergrenze**, denn jede Antwort von heute setzt neue * Termine. Mit wachsendem Abstand wird sie systematisch leerer; die Anzeige * sagt das, statt eine leere Woche zu suggerieren. * * Die neuen Fragen bleiben draußen: Sie haben keinen Termin, sondern kommen * mit der Einführungsrate — eine planbare Größe, die daneben steht. */ export function vorschauBauen( fragen: readonly PlanFrage[], tageBisTermin: number | null, ): readonly number[] { const weite = tageBisTermin === null || tageBisTermin < 0 ? VORSCHAU_TAGE : Math.min(VORSCHAU_TAGE, tageBisTermin); const tage = new Array(weite + 1).fill(0); for (const frage of fragen) { const wann = frage.faelligInTagen; if (wann === null || wann === undefined || wann > weite) { continue; } tage[wann] = (tage[wann] ?? 0) + 1; } return tage; } /** Stellt aus den Rohdaten den Lernplan zusammen. */ export function lernplanBerechnen(daten: Planungsdaten): Lernplan { const { termin, tageBisTermin, fragen } = daten; const gesamtFragen = fragen.length; const nieBeantwortet = fragen.filter((f) => f.nieBeantwortet ?? f.stabilitaet === null).length; const faellig = fragen.filter((f) => f.faellig).length; const sekundenProFrage = daten.sekundenProFrage !== null && daten.sekundenProFrage > 0 ? daten.sekundenProFrage : SEKUNDEN_PRO_FRAGE_VORGABE; /* Dieselbe Rate, mit der `Lernstand.sitzung()` die neuen Fragen deckelt – Begründung und Randfälle stehen an der Funktion selbst. */ const neu = einfuehrungsrate(nieBeantwortet, tageBisTermin); const gesamt = neu + faellig; const minuten = Math.round((gesamt * sekundenProFrage) / 60); return { termin, tageBisTermin, gesamtFragen, nieBeantwortet, faellig, zielquote: zielquoteFuer(tageBisTermin), prognoseHeute: reifegradVon(fragen, 0), prognoseAmTermin: tageBisTermin === null || tageBisTermin < 0 ? null : reifegradVon(fragen, tageBisTermin), pensum: { neu, wiederholung: faellig, gesamt, minuten }, machbarkeit: machbarkeitFuer(minuten, tageBisTermin), sekundenProFrage, vorschau: vorschauBauen(fragen, tageBisTermin), }; }