608 lines
22 KiB
JavaScript
608 lines
22 KiB
JavaScript
// 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 `<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'); });
|