This commit is contained in:
team3
2026-07-12 22:41:27 +02:00
parent a9b2f5ab9e
commit 8cedcc87ec
7 changed files with 289 additions and 0 deletions

View File

@@ -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.

View File

@@ -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)}

View File

@@ -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) =>

View File

@@ -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) {
</button>
<button class="schalter-solo" title="Fokus-Modus (ablenkungsfrei lesen)"
@click="fokus = true"></button>
<button v-if="daten" class="schalter-solo lm-start" title="Lernmodus (Vollbild)"
@click="lesemodus = true"></button>
</div>
<div class="fortschritt">
<div>Kapitel {{ aktiv + 1 }} / {{ anzeige.length }}</div>
@@ -191,5 +195,6 @@ function onScroll(e) {
<div v-if="!anzeige.length" class="guide-section">Noch kein Guide generiert.</div>
</template>
</div>
<Lesemodus v-if="lesemodus" :daten="daten" :topic="topic" @schliessen="lesemodus = false" />
</div>
</template>

View File

@@ -0,0 +1,166 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { api } from '../api.js'
import { render, lesestat } from '../markdown.js'
const props = defineProps({ daten: Object, topic: String })
const emit = defineEmits(['schliessen'])
const wurzel = ref(null)
const pos = ref(0)
const fertig = ref(new Set()) // baustein-ids mit bestandener Abrufprüfung
// Flache Screen-Liste aus den Guide-Daten (alle Kapitel/Level in ord):
// je Kapitel ein Intro, je Baustein Fundament + Abruf, am Ende ein Abschluss.
const screens = computed(() => {
const out = []
const kaps = props.daten?.kapitel || []
kaps.forEach((kap, ki) => {
out.push({ typ: 'intro', kap, nr: ki + 1, von: kaps.length })
for (const s of kap.sections) {
out.push({ typ: 'fundament', baustein: s.baustein, titel: s.titel, lang: s.lang })
out.push({ typ: 'abruf', baustein: s.baustein, titel: s.titel })
}
})
out.push({ typ: 'ende' })
return out
})
const screen = computed(() => screens.value[pos.value] || { typ: 'ende' })
const bausteinAnzahl = computed(() =>
(props.daten?.kapitel || []).reduce((a, k) => a + k.sections.length, 0))
const fortschrittText = computed(() => `${fertig.value.size} / ${bausteinAnzahl.value} Bausteine`)
// ── Stats (wie Guide.vue: 100 Wörter/min + 5 s je Display-Formel) ──────────────
function zeit(min) { return !min ? '~0 min' : min < 1 ? '<1 min' : `~${Math.round(min)} min` }
function kapStat(kap) {
let w = 0, f = 0
for (const s of kap.sections) { const t = lesestat(s.lang || ''); w += t.woerter; f += t.displayFormeln }
return { woerter: w, minuten: w / 100 + f * 5 / 60 }
}
const htmlCache = new Map()
function fundamentHtml(s) {
if (!htmlCache.has(s.baustein)) htmlCache.set(s.baustein, render(s.lang))
return htmlCache.get(s.baustein)
}
// ── Navigation ───────────────────────────────────────────────────────────────
// Kein Zwang, alle Karten zu sehen: Weiter ist immer frei. Verlässt man einen
// Abruf-Screen vorwärts, gilt der Baustein als erledigt (Fortschritt persistiert).
function vor() {
if (pos.value >= screens.value.length - 1) return
if (screen.value.typ === 'abruf') markFertig(screen.value.baustein)
pos.value++
}
function zurueck() { if (pos.value > 0) pos.value-- }
function taste(e) {
if (e.key === 'ArrowRight') vor()
else if (e.key === 'ArrowLeft') zurueck()
}
// ── Abrufprüfung (Karteikarten, Loop wie Ueben.vue) ─────────────────────────────
const stapel = ref([])
const zeigeAntwort = ref(false)
const sende = ref(false)
async function abrufLaden(bausteinId) {
stapel.value = await api.bausteinKarten(props.topic, bausteinId)
zeigeAntwort.value = false
if (!stapel.value.length) markFertig(bausteinId) // keine Karten → sofort bestanden
}
async function antworten(richtig) {
if (sende.value || !stapel.value.length) return
sende.value = true
try {
const karte = stapel.value[0]
await api.antwort(karte.id, richtig)
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
zeigeAntwort.value = false
if (!stapel.value.length) markFertig(screen.value.baustein)
} finally { sende.value = false }
}
async function markFertig(bausteinId) {
if (fertig.value.has(bausteinId)) return
fertig.value = new Set([...fertig.value, bausteinId])
await api.bausteinFertig(props.topic, bausteinId).catch(() => {})
}
// Beim Betreten eines Abruf-Screens die Karten des Bausteins laden.
watch(screen, (s) => {
if (s.typ === 'abruf' && !fertig.value.has(s.baustein)) abrufLaden(s.baustein)
})
// ── Fullscreen + Resume ─────────────────────────────────────────────────────────
function schliessen() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
emit('schliessen')
}
onMounted(async () => {
window.addEventListener('keydown', taste)
try { await wurzel.value?.requestFullscreen() } catch { /* CSS-Overlay reicht als Fallback */ }
const stand = await api.lernstand(props.topic).catch(() => [])
fertig.value = new Set(stand.filter(s => s.status === 'fertig').map(s => s.baustein_id))
// Resume: zum Fundament des ersten noch nicht bestandenen Bausteins springen.
const idx = screens.value.findIndex(s => s.typ === 'fundament' && !fertig.value.has(s.baustein))
pos.value = idx >= 0 ? idx : screens.value.length - 1
})
onUnmounted(() => {
window.removeEventListener('keydown', taste)
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
})
</script>
<template>
<div ref="wurzel" class="lesemodus">
<header class="lm-kopf">
<span class="lm-fort">{{ fortschrittText }}</span>
<button class="lm-x" title="Schließen" @click="schliessen"></button>
</header>
<main class="lm-buehne">
<section v-if="screen.typ === 'intro'" class="lm-screen lm-intro">
<div class="lm-kapnr">Kapitel {{ screen.nr }} / {{ screen.von }}</div>
<h1>{{ screen.kap.titel }}</h1>
<p v-if="screen.kap.intro" class="lm-introtext">{{ screen.kap.intro }}</p>
<div class="lm-stats">
{{ kapStat(screen.kap).woerter.toLocaleString('de-DE') }} Wörter ·
{{ zeit(kapStat(screen.kap).minuten) }} · {{ screen.kap.sections.length }} Bausteine
</div>
</section>
<section v-else-if="screen.typ === 'fundament'" class="lm-screen">
<div class="lm-label">Fundament</div>
<h2 class="lm-titel">{{ screen.titel }}</h2>
<div class="markdown" v-html="fundamentHtml(screen)"></div>
</section>
<section v-else-if="screen.typ === 'abruf'" class="lm-screen lm-abruf">
<div class="lm-label">Abrufprüfung · {{ screen.titel }}</div>
<div v-if="stapel.length" class="lm-karte">
<div class="lm-frage markdown" v-html="render(stapel[0].inhalt.frage)"></div>
<div v-if="zeigeAntwort" class="lm-antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
<div class="lm-karte-akt">
<button v-if="!zeigeAntwort" @click="zeigeAntwort = true">Antwort zeigen</button>
<template v-else>
<button class="lm-falsch" @click="antworten(false)">Falsch</button>
<button class="lm-richtig" @click="antworten(true)">Richtig</button>
</template>
</div>
<div class="lm-rest">noch {{ stapel.length }} · Box {{ stapel[0].box }}</div>
</div>
<div v-else class="lm-bestanden"> Abruf bestanden weiter mit </div>
</section>
<section v-else class="lm-screen lm-ende">
<h1>Fundament abgeschlossen 🎉</h1>
<p class="lm-introtext">Schritt 2 (Fehlersuche) und Schritt 3 (Aufgaben) folgen.</p>
</section>
</main>
<footer class="lm-fuss">
<button :disabled="pos === 0" @click="zurueck"> Zurück</button>
<button :disabled="pos >= screens.length - 1" @click="vor">Weiter </button>
</footer>
</div>
</template>

View File

@@ -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; }

37
tests/test_lesemodus.py Normal file
View File

@@ -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