// 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 `
${esc(cat.title)} ${cat.total} ${bar(cat.lastCorrect, cat.lastWrong, cat.total)}
${cat.subcategories.map((sub) => ` `).join('')}
`; }).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
${correct}
richtig
${wrong}
falsch
${pct(correct, answered.length)}%
Trefferquote
${skipped ? `
${skipped}
übersprungen
` : ''}`; 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'); });