diff --git a/CHANGELOG.md b/CHANGELOG.md index f68de1b..2d23cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DEVLOG.md b/DEVLOG.md index adaa3f1..29503ba 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 | diff --git a/ENTSCHEIDUNGEN.md b/ENTSCHEIDUNGEN.md index 95d6acd..c7a9880 100644 --- a/ENTSCHEIDUNGEN.md +++ b/ENTSCHEIDUNGEN.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 7b24f19..bbe9abc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/docs/bilder/02-ausbilder-ueberblick.png b/docs/bilder/02-ausbilder-ueberblick.png index 1457058..39ed84f 100644 Binary files a/docs/bilder/02-ausbilder-ueberblick.png and b/docs/bilder/02-ausbilder-ueberblick.png differ diff --git a/docs/bilder/03-verwaltung.png b/docs/bilder/03-verwaltung.png index 8f5601c..970dff9 100644 Binary files a/docs/bilder/03-verwaltung.png and b/docs/bilder/03-verwaltung.png differ diff --git a/docs/bilder/04-wochenplan.png b/docs/bilder/04-wochenplan.png index 4b89041..f1c1e94 100644 Binary files a/docs/bilder/04-wochenplan.png and b/docs/bilder/04-wochenplan.png differ diff --git a/docs/bilder/10-azubi-woche.png b/docs/bilder/10-azubi-woche.png index 14cbcd9..1cdeffa 100644 Binary files a/docs/bilder/10-azubi-woche.png and b/docs/bilder/10-azubi-woche.png differ diff --git a/docs/bilder/11-handouts-liste.png b/docs/bilder/11-handouts-liste.png index 82a0036..b1fd117 100644 Binary files a/docs/bilder/11-handouts-liste.png and b/docs/bilder/11-handouts-liste.png differ diff --git a/docs/bilder/12-handouts-suche.png b/docs/bilder/12-handouts-suche.png index 22be644..a8ba06d 100644 Binary files a/docs/bilder/12-handouts-suche.png and b/docs/bilder/12-handouts-suche.png differ diff --git a/docs/bilder/13-handout-n3.png b/docs/bilder/13-handout-n3.png index 58c363a..747535a 100644 Binary files a/docs/bilder/13-handout-n3.png and b/docs/bilder/13-handout-n3.png differ diff --git a/docs/bilder/14-glossar.png b/docs/bilder/14-glossar.png deleted file mode 100644 index 066beb0..0000000 Binary files a/docs/bilder/14-glossar.png and /dev/null differ diff --git a/docs/bilder/14-merkkarten.png b/docs/bilder/14-merkkarten.png new file mode 100644 index 0000000..5d983ae Binary files /dev/null and b/docs/bilder/14-merkkarten.png differ diff --git a/docs/bilder/15-glossar.png b/docs/bilder/15-glossar.png new file mode 100644 index 0000000..a1f232c Binary files /dev/null and b/docs/bilder/15-glossar.png differ diff --git a/docs/bilder/20-dunkel.png b/docs/bilder/20-dunkel.png index 4029c49..52b28bc 100644 Binary files a/docs/bilder/20-dunkel.png and b/docs/bilder/20-dunkel.png differ diff --git a/docs/bilder/30-handy-handout.png b/docs/bilder/30-handy-handout.png index 970d084..319a7d4 100644 Binary files a/docs/bilder/30-handy-handout.png and b/docs/bilder/30-handy-handout.png differ diff --git a/docs/bilder/31-handy-lernmodus-frage.png b/docs/bilder/31-handy-lernmodus-frage.png new file mode 100644 index 0000000..979e834 Binary files /dev/null and b/docs/bilder/31-handy-lernmodus-frage.png differ diff --git a/docs/bilder/32-handy-lernmodus-antwort.png b/docs/bilder/32-handy-lernmodus-antwort.png new file mode 100644 index 0000000..dbc3b82 Binary files /dev/null and b/docs/bilder/32-handy-lernmodus-antwort.png differ diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index c27f525..38afd2a 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -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.'); } diff --git a/src/app/(angemeldet)/karten/aktionen.ts b/src/app/(angemeldet)/karten/aktionen.ts new file mode 100644 index 0000000..d3ccf47 --- /dev/null +++ b/src/app/(angemeldet)/karten/aktionen.ts @@ -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 { + 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, + }; +} diff --git a/src/app/(angemeldet)/karten/lernen/Lernmodus.tsx b/src/app/(angemeldet)/karten/lernen/Lernmodus.tsx new file mode 100644 index 0000000..741ddb6 --- /dev/null +++ b/src/app/(angemeldet)/karten/lernen/Lernmodus.tsx @@ -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(karten); + const [stelle, setzeStelle] = useState(0); + const [umgedreht, setzeUmgedreht] = useState(false); + const [laeuft, setzeLaeuft] = useState(false); + const [rueckmeldung, setzeRueckmeldung] = useState(null); + const [erledigt, setzeErledigt] = useState(0); + const [zaehler, setzeZaehler] = useState>({ 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 ( +
+
+ +

