Baustein 4: Merkkarten mit Wiederholung nach Zeitabstand

- Abgespecktes SM-2 mit drei Bewertungsstufen statt sechs
- Abstaende 1, 3, dann Abstand mal Leichtigkeit; gedeckelt bei 180 Tagen
- "nochmal" bringt die Karte in derselben Sitzung wieder
- Taeglicher Stapel, gedeckelt auf 20 Karten, ueberfaellige zuerst
- Fuenf Faecher zur Anzeige, abgeleitet aus dem Abstand
- Filter nach Themenblock fuer gezieltes Ueben
- Lernmodus fuers Handy: grosse Flaechen, Antippen, Wischen, kein Hover;
  am Rechner zusaetzlich Leertaste und Tasten 1/2/3
- Tabellen karte und karten_verlauf, jede Bewertung wird mitgeschrieben
- Merkkarten zaehlen im Wochenfortschritt mit, gelernt ab Fach 4
- Kopfzeile am Handy einzeilig, Tipp-Hervorhebung abgeschaltet
- 29 neue Tests, insgesamt 133

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 11:16:40 +02:00
co-authored by Claude Opus 5
parent 103a603eb4
commit bb592ad2bd
32 changed files with 1489 additions and 7 deletions
+130
View File
@@ -0,0 +1,130 @@
import crypto from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import { datenbank, schema } from '../db';
import { heute, jetzt } from '../zeit';
import { fach, faelligAm, giltAlsGelernt, naechsterStand, NEUE_KARTE, type Bewertung, type Kartenstand } from '../lernen/sm2';
import type { Karte } from '../db/schema';
/** Lernstand der Merkkarten. Die Rechnung steckt in ../lernen/sm2. */
export function staendeVon(benutzerId: string): Map<string, Karte> {
const zeilen = datenbank().select().from(schema.karte).where(eq(schema.karte.benutzerId, benutzerId)).all();
return new Map(zeilen.map((z) => [z.karteId, z]));
}
export function standVon(benutzerId: string, karteId: string): Karte | null {
return (
datenbank()
.select()
.from(schema.karte)
.where(and(eq(schema.karte.benutzerId, benutzerId), eq(schema.karte.karteId, karteId)))
.get() ?? null
);
}
export function alsKartenstand(zeile: Karte | null | undefined): Kartenstand {
if (!zeile) return NEUE_KARTE;
return {
wiederholungen: zeile.wiederholungen,
abstandTage: zeile.abstandTage,
leichtigkeit: zeile.leichtigkeit,
};
}
/**
* Nimmt eine Bewertung entgegen, rechnet den neuen Stand und schreibt ihn.
* Gibt den neuen Stand zurück, damit die Oberfläche gleich anzeigen kann,
* wann die Karte wiederkommt.
*/
export function bewerte(
benutzerId: string,
karteId: string,
bewertung: Bewertung,
heuteDatum = heute(),
): { stand: Kartenstand; faelligAm: string; fach: number } {
const db = datenbank();
const vorher = alsKartenstand(standVon(benutzerId, karteId));
const nachher = naechsterStand(vorher, bewertung);
const faellig = faelligAm(heuteDatum, nachher);
db.insert(schema.karte)
.values({
benutzerId,
karteId,
fach: fach(nachher),
faelligAm: faellig,
letzteBewertung: bewertung,
wiederholungen: nachher.wiederholungen,
abstandTage: nachher.abstandTage,
leichtigkeit: nachher.leichtigkeit,
zuletztGelerntAm: jetzt(),
})
.onConflictDoUpdate({
target: [schema.karte.benutzerId, schema.karte.karteId],
set: {
fach: fach(nachher),
faelligAm: faellig,
letzteBewertung: bewertung,
wiederholungen: nachher.wiederholungen,
abstandTage: nachher.abstandTage,
leichtigkeit: nachher.leichtigkeit,
zuletztGelerntAm: jetzt(),
},
})
.run();
db.insert(schema.kartenVerlauf)
.values({
id: crypto.randomUUID(),
benutzerId,
karteId,
bewertung,
bewertetAm: jetzt(),
})
.run();
return { stand: nachher, faelligAm: faellig, fach: fach(nachher) };
}
/** Setzt den Lernstand einer Karte zurück – für den Fall, dass sich der Inhalt geändert hat. */
export function zuruecksetzen(benutzerId: string, karteId: string): void {
datenbank()
.delete(schema.karte)
.where(and(eq(schema.karte.benutzerId, benutzerId), eq(schema.karte.karteId, karteId)))
.run();
}
export type Kartenuebersicht = {
gesamt: number;
gelernt: number;
faellig: number;
nieGelernt: number;
jeFach: Record<number, number>;
};
/** Zählwerk über alle Karten – für Wochenfortschritt und Auswertung. */
export function uebersicht(benutzerId: string, karteIds: string[], heuteDatum = heute()): Kartenuebersicht {
const staende = staendeVon(benutzerId);
const jeFach: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
let gelernt = 0;
let faellig = 0;
let nieGelernt = 0;
for (const karteId of karteIds) {
const zeile = staende.get(karteId);
if (!zeile) {
nieGelernt += 1;
faellig += 1;
jeFach[1] = (jeFach[1] ?? 0) + 1;
continue;
}
const stand = alsKartenstand(zeile);
jeFach[fach(stand)] = (jeFach[fach(stand)] ?? 0) + 1;
if (giltAlsGelernt(stand)) gelernt += 1;
if (zeile.faelligAm <= heuteDatum) faellig += 1;
}
return { gesamt: karteIds.length, gelernt, faellig, nieGelernt, jeFach };
}