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>
@@ -26,6 +26,47 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [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
|
## [0.3.0] – 2026-09-10
|
||||||
|
|
||||||
Der Wochenplan wird zur Startseite, und der Ausbilder kann ihn umplanen.
|
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
|
- `npm run konto:anlegen` für das erste Ausbilderkonto
|
||||||
|
|
||||||
[Unveröffentlicht]: #unveröffentlicht
|
[Unveröffentlicht]: #unveröffentlicht
|
||||||
|
[0.4.0]: #040--2026-09-10
|
||||||
[0.3.0]: #030--2026-09-10
|
[0.3.0]: #030--2026-09-10
|
||||||
[0.2.0]: #020--2026-09-10
|
[0.2.0]: #020--2026-09-10
|
||||||
[0.1.0]: #010--2026-09-10
|
[0.1.0]: #010--2026-09-10
|
||||||
|
|||||||
@@ -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
|
## 2026-09-10 · Baustein 3 – Wochenplan
|
||||||
|
|
||||||
| Von | Bis | Dauer | Tätigkeit |
|
| Von | Bis | Dauer | Tätigkeit |
|
||||||
|
|||||||
@@ -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
|
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
|
auf, im Test sofort. Und der Zeilenversatz für die Fehlermeldungen ist so
|
||||||
nachweislich richtig statt nachgebaut.
|
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.
|
||||||
|
|||||||
@@ -44,7 +44,15 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
|||||||
- [x] Reihenfolge per Ziehen und Ablegen **und** per Tastatur
|
- [x] Reihenfolge per Ziehen und Ablegen **und** per Tastatur
|
||||||
- [x] Zustand je Woche (geplant / läuft / abgeschlossen) und Notiz für den Azubi
|
- [x] Zustand je Woche (geplant / läuft / abgeschlossen) und Notiz für den Azubi
|
||||||
- [x] Warnung bei sich überlappenden Terminen
|
- [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
|
## 0.4 – Nachweise
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -97,9 +97,13 @@ async function main() {
|
|||||||
await seite.click('.pruefungsfrage > summary');
|
await seite.click('.pruefungsfrage > summary');
|
||||||
await bild(seite, '13-handout-n3');
|
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.goto(`${BASIS}/glossar`);
|
||||||
await seite.waitForSelector('h1');
|
await seite.waitForSelector('h1');
|
||||||
await bild(seite, '14-glossar');
|
await bild(seite, '15-glossar');
|
||||||
|
|
||||||
// --- Dunkle Darstellung ---
|
// --- Dunkle Darstellung ---
|
||||||
await seite.goto(`${BASIS}/handouts/N3`);
|
await seite.goto(`${BASIS}/handouts/N3`);
|
||||||
@@ -123,6 +127,15 @@ async function main() {
|
|||||||
await handySeite.waitForSelector('h1');
|
await handySeite.waitForSelector('h1');
|
||||||
await bild(handySeite, '30-handy-handout');
|
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();
|
await browser.close();
|
||||||
console.log('Fertig.');
|
console.log('Fertig.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 „sitzt“, nach links für „nochmal“.
|
||||||
|
</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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
const fuerAzubi: Navigationseintrag[] = [
|
const fuerAzubi: Navigationseintrag[] = [
|
||||||
{ pfad: '/', titel: 'Woche' },
|
{ pfad: '/', titel: 'Woche' },
|
||||||
{ pfad: '/handouts', titel: 'Handouts' },
|
{ pfad: '/handouts', titel: 'Handouts' },
|
||||||
|
{ pfad: '/karten', titel: 'Merkkarten' },
|
||||||
{ pfad: '/glossar', titel: 'Glossar' },
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const fuerAusbilder: Navigationseintrag[] = [
|
|||||||
{ pfad: '/', titel: 'Überblick' },
|
{ pfad: '/', titel: 'Überblick' },
|
||||||
{ pfad: '/wochenplan', titel: 'Wochenplan' },
|
{ pfad: '/wochenplan', titel: 'Wochenplan' },
|
||||||
{ pfad: '/handouts', titel: 'Handouts' },
|
{ pfad: '/handouts', titel: 'Handouts' },
|
||||||
|
{ pfad: '/karten', titel: 'Merkkarten' },
|
||||||
{ pfad: '/glossar', titel: 'Glossar' },
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -878,3 +878,225 @@ a.glossarbegriff:hover {
|
|||||||
.planzeile-griff,
|
.planzeile-griff,
|
||||||
.planzeile-knoepfe { display: none; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function Kopfzeile({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="kopf-rechts">
|
<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}
|
{name} · {rolle}
|
||||||
</span>
|
</span>
|
||||||
<ThemaUmschalter />
|
<ThemaUmschalter />
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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. */
|
/** Rollen – bewusst nur zwei. */
|
||||||
export const rollen = ['ausbilder', 'azubi'] as const;
|
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 Benutzer = typeof benutzer.$inferSelect;
|
||||||
export type Sitzung = typeof sitzung.$inferSelect;
|
export type Sitzung = typeof sitzung.$inferSelect;
|
||||||
export type Ausbildung = typeof ausbildung.$inferSelect;
|
export type Ausbildung = typeof ausbildung.$inferSelect;
|
||||||
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
|
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
|
||||||
export type Wochenplanung = typeof wochenplanung.$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. */
|
/** 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 };
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ import { inhalte, type Handout, type Aufgabe } from '../inhalte';
|
|||||||
import type { Checkliste, Kontrolle, Woche } from '../inhalte/schema';
|
import type { Checkliste, Kontrolle, Woche } from '../inhalte/schema';
|
||||||
import { gelesenSet } from '../repos/fortschritt';
|
import { gelesenSet } from '../repos/fortschritt';
|
||||||
import { planVon } from '../repos/wochenplanung';
|
import { planVon } from '../repos/wochenplanung';
|
||||||
|
import { uebersicht as kartenUebersicht } from '../repos/karten';
|
||||||
import { heute } from '../zeit';
|
import { heute } from '../zeit';
|
||||||
import { aktuelleWoche } from './wochen';
|
import { aktuelleWoche } from './wochen';
|
||||||
import { leer, wochenFortschritt, type Fortschritt } from './fortschritt';
|
import { wochenFortschritt, type Fortschritt } from './fortschritt';
|
||||||
import type { Wochenplanung } from '../db/schema';
|
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 checklisten = woche.checklisten.map((id) => alle.checklisteNach.get(id)).filter((c) => c !== undefined);
|
||||||
const kontrolle = woche.lernzielkontrolle ? (alle.kontrolleNach.get(woche.lernzielkontrolle) ?? null) : null;
|
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({
|
const fortschritt = wochenFortschritt({
|
||||||
handouts: { erledigt: handouts.filter((h) => h.gelesen).length, gesamt: handouts.length },
|
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
|
// Diese Bereiche werden mit ihren Bausteinen scharf geschaltet. Bis dahin
|
||||||
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
|
// zeigen sie ehrlich an, was die Woche vorsieht und noch offen ist.
|
||||||
karten: leer,
|
|
||||||
aufgaben: { erledigt: 0, gesamt: aufgaben.length },
|
aufgaben: { erledigt: 0, gesamt: aufgaben.length },
|
||||||
checklisten: { erledigt: 0, gesamt: checklisten.length },
|
checklisten: { erledigt: 0, gesamt: checklisten.length },
|
||||||
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
|
kontrolle: { erledigt: 0, gesamt: kontrolle ? 1 : 0 },
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||