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
+42
View File
@@ -26,6 +26,47 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht]
## [0.4.0] – 2026-09-10
Merkkarten mit Wiederholung nach Zeitabstand – der tägliche kleine Stapel
statt aller Karten auf einmal.
### Hinzugefügt
- **Wiederholungsverfahren** als abgespecktes SM-2: drei Bewertungsstufen
statt sechs (*sitzt*, *unsicher*, *nochmal*), weil im Alltag niemand
zwischen „fast richtig" und „mit Mühe richtig" unterscheidet
- Erste beiden Abstände fest bei 1 und 3 Tagen, danach greift die
Leichtigkeit (1,3 bis 2,8)
- Abstände wachsen zu 1, 3, 8, 22, 62, 174 Tagen, gedeckelt bei einem
halben Jahr
- „nochmal" setzt die Zählung zurück und bringt die Karte **noch in
derselben Sitzung** wieder – sonst wäre die Bewertung folgenlos
- Täglich fälliger Stapel, gedeckelt auf 20 Karten. Überfällige zuerst, nie
gelernte zuletzt: Wer drei Wochen ausgesetzt hat, findet nicht 52 Karten vor,
sondern die dringendsten 20
- Fünf Fächer im Sinne der Leitner-Kartei, nur zur Anzeige – abgeleitet aus dem
Abstand, damit niemand über Leichtigkeitswerte nachdenken muss
- Filter nach Themenblock, für gezieltes Üben vor einer Lernzielkontrolle
- **Lernmodus fürs Handy**: ganzflächige Karte, Antippen zum Umdrehen, Wischen
nach rechts für „sitzt" und nach links für „nochmal", drei Knöpfe mit
mindestens 76 Pixeln Höhe, kein Überfahren mit der Maus
- Am Rechner zusätzlich Leertaste zum Umdrehen und die Tasten 1, 2, 3
- Kartenübersicht: heute fällig, Stand je Themenblock, Verteilung über die
Fächer, die nächsten fünf fälligen Karten
- Tabellen `karte` (Lernstand) und `karten_verlauf` (jede einzelne Bewertung
mit Zeitstempel, Grundlage der späteren Auswertung)
- Merkkarten zählen im Wochenfortschritt mit; als gelernt gilt eine Karte ab
Fach 4, also wenn sie mindestens eine Woche weggelegt wurde
- 29 Tests für Abstände, Leichtigkeitsgrenzen, Fächer und Stapelauswahl
### Geändert
- Kopfzeile auf schmalen Bildschirmen: Marke und Bedienung in einer Zeile,
Navigation darunter in einer schiebbaren Zeile statt über drei Zeilen umbrechend
- `-webkit-tap-highlight-color` abgeschaltet – der Browser legte beim Antippen
eine blaue Fläche über die Lernkarte
## [0.3.0] – 2026-09-10
Der Wochenplan wird zur Startseite, und der Ausbilder kann ihn umplanen.
@@ -144,6 +185,7 @@ anmelden und verwalten.
- `npm run konto:anlegen` für das erste Ausbilderkonto
[Unveröffentlicht]: #unveröffentlicht
[0.4.0]: #040--2026-09-10
[0.3.0]: #030--2026-09-10
[0.2.0]: #020--2026-09-10
[0.1.0]: #010--2026-09-10
+32
View File
@@ -7,6 +7,38 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
---
## 2026-09-10 · Baustein 4 – Merkkarten
| Von | Bis | Dauer | Tätigkeit |
|---|---|---|---|
| 11:00 | 11:20 | 0:20 | Wiederholungsverfahren: SM-2 auf drei Bewertungsstufen eingedampft, Fächer, Stapelauswahl |
| 11:20 | 11:35 | 0:15 | 29 Tests dazu – einer schlug an, der Test war falsch, nicht der Code (siehe unten) |
| 11:35 | 11:50 | 0:15 | Migration, Schema, Repository, Serveraktion für die Bewertung |
| 11:50 | 12:15 | 0:25 | Lernmodus: Karte, Umdrehen, Wischen, Tastatur; Kartenübersicht mit Fächern |
| 12:15 | 12:30 | 0:15 | Durchstich am Rechner und am Handy, zwei Mängel am Handy behoben |
| 12:30 | 12:40 | 0:10 | Karten in den Wochenfortschritt gehängt, Dokumente nachgezogen |
**Summe Baustein 4: rund 1 h 40 min**
### Was aufgefallen ist
- Ein Test schlug fehl: „gibt einen leeren Stapel zurück, wenn nichts fällig
ist". Der Code lieferte eine nie gelernte Karte zurück – und hatte recht.
Eine Karte ohne Fälligkeitsdatum muss immer fällig sein, sonst käme sie nie
dran. Ich habe die Erwartung korrigiert und den Fall in zwei Tests getrennt.
- „nochmal" muss die Karte **in derselben Sitzung** wiederbringen. Sie nur auf
morgen zu legen, wäre folgenlos gewesen – man drückt „nochmal" ja gerade,
weil man sie jetzt noch einmal sehen will.
- Am Handy legte der Browser beim Antippen eine blaue Fläche über die ganze
Lernkarte. `-webkit-tap-highlight-color: transparent` behebt das; ohne einen
Blick auf ein echtes Gerätemaß wäre es nie aufgefallen.
- Die Kopfzeile brauchte am Handy drei Zeilen und schob die Karte nach unten.
Jetzt: Marke und Bedienung in einer Zeile, Navigation darunter zum Schieben.
- Der Stapel wird bei 20 Karten gedeckelt. Ohne Deckel sitzt jemand nach drei
Wochen Pause vor 52 Karten und macht es gar nicht.
---
## 2026-09-10 · Baustein 3 – Wochenplan
| Von | Bis | Dauer | Tätigkeit |
+29
View File
@@ -135,3 +135,32 @@ Gründe: `gray-matter` merkt sich Ergebnisse und liefert beim zweiten Einlesen
desselben Textes ein Objekt ohne das Feld `matter` – im Betrieb fällt das nie
auf, im Test sofort. Und der Zeilenversatz für die Fehlermeldungen ist so
nachweislich richtig statt nachgebaut.
## E14 · SM-2 mit drei Bewertungsstufen statt sechs
Das Original arbeitet mit einer Skala von 0 bis 5. In der Praxis unterscheidet
niemand ehrlich zwischen „fast richtig" und „mit Mühe richtig" – und eine
Selbstbewertung, die man nicht ehrlich abgeben kann, verfälscht die Rechnung.
Deshalb: **sitzt** / **unsicher** / **nochmal**. Die Leichtigkeit bewegt sich
weiterhin zwischen 1,3 und 2,8, die Abstände folgen 1, 3, dann Abstand mal
Leichtigkeit. Gedeckelt wird bei 180 Tagen – die Ausbildung dauert drei Jahre,
weiter zu planen bringt nichts.
## E15 · „nochmal" bringt die Karte in derselben Sitzung wieder
Erste Fassung legte sie nur auf den nächsten Tag. Das ist falsch: Wer „nochmal"
drückt, will die Karte *jetzt* noch einmal sehen. Sie wird deshalb hinten an den
laufenden Stapel gehängt.
## E16 · Der tägliche Stapel wird bei 20 Karten gedeckelt
Ohne Deckel sitzt jemand nach drei Wochen Pause vor 52 fälligen Karten und
macht es gar nicht. Sortiert wird nach Überfälligkeit; nie gelernte Karten
kommen zuletzt, damit zuerst der Rückstand abgebaut wird.
## E17 · Als „gelernt" gilt eine Karte ab Fach 4
Fach 4 heißt: Abstand über drei Tage, also mindestens dreimal in Folge gekonnt.
Das ist die Schwelle, ab der die Karte im Wochenfortschritt als erledigt zählt.
Wer sie einmal richtig hatte, hat sie noch nicht gelernt.
+9 -1
View File
@@ -44,7 +44,15 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
- [x] Reihenfolge per Ziehen und Ablegen **und** per Tastatur
- [x] Zustand je Woche (geplant / läuft / abgeschlossen) und Notiz für den Azubi
- [x] Warnung bei sich überlappenden Terminen
- [ ] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
- [x] **B4 · Merkkarten mit Wiederholung nach Zeitabstand (SM-2)**
- [x] Vereinfachtes SM-2 mit drei Bewertungsstufen statt sechs
- [x] Täglich fälliger Stapel, gedeckelt auf 20 Karten
- [x] Fünf Fächer zur Anzeige, abgeleitet aus dem Abstand
- [x] Filter nach Themenblock für gezieltes Üben vor der Kontrolle
- [x] Lernmodus fürs Handy: große Flächen, Antippen, Wischen, kein Hover
- [x] Am Rechner zusätzlich Leertaste und Tasten 1 / 2 / 3
- [x] Jede Bewertung wird mitgeschrieben (Verlauf für die spätere Auswertung)
- [x] Merkkarten zählen im Wochenfortschritt mit
## 0.4 – Nachweise
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+14 -1
View File
@@ -97,9 +97,13 @@ async function main() {
await seite.click('.pruefungsfrage > summary');
await bild(seite, '13-handout-n3');
await seite.goto(`${BASIS}/karten`);
await seite.waitForSelector('h1');
await bild(seite, '14-merkkarten');
await seite.goto(`${BASIS}/glossar`);
await seite.waitForSelector('h1');
await bild(seite, '14-glossar');
await bild(seite, '15-glossar');
// --- Dunkle Darstellung ---
await seite.goto(`${BASIS}/handouts/N3`);
@@ -123,6 +127,15 @@ async function main() {
await handySeite.waitForSelector('h1');
await bild(handySeite, '30-handy-handout');
await handySeite.goto(`${BASIS}/karten/lernen`);
await handySeite.waitForSelector('.lernkarte, h1');
if (await handySeite.$('.lernkarte')) {
await bild(handySeite, '31-handy-lernmodus-frage', false);
await handySeite.tap('.lernkarte');
await handySeite.waitForSelector('.lernkarte-antwort');
await bild(handySeite, '32-handy-lernmodus-antwort', false);
}
await browser.close();
console.log('Fertig.');
}
+56
View File
@@ -0,0 +1,56 @@
'use server';
import { revalidatePath } from 'next/cache';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { bewerte } from '@/lib/repos/karten';
import { bewertungen, fachNamen, type Bewertung } from '@/lib/lernen/sm2';
import { alsDatumText, heute } from '@/lib/zeit';
export type Bewertungsergebnis = {
fehler?: string;
/** Wann die Karte wieder drankommt, in Worten. */
wiederAm?: string;
fach?: number;
fachName?: string;
/** Kommt die Karte noch in dieser Sitzung wieder? */
nochHeute?: boolean;
};
/**
* Eine Selbstbewertung entgegennehmen.
*
* Nur der Azubi lernt – der Ausbilder sieht die Karten, bewertet sie aber nicht
* für ihn. Sonst stünde im Lernstand etwas, das der Azubi nie gekonnt hat.
*/
export async function karteBewerten(karteId: string, bewertung: string): Promise<Bewertungsergebnis> {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') {
return { fehler: 'Nur der Azubi führt seinen eigenen Lernstand.' };
}
if (!inhalte().karteNach.has(karteId)) {
return { fehler: 'Diese Karte gibt es nicht.' };
}
if (!bewertungen.includes(bewertung as Bewertung)) {
return { fehler: 'Unbekannte Bewertung.' };
}
const heuteDatum = heute();
const ergebnis = bewerte(benutzer.id, karteId, bewertung as Bewertung, heuteDatum);
revalidatePath('/karten');
revalidatePath('/');
return {
wiederAm:
ergebnis.faelligAm <= heuteDatum
? 'heute noch einmal'
: ergebnis.stand.abstandTage === 1
? 'morgen'
: `in ${ergebnis.stand.abstandTage} Tagen (${alsDatumText(ergebnis.faelligAm)})`,
fach: ergebnis.fach,
fachName: fachNamen[ergebnis.fach],
nochHeute: ergebnis.faelligAm <= heuteDatum,
};
}
@@ -0,0 +1,241 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { karteBewerten, type Bewertungsergebnis } from '../aktionen';
export type Lernkarte = {
id: string;
frage: string;
antwort: string;
handoutId: string;
handoutTitel: string;
blockTitel: string;
fach: number;
fachName: string;
};
type Bewertung = 'sitzt' | 'unsicher' | 'nochmal';
const knoepfe: { bewertung: Bewertung; text: string; taste: string; klasse: string; erklaerung: string }[] = [
{ bewertung: 'nochmal', text: 'Nochmal', taste: '1', klasse: 'bewerten-nochmal', erklaerung: 'kommt heute wieder' },
{ bewertung: 'unsicher', text: 'Unsicher', taste: '2', klasse: 'bewerten-unsicher', erklaerung: 'kommt morgen' },
{ bewertung: 'sitzt', text: 'Sitzt', taste: '3', klasse: 'bewerten-sitzt', erklaerung: 'kommt später' },
];
/**
* Der Lernmodus.
*
* Gebaut für das Handy: große Flächen, Antippen zum Umdrehen, Wischen als
* Abkürzung, kein Überfahren mit der Maus. Am Rechner tun es zusätzlich die
* Tasten 1, 2, 3 und die Leertaste.
*/
export function Lernmodus({ karten, block }: { karten: Lernkarte[]; block: string | null }) {
const [stapel, setzeStapel] = useState<Lernkarte[]>(karten);
const [stelle, setzeStelle] = useState(0);
const [umgedreht, setzeUmgedreht] = useState(false);
const [laeuft, setzeLaeuft] = useState(false);
const [rueckmeldung, setzeRueckmeldung] = useState<Bewertungsergebnis | null>(null);
const [erledigt, setzeErledigt] = useState(0);
const [zaehler, setzeZaehler] = useState<Record<Bewertung, number>>({ sitzt: 0, unsicher: 0, nochmal: 0 });
const beruehrungStart = useRef<{ x: number; y: number } | null>(null);
const karte = stapel[stelle];
const fertig = !karte;
const bewerten = useCallback(
async (bewertung: Bewertung) => {
if (!karte || laeuft) return;
setzeLaeuft(true);
const ergebnis = await karteBewerten(karte.id, bewertung);
setzeRueckmeldung(ergebnis);
setzeZaehler((z) => ({ ...z, [bewertung]: z[bewertung] + 1 }));
setzeErledigt((e) => e + 1);
// „Nochmal" heißt: heute wieder. Also hinten anhängen, statt sie
// erst am nächsten Tag zu zeigen – sonst ist die Bewertung folgenlos.
if (bewertung === 'nochmal') {
setzeStapel((s) => [...s, karte]);
}
setzeUmgedreht(false);
setzeStelle((s) => s + 1);
setzeLaeuft(false);
},
[karte, laeuft],
);
useEffect(() => {
function beiTaste(ereignis: KeyboardEvent) {
if (ereignis.target instanceof HTMLElement && ['INPUT', 'TEXTAREA'].includes(ereignis.target.tagName)) return;
if (ereignis.key === ' ' || ereignis.key === 'Enter') {
ereignis.preventDefault();
if (!umgedreht) setzeUmgedreht(true);
return;
}
if (!umgedreht) return;
const treffer = knoepfe.find((k) => k.taste === ereignis.key);
if (treffer) {
ereignis.preventDefault();
void bewerten(treffer.bewertung);
}
}
window.addEventListener('keydown', beiTaste);
return () => window.removeEventListener('keydown', beiTaste);
}, [umgedreht, bewerten]);
function beiBeruehrungStart(ereignis: React.TouchEvent) {
const punkt = ereignis.touches[0];
if (punkt) beruehrungStart.current = { x: punkt.clientX, y: punkt.clientY };
}
function beiBeruehrungEnde(ereignis: React.TouchEvent) {
const start = beruehrungStart.current;
const punkt = ereignis.changedTouches[0];
beruehrungStart.current = null;
if (!start || !punkt) return;
const dx = punkt.clientX - start.x;
const dy = punkt.clientY - start.y;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy)) return; // kein eindeutiges Wischen
if (!umgedreht) {
setzeUmgedreht(true);
return;
}
void bewerten(dx > 0 ? 'sitzt' : 'nochmal');
}
if (fertig) {
return (
<div className="stapel">
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2rem 1.25rem' }}>
<p style={{ fontSize: '2.5rem', margin: 0 }} aria-hidden="true">
✓
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Stapel durch</h1>
<p className="leise">
{erledigt} {erledigt === 1 ? 'Karte' : 'Karten'} bewertet.
</p>
<ul className="bewertung-bilanz">
<li>
<strong>{zaehler.sitzt}</strong> <span>sitzt</span>
</li>
<li>
<strong>{zaehler.unsicher}</strong> <span>unsicher</span>
</li>
<li>
<strong>{zaehler.nochmal}</strong> <span>nochmal</span>
</li>
</ul>
<p style={{ marginBottom: 0 }}>
Morgen wartet der nächste Stapel. Wer täglich zehn Minuten macht, hat vor der Prüfung nichts
nachzuholen.
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/karten" className="knopf">
Zurück zur Übersicht
</Link>
<Link href="/" className="knopf knopf-zweit">
Zur Woche
</Link>
</div>
</div>
</div>
);
}
const uebrig = stapel.length - stelle;
return (
<div className="lernmodus">
<div className="lernmodus-kopf nicht-drucken">
<Link href="/karten" className="knopf knopf-still">
← Abbrechen
</Link>
<span className="leise">
{block ? `${karte.blockTitel} · ` : ''}
noch {uebrig} {uebrig === 1 ? 'Karte' : 'Karten'}
</span>
</div>
<div
className="balken"
role="progressbar"
aria-valuenow={erledigt}
aria-valuemin={0}
aria-valuemax={erledigt + uebrig}
aria-label={`${erledigt} von ${erledigt + uebrig} Karten bewertet`}
>
<span style={{ width: `${Math.round((erledigt / (erledigt + uebrig)) * 100)}%` }} />
</div>
<button
type="button"
className={`lernkarte${umgedreht ? ' lernkarte-offen' : ''}`}
onClick={() => !umgedreht && setzeUmgedreht(true)}
onTouchStart={beiBeruehrungStart}
onTouchEnd={beiBeruehrungEnde}
aria-label={umgedreht ? 'Antwort' : 'Frage – zum Umdrehen antippen'}
aria-expanded={umgedreht}
>
<span className="lernkarte-herkunft">
<span className="handout-kuerzel">{karte.handoutId}</span>
<span className="leise">{karte.handoutTitel}</span>
</span>
<span className="lernkarte-frage">{karte.frage}</span>
{umgedreht ? (
<>
<span className="lernkarte-trenner" aria-hidden="true" />
<span className="lernkarte-antwort">{karte.antwort}</span>
</>
) : (
<span className="lernkarte-hinweis">
Antworte erst laut oder im Kopf.
<br />
<strong>Antippen zum Umdrehen</strong>
<span className="nur-am-rechner"> · oder Leertaste</span>
</span>
)}
</button>
{umgedreht ? (
<div className="bewerten">
{knoepfe.map((knopf) => (
<button
key={knopf.bewertung}
type="button"
className={`bewerten-knopf ${knopf.klasse}`}
onClick={() => void bewerten(knopf.bewertung)}
disabled={laeuft}
>
<span className="bewerten-text">{knopf.text}</span>
<span className="bewerten-erklaerung">{knopf.erklaerung}</span>
<span className="bewerten-taste nur-am-rechner" aria-hidden="true">
{knopf.taste}
</span>
</button>
))}
</div>
) : (
<p className="leise lernmodus-fuss">
Wischen: nach rechts für &bdquo;sitzt&ldquo;, nach links für &bdquo;nochmal&ldquo;.
</p>
)}
<p aria-live="polite" className="lernmodus-rueckmeldung">
{rueckmeldung?.wiederAm ? `Zuletzt: kommt ${rueckmeldung.wiederAm}.` : ''}
{rueckmeldung?.fehler ? rueckmeldung.fehler : ''}
</p>
</div>
);
}
@@ -0,0 +1,77 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { alsKartenstand, staendeVon } from '@/lib/repos/karten';
import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2';
import { heute } from '@/lib/zeit';
import { Lernmodus, type Lernkarte } from './Lernmodus';
export const metadata: Metadata = { title: 'Lernmodus' };
export const dynamic = 'force-dynamic';
const STAPELGROESSE = 20;
export default async function LernSeite({ searchParams }: { searchParams: Promise<{ block?: string }> }) {
const benutzer = await erfordereBenutzer();
if (benutzer.rolle !== 'azubi') redirect('/karten');
const { block } = await searchParams;
const alle = inhalte();
const gewaehlterBlock = block && alle.blockNach.has(block) ? block : undefined;
const staende = staendeVon(benutzer.id);
const heuteDatum = heute();
const faellige = heutigerStapel(
alle.karten.map((karte) => ({
karteId: karte.id,
faelligAm: staende.get(karte.id)?.faelligAm ?? null,
block: karte.block,
})),
heuteDatum,
STAPELGROESSE,
gewaehlterBlock,
);
const karten: Lernkarte[] = faellige.map((eintrag) => {
const karte = alle.karteNach.get(eintrag.karteId)!;
const stand = alsKartenstand(staende.get(karte.id));
return {
id: karte.id,
frage: karte.frage,
antwort: karte.antwort,
handoutId: karte.handoutId,
handoutTitel: karte.handoutTitel,
blockTitel: alle.blockNach.get(karte.block)?.titel ?? karte.block,
fach: fach(stand),
fachName: fachNamen[fach(stand)] ?? '',
};
});
if (karten.length === 0) {
return (
<div className="stapel">
<div className="karte-grund stapel" style={{ textAlign: 'center', padding: '2rem 1.25rem' }}>
<p style={{ fontSize: '2.5rem', margin: 0 }} aria-hidden="true">
✓
</p>
<h1 style={{ marginBottom: '0.2rem' }}>Für heute nichts fällig</h1>
<p className="leise" style={{ marginBottom: 0 }}>
{gewaehlterBlock
? 'In diesem Themenblock ist heute keine Karte dran.'
: 'Alle Karten sitzen gerade fest genug. Morgen kommt der nächste Stapel.'}
</p>
<div className="reihe" style={{ justifyContent: 'center' }}>
<Link href="/karten" className="knopf">
Zurück zur Übersicht
</Link>
</div>
</div>
</div>
);
}
return <Lernmodus karten={karten} block={gewaehlterBlock ?? null} />;
}
+190
View File
@@ -0,0 +1,190 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { erfordereBenutzer } from '@/lib/auth/rechte';
import { inhalte } from '@/lib/inhalte';
import { alsKartenstand, staendeVon, uebersicht } from '@/lib/repos/karten';
import { fach, fachNamen, heutigerStapel } from '@/lib/lernen/sm2';
import { alsDatumText, heute } from '@/lib/zeit';
export const metadata: Metadata = { title: 'Merkkarten' };
export const dynamic = 'force-dynamic';
const STAPELGROESSE = 20;
export default async function KartenSeite() {
const benutzer = await erfordereBenutzer();
const alle = inhalte();
const heuteDatum = heute();
const istAzubi = benutzer.rolle === 'azubi';
const staende = istAzubi ? staendeVon(benutzer.id) : new Map();
const stand = istAzubi
? uebersicht(benutzer.id, alle.karten.map((k) => k.id), heuteDatum)
: { gesamt: alle.karten.length, gelernt: 0, faellig: 0, nieGelernt: alle.karten.length, jeFach: { 1: alle.karten.length, 2: 0, 3: 0, 4: 0, 5: 0 } };
const faelligeAlle = heutigerStapel(
alle.karten.map((karte) => ({
karteId: karte.id,
faelligAm: staende.get(karte.id)?.faelligAm ?? null,
block: karte.block,
})),
heuteDatum,
9999,
);
const jeBlock = alle.bloecke.map((block) => {
const desBlocks = alle.karten.filter((k) => k.block === block.id);
const faellig = faelligeAlle.filter((f) => f.block === block.id).length;
const gelernt = desBlocks.filter((k) => {
const zeile = staende.get(k.id);
return zeile ? fach(alsKartenstand(zeile)) >= 4 : false;
}).length;
return { block, gesamt: desBlocks.length, faellig, gelernt };
});
if (!istAzubi) {
return (
<div className="stapel">
<div>
<h1>Merkkarten</h1>
<p className="leise">
{alle.karten.length} Karten aus {alle.handouts.length} Handouts. Gelernt wird nur vom Azubi – du
siehst hier, was es gibt.
</p>
</div>
<div className="raster">
{jeBlock.map(({ block, gesamt }) => (
<section key={block.id} className="karte">
<h2 style={{ fontSize: '1rem', marginBottom: '0.2rem' }}>{block.titel}</h2>
<p className="leise" style={{ marginBottom: 0 }}>
{gesamt} {gesamt === 1 ? 'Karte' : 'Karten'}
</p>
</section>
))}
</div>
</div>
);
}
const heuteStapel = Math.min(stand.faellig, STAPELGROESSE);
return (
<div className="stapel">
<div>
<h1>Merkkarten</h1>
<p className="leise">
{stand.gesamt} Karten insgesamt · {stand.gelernt} sitzen · {stand.nieGelernt} noch nie gelernt
</p>
</div>
<section className="karte-grund stapel" aria-labelledby="heute">
<h2 id="heute" style={{ marginBottom: 0 }}>
Heute fällig
</h2>
{stand.faellig === 0 ? (
<p style={{ marginBottom: 0 }}>
Nichts. Alle Karten liegen weiter hinten – morgen ist wieder etwas dran.
</p>
) : (
<>
<p style={{ marginBottom: 0 }}>
<strong style={{ fontSize: '2rem' }}>{heuteStapel}</strong>{' '}
{heuteStapel === 1 ? 'Karte' : 'Karten'}
{stand.faellig > STAPELGROESSE ? (
<span className="leise"> von {stand.faellig} fälligen – der Rest kommt morgen</span>
) : null}
</p>
<div>
<Link href="/karten/lernen" className="knopf knopf-akzent">
Lernen starten
</Link>
</div>
</>
)}
</section>
<section className="stapel" aria-labelledby="bloecke">
<div>
<h2 id="bloecke" style={{ marginBottom: '0.2rem' }}>
Nach Themenblock
</h2>
<p className="leise" style={{ margin: 0 }}>
Vor einer Lernzielkontrolle gezielt üben – hier sind auch Karten dabei, die eigentlich erst später
dran wären.
</p>
</div>
<div className="raster">
{jeBlock.map(({ block, gesamt, faellig, gelernt }) => (
<section key={block.id} className="karte stapel-eng">
<h3 style={{ fontSize: '1rem', marginBottom: '0.2rem' }}>{block.titel}</h3>
<p className="leise" style={{ margin: 0 }}>
{gesamt} Karten · {gelernt} sitzen · {faellig} heute fällig
</p>
<div className="balken" aria-hidden="true">
<span style={{ width: `${gesamt === 0 ? 0 : Math.round((gelernt / gesamt) * 100)}%` }} />
</div>
<div>
<Link
href={`/karten/lernen?block=${block.id}`}
className={`knopf ${faellig > 0 ? '' : 'knopf-zweit'}`}
>
{faellig > 0 ? `${Math.min(faellig, STAPELGROESSE)} lernen` : 'Trotzdem üben'}
</Link>
</div>
</section>
))}
</div>
</section>
<section className="karte stapel-eng" aria-labelledby="faecher">
<h2 id="faecher" style={{ fontSize: '1rem', marginBottom: '0.2rem' }}>
Wie fest die Karten sitzen
</h2>
<ul className="faecher">
{[1, 2, 3, 4, 5].map((nummer) => {
const anzahl = stand.jeFach[nummer] ?? 0;
const anteil = stand.gesamt === 0 ? 0 : Math.round((anzahl / stand.gesamt) * 100);
return (
<li key={nummer}>
<span className="fach-nummer">{nummer}</span>
<span className="fach-name">{fachNamen[nummer]}</span>
<span className="fach-balken" aria-hidden="true">
<span style={{ width: `${anteil}%` }} />
</span>
<span className="fach-zahl">{anzahl}</span>
</li>
);
})}
</ul>
</section>
{stand.gesamt > stand.nieGelernt ? (
<section className="karte" aria-labelledby="naechste">
<h2 id="naechste" style={{ fontSize: '1rem', marginBottom: '0.4rem' }}>
Als Nächstes fällig
</h2>
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{[...staende.values()]
.filter((z) => z.faelligAm > heuteDatum)
.sort((a, b) => a.faelligAm.localeCompare(b.faelligAm))
.slice(0, 5)
.map((z) => {
const karte = alle.karteNach.get(z.karteId);
if (!karte) return null;
return (
<li key={z.karteId} className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
<span className="handout-kuerzel">{karte.handoutId}</span>
<span style={{ flex: 1 }}>{karte.frage}</span>
<span className="leise">{alsDatumText(z.faelligAm)}</span>
</li>
);
})}
</ul>
</section>
) : null}
</div>
);
}
+2
View File
@@ -9,6 +9,7 @@ export const dynamic = 'force-dynamic';
const fuerAzubi: Navigationseintrag[] = [
{ pfad: '/', titel: 'Woche' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/glossar', titel: 'Glossar' },
];
@@ -16,6 +17,7 @@ const fuerAusbilder: Navigationseintrag[] = [
{ pfad: '/', titel: 'Überblick' },
{ pfad: '/wochenplan', titel: 'Wochenplan' },
{ pfad: '/handouts', titel: 'Handouts' },
{ pfad: '/karten', titel: 'Merkkarten' },
{ pfad: '/glossar', titel: 'Glossar' },
{ pfad: '/verwaltung', titel: 'Verwaltung' },
];
+222
View File
@@ -878,3 +878,225 @@ a.glossarbegriff:hover {
.planzeile-griff,
.planzeile-knoepfe { display: none; }
}
/* --- Merkkarten: Lernmodus --- */
.lernmodus {
display: flex;
flex-direction: column;
gap: 0.9rem;
min-height: calc(100dvh - 8rem);
}
.lernmodus-kopf {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.lernkarte {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.9rem;
width: 100%;
min-height: 46vh;
text-align: left;
font: inherit;
color: var(--text);
background: var(--grund);
border: 2px solid var(--rand);
border-radius: var(--radius);
padding: 1.25rem;
cursor: pointer;
box-shadow: var(--schatten);
}
.lernkarte-offen {
cursor: default;
border-color: var(--petrol);
}
.lernkarte-herkunft {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.lernkarte-frage {
font-size: 1.35rem;
font-weight: 650;
line-height: 1.35;
}
.lernkarte-trenner {
display: block;
border-top: 2px dashed var(--rand);
margin: 0.2rem 0;
}
.lernkarte-antwort {
font-size: 1.15rem;
line-height: 1.55;
}
.lernkarte-hinweis {
margin-top: auto;
text-align: center;
color: var(--text-leise);
font-size: 0.95rem;
line-height: 1.7;
}
.bewerten {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.bewerten-knopf {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
min-height: 76px;
padding: 0.6rem 0.4rem;
border-radius: var(--radius);
border: 2px solid;
font: inherit;
cursor: pointer;
background: var(--grund);
}
.bewerten-knopf:disabled { opacity: 0.5; cursor: wait; }
.bewerten-text { font-weight: 700; font-size: 1.05rem; }
.bewerten-erklaerung { font-size: 0.78rem; opacity: 0.85; text-align: center; }
.bewerten-taste {
position: absolute;
top: 0.3rem;
right: 0.45rem;
font-size: 0.7rem;
opacity: 0.55;
font-family: var(--schrift-fest);
}
.bewerten-nochmal { border-color: var(--fehler); color: var(--fehler); background: var(--fehler-flaeche); }
.bewerten-unsicher { border-color: var(--warnung); color: var(--warnung); background: var(--warnung-flaeche); }
.bewerten-sitzt { border-color: var(--gut); color: var(--gut); background: var(--gut-flaeche); }
.lernmodus-fuss { text-align: center; margin: 0; }
.lernmodus-rueckmeldung {
text-align: center;
min-height: 1.4rem;
margin: 0;
font-size: 0.9rem;
color: var(--text-leise);
}
/* Auf Geräten ohne Tastatur die Tastenhinweise weglassen. */
@media (hover: none) and (pointer: coarse) {
.nur-am-rechner { display: none; }
}
.bewertung-bilanz {
list-style: none;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
gap: 1.75rem;
}
.bewertung-bilanz strong { display: block; font-size: 1.6rem; }
.bewertung-bilanz span { font-size: 0.85rem; color: var(--text-leise); }
/* --- Fächerübersicht --- */
.faecher { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
.faecher li {
display: grid;
grid-template-columns: 1.6rem minmax(7rem, auto) 1fr 2.5rem;
align-items: center;
gap: 0.6rem;
font-size: 0.92rem;
}
.fach-nummer {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background: var(--flaeche-kraeftig);
font-weight: 700;
font-size: 0.8rem;
}
.fach-name { color: var(--text-leise); }
.fach-balken {
display: block;
height: 8px;
background: var(--flaeche-kraeftig);
border-radius: 999px;
overflow: hidden;
}
.fach-balken > span { display: block; height: 100%; background: var(--petrol); }
.fach-zahl { text-align: right; font-variant-numeric: tabular-nums; }
@media (max-width: 520px) {
.faecher li { grid-template-columns: 1.6rem 1fr 2.5rem; }
.fach-name { display: none; }
}
/* --- Feinschliff fürs Handy --- */
/* Ohne das legt der Browser beim Antippen eine blaue Fläche über die Karte. */
.lernkarte,
.bewerten-knopf,
.knopf,
.kopf-knopf,
.hauptnavigation a {
-webkit-tap-highlight-color: transparent;
}
.lernkarte:focus-visible {
outline: 3px solid var(--bernstein);
outline-offset: 3px;
}
@media (max-width: 640px) {
/* Die Kopfzeile bleibt einzeilig: Marke links, Bedienung rechts,
die Navigation darunter in einer Zeile zum Schieben. */
.kopf-innen {
gap: 0.5rem 0.75rem;
padding: 0.5rem 0.75rem;
}
.hauptnavigation {
order: 3;
flex: 1 0 100%;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
margin: 0 -0.75rem;
padding: 0 0.75rem;
}
.hauptnavigation::-webkit-scrollbar { display: none; }
.hauptnavigation a { padding: 0.35rem 0.55rem; font-size: 0.9rem; }
.kopf-rechts { gap: 0.35rem; }
.kopf-rechts > .leise { display: none; }
.huelle { padding: 1rem 0.85rem 3rem; }
h1 { font-size: 1.4rem; }
h2 { font-size: 1.15rem; }
.lernmodus { min-height: calc(100dvh - 6rem); }
.lernkarte { min-height: 40vh; padding: 1rem; }
.lernkarte-frage { font-size: 1.2rem; }
.bewerten { gap: 0.4rem; }
.bewerten-knopf { min-height: 84px; }
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function Kopfzeile({
</nav>
<div className="kopf-rechts">
<span className="leise" style={{ color: '#cfe2e9', fontSize: '0.85rem' }}>
<span className="leise" style={{ color: '#cfe2e9', fontSize: '0.85rem' }} title={`${name} – ${rolle}`}>
{name} · {rolle}
</span>
<ThemaUmschalter />
+35
View File
@@ -0,0 +1,35 @@
-- Baustein 4: Lernstand der Merkkarten.
--
-- Frage und Antwort stehen im Handout, also in einer Datei. Hier steht nur,
-- wie gut die Karte bei diesem Benutzer sitzt und wann sie wieder dran ist.
--
-- fach 1 bis 5, nur zur Anzeige – abgeleitet aus dem Abstand
-- abstand_tage Abstand bis zur nächsten Fälligkeit (0 = heute wieder)
-- leichtigkeit Faktor aus SM-2, zwischen 1,3 und 2,8
CREATE TABLE karte (
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
karte_id TEXT NOT NULL,
fach INTEGER NOT NULL DEFAULT 1 CHECK (fach BETWEEN 1 AND 5),
faellig_am TEXT NOT NULL,
letzte_bewertung TEXT CHECK (letzte_bewertung IN ('sitzt', 'unsicher', 'nochmal')),
wiederholungen INTEGER NOT NULL DEFAULT 0,
abstand_tage INTEGER NOT NULL DEFAULT 0,
leichtigkeit REAL NOT NULL DEFAULT 2.5,
zuletzt_gelernt_am TEXT,
PRIMARY KEY (benutzer_id, karte_id)
);
CREATE INDEX karte_faellig_idx ON karte (benutzer_id, faellig_am);
-- Jede Bewertung wird mitgeschrieben. Das kostet fast nichts und erlaubt
-- später eine ehrliche Auswertung – ohne sie sieht man nur den Endstand.
CREATE TABLE karten_verlauf (
id TEXT PRIMARY KEY,
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
karte_id TEXT NOT NULL,
bewertung TEXT NOT NULL CHECK (bewertung IN ('sitzt', 'unsicher', 'nochmal')),
bewertet_am TEXT NOT NULL
);
CREATE INDEX karten_verlauf_benutzer_idx ON karten_verlauf (benutzer_id, bewertet_am);
+39 -2
View File
@@ -1,4 +1,4 @@
import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { index, integer, primaryKey, real, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
/** Rollen – bewusst nur zwei. */
export const rollen = ['ausbilder', 'azubi'] as const;
@@ -89,11 +89,48 @@ export const wochenplanung = sqliteTable(
],
);
export const kartenBewertungen = ['sitzt', 'unsicher', 'nochmal'] as const;
export type KartenBewertung = (typeof kartenBewertungen)[number];
export const karte = sqliteTable(
'karte',
{
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
karteId: text('karte_id').notNull(),
fach: integer('fach').notNull().default(1),
faelligAm: text('faellig_am').notNull(),
letzteBewertung: text('letzte_bewertung', { enum: kartenBewertungen }),
wiederholungen: integer('wiederholungen').notNull().default(0),
abstandTage: integer('abstand_tage').notNull().default(0),
leichtigkeit: real('leichtigkeit').notNull().default(2.5),
zuletztGelerntAm: text('zuletzt_gelernt_am'),
},
(t) => [primaryKey({ columns: [t.benutzerId, t.karteId] }), index('karte_faellig_idx').on(t.benutzerId, t.faelligAm)],
);
export const kartenVerlauf = sqliteTable(
'karten_verlauf',
{
id: text('id').primaryKey(),
benutzerId: text('benutzer_id')
.notNull()
.references(() => benutzer.id, { onDelete: 'cascade' }),
karteId: text('karte_id').notNull(),
bewertung: text('bewertung', { enum: kartenBewertungen }).notNull(),
bewertetAm: text('bewertet_am').notNull(),
},
(t) => [index('karten_verlauf_benutzer_idx').on(t.benutzerId, t.bewertetAm)],
);
export type Benutzer = typeof benutzer.$inferSelect;
export type Sitzung = typeof sitzung.$inferSelect;
export type Ausbildung = typeof ausbildung.$inferSelect;
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
export type Wochenplanung = typeof wochenplanung.$inferSelect;
export type Karte = typeof karte.$inferSelect;
export type KartenVerlauf = typeof kartenVerlauf.$inferSelect;
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen, wochenplanung };
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen, wochenplanung, karte, kartenVerlauf };
+208
View File
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest';
import {
fach,
faelligAm,
giltAlsGelernt,
heutigerStapel,
HOECHSTER_ABSTAND_TAGE,
HOECHSTE_LEICHTIGKEIT,
MINDEST_LEICHTIGKEIT,
naechsterStand,
NEUE_KARTE,
type Bewertung,
type Kartenstand,
} from './sm2';
/** Bewertet eine Karte der Reihe nach und gibt den Stand danach zurück. */
function lerne(bewertungen: Bewertung[], start: Kartenstand = NEUE_KARTE): Kartenstand {
return bewertungen.reduce((stand, bewertung) => naechsterStand(stand, bewertung), start);
}
describe('Abstände beim Lernen', () => {
it('legt die erste Wiederholung auf morgen', () => {
const stand = naechsterStand(NEUE_KARTE, 'sitzt');
expect(stand.abstandTage).toBe(1);
expect(stand.wiederholungen).toBe(1);
});
it('legt die zweite Wiederholung auf drei Tage', () => {
expect(lerne(['sitzt', 'sitzt']).abstandTage).toBe(3);
});
it('rechnet ab der dritten Wiederholung mit der Leichtigkeit', () => {
const stand = lerne(['sitzt', 'sitzt', 'sitzt']);
// 3 Tage mal Leichtigkeit (2,5 + dreimal 0,1 = 2,8, gedeckelt) = 8 Tage
expect(stand.abstandTage).toBe(8);
expect(stand.wiederholungen).toBe(3);
});
it('lässt die Abstände über die Zeit deutlich wachsen', () => {
const abstaende: number[] = [];
let stand = NEUE_KARTE;
for (let i = 0; i < 6; i += 1) {
stand = naechsterStand(stand, 'sitzt');
abstaende.push(stand.abstandTage);
}
expect(abstaende).toEqual([1, 3, 8, 22, 62, 174]);
});
it('deckelt den Abstand bei einem halben Jahr', () => {
let stand = NEUE_KARTE;
for (let i = 0; i < 20; i += 1) stand = naechsterStand(stand, 'sitzt');
expect(stand.abstandTage).toBe(HOECHSTER_ABSTAND_TAGE);
});
});
describe('Bewertung „unsicher"', () => {
it('zählt als gekonnt, legt die Karte aber auf morgen', () => {
const stand = lerne(['sitzt', 'sitzt', 'sitzt', 'unsicher']);
expect(stand.abstandTage).toBe(1);
expect(stand.wiederholungen).toBe(4);
});
it('senkt die Leichtigkeit', () => {
const vorher = lerne(['sitzt']);
const nachher = naechsterStand(vorher, 'unsicher');
expect(nachher.leichtigkeit).toBeLessThan(vorher.leichtigkeit);
});
});
describe('Bewertung „nochmal"', () => {
it('setzt die Zählung zurück und bringt die Karte heute wieder', () => {
const stand = lerne(['sitzt', 'sitzt', 'sitzt', 'nochmal']);
expect(stand.wiederholungen).toBe(0);
expect(stand.abstandTage).toBe(0);
});
it('macht den Aufbau danach wieder von vorn', () => {
const stand = lerne(['sitzt', 'sitzt', 'sitzt', 'nochmal', 'sitzt']);
expect(stand.abstandTage).toBe(1);
});
it('behält die gesenkte Leichtigkeit über den Rücksetzer hinweg', () => {
const stand = lerne(['nochmal', 'nochmal']);
expect(stand.leichtigkeit).toBeCloseTo(2.1, 5);
});
});
describe('Leichtigkeit', () => {
it('fällt nie unter den Mindestwert, auch nach vielen Fehlversuchen', () => {
let stand = NEUE_KARTE;
for (let i = 0; i < 30; i += 1) stand = naechsterStand(stand, 'nochmal');
expect(stand.leichtigkeit).toBe(MINDEST_LEICHTIGKEIT);
});
it('steigt nie über den Höchstwert', () => {
let stand = NEUE_KARTE;
for (let i = 0; i < 30; i += 1) stand = naechsterStand(stand, 'sitzt');
expect(stand.leichtigkeit).toBe(HOECHSTE_LEICHTIGKEIT);
});
it('sorgt dafür, dass eine schwere Karte häufiger kommt als eine leichte', () => {
const schwer = lerne(['nochmal', 'nochmal', 'sitzt', 'sitzt', 'sitzt']);
const leicht = lerne(['sitzt', 'sitzt', 'sitzt']);
expect(schwer.abstandTage).toBeLessThan(leicht.abstandTage);
});
});
describe('Fälligkeitsdatum', () => {
it('legt eine zurückgesetzte Karte auf heute', () => {
expect(faelligAm('2026-09-10', { wiederholungen: 0, abstandTage: 0, leichtigkeit: 2.5 })).toBe('2026-09-10');
});
it('rechnet über Monatsgrenzen', () => {
expect(faelligAm('2026-09-28', { wiederholungen: 3, abstandTage: 8, leichtigkeit: 2.5 })).toBe('2026-10-06');
});
});
describe('Fächer zur Anzeige', () => {
it('ordnet eine neue Karte in Fach 1', () => {
expect(fach(NEUE_KARTE)).toBe(1);
});
it('steigt mit dem Abstand', () => {
expect(fach(lerne(['sitzt']))).toBe(2);
expect(fach(lerne(['sitzt', 'sitzt']))).toBe(3);
expect(fach(lerne(['sitzt', 'sitzt', 'sitzt']))).toBe(4);
expect(fach(lerne(['sitzt', 'sitzt', 'sitzt', 'sitzt']))).toBe(5);
});
it('fällt nach „nochmal" auf Fach 1 zurück', () => {
expect(fach(lerne(['sitzt', 'sitzt', 'sitzt', 'sitzt', 'nochmal']))).toBe(1);
});
it('zählt eine Karte ab Fach 4 als gelernt', () => {
expect(giltAlsGelernt(lerne(['sitzt', 'sitzt']))).toBe(false);
expect(giltAlsGelernt(lerne(['sitzt', 'sitzt', 'sitzt']))).toBe(true);
});
});
describe('Heutiger Stapel', () => {
const karten = [
{ karteId: 'N1-1', faelligAm: '2026-09-08', block: 'netzwerk' }, // überfällig
{ karteId: 'N2-1', faelligAm: '2026-09-10', block: 'netzwerk' }, // heute
{ karteId: 'N3-1', faelligAm: '2026-09-11', block: 'netzwerk' }, // morgen
{ karteId: 'H1-1', faelligAm: null, block: 'hardware' }, // nie gelernt
{ karteId: 'H2-1', faelligAm: '2026-09-05', block: 'hardware' }, // lange überfällig
];
it('nimmt nur, was heute oder früher fällig ist', () => {
const stapel = heutigerStapel(karten, '2026-09-10');
expect(stapel.map((k) => k.karteId)).not.toContain('N3-1');
expect(stapel).toHaveLength(4);
});
it('bringt die am längsten überfälligen Karten zuerst', () => {
const stapel = heutigerStapel(karten, '2026-09-10');
expect(stapel.map((k) => k.karteId)).toEqual(['H2-1', 'N1-1', 'N2-1', 'H1-1']);
});
it('stellt neue Karten hinten an – erst den Rückstand abbauen', () => {
const stapel = heutigerStapel(karten, '2026-09-10');
expect(stapel.at(-1)!.karteId).toBe('H1-1');
});
it('deckelt den Stapel, damit niemand vor 52 Karten sitzt', () => {
const viele = Array.from({ length: 52 }, (_, i) => ({
karteId: `N${i}-1`,
faelligAm: '2026-09-01',
block: 'netzwerk',
}));
expect(heutigerStapel(viele, '2026-09-10')).toHaveLength(20);
expect(heutigerStapel(viele, '2026-09-10', 5)).toHaveLength(5);
});
it('filtert auf einen Themenblock – für das gezielte Üben vor der Kontrolle', () => {
const stapel = heutigerStapel(karten, '2026-09-10', 20, 'hardware');
expect(stapel.map((k) => k.karteId)).toEqual(['H2-1', 'H1-1']);
});
it('hält eine nie gelernte Karte immer für fällig – sonst käme sie nie dran', () => {
const stapel = heutigerStapel(karten, '2026-09-01');
expect(stapel.map((k) => k.karteId)).toEqual(['H1-1']);
});
it('gibt einen leeren Stapel zurück, wenn alles in der Zukunft liegt', () => {
const alleSpaeter = karten.filter((k) => k.faelligAm !== null);
expect(heutigerStapel(alleSpaeter, '2026-09-01')).toEqual([]);
});
it('verändert die übergebene Liste nicht', () => {
const kopie = [...karten];
heutigerStapel(karten, '2026-09-10');
expect(karten).toEqual(kopie);
});
});
describe('Der Sinn der Sache', () => {
it('sorgt dafür, dass gut gekonnte Karten aus dem täglichen Stapel verschwinden', () => {
// Eine Karte, die viermal saß, ist erst in Wochen wieder dran.
const stand = lerne(['sitzt', 'sitzt', 'sitzt', 'sitzt']);
expect(faelligAm('2026-09-10', stand)).toBe('2026-10-02');
});
it('hält eine wackelige Karte im täglichen Stapel', () => {
const stand = lerne(['nochmal', 'sitzt', 'nochmal']);
expect(faelligAm('2026-09-10', stand)).toBe('2026-09-10');
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Wiederholung nach Zeitabstand – eine bewusst abgespeckte Fassung von SM-2.
*
* Das Original von Piotr Woźniak arbeitet mit sechs Bewertungsstufen. Hier gibt
* es drei, weil im Alltag niemand zwischen „fast richtig" und „mit Mühe
* richtig" unterscheidet: **sitzt**, **unsicher**, **nochmal**.
*
* Gerechnet wird mit zwei Größen je Karte:
* - `leichtigkeit` wie leicht die Karte fällt (2,5 zu Beginn, nie unter 1,3)
* - `wiederholungen` wie oft sie in Folge saß
*
* Daraus ergibt sich der Abstand bis zur nächsten Fälligkeit. „nochmal" setzt
* die Zählung zurück – die Karte kommt am selben Tag wieder.
*/
export const bewertungen = ['sitzt', 'unsicher', 'nochmal'] as const;
export type Bewertung = (typeof bewertungen)[number];
export type Kartenstand = {
/** Wie oft die Karte in Folge saß. 0 heißt: neu oder gerade zurückgesetzt. */
wiederholungen: number;
/** Abstand in Tagen bis zur nächsten Fälligkeit. */
abstandTage: number;
/** Leichtigkeit im Sinne von SM-2, zwischen 1,3 und 2,8. */
leichtigkeit: number;
};
export const NEUE_KARTE: Kartenstand = { wiederholungen: 0, abstandTage: 0, leichtigkeit: 2.5 };
export const MINDEST_LEICHTIGKEIT = 1.3;
export const HOECHSTE_LEICHTIGKEIT = 2.8;
/** Weiter als ein halbes Jahr wird nicht geplant – die Ausbildung dauert drei Jahre. */
export const HOECHSTER_ABSTAND_TAGE = 180;
/** Die Änderung der Leichtigkeit je Bewertung. */
const leichtigkeitsAenderung: Record<Bewertung, number> = {
sitzt: +0.1,
unsicher: -0.15,
nochmal: -0.2,
};
/**
* Rechnet den neuen Stand einer Karte aus.
*
* Die ersten beiden Abstände sind fest (1 und 3 Tage), erst danach greift die
* Leichtigkeit. Das ist die Stelle, an der SM-2 zuverlässig funktioniert, und
* sie verhindert, dass eine neue Karte gleich in zwei Wochen wiederkommt.
*/
export function naechsterStand(vorher: Kartenstand, bewertung: Bewertung): Kartenstand {
const leichtigkeit = begrenze(
vorher.leichtigkeit + leichtigkeitsAenderung[bewertung],
MINDEST_LEICHTIGKEIT,
HOECHSTE_LEICHTIGKEIT,
);
if (bewertung === 'nochmal') {
// Zurück auf Anfang: heute noch einmal.
return { wiederholungen: 0, abstandTage: 0, leichtigkeit };
}
const wiederholungen = vorher.wiederholungen + 1;
if (bewertung === 'unsicher') {
// Die Karte zählt als gekonnt, kommt aber schon morgen wieder.
return { wiederholungen, abstandTage: 1, leichtigkeit };
}
const abstandTage =
wiederholungen === 1
? 1
: wiederholungen === 2
? 3
: Math.min(HOECHSTER_ABSTAND_TAGE, Math.round(vorher.abstandTage * leichtigkeit));
return { wiederholungen, abstandTage: Math.max(1, abstandTage), leichtigkeit };
}
/** Das Fälligkeitsdatum aus einem Stand – `abstandTage: 0` heißt heute. */
export function faelligAm(heute: string, stand: Kartenstand): string {
return plusTage(heute, stand.abstandTage);
}
/**
* Fach im Sinne der Leitner-Kartei – nur zur Anzeige.
*
* Der Azubi soll sehen, wie fest eine Karte sitzt, ohne über Leichtigkeitswerte
* nachdenken zu müssen. Fünf Fächer, abgeleitet aus dem Abstand.
*/
export function fach(stand: Kartenstand): number {
if (stand.wiederholungen === 0) return 1;
if (stand.abstandTage <= 1) return 2;
if (stand.abstandTage <= 3) return 3;
if (stand.abstandTage <= 14) return 4;
return 5;
}
export const fachNamen: Record<number, string> = {
1: 'neu oder wackelig',
2: 'im Aufbau',
3: 'wird fester',
4: 'sitzt',
5: 'sitzt sicher',
};
/** Gilt eine Karte als gelernt? Ab Fach 4 – dann liegt sie mindestens eine Woche weg. */
export function giltAlsGelernt(stand: Kartenstand): boolean {
return fach(stand) >= 4;
}
/**
* Wählt den heutigen Stapel aus.
*
* Zwei Dinge, die im Alltag zählen:
* - Überfällige Karten kommen zuerst, sonst wächst der Rückstand.
* - Der Stapel wird gedeckelt. Wer drei Wochen nicht gelernt hat, soll nicht
* 52 Karten vorfinden und aufgeben, sondern die dringendsten 20.
*/
export type FaelligeKarte = { karteId: string; faelligAm: string | null; block: string };
export function heutigerStapel<T extends FaelligeKarte>(
karten: T[],
heute: string,
hoechstens = 20,
block?: string,
): T[] {
const gefiltert = block ? karten.filter((k) => k.block === block) : karten;
const faellig = gefiltert.filter((k) => k.faelligAm === null || k.faelligAm <= heute);
return [...faellig]
.sort((a, b) => {
// Nie gelernte Karten zuletzt – erst den Rückstand abbauen.
if (a.faelligAm === null && b.faelligAm !== null) return 1;
if (a.faelligAm !== null && b.faelligAm === null) return -1;
if (a.faelligAm !== null && b.faelligAm !== null && a.faelligAm !== b.faelligAm) {
return a.faelligAm.localeCompare(b.faelligAm);
}
return a.karteId.localeCompare(b.karteId, 'de-DE', { numeric: true });
})
.slice(0, hoechstens);
}
// --- Hilfsmittel ----------------------------------------------------------
function begrenze(wert: number, unten: number, oben: number): number {
return Math.min(oben, Math.max(unten, Math.round(wert * 100) / 100));
}
function plusTage(datum: string, tage: number): string {
const d = new Date(`${datum}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + tage);
return d.toISOString().slice(0, 10);
}
+9 -2
View File
@@ -2,9 +2,10 @@ import { inhalte, type Handout, type Aufgabe } from '../inhalte';
import type { Checkliste, Kontrolle, Woche } from '../inhalte/schema';
import { gelesenSet } from '../repos/fortschritt';
import { planVon } from '../repos/wochenplanung';
import { uebersicht as kartenUebersicht } from '../repos/karten';
import { heute } from '../zeit';
import { aktuelleWoche } from './wochen';
import { leer, wochenFortschritt, type Fortschritt } from './fortschritt';
import { wochenFortschritt, type Fortschritt } from './fortschritt';
import type { Wochenplanung } from '../db/schema';
/**
@@ -66,11 +67,17 @@ export function wochensicht(ausbildungId: string, azubiId: string, wochenNummer:
const checklisten = woche.checklisten.map((id) => alle.checklisteNach.get(id)).filter((c) => c !== undefined);
const kontrolle = woche.lernzielkontrolle ? (alle.kontrolleNach.get(woche.lernzielkontrolle) ?? null) : null;
// Die Merkkarten dieser Woche sind die Karten ihrer Handouts.
const karteIds = handouts.flatMap(({ handout }) =>
alle.karten.filter((k) => k.handoutId === handout.id).map((k) => k.id),
);
const kartenStand = kartenUebersicht(azubiId, karteIds, heuteDatum);
const fortschritt = wochenFortschritt({
handouts: { erledigt: handouts.filter((h) => h.gelesen).length, gesamt: handouts.length },
karten: { erledigt: kartenStand.gelernt, gesamt: kartenStand.gesamt },
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
karten: leer,
aufgaben: { erledigt: 0, gesamt: aufgaben.length },
checklisten: { erledigt: 0, gesamt: checklisten.length },
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
+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 };
}