Stapel durch

+

+ {erledigt} {erledigt === 1 ? 'Karte' : 'Karten'} bewertet. +

+ +
    +
  • + {zaehler.sitzt} sitzt +
  • +
  • + {zaehler.unsicher} unsicher +
  • +
  • + {zaehler.nochmal} nochmal +
  • +
+ +

+ Morgen wartet der nächste Stapel. Wer täglich zehn Minuten macht, hat vor der Prüfung nichts + nachzuholen. +

+ +
+ + Zurück zur Übersicht + + + Zur Woche + +
+
+
+ ); + } + + const uebrig = stapel.length - stelle; + + return ( +
+
+ + ← Abbrechen + + + {block ? `${karte.blockTitel} · ` : ''} + noch {uebrig} {uebrig === 1 ? 'Karte' : 'Karten'} + +
+ +
+ +
+ + + + {umgedreht ? ( +
+ {knoepfe.map((knopf) => ( + + ))} +
+ ) : ( +

+ Wischen: nach rechts für „sitzt“, nach links für „nochmal“. +

+ )} + +

+ {rueckmeldung?.wiederAm ? `Zuletzt: kommt ${rueckmeldung.wiederAm}.` : ''} + {rueckmeldung?.fehler ? rueckmeldung.fehler : ''} +

+
+ ); +} diff --git a/src/app/(angemeldet)/karten/lernen/page.tsx b/src/app/(angemeldet)/karten/lernen/page.tsx new file mode 100644 index 0000000..d7be293 --- /dev/null +++ b/src/app/(angemeldet)/karten/lernen/page.tsx @@ -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 ( +
+
+ +

Für heute nichts fällig

+

+ {gewaehlterBlock + ? 'In diesem Themenblock ist heute keine Karte dran.' + : 'Alle Karten sitzen gerade fest genug. Morgen kommt der nächste Stapel.'} +

+
+ + Zurück zur Übersicht + +
+
+
+ ); + } + + return ; +} diff --git a/src/app/(angemeldet)/karten/page.tsx b/src/app/(angemeldet)/karten/page.tsx new file mode 100644 index 0000000..0bc07f2 --- /dev/null +++ b/src/app/(angemeldet)/karten/page.tsx @@ -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 ( +
+
+

Merkkarten

+

+ {alle.karten.length} Karten aus {alle.handouts.length} Handouts. Gelernt wird nur vom Azubi – du + siehst hier, was es gibt. +

+
+ +
+ {jeBlock.map(({ block, gesamt }) => ( +
+

{block.titel}

+

+ {gesamt} {gesamt === 1 ? 'Karte' : 'Karten'} +

+
+ ))} +
+
+ ); + } + + const heuteStapel = Math.min(stand.faellig, STAPELGROESSE); + + return ( +
+
+

Merkkarten

+

+ {stand.gesamt} Karten insgesamt · {stand.gelernt} sitzen · {stand.nieGelernt} noch nie gelernt +

+
+ +
+

+ Heute fällig +

+ + {stand.faellig === 0 ? ( +

+ Nichts. Alle Karten liegen weiter hinten – morgen ist wieder etwas dran. +

+ ) : ( + <> +

+ {heuteStapel}{' '} + {heuteStapel === 1 ? 'Karte' : 'Karten'} + {stand.faellig > STAPELGROESSE ? ( + von {stand.faellig} fälligen – der Rest kommt morgen + ) : null} +

+
+ + Lernen starten + +
+ + )} +
+ +
+
+

+ Nach Themenblock +

+

+ Vor einer Lernzielkontrolle gezielt üben – hier sind auch Karten dabei, die eigentlich erst später + dran wären. +

+
+ +
+ {jeBlock.map(({ block, gesamt, faellig, gelernt }) => ( +
+

{block.titel}

+

+ {gesamt} Karten · {gelernt} sitzen · {faellig} heute fällig +

+ +
+ 0 ? '' : 'knopf-zweit'}`} + > + {faellig > 0 ? `${Math.min(faellig, STAPELGROESSE)} lernen` : 'Trotzdem üben'} + +
+
+ ))} +
+
+ +
+

+ Wie fest die Karten sitzen +

+
    + {[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 ( +
  • + {nummer} + {fachNamen[nummer]} +
  • + ); + })} +
+
+ + {stand.gesamt > stand.nieGelernt ? ( +
+

+ Als Nächstes fällig +

+
    + {[...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 ( +
  • + {karte.handoutId} + {karte.frage} + {alsDatumText(z.faelligAm)} +
  • + ); + })} +
+
+ ) : null} +
+ ); +} diff --git a/src/app/(angemeldet)/layout.tsx b/src/app/(angemeldet)/layout.tsx index 85cea11..aad4e58 100644 --- a/src/app/(angemeldet)/layout.tsx +++ b/src/app/(angemeldet)/layout.tsx @@ -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' }, ]; diff --git a/src/app/globals.css b/src/app/globals.css index 610176f..77d56e0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } +} diff --git a/src/komponenten/Kopfzeile.tsx b/src/komponenten/Kopfzeile.tsx index 6e16f07..3a5c055 100644 --- a/src/komponenten/Kopfzeile.tsx +++ b/src/komponenten/Kopfzeile.tsx @@ -40,7 +40,7 @@ export function Kopfzeile({
- + {name} · {rolle} diff --git a/src/lib/db/migrationen/0004_karten.sql b/src/lib/db/migrationen/0004_karten.sql new file mode 100644 index 0000000..91e30a6 --- /dev/null +++ b/src/lib/db/migrationen/0004_karten.sql @@ -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); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 9f59f4b..1c0854d 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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 }; diff --git a/src/lib/lernen/sm2.test.ts b/src/lib/lernen/sm2.test.ts new file mode 100644 index 0000000..fe18626 --- /dev/null +++ b/src/lib/lernen/sm2.test.ts @@ -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'); + }); +}); diff --git a/src/lib/lernen/sm2.ts b/src/lib/lernen/sm2.ts new file mode 100644 index 0000000..769417b --- /dev/null +++ b/src/lib/lernen/sm2.ts @@ -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 = { + 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 = { + 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( + 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); +} diff --git a/src/lib/planung/sicht.ts b/src/lib/planung/sicht.ts index cebc29e..834ac62 100644 --- a/src/lib/planung/sicht.ts +++ b/src/lib/planung/sicht.ts @@ -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 }, diff --git a/src/lib/repos/karten.ts b/src/lib/repos/karten.ts new file mode 100644 index 0000000..c321969 --- /dev/null +++ b/src/lib/repos/karten.ts @@ -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 { + 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; +}; + +/** 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 = { 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 }; +}