From 8cedcc87ecf2ccb02efa32e54c56d37269b2f12c Mon Sep 17 00:00:00 2001 From: team3 Date: Sun, 12 Jul 2026 22:41:27 +0200 Subject: [PATCH] update --- backend/db.py | 4 + backend/main.py | 26 ++++ frontend/src/api.js | 5 + frontend/src/components/Guide.vue | 5 + frontend/src/components/Lesemodus.vue | 166 ++++++++++++++++++++++++++ frontend/src/style.css | 46 +++++++ tests/test_lesemodus.py | 37 ++++++ 7 files changed, 289 insertions(+) create mode 100644 frontend/src/components/Lesemodus.vue create mode 100644 tests/test_lesemodus.py 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 @@ + + + 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