update
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
166
frontend/src/components/Lesemodus.vue
Normal file
166
frontend/src/components/Lesemodus.vue
Normal 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>
|
||||
@@ -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
37
tests/test_lesemodus.py
Normal 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
|
||||
Reference in New Issue
Block a user