LAPractice/public/app.js

608 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<div class="bar">
<span class="ok" style="width:${pct(good, total)}%"></span>
<span class="no" style="width:${pct(bad, total)}%"></span>
<span class="neutral" style="width:${pct(rest, total)}%"></span>
</div>`;
}
/* ------------------------------------------------------------ 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 = `<div class="empty">Noch kein Fragenset importiert.<br>
Lade oben eine JSON-Datei hoch, um loszulegen.</div>`;
return;
}
list.innerHTML = state.sets.map((s) => {
const known = s.questions - s.seen;
return `<article class="card set-card" data-set="${s.id}">
<div>
<div class="title">${esc(s.title)}</div>
<div class="faint">zuletzt aktualisiert ${new Date(s.updated_at).toLocaleString('de-AT')}</div>
</div>
<div class="meta">
<span class="pill">${s.questions} Fragen</span>
<span class="pill">${s.seen} geübt</span>
<span class="pill">${known} offen</span>
<span class="pill ${s.answers_correct / (s.answers || 1) >= 0.7 ? 'good' : ''}">
${s.answers} Antworten · ${pct(s.answers_correct, s.answers)}% richtig</span>
</div>
<div class="bar">
<span class="seen" style="width:${pct(s.seen, s.questions)}%"></span>
<span class="neutral" style="width:${100 - pct(s.seen, s.questions)}%"></span>
</div>
<div class="actions">
<button class="primary" data-act="practice">Üben</button>
<button class="ghost" data-act="stats">Statistik</button>
<button class="ghost" data-act="reset">Fortschritt zurücksetzen</button>
<div class="spacer"></div>
<button class="danger ghost" data-act="delete">Löschen</button>
</div>
</article>`;
}).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 `<div class="tree-cat ${picked ? 'open' : ''}" data-cat="${cat.id}">
<header>
<span class="twist">▶</span>
<input type="checkbox" data-cat-check="${cat.id}"
${picked === cat.subcategories.length ? 'checked' : ''}>
<span class="cat-title">${esc(cat.title)}</span>
<span class="faint">${cat.total}</span>
${bar(cat.lastCorrect, cat.lastWrong, cat.total)}
</header>
<div class="tree-subs">
${cat.subcategories.map((sub) => `
<label>
<input type="checkbox" data-sub="${sub.id}" ${state.selection.has(sub.id) ? 'checked' : ''}>
<span>${esc(sub.name)}</span>
<span class="count">${sub.lastCorrect}${sub.lastWrong}✗ / ${sub.total}</span>
</label>`).join('')}
</div>
</div>`;
}).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 = `
<div class="score"><div class="num">${answered.length}</div><div class="lbl">beantwortet</div></div>
<div class="score ok"><div class="num">${correct}</div><div class="lbl">richtig</div></div>
<div class="score no"><div class="num">${wrong}</div><div class="lbl">falsch</div></div>
<div class="score"><div class="num">${pct(correct, answered.length)}%</div><div class="lbl">Trefferquote</div></div>
${skipped ? `<div class="score"><div class="num">${skipped}</div><div class="lbl">übersprungen</div></div>` : ''}`;
const order = [...s.results].sort((a, b) => (a.correct === false ? -1 : 1) - (b.correct === false ? -1 : 1));
$('#summary-list').innerHTML = order.map((r) => `
<div class="review-item ${r.correct ? 'ok' : ''}">
<div class="q">${r.correct === null ? '⤼' : r.correct ? '✓' : '✗'} ${esc(r.q)}</div>
<div class="a">${esc(r.a)}</div>
<div class="faint" style="margin-top:6px">${esc(r.category)} ${esc(r.subcategory)}</div>
</div>`).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 = `
<div class="score"><div class="num">${totals.questions}</div><div class="lbl">Fragen</div></div>
<div class="score"><div class="num">${totals.seen}</div><div class="lbl">davon geübt</div></div>
<div class="score"><div class="num">${totals.answers}</div><div class="lbl">Antworten</div></div>
<div class="score ${pct(totals.answers_correct, totals.answers) >= 70 ? 'ok' : 'no'}">
<div class="num">${pct(totals.answers_correct, totals.answers)}%</div>
<div class="lbl">richtig gesamt</div></div>`;
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 `<div class="day" title="${d.day}: ${d.correct}/${d.answers} richtig">
<div class="stack ${d.answers ? '' : 'idle'}" style="height:${h}px">
${d.answers ? `<span class="ok" style="height:${okShare}%"></span>
<span class="no" style="height:${100 - okShare}%"></span>` : ''}
</div>
<div class="lbl">${d.day.slice(8)}.${d.day.slice(5, 7)}</div>
</div>`;
}).join('');
$('#stats-categories').innerHTML = categories.map((c) => `
<div class="stat-row">
<span class="name" title="${esc(c.title)}">${esc(c.title)}</span>
${bar(c.last_correct, c.last_wrong, c.total)}
<span class="pct">${pct(c.last_correct, c.total)}% · ${c.total}</span>
</div>`).join('');
$('#stats-weakest').innerHTML = weakest.length
? weakest.map((w) => `
<div class="stat-row">
<span class="name" title="${esc(w.q)}">${esc(w.q)}
<span class="faint"> · ${esc(w.category)}</span></span>
${bar(w.correct, w.wrong, w.reviews)}
<span class="pct">${w.correct}${w.wrong}✗</span>
</div>`).join('')
: '<div class="faint">Keine falsch beantworteten Fragen stark.</div>';
}
$('#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'); });