Тренажёр случайных задач
-В F3 здесь появится поле ввода ответа с автопроверкой, а в F5 — выборка случайных задач из банка с фильтром «нерешённые / слабые».
+ +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 @@ + + +
В F3 здесь появится поле ввода ответа с автопроверкой, а в F5 — выборка случайных задач из банка с фильтром «нерешённые / слабые».
+ +