diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index 65cc5a7..39efdcf 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -255,6 +255,68 @@ const SQL = { AND a.is_correct = 1 AND DATE(a.created_at / 1000, 'unixepoch') = DATE('now') `), + + /* ── Topics (F7) ────────────────────────────────────────────── */ + + listTopicsWithCounts: db.prepare(` + SELECT + tp.slug, tp.parent_slug, tp.title, tp.sort_order, + COALESCE(stat.total, 0) AS total, + COALESCE(stat.attempted, 0) AS attempted, + COALESCE(stat.solved, 0) AS solved, + COALESCE(stat.attempts, 0) AS attempts, + COALESCE(stat.correct, 0) AS correct + FROM exam_topics tp + LEFT JOIN ( + SELECT + t.subtopic, + COUNT(DISTINCT t.id) AS total, + COUNT(DISTINCT a.exam_task_id) AS attempted, + COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved, + COUNT(a.id) AS attempts, + COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct + FROM exam_tasks t + LEFT JOIN exam_attempts a + ON a.exam_task_id = t.id AND a.user_id = ? + WHERE t.exam_key = ? AND t.subtopic IS NOT NULL + GROUP BY t.subtopic + ) stat ON stat.subtopic = tp.slug + WHERE tp.exam_key = ? + ORDER BY tp.sort_order + `), + + /* Pick tasks for a topic practice session. + Excludes long tasks (no auto-check) and tasks the user has already solved + correctly, when ?exclude_solved=1. */ + topicTasksUnsolved: db.prepare(` + SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, + answer, solution_html, topic, subtopic, difficulty + FROM exam_tasks t + WHERE t.exam_key = ? AND t.subtopic = ? + AND t.task_type IN ('mc','open') + AND NOT EXISTS ( + SELECT 1 FROM exam_attempts a + WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1 + ) + ORDER BY RANDOM() + LIMIT ? + `), + + topicTasksAny: db.prepare(` + SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, + answer, solution_html, topic, subtopic, difficulty + FROM exam_tasks t + WHERE t.exam_key = ? AND t.subtopic = ? + AND t.task_type IN ('mc','open') + ORDER BY RANDOM() + LIMIT ? + `), + + getTopicMeta: db.prepare(` + SELECT slug, parent_slug, title, description + FROM exam_topics + WHERE exam_key = ? AND slug = ? + `), }; /* ── GET /api/exam-prep/tracks ── @@ -533,6 +595,85 @@ function stripPreview(html) { return text.length > 100 ? text.slice(0, 100) + '…' : text; } +/* ────────────────────────────────────────────────────────────────── + Topics (F7) — list + per-topic practice batch + ────────────────────────────────────────────────────────────────── */ + +/* ── GET /api/exam-prep/:examKey/topics ── + Returns sections (parents) with children + counts and user accuracy. */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.get('/:examKey/topics', (req, res) => { + const { examKey } = req.params; + if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); + + const rows = SQL.listTopicsWithCounts.all(req.user.id, examKey, examKey); + + // Build {sections: [{slug,title,subtopics:[...]}]} + const byParent = new Map(); + const sections = []; + for (const r of rows) { + if (r.parent_slug == null) { + const section = { slug: r.slug, title: r.title, subtopics: [], total: 0, solved: 0 }; + sections.push(section); + byParent.set(r.slug, section); + } + } + for (const r of rows) { + if (r.parent_slug != null) { + const sec = byParent.get(r.parent_slug); + if (!sec) continue; + const accuracy = r.attempts > 0 ? Math.round((r.correct / r.attempts) * 100) : null; + const solvedPct = r.total > 0 ? Math.round((r.solved / r.total) * 100) : 0; + sec.subtopics.push({ + slug: r.slug, + title: r.title, + total: r.total, + solved: r.solved, + attempted: r.attempted, + attempts: r.attempts, + correct: r.correct, + accuracy, + solved_pct: solvedPct, + }); + sec.total += r.total; + sec.solved += r.solved; + } + } + + res.json({ sections }); +}); + +/* ── GET /api/exam-prep/:examKey/topics/:slug/tasks ── + Returns a batch of tasks for topic practice. + Query: ?count=10 (5-30) ?exclude_solved=1 (default 1) */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.get('/:examKey/topics/:slug/tasks', (req, res) => { + const { examKey, slug } = req.params; + const meta = SQL.getTopicMeta.get(examKey, slug); + if (!meta) return res.status(404).json({ error: 'Topic not found' }); + if (meta.parent_slug == null) { + return res.status(400).json({ error: 'Cannot fetch tasks for a section — pick a specific subtopic' }); + } + + let count = Number(req.query.count) || 10; + count = Math.max(5, Math.min(count, 30)); + const excludeSolved = req.query.exclude_solved !== '0'; + + let rows; + if (excludeSolved) { + rows = SQL.topicTasksUnsolved.all(examKey, slug, req.user.id, count); + if (!rows.length) rows = SQL.topicTasksAny.all(examKey, slug, count); + } else { + rows = SQL.topicTasksAny.all(examKey, slug, count); + } + + res.json({ + topic: { slug: meta.slug, title: meta.title, parent: meta.parent_slug }, + session_id: Date.now(), + tasks: rows.map(shapeTask), + }); +}); + /* ────────────────────────────────────────────────────────────────── Study plan (F10) — by exam date ────────────────────────────────────────────────────────────────── */ diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index 833b8e7..669d486 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -901,6 +901,102 @@ } .dh-plan-derived-card b { color: var(--text); } +/* ═══════════════════════════════════════════════════════════════ + Topics view (`tp-*`) — used by exam-prep-topics.html (F7) + ═══════════════════════════════════════════════════════════════ */ + +.tp-sections { display: flex; flex-direction: column; gap: 16px; } + +.tp-section-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 8px; +} +.tp-section-head h3 { margin-bottom: 0; } +.tp-section-meta { + font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700; + color: var(--text-2); +} +.tp-section-bar { margin-bottom: 14px; } + +.tp-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.tp-card { + display: flex; flex-direction: column; gap: 8px; + padding: 14px 16px; + border: 1.5px solid var(--border-h); + border-radius: 11px; + background: var(--surface); + text-decoration: none; color: var(--text); + transition: all .15s; +} +.tp-card:hover { + border-color: var(--violet); + box-shadow: 0 4px 14px rgba(155,93,229,.10); +} + +.tp-card-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; +} +.tp-card-title { + font-family: 'Unbounded', sans-serif; font-size: .88rem; font-weight: 700; + line-height: 1.3; +} +.tp-card-count { + font-family: 'Unbounded', sans-serif; font-size: .72rem; font-weight: 800; + color: var(--text-3); + padding: 2px 7px; border-radius: 5px; + background: rgba(15,23,42,.05); + white-space: nowrap; +} + +.tp-card-progress { + display: flex; align-items: center; gap: 8px; + font-size: .75rem; color: var(--text-3); +} +.tp-card-progress .ep-bar { flex: 1; margin-top: 0; } + +.tp-card-foot { + display: flex; align-items: center; justify-content: space-between; + margin-top: 4px; +} +.tp-card-accuracy { + font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: .82rem; + padding: 3px 9px; border-radius: 5px; + background: rgba(15,23,42,.05); + color: var(--text-2); +} +.tp-card-accuracy.tp-good { background: rgba(6,214,160,.16); color: #06D6A0; } +.tp-card-accuracy.tp-warn { background: rgba(248,150,30,.14); color: #B45309; } +.tp-card-accuracy.tp-bad { background: rgba(230,57,70,.14); color: #E63946; } + +.tp-card-cta { + display: inline-flex; align-items: center; gap: 4px; + font-size: .75rem; font-weight: 700; color: var(--violet); +} +.tp-card-cta svg { width: 12px; height: 12px; } + +/* Topic practice header */ +.tp-back-bar { margin-bottom: 12px; } +.tp-back { + display: inline-flex; align-items: center; gap: 6px; + font-size: .85rem; font-weight: 600; color: var(--text-2); + text-decoration: none; + padding: 6px 10px; border-radius: 7px; + transition: background .12s; +} +.tp-back:hover { background: rgba(155,93,229,.06); color: var(--violet); } +.tp-back svg { width: 14px; height: 14px; } + +.tp-checkbox { + display: inline-flex; align-items: center; gap: 8px; + font-size: .85rem; color: var(--text-2); cursor: pointer; +} +.tp-checkbox input { accent-color: var(--violet); } + /* ── Mobile tweaks ─────────────────────────────────────────────── */ @media (max-width: 640px) { .ep-wrap { padding: 20px 16px 60px; } diff --git a/frontend/exam-prep-topics.html b/frontend/exam-prep-topics.html index 0248116..ce08349 100644 --- a/frontend/exam-prep-topics.html +++ b/frontend/exam-prep-topics.html @@ -8,6 +8,9 @@ + + +
@@ -24,9 +27,9 @@
-
+
 
-
 
+
 
@@ -34,9 +37,8 @@
- -

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

-

В F6 проставим теги темам (LLM-классификация), а в F7 здесь появится список из ~25 подтем с точностью пользователя и кнопкой «Прорешать 20 задач».

+ +

Загрузка…

@@ -52,6 +54,9 @@ - + + + + diff --git a/frontend/js/exam-prep/topics.js b/frontend/js/exam-prep/topics.js new file mode 100644 index 0000000..5d421d0 --- /dev/null +++ b/frontend/js/exam-prep/topics.js @@ -0,0 +1,306 @@ +'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])); +}