'use strict'; /* ────────────────────────────────────────────────────────────────── Topics view — two phases on one page: list : /exam-prep/:examKey/topics shows sections (parents) with subtopics + per-user accuracy drill : /exam-prep/:examKey/topics/:slug renders TaskCards for a topic-specific practice batch ────────────────────────────────────────────────────────────────── */ (async function () { await EP.boot(); const examKey = EP.examKey; // Parse slug from URL: /exam-prep//topics/ const slug = (() => { const m = location.pathname.match(/\/topics\/([a-z0-9\-_]+)/i); return m ? m[1] : null; })(); if (slug) await renderTopicView(slug); else await renderListView(); })(); /* ════════════════════════════════════════════════════════════════ LIST VIEW ════════════════════════════════════════════════════════════════ */ async function renderListView() { const examKey = EP.examKey; const main = document.getElementById('ep-main'); let payload; try { payload = await EP.api.listTopics(examKey); } catch (e) { main.innerHTML = errorHtml('Не удалось загрузить темы', e); if (window.lucide) lucide.createIcons(); return; } const { sections } = payload; if (!sections.length) { main.innerHTML = `

Темы не настроены

Запустите тегирование: node backend/scripts/tag-exam-tasks.js math9

`; if (window.lucide) lucide.createIcons(); return; } main.innerHTML = `

Тренировка по темам

Выберите тему — модуль соберёт батч из задач этой темы. По умолчанию даём только нерешённые.
${sections.map(renderSection).join('')}
`; if (window.lucide) lucide.createIcons(); } function renderSection(s) { const subtopics = s.subtopics .filter(st => st.total > 0) .sort((a, b) => b.total - a.total); return `

${escapeHtml(s.title)}

${subtopics.map(renderSubtopicCard).join('')}
`; } function renderSubtopicCard(st) { const accuracyClass = st.accuracy == null ? '' : st.accuracy >= 70 ? 'tp-good' : st.accuracy >= 40 ? 'tp-warn' : 'tp-bad'; const accuracyText = st.accuracy == null ? '—' : st.accuracy + '%'; const solvedPct = st.total ? Math.round((st.solved / st.total) * 100) : 0; const href = `/exam-prep/${EP.examKey}/topics/${encodeURIComponent(st.slug)}`; return `
${escapeHtml(st.title)} ${st.total}
${st.solved} решено
${accuracyText} Прокачать
`; } /* ════════════════════════════════════════════════════════════════ TOPIC PRACTICE VIEW ════════════════════════════════════════════════════════════════ */ async function renderTopicView(slug) { const examKey = EP.examKey; const main = document.getElementById('ep-main'); let count = readPersistedCount(examKey); let excludeSolved = true; let batch = null; let finalized = false; const results = new Map(); await loadBatch(); async function loadBatch() { finalized = false; results.clear(); main.innerHTML = `
${controlsHTML()}

Загрузка…

`; if (window.lucide) lucide.createIcons(); wireControls(); try { batch = await EP.api.getTopicTasks(examKey, slug, { count, exclude_solved: excludeSolved ? 1 : 0 }); } catch (e) { main.querySelector('.ep-empty').outerHTML = errorHtml('Не удалось загрузить тему', e); if (window.lucide) lucide.createIcons(); return; } // Update header title with topic name const subEl = document.getElementById('ep-sub'); if (subEl && batch.topic) subEl.textContent = `Тема: ${batch.topic.title} · ${batch.tasks.length} задач`; if (!batch.tasks.length) { main.querySelector('.ep-empty').outerHTML = `

В этой теме всё решено!

Снимите фильтр «только нерешённые», чтобы повторить.

`; if (window.lucide) lucide.createIcons(); return; } renderBatch(); } function controlsHTML() { const opts = [5, 10, 15, 20].map(n => ``).join(''); return `
`; } function wireControls() { document.getElementById('tp-exclude').onchange = (e) => { excludeSolved = e.target.checked; }; document.getElementById('tp-count').onchange = (e) => { count = Number(e.target.value) || 10; persistCount(examKey, count); }; document.getElementById('tp-restart').onclick = () => loadBatch(); } function renderBatch() { const empty = main.querySelector('.ep-empty'); const taskContainer = document.createElement('div'); taskContainer.className = 'pr-tasks'; if (empty) empty.replaceWith(taskContainer); batch.tasks.forEach((task, i) => { results.set(task.id, { isCorrect: null, attempts: 0 }); EP.TaskCard.render(taskContainer, task, { mode: 'topic', sessionId: batch.session_id, numbering: i + 1, onAttempt: ({ taskId, isCorrect, attempt }) => { if (isCorrect == null) return; const r = results.get(taskId); if (!r) return; if (r.isCorrect == null) r.isCorrect = isCorrect; r.attempts = attempt || r.attempts + 1; renderProgress(); maybeFinalize(); }, }); }); // Bottom finish row const finish = document.createElement('div'); finish.className = 'pr-finish-row'; finish.innerHTML = ``; main.appendChild(finish); finish.querySelector('.pr-finish-btn').onclick = finalize; renderProgress(); if (window.lucide) lucide.createIcons(); } function renderProgress() { const el = document.getElementById('pr-progress'); if (!el || !batch) return; const total = batch.tasks.length; const answered = Array.from(results.values()).filter(r => r.isCorrect != null).length; const correct = Array.from(results.values()).filter(r => r.isCorrect === 1).length; const pct = total ? Math.round((answered / total) * 100) : 0; el.innerHTML = `
Прогресс набора ${answered}/${total} · ${correct} верно
`; } function maybeFinalize() { const answered = Array.from(results.values()).filter(r => r.isCorrect != null).length; if (answered === batch.tasks.length && !finalized) finalize(); } function finalize() { if (finalized) return; finalized = true; const total = batch.tasks.length; const correct = Array.from(results.values()).filter(r => r.isCorrect === 1).length; const acc = total ? Math.round((correct / total) * 100) : 0; const summary = document.createElement('div'); summary.className = 'pr-summary ep-card'; summary.innerHTML = `
Тема
${escapeHtml(batch.topic.title)}
Верно
${correct}/${total}
${acc}% точности
К списку тем
`; main.appendChild(summary); summary.querySelector('#pr-summary-restart').onclick = loadBatch; if (window.lucide) lucide.createIcons(); summary.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } /* ════════════════════════════════════════════════════════════════ Utils ════════════════════════════════════════════════════════════════ */ function readPersistedCount(examKey) { try { const n = Number(localStorage.getItem(`exam_prep_${examKey}_topic_count`)); return (n >= 5 && n <= 30) ? n : 10; } catch { return 10; } } function persistCount(examKey, n) { try { localStorage.setItem(`exam_prep_${examKey}_topic_count`, String(n)); } catch {} } function errorHtml(title, e) { return `

${escapeHtml(title)}

${escapeHtml(e?.message || String(e))}

`; } function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); }