// LAPractice – Karteikarten-Trainer. Alles state-in-memory, Persistenz via API.
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const state = {
sets: [],
setId: Number(localStorage.getItem('lap.setId')) || null,
tree: [],
selection: new Set(),
session: null,
};
/* -------------------------------------------------------------------- api */
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
let data = {};
try { data = await res.json(); } catch { /* leerer Body */ }
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
/* ------------------------------------------------------------------ utils */
const esc = (s) => String(s).replace(/[&<>"']/g,
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const pct = (part, whole) => (whole ? Math.round((part / whole) * 100) : 0);
function toast(message, kind = '') {
const el = document.createElement('div');
el.className = `toast ${kind}`;
el.textContent = message;
$('#toasts').append(el);
setTimeout(() => el.remove(), kind === 'error' ? 6000 : 3500);
}
function bar(good, bad, total) {
const rest = Math.max(total - good - bad, 0);
return `
`;
}
/* ------------------------------------------------------------ navigation */
function navigate(view) {
$$('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${view}`));
$$('[data-nav]').forEach((b) => b.classList.toggle('active', b.dataset.nav === view));
window.scrollTo({ top: 0, behavior: 'instant' });
if (view === 'stats') loadStats();
}
document.addEventListener('click', (ev) => {
const nav = ev.target.closest('[data-nav]');
if (nav) navigate(nav.dataset.nav);
});
/* --------------------------------------------------------------- library */
async function loadSets() {
const { sets } = await api('GET', '/api/sets');
state.sets = sets;
if (!sets.some((s) => s.id === state.setId)) state.setId = sets[0]?.id ?? null;
renderLibrary();
updateNav();
if (state.setId) await loadTree();
}
function updateNav() {
const has = state.setId !== null;
$('#nav-setup').hidden = !has;
$('#nav-stats').hidden = !has;
}
function renderLibrary() {
const list = $('#set-list');
if (!state.sets.length) {
list.innerHTML = `Noch kein Fragenset importiert.
Lade oben eine JSON-Datei hoch, um loszulegen.
`;
return;
}
list.innerHTML = state.sets.map((s) => {
const known = s.questions - s.seen;
return `
${esc(s.title)}
zuletzt aktualisiert ${new Date(s.updated_at).toLocaleString('de-AT')}
${s.questions} Fragen
${s.seen} geübt
${known} offen
${s.answers} Antworten · ${pct(s.answers_correct, s.answers)}% richtig
`;
}).join('');
}
$('#set-list').addEventListener('click', async (ev) => {
const btn = ev.target.closest('[data-act]');
if (!btn) return;
const setId = Number(btn.closest('[data-set]').dataset.set);
const set = state.sets.find((s) => s.id === setId);
try {
if (btn.dataset.act === 'practice') {
await selectSet(setId);
navigate('setup');
} else if (btn.dataset.act === 'stats') {
await selectSet(setId);
navigate('stats');
} else if (btn.dataset.act === 'reset') {
if (!confirm(`Alle Antwort-Statistiken von „${set.title}" löschen?`)) return;
const { deletedReviews } = await api('POST', `/api/sets/${setId}/reset`);
toast(`${deletedReviews} Antworten gelöscht.`, 'success');
await loadSets();
} else if (btn.dataset.act === 'delete') {
if (!confirm(`Fragenset „${set.title}" mit allen Statistiken endgültig löschen?`)) return;
await api('DELETE', `/api/sets/${setId}`);
if (state.setId === setId) state.setId = null;
toast('Fragenset gelöscht.', 'success');
await loadSets();
}
} catch (err) {
toast(err.message, 'error');
}
});
async function selectSet(setId) {
state.setId = setId;
localStorage.setItem('lap.setId', String(setId));
updateNav();
await loadTree();
}
/* ---------------------------------------------------------------- import */
const dropzone = $('#dropzone');
$('#pick-file').addEventListener('click', () => $('#file-input').click());
$('#file-input').addEventListener('change', (ev) => importFiles([...ev.target.files]));
['dragenter', 'dragover'].forEach((type) =>
dropzone.addEventListener(type, (ev) => { ev.preventDefault(); dropzone.classList.add('drag'); }));
['dragleave', 'drop'].forEach((type) =>
dropzone.addEventListener(type, (ev) => { ev.preventDefault(); dropzone.classList.remove('drag'); }));
dropzone.addEventListener('drop', (ev) => importFiles([...(ev.dataTransfer?.files || [])]));
async function importFiles(files) {
const usable = files.filter((f) => /\.json$/i.test(f.name) || f.type.includes('json'));
for (const file of files.filter((f) => !usable.includes(f))) {
toast(`${file.name}: keine JSON-Datei – übersprungen.`, 'error');
}
for (const file of usable) {
try {
const data = JSON.parse(await file.text());
const { import: r } = await api('POST', '/api/sets', {
data,
filename: file.name.replace(/\.json$/i, ''),
});
toast(r.isNew
? `„${r.title}" importiert: ${r.total} Fragen.`
: `„${r.title}" aktualisiert: ${r.added} neu, ${r.updated} geändert, ${r.archived} entfernt.`,
'success');
await loadSets();
await selectSet(r.setId);
} catch (err) {
toast(`${file.name}: ${err.message}`, 'error');
}
}
$('#file-input').value = '';
}
/* ----------------------------------------------------------------- setup */
async function loadTree() {
const { set, categories } = await api('GET', `/api/sets/${state.setId}`);
state.tree = categories;
$('#setup-title').textContent = set.title;
$('#stats-title').textContent = `Statistik – ${set.title}`;
const stored = JSON.parse(localStorage.getItem(`lap.sel.${set.id}`) || 'null');
const all = categories.flatMap((c) => c.subcategories.map((s) => s.id));
state.selection = new Set(stored ? stored.filter((id) => all.includes(id)) : all);
renderTree();
}
function renderTree() {
$('#tree').innerHTML = state.tree.map((cat) => {
const picked = cat.subcategories.filter((s) => state.selection.has(s.id)).length;
return ``;
}).join('');
updateSelectionInfo();
}
$('#tree').addEventListener('click', (ev) => {
const subBox = ev.target.closest('[data-sub]');
if (subBox) {
const id = Number(subBox.dataset.sub);
subBox.checked ? state.selection.add(id) : state.selection.delete(id);
syncCategoryChecks();
persistSelection();
return;
}
const catBox = ev.target.closest('[data-cat-check]');
if (catBox) {
ev.stopPropagation();
const cat = state.tree.find((c) => c.id === Number(catBox.dataset.catCheck));
for (const sub of cat.subcategories) {
catBox.checked ? state.selection.add(sub.id) : state.selection.delete(sub.id);
}
renderTree();
persistSelection();
return;
}
const header = ev.target.closest('.tree-cat > header');
if (header) header.parentElement.classList.toggle('open');
});
function syncCategoryChecks() {
for (const cat of state.tree) {
const box = $(`[data-cat-check="${cat.id}"]`);
if (!box) continue;
const picked = cat.subcategories.filter((s) => state.selection.has(s.id)).length;
box.checked = picked === cat.subcategories.length;
box.indeterminate = picked > 0 && picked < cat.subcategories.length;
}
updateSelectionInfo();
}
function persistSelection() {
localStorage.setItem(`lap.sel.${state.setId}`, JSON.stringify([...state.selection]));
}
function selectedTotals() {
let total = 0; let seen = 0; let wrong = 0;
for (const cat of state.tree) {
for (const sub of cat.subcategories) {
if (!state.selection.has(sub.id)) continue;
total += sub.total; seen += sub.seen; wrong += sub.lastWrong;
}
}
return { total, unseen: total - seen, wrong };
}
function poolSize() {
const t = selectedTotals();
const filter = $('#filter').value;
return { pool: filter === 'wrong' ? t.wrong : filter === 'unseen' ? t.unseen : t.total, ...t };
}
function updateSelectionInfo() {
const { pool, total } = poolSize();
const subs = state.selection.size;
const opts = [
$('#shuffle').checked ? 'gemischt' : 'Skriptum-Reihenfolge',
$('#smart').checked ? 'Schwächen zuerst' : null,
$('#endless').checked ? 'endlos' : null,
].filter(Boolean).join(' · ');
$('#selection-info').textContent = subs
? `${subs} Themen · ${pool} Fragen im Pool${pool === total ? '' : ` (von ${total})`} · ${opts}`
: 'Nichts ausgewählt';
$('#start').disabled = subs === 0 || pool === 0;
}
$('#setup-all').addEventListener('click', () => {
state.selection = new Set(state.tree.flatMap((c) => c.subcategories.map((s) => s.id)));
renderTree(); persistSelection();
});
$('#setup-none').addEventListener('click', () => {
state.selection.clear();
renderTree(); persistSelection();
});
for (const id of ['filter', 'shuffle', 'smart', 'endless', 'limit']) {
$(`#${id}`).addEventListener('change', () => {
const el = $(`#${id}`);
localStorage.setItem(`lap.${id}`, el.type === 'checkbox' ? String(el.checked) : el.value);
if (id === 'endless') {
$('#limit-row').querySelector('.label').textContent = el.checked ? 'Pro Runde' : 'Anzahl';
}
updateSelectionInfo();
});
}
/** Die im Setup gewählten Optionen als Session-Konfiguration. */
function readOptions() {
return {
subcategoryIds: [...state.selection],
filter: $('#filter').value,
shuffle: $('#shuffle').checked,
smart: $('#smart').checked,
endless: $('#endless').checked,
limit: Number($('#limit').value),
};
}
const apiPayload = ({ endless, ...rest }) => rest;
/* -------------------------------------------------------------- practice */
$('#start').addEventListener('click', () => startSession());
async function startSession(override) {
const config = override || readOptions();
try {
const { questions } = await api('POST', `/api/sets/${state.setId}/session`, apiPayload(config));
if (!questions.length) {
toast('Keine Fragen gefunden – andere Auswahl oder Filter probieren.', 'error');
return;
}
state.session = { questions, index: 0, results: [], config, endless: !!config.endless, cycle: 1 };
navigate('practice');
renderCard();
} catch (err) {
toast(err.message, 'error');
}
}
/**
* Endlos-Modus: ist der Stapel durch, wird die naechste Runde geholt. Der
* Server sortiert dabei mit den frisch gespeicherten Ergebnissen, sodass gerade
* falsch beantwortete Fragen wieder nach vorne rutschen.
*/
async function nextRound() {
const s = state.session;
$('#p-question').textContent = 'Nächste Runde wird geladen …';
let next = [];
try {
({ questions: next } = await api('POST', `/api/sets/${state.setId}/session`, apiPayload(s.config)));
} catch (err) {
toast(err.message, 'error');
}
if (!next.length) next = s.questions.slice(); // Fallback: dieselben Karten nochmal
if (!next.length) return finishSession();
s.cycle++;
s.questions = next;
s.index = 0;
toast(`Runde ${s.cycle} · ${next.length} Karten`);
renderCard();
}
function currentCard() {
const s = state.session;
return s ? s.questions[s.index] : null;
}
function renderCard() {
const s = state.session;
const card = currentCard();
if (!card) return s.endless ? nextRound() : finishSession();
$('#p-position').textContent = s.endless
? `Karte ${s.results.length + 1} · Runde ${s.cycle}`
: `${s.index + 1} / ${s.questions.length}`;
$('#p-progress').style.width = `${pct(s.index, s.questions.length)}%`;
$('#p-correct').textContent = `✓ ${s.results.filter((r) => r.correct).length}`;
$('#p-wrong').textContent = `✗ ${s.results.filter((r) => r.correct === false).length}`;
$('#p-category').textContent = card.category;
$('#p-subcategory').textContent = card.subcategory;
const hist = $('#p-history');
if (card.stats.reviews > 0) {
hist.hidden = false;
hist.className = `pill ${card.stats.lastCorrect ? 'good' : 'bad'}`;
hist.textContent = `bisher ${card.stats.correct}✓ ${card.stats.wrong}✗`;
} else {
hist.hidden = false;
hist.className = 'pill accent';
hist.textContent = 'neu';
}
$('#p-question').textContent = card.q;
$('#p-answer-text').textContent = card.a;
setRevealed(false);
}
function setRevealed(revealed) {
state.session.revealed = revealed;
$('#p-answer').hidden = !revealed;
$('#p-judge').hidden = !revealed;
$('#p-reveal').hidden = revealed;
if (!revealed) $('#p-reveal').focus({ preventScroll: true });
}
$('#p-reveal').addEventListener('click', () => setRevealed(true));
$('#p-yes').addEventListener('click', () => judge(true));
$('#p-no').addEventListener('click', () => judge(false));
$('#p-quit').addEventListener('click', () => {
if (state.session?.results.length) finishSession();
else navigate('setup');
});
async function judge(correct) {
const s = state.session;
const card = currentCard();
if (!s || !card || !s.revealed) return;
s.results.push({ ...card, correct });
if (s.endless && !correct) {
// Falsche Karte kommt noch in dieser Runde wieder.
s.questions.splice(Math.min(s.index + 5, s.questions.length), 0, card);
}
s.index++;
renderCard();
try {
const { stats } = await api('POST', '/api/reviews', { questionId: card.id, correct });
card.stats = stats;
} catch (err) {
toast(`Antwort nicht gespeichert: ${err.message}`, 'error');
}
}
function skipCard() {
const s = state.session;
if (!s) return;
s.results.push({ ...currentCard(), correct: null });
s.index++;
renderCard();
}
document.addEventListener('keydown', (ev) => {
if (!$('#view-practice').classList.contains('active')) return;
if (ev.target.matches('input, select, textarea')) return;
const s = state.session;
if (!s) return;
if (ev.key === 'Escape') { ev.preventDefault(); $('#p-quit').click(); return; }
if (ev.code === 'Space' || ev.key === 'Enter') {
ev.preventDefault();
if (!s.revealed) setRevealed(true);
return;
}
if (ev.key.toLowerCase() === 's') { ev.preventDefault(); skipCard(); return; }
if (!s.revealed) return;
if (ev.key === '2' || ev.key === 'ArrowRight') { ev.preventDefault(); judge(true); }
else if (ev.key === '1' || ev.key === 'ArrowLeft') { ev.preventDefault(); judge(false); }
});
/* -------------------------------------------------------------- summary */
function finishSession() {
const s = state.session;
const answered = s.results.filter((r) => r.correct !== null);
const correct = answered.filter((r) => r.correct).length;
const wrong = answered.length - correct;
const skipped = s.results.length - answered.length;
$('#summary-scores').innerHTML = `
${answered.length}
beantwortet
${pct(correct, answered.length)}%
Trefferquote
${skipped ? `` : ''}`;
const order = [...s.results].sort((a, b) => (a.correct === false ? -1 : 1) - (b.correct === false ? -1 : 1));
$('#summary-list').innerHTML = order.map((r) => `
${r.correct === null ? '⤼' : r.correct ? '✓' : '✗'} ${esc(r.q)}
${esc(r.a)}
${esc(r.category)} › ${esc(r.subcategory)}
`).join('');
$('#again-wrong').disabled = wrong === 0;
$('#again-wrong').dataset.ids = JSON.stringify(
s.results.filter((r) => r.correct === false).map((r) => r.id));
navigate('summary');
loadSets().catch(() => {});
}
$('#again-wrong').addEventListener('click', () => {
const ids = new Set(JSON.parse($('#again-wrong').dataset.ids || '[]'));
const questions = state.session.results
.filter((r) => ids.has(r.id))
.map(({ correct, ...q }) => q);
state.session = {
questions, index: 0, results: [],
config: { ...state.session.config, endless: false }, endless: false, cycle: 1,
};
navigate('practice');
renderCard();
});
$('#again-same').addEventListener('click', () => startSession(state.session.config));
/* ----------------------------------------------------------------- stats */
async function loadStats() {
if (!state.setId) return;
try {
const data = await api('GET', `/api/sets/${state.setId}/stats`);
renderStats(data);
} catch (err) {
toast(err.message, 'error');
}
}
function renderStats({ set, totals, categories, weakest, daily }) {
$('#stats-title').textContent = `Statistik – ${set.title}`;
$('#stats-scores').innerHTML = `
${totals.questions}
Fragen
${totals.seen}
davon geübt
${totals.answers}
Antworten
${pct(totals.answers_correct, totals.answers)}%
richtig gesamt
`;
const byDay = new Map(daily.map((d) => [d.day, d]));
const days = [...Array(14)].map((_, i) => {
const date = new Date();
date.setDate(date.getDate() - (13 - i));
const key = date.toISOString().slice(0, 10);
return byDay.get(key) || { day: key, answers: 0, correct: 0 };
});
const max = Math.max(1, ...days.map((d) => d.answers));
$('#stats-days').innerHTML = days.map((d) => {
const h = d.answers ? Math.round((d.answers / max) * 68) + 4 : 3;
const okShare = pct(d.correct, d.answers);
return `
${d.answers ? `
` : ''}
${d.day.slice(8)}.${d.day.slice(5, 7)}
`;
}).join('');
$('#stats-categories').innerHTML = categories.map((c) => `
${esc(c.title)}
${bar(c.last_correct, c.last_wrong, c.total)}
${pct(c.last_correct, c.total)}% · ${c.total}
`).join('');
$('#stats-weakest').innerHTML = weakest.length
? weakest.map((w) => `
${esc(w.q)}
· ${esc(w.category)}
${bar(w.correct, w.wrong, w.reviews)}
${w.correct}✓ ${w.wrong}✗
`).join('')
: 'Keine falsch beantworteten Fragen – stark.
';
}
$('#stats-reset').addEventListener('click', async () => {
const set = state.sets.find((s) => s.id === state.setId);
if (!confirm(`Alle Antwort-Statistiken von „${set?.title}" löschen?`)) return;
try {
const { deletedReviews } = await api('POST', `/api/sets/${state.setId}/reset`);
toast(`${deletedReviews} Antworten gelöscht.`, 'success');
await loadSets();
await loadStats();
} catch (err) {
toast(err.message, 'error');
}
});
/* ------------------------------------------------------------------ boot */
$('#filter').value = localStorage.getItem('lap.filter') || 'all';
$('#limit').value = localStorage.getItem('lap.limit') || '20';
for (const id of ['shuffle', 'smart', 'endless']) {
const stored = localStorage.getItem(`lap.${id}`);
if (stored !== null) $(`#${id}`).checked = stored === 'true';
}
$('#limit-row').querySelector('.label').textContent = $('#endless').checked ? 'Pro Runde' : 'Anzahl';
loadSets()
.then(() => navigate(state.setId ? 'setup' : 'library'))
.catch((err) => { toast(err.message, 'error'); navigate('library'); });