diff --git a/backend/db.py b/backend/db.py
index ada28a4..3b36cd7 100644
--- a/backend/db.py
+++ b/backend/db.py
@@ -106,6 +106,10 @@ CREATE TABLE IF NOT EXISTS leitner(
CREATE TABLE IF NOT EXISTS befunde(
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, ebene TEXT NOT NULL, art TEXT NOT NULL,
item TEXT DEFAULT '', detail TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'offen');
+CREATE TABLE IF NOT EXISTS lernstand(
+ topic TEXT NOT NULL, baustein_id INTEGER NOT NULL,
+ status TEXT NOT NULL DEFAULT 'aktiv', xp INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY(topic, baustein_id));
"""
# Tabellen, deren Änderungen das Live-Board interessieren.
diff --git a/backend/main.py b/backend/main.py
index f397ef9..76215f0 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -276,6 +276,32 @@ def ueben_antwort(a: UebenAntwort):
return {"box": box}
+@app.get("/api/topics/{topic}/baustein/{baustein_id}/karten")
+def baustein_karten(topic: str, baustein_id: int):
+ # Flashcards genau dieses Bausteins — NICHT faellig-gefiltert (gerade gelesen → jetzt
+ # prüfen). Beantwortet wird über /api/ueben/antwort, Leitner-Spacing bleibt intakt.
+ rows = db.query(
+ "SELECT ar.id, ar.inhalt, a.titel, a.level, COALESCE(l.box,1) AS box"
+ " FROM artefakte ar JOIN atome a ON ar.atom_id=a.id"
+ " LEFT JOIN leitner l ON l.artefakt_id=ar.id"
+ " WHERE a.topic=? AND a.baustein_id=? AND ar.typ='flashcard'"
+ " AND ar.status='verifiziert' ORDER BY a.ord, ar.id", (topic, baustein_id))
+ return [{**r, "inhalt": db.uj(r["inhalt"], {})} for r in rows]
+
+
+@app.get("/api/topics/{topic}/lernstand")
+def lernstand_holen(topic: str):
+ return db.query("SELECT baustein_id, status, xp FROM lernstand WHERE topic=?", (topic,))
+
+
+@app.post("/api/topics/{topic}/baustein/{baustein_id}/fertig")
+def baustein_fertig(topic: str, baustein_id: int):
+ db.execute("INSERT INTO lernstand(topic, baustein_id, status) VALUES(?,?,'fertig')"
+ " ON CONFLICT(topic, baustein_id) DO UPDATE SET status='fertig'",
+ (topic, baustein_id))
+ return {"ok": True}
+
+
@app.get("/api/runs/{run_id}/kennzahlen")
def kennzahlen(run_id: int):
return {"zeilen": ledger.kennzahlen(run_id), "verbraucht": ledger.verbraucht(run_id)}
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 714fa48..adf594d 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -31,6 +31,11 @@ export const api = {
ueben: (topic) => req(`/api/topics/${e(topic)}/ueben`),
antwort: (artefakt_id, richtig) =>
req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }),
+ bausteinKarten: (topic, bausteinId) =>
+ req(`/api/topics/${e(topic)}/baustein/${e(bausteinId)}/karten`),
+ lernstand: (topic) => req(`/api/topics/${e(topic)}/lernstand`),
+ bausteinFertig: (topic, bausteinId) =>
+ req(`/api/topics/${e(topic)}/baustein/${e(bausteinId)}/fertig`, { method: 'POST', body: '{}' }),
kennzahlen: (runId) => req(`/api/runs/${e(runId)}/kennzahlen`),
transferInfo: () => req('/api/transfer'),
transfer: (topic, richtung) =>
diff --git a/frontend/src/components/Guide.vue b/frontend/src/components/Guide.vue
index 32536a6..7d185d6 100644
--- a/frontend/src/components/Guide.vue
+++ b/frontend/src/components/Guide.vue
@@ -2,8 +2,10 @@
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { api } from '../api.js'
import { render, lesestat } from '../markdown.js'
+import Lesemodus from './Lesemodus.vue'
const props = defineProps({ topic: String, state: Object })
+const lesemodus = ref(false) // Vollbild-Lernmodus (Fundament + Abrufprüfung)
const daten = ref(null)
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
@@ -156,6 +158,8 @@ function onScroll(e) {
+
Kapitel {{ aktiv + 1 }} / {{ anzeige.length }}
@@ -191,5 +195,6 @@ function onScroll(e) {
Noch kein Guide generiert.
+
diff --git a/frontend/src/components/Lesemodus.vue b/frontend/src/components/Lesemodus.vue
new file mode 100644
index 0000000..1f6fa42
--- /dev/null
+++ b/frontend/src/components/Lesemodus.vue
@@ -0,0 +1,166 @@
+
+
+
+
+
+ {{ fortschrittText }}
+
+
+
+
+
+ Kapitel {{ screen.nr }} / {{ screen.von }}
+ {{ screen.kap.titel }}
+ {{ screen.kap.intro }}
+
+ {{ kapStat(screen.kap).woerter.toLocaleString('de-DE') }} Wörter ·
+ {{ zeit(kapStat(screen.kap).minuten) }} · {{ screen.kap.sections.length }} Bausteine
+
+
+
+
+ Fundament
+ {{ screen.titel }}
+
+
+
+
+ Abrufprüfung · {{ screen.titel }}
+
+
+
+
+
+
+
+
+
+
+
noch {{ stapel.length }} · Box {{ stapel[0].box }}
+
+ ✓ Abruf bestanden — weiter mit →
+
+
+
+ Fundament abgeschlossen 🎉
+ Schritt 2 (Fehlersuche) und Schritt 3 (Aufgaben) folgen.
+
+
+
+
+
+
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 877691c..8d46993 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -277,3 +277,49 @@ table.kennzahlen th { color: var(--dim); }
.guide-text .kapitel-titel { margin-top: 20px; }
.flash { max-width: 100%; padding: 18px; margin: 16px auto; }
}
+
+/* ── Lesemodus (Lern-Schritt 1) ─────────────────────────────────────────────── */
+.lesemodus {
+ position: fixed; inset: 0; z-index: 1000; background: var(--bg);
+ display: flex; flex-direction: column; color: var(--text);
+}
+.lm-kopf, .lm-fuss {
+ display: flex; align-items: center; gap: 12px; padding: 10px 18px;
+ border-bottom: 1px solid var(--rand); background: var(--panel); flex: 0 0 auto;
+}
+.lm-fuss { border-top: 1px solid var(--rand); border-bottom: none; justify-content: space-between; }
+.lm-fort { font-size: 13px; color: var(--dim); flex: 1; }
+.lm-x { margin-left: auto; background: none; border: none; font-size: 18px; cursor: pointer; color: var(--dim); }
+.lm-fuss button {
+ padding: 8px 18px; border: 1px solid var(--rand); border-radius: 6px;
+ background: var(--karte); color: var(--text); cursor: pointer; font-size: 14px;
+}
+.lm-fuss button:disabled { opacity: 0.4; cursor: default; }
+.lm-buehne { flex: 1; overflow-y: auto; } /* Block-Scroll: Padding am Ende bleibt erhalten */
+.lm-screen {
+ width: 100%; max-width: 760px; margin: 0 auto; padding: 40px 24px 96px;
+ font-family: 'Inter Variable', system-ui, sans-serif; /* Lesetypografie wie im Guide */
+ font-size: 19px; line-height: 1.55; color: var(--lese-text, var(--text));
+}
+.lm-label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--akzent); margin-bottom: 6px; }
+.lm-titel { margin: 0 0 18px; }
+.lm-intro { text-align: center; padding-top: 12vh; }
+.lm-kapnr { color: var(--dim); font-size: 14px; margin-bottom: 8px; }
+.lm-intro h1 { margin: 0 0 16px; }
+.lm-introtext { color: var(--text); font-size: 17px; line-height: 1.6; }
+.lm-stats { margin-top: 20px; color: var(--dim); font-size: 14px; }
+/* Abrufprüfung */
+.lm-abruf { display: flex; flex-direction: column; align-items: center; }
+.lm-karte {
+ width: 100%; max-width: 560px; margin-top: 6vh; padding: 28px;
+ background: var(--karte); border: 1px solid var(--rand); border-radius: 12px; text-align: center;
+}
+.lm-frage { font-size: 20px; margin-bottom: 18px; }
+.lm-antwort { font-size: 17px; color: var(--text); border-top: 1px solid var(--rand); padding-top: 16px; margin-bottom: 18px; }
+.lm-karte-akt { display: flex; gap: 12px; justify-content: center; margin-top: 10px; }
+.lm-karte-akt button { padding: 10px 22px; border-radius: 8px; border: 1px solid var(--rand); cursor: pointer; font-size: 15px; background: var(--panel); color: var(--text); }
+.lm-richtig { background: #2f7d32 !important; border-color: #2f7d32 !important; color: #fff !important; }
+.lm-falsch { background: #a23b3b !important; border-color: #a23b3b !important; color: #fff !important; }
+.lm-rest { margin-top: 16px; color: var(--dim); font-size: 13px; }
+.lm-bestanden { margin-top: 8vh; font-size: 18px; color: var(--akzent); text-align: center; }
+.lm-ende { text-align: center; padding-top: 14vh; }
diff --git a/tests/test_lesemodus.py b/tests/test_lesemodus.py
new file mode 100644
index 0000000..23b15b8
--- /dev/null
+++ b/tests/test_lesemodus.py
@@ -0,0 +1,37 @@
+"""Lesemodus-Endpoints: Baustein-Karten (nicht faellig-gefiltert), Lernstand, fertig."""
+
+import db
+import main
+from conftest import topic_anlegen
+
+
+def _setup(topic):
+ b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
+ a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
+ status="neu", baustein_id=b, ord=0, braucht=db.j([]))
+ db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
+ inhalt=db.j({"frage": "F1", "antwort": "A1"}))
+ db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat", # unverifiziert
+ inhalt=db.j({"frage": "F2", "antwort": "A2"}))
+ db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert", # kein Flashcard
+ inhalt=db.j({"form": "text", "text": "bsp"}))
+ return b, a
+
+
+def test_baustein_karten_nur_verifizierte_flashcards():
+ topic = topic_anlegen("lm1")
+ b, _ = _setup(topic)
+ karten = main.baustein_karten(topic, b)
+ assert len(karten) == 1 # nur die verifizierte Flashcard
+ assert karten[0]["inhalt"]["frage"] == "F1"
+ assert karten[0]["box"] == 1 # Default ohne Leitner-Eintrag
+
+
+def test_lernstand_und_fertig_idempotent():
+ topic = topic_anlegen("lm2")
+ b, _ = _setup(topic)
+ assert main.lernstand_holen(topic) == []
+ main.baustein_fertig(topic, b)
+ assert main.lernstand_holen(topic) == [{"baustein_id": b, "status": "fertig", "xp": 0}]
+ main.baustein_fertig(topic, b) # zweimal → kein Duplikat
+ assert len(main.lernstand_holen(topic)) == 1