diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index 6707c17..d849fb3 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -69,6 +69,38 @@ const SQL = { ORDER BY task_idx `), + /* Practice batch — RANDOM strategy. + Excludes long tasks (no auto-check) by default to keep UX consistent. */ + practiceRandom: db.prepare(` + SELECT + id, task_idx, variant, task_type, text_html, figure_html, opts_json, + answer, solution_html, topic + FROM exam_tasks + WHERE exam_key = ? + AND task_type IN ('mc','open') + ORDER BY RANDOM() + LIMIT ? + `), + + /* Practice batch — UNSOLVED strategy. + Excludes tasks the user has already solved correctly at least once. */ + practiceUnsolved: db.prepare(` + SELECT + t.id, t.task_idx, t.variant, t.task_type, t.text_html, t.figure_html, + t.opts_json, t.answer, t.solution_html, t.topic + FROM exam_tasks t + WHERE t.exam_key = ? + 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 ? + `), + /* For attempt save: confirm the task belongs to a known track + user */ getTaskExamKey: db.prepare(`SELECT exam_key FROM exam_tasks WHERE id = ?`), @@ -168,6 +200,51 @@ router.get('/:examKey/variants/:n/tasks', (req, res) => { function safeJson(s) { try { return JSON.parse(s); } catch { return null; } } +function shapeTask(r) { + return { + id: r.id, + idx: r.task_idx, + variant: r.variant ?? null, + type: r.task_type, + text: r.text_html, + figure: r.figure_html, + opts: r.opts_json ? safeJson(r.opts_json) : null, + answer: r.answer, + solution: r.solution_html, + topic: r.topic ?? null, + }; +} + +/* ── GET /api/exam-prep/:examKey/practice/next ── + Returns up to ?count=N tasks for the practice trainer. + ?strategy=random — any mc/open task, fresh random sample + =unsolved — only tasks the user has not yet solved correctly + Long tasks are excluded (no auto-check available). */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.get('/:examKey/practice/next', (req, res) => { + const { examKey } = req.params; + if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); + + let count = Number(req.query.count) || 10; + count = Math.max(1, Math.min(count, 30)); + + const strategy = req.query.strategy === 'unsolved' ? 'unsolved' : 'random'; + let rows; + if (strategy === 'unsolved') { + rows = SQL.practiceUnsolved.all(examKey, req.user.id, count); + // Fallback: if user has solved everything, supply random anyway so UX isn't a dead-end. + if (!rows.length) rows = SQL.practiceRandom.all(examKey, count); + } else { + rows = SQL.practiceRandom.all(examKey, count); + } + + res.json({ + strategy, + session_id: Date.now(), // ephemeral grouping key for these N attempts + tasks: rows.map(shapeTask), + }); +}); + /* ── POST /api/exam-prep/attempts ── Save a single attempt. Used by the variants view (F2) for "solution viewed" markers, and by the practice / mock views (F3+) for actual answer checking. diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index 6ebf1ee..8b4c03c 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -507,6 +507,111 @@ font-family: 'Unbounded', sans-serif; font-weight: 800; color: #06D6A0; } +/* ═══════════════════════════════════════════════════════════════ + Practice view (`pr-*`) — used by exam-prep-practice.html + ═══════════════════════════════════════════════════════════════ */ + +.pr-controls { + background: var(--surface); + border: 1.5px solid var(--border); + border-radius: 14px; + padding: 14px 18px; + margin-bottom: 16px; +} +.pr-controls-row { + display: flex; flex-wrap: wrap; align-items: center; gap: 12px; +} +.pr-strategy { + display: flex; gap: 6px; flex-wrap: wrap; +} +.pr-strategy-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 14px; + border: 1.5px solid var(--border-h); + border-radius: 9px; + background: transparent; color: var(--text-2); + font-family: 'Manrope', sans-serif; font-size: .85rem; font-weight: 700; + cursor: pointer; transition: all .12s; +} +.pr-strategy-btn:hover:not(:disabled):not(.disabled) { + border-color: var(--violet); color: var(--violet); +} +.pr-strategy-btn.active { + background: var(--violet); border-color: var(--violet); color: #fff; +} +.pr-strategy-btn.disabled, .pr-strategy-btn:disabled { + opacity: .42; cursor: not-allowed; +} +.pr-strategy-btn svg { width: 14px; height: 14px; } + +.pr-count-pick { + display: inline-flex; align-items: center; gap: 8px; + margin-left: auto; +} +.pr-count-pick label { + font-size: .78rem; color: var(--text-3); font-weight: 700; + text-transform: uppercase; letter-spacing: .04em; +} +.pr-count-select { + padding: 7px 10px; + border: 1.5px solid var(--border-h); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-family: 'Manrope', sans-serif; font-size: .85rem; font-weight: 600; + cursor: pointer; +} +.pr-count-select:focus { outline: none; border-color: var(--violet); } + +.pr-restart { white-space: nowrap; } +.pr-restart svg { width: 14px; height: 14px; } + +.pr-progress { + margin-top: 12px; padding-top: 12px; + border-top: 1px dashed var(--border); +} +.pr-progress-meta { + display: flex; justify-content: space-between; + font-size: .78rem; color: var(--text-3); + text-transform: uppercase; letter-spacing: .04em; font-weight: 700; + margin-bottom: 6px; +} +.pr-progress-counts { + color: var(--violet); + text-transform: none; letter-spacing: 0; + font-size: .8rem; +} + +.pr-tasks { margin-top: 6px; } + +.pr-finish-row { + display: flex; justify-content: center; + margin: 22px 0 0; +} +.pr-finish-btn { + display: inline-flex; align-items: center; gap: 7px; + padding: 9px 18px; + border: 1.5px solid var(--border-h); border-radius: 10px; + background: var(--surface); color: var(--text-2); + font-family: 'Manrope', sans-serif; font-size: .85rem; font-weight: 700; + cursor: pointer; transition: all .15s; +} +.pr-finish-btn:hover { border-color: var(--violet); color: var(--violet); } +.pr-finish-btn svg { width: 14px; height: 14px; } + +.pr-summary { margin-top: 22px; } +.pr-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} +.pr-summary-stat { + padding: 12px 14px; + background: rgba(155,93,229,.05); + border-radius: 10px; +} + /* ── Mobile tweaks ─────────────────────────────────────────────── */ @media (max-width: 640px) { .ep-wrap { padding: 20px 16px 60px; } diff --git a/frontend/exam-prep-practice.html b/frontend/exam-prep-practice.html index 4f26948..dccf49e 100644 --- a/frontend/exam-prep-practice.html +++ b/frontend/exam-prep-practice.html @@ -8,6 +8,9 @@ + + +
@@ -24,9 +27,9 @@
-
+
 
-
 
+
 
@@ -34,9 +37,8 @@
- -

Тренажёр случайных задач

-

В F3 здесь появится поле ввода ответа с автопроверкой, а в F5 — выборка случайных задач из банка с фильтром «нерешённые / слабые».

+ +

Загрузка…

@@ -52,6 +54,9 @@ - + + + + diff --git a/frontend/js/exam-prep/practice.js b/frontend/js/exam-prep/practice.js new file mode 100644 index 0000000..0943da5 --- /dev/null +++ b/frontend/js/exam-prep/practice.js @@ -0,0 +1,236 @@ +'use strict'; +/* ────────────────────────────────────────────────────────────────── + Practice view — random / unsolved tasks trainer. + Renders a batch of N TaskCards; once all are answered (or user + clicks "Завершить"), shows a summary card with score + restart. + ────────────────────────────────────────────────────────────────── */ + +(async function () { + await EP.boot(); + const examKey = EP.examKey; + + const main = document.getElementById('ep-main'); + + // Per-session state + let batch = null; // { strategy, session_id, tasks: [...] } + let strategy = readPersistedStrategy() || 'random'; + let count = readPersistedCount(); + let finalized = false; + const results = new Map(); // taskId -> { isCorrect: 0|1|null, attempts: number } + + /* ── Strategy persistence (local pref) ──────────────────────── */ + function readPersistedStrategy() { + try { return localStorage.getItem(`exam_prep_${examKey}_practice_strategy`); } catch { return null; } + } + function persistStrategy(s) { + try { localStorage.setItem(`exam_prep_${examKey}_practice_strategy`, s); } catch {} + } + function readPersistedCount() { + try { + const n = Number(localStorage.getItem(`exam_prep_${examKey}_practice_count`)); + return (n >= 5 && n <= 30) ? n : 10; + } catch { return 10; } + } + function persistCount(n) { + try { localStorage.setItem(`exam_prep_${examKey}_practice_count`, String(n)); } catch {} + } + + /* ── Boot: show controls and load first batch ───────────────── */ + await loadBatch(); + + /* ── Controls bar ───────────────────────────────────────────── */ + function controlsHTML() { + const opts = [5, 10, 15, 20].map(n => ` + `).join(''); + return ` +
+
+
+ + + +
+
+ + +
+ +
+
+
`; + } + + 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} верно +
+
`; + } + + /* ── Batch loading + render ─────────────────────────────────── */ + async function loadBatch() { + finalized = false; + results.clear(); + main.innerHTML = controlsHTML() + ` +
+ +

Загрузка задач…

+
`; + if (window.lucide) lucide.createIcons(); + wireControls(); + + try { + batch = await EP.api.getPracticeNext(examKey, { count, strategy }); + } catch (e) { + main.querySelector('.ep-empty').outerHTML = ` +
+ +

Не удалось загрузить задачи

+

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

+
`; + if (window.lucide) lucide.createIcons(); + return; + } + + if (!batch.tasks.length) { + main.querySelector('.ep-empty').outerHTML = ` +
+ +

Не нашлось задач по этой стратегии

+

Попробуйте другую — или это знак, что вы уже всё решили.

+
`; + if (window.lucide) lucide.createIcons(); + return; + } + + renderBatch(); + } + + function renderBatch() { + // Replace empty placeholder with task list + 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: 'practice', + sessionId: batch.session_id, + numbering: i + 1, + onAttempt: ({ taskId, isCorrect, attempt }) => { + if (isCorrect == null) return; // ignore solution-only events + const r = results.get(taskId); + if (!r) return; + // We record only the FIRST attempt result toward batch score. + if (r.isCorrect == null) r.isCorrect = isCorrect; + r.attempts = attempt || r.attempts + 1; + renderProgress(); + maybeFinalize(); + }, + }); + }); + + // Bottom: "Завершить" button (allow finalizing early) + 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 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 answered = Array.from(results.values()).filter(r => r.isCorrect != null).length; + const correct = Array.from(results.values()).filter(r => r.isCorrect === 1).length; + const acc = answered ? Math.round((correct / answered) * 100) : 0; + + const summary = document.createElement('div'); + summary.className = 'pr-summary ep-card'; + summary.innerHTML = ` +
+
+
Решено
+
${answered} / ${total}
+
+
+
Верно
+
${correct}
+
${acc}% точности
+
+
+
Стратегия
+
${strategy === 'unsolved' ? 'Нерешённые' : 'Случайные'}
+
+
+
+ + + На дашборд + +
`; + main.appendChild(summary); + summary.querySelector('#pr-summary-restart').onclick = () => loadBatch(); + if (window.lucide) lucide.createIcons(); + summary.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + /* ── Wire controls (strategy toggle, count select, restart) ── */ + function wireControls() { + main.querySelectorAll('[data-strat]').forEach(btn => { + btn.onclick = () => { + if (btn.disabled) return; + strategy = btn.dataset.strat; + persistStrategy(strategy); + loadBatch(); + }; + }); + const sel = main.querySelector('.pr-count-select'); + if (sel) { + sel.onchange = () => { + count = Number(sel.value) || 10; + persistCount(count); + }; + } + const restart = main.querySelector('.pr-restart'); + if (restart) restart.onclick = () => loadBatch(); + } + + function escapeHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); + } +})();