feat(exam9): link tasks to textbook + difficulty-ordered random + topic exclusion
Practice (random) now picks tasks by ascending difficulty so the first slot is always level 1 and the session ramps up. Adds ?exclude= to drop specific subtopics from the random pool, with a per-section checkbox modal in the UI. Each task carries a topic_ref (textbook chapter + paragraph) shown as a 'Учить тему · §N' button next to the solution, deep-linking to the right section of /textbook/<slug>. Mapping seeded for all 15 math9 subtopics in migration 028. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 028: Link exam_topics → textbook chapter + paragraph
|
||||||
|
--
|
||||||
|
-- Adds two columns to exam_topics:
|
||||||
|
-- textbook_slug — slug of the textbook chapter (e.g. 'algebra-9-ch3')
|
||||||
|
-- textbook_paragraph — paragraph number inside that chapter (NULL → no anchor)
|
||||||
|
--
|
||||||
|
-- The frontend builds a link like
|
||||||
|
-- /textbook/<slug> (when paragraph is NULL)
|
||||||
|
-- /textbook/<slug>#sec-pN (when paragraph = N)
|
||||||
|
-- so a student who fails an exam task is sent straight to the right §.
|
||||||
|
--
|
||||||
|
-- Mapping covers math9 subtopics. Sections (parent_slug IS NULL) are
|
||||||
|
-- left without a textbook_slug — the per-task link is driven by the
|
||||||
|
-- specific subtopic, not the section.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE exam_topics ADD COLUMN textbook_slug TEXT;
|
||||||
|
ALTER TABLE exam_topics ADD COLUMN textbook_paragraph INTEGER;
|
||||||
|
|
||||||
|
-- ── math9 ───────────────────────────────────────────────────────
|
||||||
|
-- Algebra
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch1', textbook_paragraph = 5 WHERE slug = 'alg-expressions';
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch1', textbook_paragraph = 3 WHERE slug = 'alg-fractions';
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch2', textbook_paragraph = 6 WHERE slug = 'alg-functions';
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch3', textbook_paragraph = 10 WHERE slug = 'alg-equations';
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch3', textbook_paragraph = 13 WHERE slug = 'alg-inequalities';
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch4', textbook_paragraph = 15 WHERE slug = 'alg-progressions';
|
||||||
|
|
||||||
|
-- Topics that aren't covered by a single Grade-9 paragraph — link to hub.
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'algebra-9' WHERE slug IN (
|
||||||
|
'alg-numbers', 'alg-arithmetic', 'alg-powers',
|
||||||
|
'alg-polynomials', 'alg-word-problems'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Geometry
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'geometry-9-ch1', textbook_paragraph = 1 WHERE slug = 'geom-triangles';
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'geometry-9-ch2', textbook_paragraph = 9 WHERE slug = 'geom-quadrilaterals';
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'geometry-9-ch2', textbook_paragraph = 7 WHERE slug = 'geom-circle';
|
||||||
|
-- Coordinates / circle-equation lives in algebra-9 ch3 §12 (уравнение окружности)
|
||||||
|
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch3', textbook_paragraph = 12 WHERE slug = 'geom-coordinates';
|
||||||
|
|
||||||
|
-- theory-statements has no single textbook home — leave NULL.
|
||||||
@@ -70,11 +70,14 @@ const SQL = {
|
|||||||
`),
|
`),
|
||||||
|
|
||||||
/* Practice batch — RANDOM strategy.
|
/* Practice batch — RANDOM strategy.
|
||||||
Excludes long tasks (no auto-check) by default to keep UX consistent. */
|
Excludes long tasks (no auto-check) by default to keep UX consistent.
|
||||||
|
NOTE: kept for backwards compatibility / fallback. The primary random
|
||||||
|
path goes through pickRandomByDifficulty() below (difficulty-ordered
|
||||||
|
with optional topic exclusions). */
|
||||||
practiceRandom: db.prepare(`
|
practiceRandom: db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||||||
answer, solution_html, topic
|
answer, solution_html, topic, subtopic, difficulty
|
||||||
FROM exam_tasks
|
FROM exam_tasks
|
||||||
WHERE exam_key = ?
|
WHERE exam_key = ?
|
||||||
AND task_type IN ('mc','open')
|
AND task_type IN ('mc','open')
|
||||||
@@ -82,6 +85,22 @@ const SQL = {
|
|||||||
LIMIT ?
|
LIMIT ?
|
||||||
`),
|
`),
|
||||||
|
|
||||||
|
/* All subtopic slugs known for an exam — used to validate ?exclude=
|
||||||
|
so a bogus slug doesn't silently drop the filter. */
|
||||||
|
listSubtopicSlugs: db.prepare(`
|
||||||
|
SELECT slug FROM exam_topics
|
||||||
|
WHERE exam_key = ? AND parent_slug IS NOT NULL
|
||||||
|
`),
|
||||||
|
|
||||||
|
/* Topic reference map: subtopic_slug → textbook chapter / paragraph.
|
||||||
|
Loaded once and cached so we can stamp `topic_ref` onto every task
|
||||||
|
shape without an extra JOIN per query. */
|
||||||
|
listTopicRefs: db.prepare(`
|
||||||
|
SELECT slug, title, textbook_slug, textbook_paragraph
|
||||||
|
FROM exam_topics
|
||||||
|
WHERE exam_key = ? AND textbook_slug IS NOT NULL
|
||||||
|
`),
|
||||||
|
|
||||||
/* Practice batch — UNSOLVED strategy.
|
/* Practice batch — UNSOLVED strategy.
|
||||||
Excludes tasks the user has already solved correctly at least once. */
|
Excludes tasks the user has already solved correctly at least once. */
|
||||||
practiceUnsolved: db.prepare(`
|
practiceUnsolved: db.prepare(`
|
||||||
@@ -261,6 +280,7 @@ const SQL = {
|
|||||||
listTopicsWithCounts: db.prepare(`
|
listTopicsWithCounts: db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
tp.slug, tp.parent_slug, tp.title, tp.sort_order,
|
tp.slug, tp.parent_slug, tp.title, tp.sort_order,
|
||||||
|
tp.textbook_slug, tp.textbook_paragraph,
|
||||||
COALESCE(stat.total, 0) AS total,
|
COALESCE(stat.total, 0) AS total,
|
||||||
COALESCE(stat.attempted, 0) AS attempted,
|
COALESCE(stat.attempted, 0) AS attempted,
|
||||||
COALESCE(stat.solved, 0) AS solved,
|
COALESCE(stat.solved, 0) AS solved,
|
||||||
@@ -448,6 +468,7 @@ router.get('/:examKey/variants/:n/tasks', (req, res) => {
|
|||||||
const rows = SQL.getVariantTasks.all(examKey, n);
|
const rows = SQL.getVariantTasks.all(examKey, n);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });
|
if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });
|
||||||
|
|
||||||
|
const refMap = getTopicRefMap(examKey);
|
||||||
const tasks = rows.map(r => ({
|
const tasks = rows.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
idx: r.task_idx,
|
idx: r.task_idx,
|
||||||
@@ -459,13 +480,36 @@ router.get('/:examKey/variants/:n/tasks', (req, res) => {
|
|||||||
solution: r.solution_html,
|
solution: r.solution_html,
|
||||||
topic: r.topic,
|
topic: r.topic,
|
||||||
subtopic: r.subtopic,
|
subtopic: r.subtopic,
|
||||||
|
topic_ref: r.subtopic ? (refMap.get(r.subtopic) || null) : null,
|
||||||
}));
|
}));
|
||||||
res.json({ variant: n, tasks });
|
res.json({ variant: n, tasks });
|
||||||
});
|
});
|
||||||
|
|
||||||
function safeJson(s) { try { return JSON.parse(s); } catch { return null; } }
|
function safeJson(s) { try { return JSON.parse(s); } catch { return null; } }
|
||||||
|
|
||||||
function shapeTask(r) {
|
/* ── Topic-ref cache ─────────────────────────────────────────────
|
||||||
|
subtopic_slug → { title, textbook_slug, textbook_paragraph }.
|
||||||
|
Loaded lazily per examKey; invalidated never (migrations are the
|
||||||
|
only writer, and a server restart picks up new mappings). */
|
||||||
|
const _topicRefCache = new Map();
|
||||||
|
function getTopicRefMap(examKey) {
|
||||||
|
let map = _topicRefCache.get(examKey);
|
||||||
|
if (map) return map;
|
||||||
|
const rows = SQL.listTopicRefs.all(examKey);
|
||||||
|
map = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
map.set(r.slug, {
|
||||||
|
title: r.title,
|
||||||
|
slug: r.textbook_slug,
|
||||||
|
paragraph: r.textbook_paragraph ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_topicRefCache.set(examKey, map);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeTask(r, refMap) {
|
||||||
|
const ref = (refMap && r.subtopic) ? refMap.get(r.subtopic) : null;
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
idx: r.task_idx,
|
idx: r.task_idx,
|
||||||
@@ -477,9 +521,81 @@ function shapeTask(r) {
|
|||||||
answer: r.answer,
|
answer: r.answer,
|
||||||
solution: r.solution_html,
|
solution: r.solution_html,
|
||||||
topic: r.topic ?? null,
|
topic: r.topic ?? null,
|
||||||
|
subtopic: r.subtopic ?? null,
|
||||||
|
difficulty: r.difficulty ?? null,
|
||||||
|
topic_ref: ref || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Difficulty distribution for "Random" practice ───────────────
|
||||||
|
Returns an array [n1,n2,n3,n4,n5] of how many tasks of each
|
||||||
|
difficulty level (1..5) to pick, summing to N. Position 1 of the
|
||||||
|
batch is always difficulty 1 (so n1 >= 1 whenever N >= 1). */
|
||||||
|
function distributeByDifficulty(N) {
|
||||||
|
if (N <= 0) return [0, 0, 0, 0, 0];
|
||||||
|
if (N <= 5) {
|
||||||
|
// 1 task per level, then drop hardest first if N<5.
|
||||||
|
// Order [4,3,1,2] keeps slot 0 (difficulty 1) until last so the first
|
||||||
|
// task is always easiest.
|
||||||
|
const c = [1, 1, 1, 1, 1];
|
||||||
|
let drop = 5 - N;
|
||||||
|
const off = [4, 3, 1, 2];
|
||||||
|
for (let i = 0; drop > 0 && i < off.length; i++) { c[off[i]] = 0; drop--; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
// Weighted: 10% / 20% / 30% / 20% / 20%, min 1 per level so first slot = d1.
|
||||||
|
const w = [0.10, 0.20, 0.30, 0.20, 0.20];
|
||||||
|
const c = w.map(p => Math.max(1, Math.round(N * p)));
|
||||||
|
let total = c.reduce((a, b) => a + b, 0);
|
||||||
|
// adjust: trim from level 5 then 4, top-up at level 3
|
||||||
|
while (total > N) {
|
||||||
|
const idx = c[4] > 1 ? 4 : (c[3] > 1 ? 3 : c.indexOf(Math.max(...c)));
|
||||||
|
c[idx]--; total--;
|
||||||
|
}
|
||||||
|
while (total < N) { c[2]++; total++; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pick a difficulty-ordered batch with optional subtopic exclusions.
|
||||||
|
Excludes long tasks (no auto-check). Tasks come back already sorted
|
||||||
|
by difficulty ascending — the response order is the play order. */
|
||||||
|
function pickRandomByDifficulty(examKey, count, excludeSlugs) {
|
||||||
|
const dist = distributeByDifficulty(count);
|
||||||
|
const exParams = excludeSlugs.length ? excludeSlugs : null;
|
||||||
|
const exClause = exParams
|
||||||
|
? `AND (subtopic IS NULL OR subtopic NOT IN (${exParams.map(() => '?').join(',')}))`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (let d = 1; d <= 5; d++) {
|
||||||
|
const limit = dist[d - 1];
|
||||||
|
if (limit === 0) continue;
|
||||||
|
const sql = `
|
||||||
|
SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||||||
|
answer, solution_html, topic, subtopic, difficulty
|
||||||
|
FROM exam_tasks
|
||||||
|
WHERE exam_key = ? AND task_type IN ('mc','open')
|
||||||
|
AND difficulty = ?
|
||||||
|
${exClause}
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ?`;
|
||||||
|
const args = exParams
|
||||||
|
? [examKey, d, ...exParams, limit]
|
||||||
|
: [examKey, d, limit];
|
||||||
|
out.push(...db.prepare(sql).all(...args));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parse + validate ?exclude=slug1,slug2 against known subtopics for examKey. */
|
||||||
|
function parseExcludeParam(examKey, raw) {
|
||||||
|
if (!raw) return [];
|
||||||
|
const requested = String(raw).split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (!requested.length) return [];
|
||||||
|
const valid = new Set(SQL.listSubtopicSlugs.all(examKey).map(r => r.slug));
|
||||||
|
return requested.filter(s => valid.has(s));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── GET /api/exam-prep/:examKey/practice/next ──
|
/* ── GET /api/exam-prep/:examKey/practice/next ──
|
||||||
Returns up to ?count=N tasks for the practice trainer.
|
Returns up to ?count=N tasks for the practice trainer.
|
||||||
?strategy=random — any mc/open task, fresh random sample
|
?strategy=random — any mc/open task, fresh random sample
|
||||||
@@ -498,6 +614,8 @@ router.get('/:examKey/practice/next', (req, res) => {
|
|||||||
let rows;
|
let rows;
|
||||||
let weakSlugs = null;
|
let weakSlugs = null;
|
||||||
|
|
||||||
|
const excludeSlugs = parseExcludeParam(examKey, req.query.exclude);
|
||||||
|
|
||||||
if (strategy === 'weak') {
|
if (strategy === 'weak') {
|
||||||
weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug);
|
weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug);
|
||||||
if (weakSlugs.length === 0) {
|
if (weakSlugs.length === 0) {
|
||||||
@@ -515,14 +633,22 @@ router.get('/:examKey/practice/next', (req, res) => {
|
|||||||
rows = SQL.practiceUnsolved.all(examKey, req.user.id, count);
|
rows = SQL.practiceUnsolved.all(examKey, req.user.id, count);
|
||||||
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
|
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
|
||||||
} else {
|
} else {
|
||||||
rows = SQL.practiceRandom.all(examKey, count);
|
// Random: difficulty-ordered batch, position 1 = difficulty 1.
|
||||||
|
rows = pickRandomByDifficulty(examKey, count, excludeSlugs);
|
||||||
|
// Fallback if exclusions wiped the pool — drop them and retry.
|
||||||
|
if (!rows.length && excludeSlugs.length) {
|
||||||
|
rows = pickRandomByDifficulty(examKey, count, []);
|
||||||
|
}
|
||||||
|
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refMap = getTopicRefMap(examKey);
|
||||||
res.json({
|
res.json({
|
||||||
strategy,
|
strategy,
|
||||||
weak_slugs: weakSlugs,
|
weak_slugs: weakSlugs,
|
||||||
|
excluded: excludeSlugs,
|
||||||
session_id: Date.now(),
|
session_id: Date.now(),
|
||||||
tasks: rows.map(shapeTask),
|
tasks: rows.map(r => shapeTask(r, refMap)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -717,6 +843,8 @@ router.get('/:examKey/topics', (req, res) => {
|
|||||||
attempted: r.attempted,
|
attempted: r.attempted,
|
||||||
attempts: r.attempts,
|
attempts: r.attempts,
|
||||||
correct: r.correct,
|
correct: r.correct,
|
||||||
|
textbook_slug: r.textbook_slug ?? null,
|
||||||
|
textbook_paragraph: r.textbook_paragraph ?? null,
|
||||||
accuracy,
|
accuracy,
|
||||||
solved_pct: solvedPct,
|
solved_pct: solvedPct,
|
||||||
});
|
});
|
||||||
@@ -752,10 +880,11 @@ router.get('/:examKey/topics/:slug/tasks', (req, res) => {
|
|||||||
rows = SQL.topicTasksAny.all(examKey, slug, count);
|
rows = SQL.topicTasksAny.all(examKey, slug, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refMap = getTopicRefMap(examKey);
|
||||||
res.json({
|
res.json({
|
||||||
topic: { slug: meta.slug, title: meta.title, parent: meta.parent_slug },
|
topic: { slug: meta.slug, title: meta.title, parent: meta.parent_slug },
|
||||||
session_id: Date.now(),
|
session_id: Date.now(),
|
||||||
tasks: rows.map(shapeTask),
|
tasks: rows.map(r => shapeTask(r, refMap)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1054,3 +1054,84 @@
|
|||||||
.tc-input-row { gap: 8px; }
|
.tc-input-row { gap: 8px; }
|
||||||
.tc-ans-input { max-width: 100%; }
|
.tc-ans-input { max-width: 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── "Учить тему" link inside a task card ───────────────────────── */
|
||||||
|
.tc-sol-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tc-ref-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1.5px solid var(--violet, #7c3aed);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(124, 58, 237, 0.08);
|
||||||
|
color: var(--violet, #7c3aed);
|
||||||
|
font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.tc-ref-btn:hover {
|
||||||
|
background: var(--violet, #7c3aed); color: #fff;
|
||||||
|
}
|
||||||
|
.tc-ref-btn svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
/* ── Difficulty-progression note above the task list ─────────────── */
|
||||||
|
.pr-difficulty-note {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(6, 214, 160, 0.08);
|
||||||
|
border-left: 3px solid #06D6A0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.pr-difficulty-note i, .pr-difficulty-note svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── Exclude-topics button + count badge ─────────────────────────── */
|
||||||
|
.pr-exclude-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pr-exclude-count {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 18px; height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--violet, #7c3aed); color: #fff;
|
||||||
|
font-size: .7rem; font-weight: 800; line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Exclude-topics modal ────────────────────────────────────────── */
|
||||||
|
.pr-ex-form { display: flex; flex-direction: column; gap: 16px; max-height: 60vh; overflow-y: auto; }
|
||||||
|
.pr-ex-hint { font-size: .82rem; color: var(--text-3); margin: 0; }
|
||||||
|
.pr-ex-section { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; }
|
||||||
|
.pr-ex-section-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-family: 'Unbounded', sans-serif; font-size: .82rem; font-weight: 800;
|
||||||
|
letter-spacing: .02em; color: var(--text);
|
||||||
|
}
|
||||||
|
.pr-ex-toggle-all {
|
||||||
|
border: 1px solid var(--border-h); background: transparent; color: var(--text-2);
|
||||||
|
padding: 3px 10px; border-radius: 6px;
|
||||||
|
font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 700;
|
||||||
|
cursor: pointer; transition: all .12s;
|
||||||
|
}
|
||||||
|
.pr-ex-toggle-all:hover { border-color: var(--violet); color: var(--violet); }
|
||||||
|
.pr-ex-items { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.pr-ex-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .9rem;
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
.pr-ex-item:hover { background: var(--border); }
|
||||||
|
.pr-ex-item input { accent-color: var(--violet); flex-shrink: 0; }
|
||||||
|
.pr-ex-title { flex: 1; }
|
||||||
|
.pr-ex-meta { font-size: .76rem; color: var(--text-3); }
|
||||||
|
.pr-ex-empty { padding: 16px; text-align: center; color: var(--text-3); font-size: .85rem; }
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
let batch = null; // { strategy, session_id, tasks: [...] }
|
let batch = null; // { strategy, session_id, tasks: [...] }
|
||||||
let strategy = readStrategyFromUrl() || readPersistedStrategy() || 'random';
|
let strategy = readStrategyFromUrl() || readPersistedStrategy() || 'random';
|
||||||
let count = readPersistedCount();
|
let count = readPersistedCount();
|
||||||
|
let excludeSlugs = readPersistedExclude(); // Set<slug>
|
||||||
|
let topicSections = null; // { sections: [...] } — lazy-loaded
|
||||||
let finalized = false;
|
let finalized = false;
|
||||||
const results = new Map(); // taskId -> { isCorrect: 0|1|null, attempts: number }
|
const results = new Map(); // taskId -> { isCorrect: 0|1|null, attempts: number }
|
||||||
|
|
||||||
@@ -39,6 +41,17 @@
|
|||||||
function persistCount(n) {
|
function persistCount(n) {
|
||||||
try { localStorage.setItem(`exam_prep_${examKey}_practice_count`, String(n)); } catch {}
|
try { localStorage.setItem(`exam_prep_${examKey}_practice_count`, String(n)); } catch {}
|
||||||
}
|
}
|
||||||
|
function readPersistedExclude() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`exam_prep_${examKey}_practice_exclude`) || '';
|
||||||
|
return new Set(raw.split(',').filter(Boolean));
|
||||||
|
} catch { return new Set(); }
|
||||||
|
}
|
||||||
|
function persistExclude(set) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`exam_prep_${examKey}_practice_exclude`, Array.from(set).join(','));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Boot: show controls and load first batch ───────────────── */
|
/* ── Boot: show controls and load first batch ───────────────── */
|
||||||
await loadBatch();
|
await loadBatch();
|
||||||
@@ -47,6 +60,11 @@
|
|||||||
function controlsHTML() {
|
function controlsHTML() {
|
||||||
const opts = [5, 10, 15, 20].map(n => `
|
const opts = [5, 10, 15, 20].map(n => `
|
||||||
<option value="${n}" ${n === count ? 'selected' : ''}>${n} задач</option>`).join('');
|
<option value="${n}" ${n === count ? 'selected' : ''}>${n} задач</option>`).join('');
|
||||||
|
const excludeBadge = excludeSlugs.size
|
||||||
|
? `<span class="pr-exclude-count">${excludeSlugs.size}</span>` : '';
|
||||||
|
// Exclude only applies to the difficulty-ordered random pool. Hide
|
||||||
|
// for other strategies so users don't think it filters them too.
|
||||||
|
const showExclude = strategy === 'random';
|
||||||
return `
|
return `
|
||||||
<div class="pr-controls">
|
<div class="pr-controls">
|
||||||
<div class="pr-controls-row">
|
<div class="pr-controls-row">
|
||||||
@@ -66,10 +84,19 @@
|
|||||||
<label>Размер сессии:</label>
|
<label>Размер сессии:</label>
|
||||||
<select class="pr-count-select">${opts}</select>
|
<select class="pr-count-select">${opts}</select>
|
||||||
</div>
|
</div>
|
||||||
|
${showExclude ? `
|
||||||
|
<button class="ep-btn pr-exclude-btn" title="Исключить темы из пула случайных задач">
|
||||||
|
<i data-lucide="filter-x"></i> Исключить темы ${excludeBadge}
|
||||||
|
</button>` : ''}
|
||||||
<button class="ep-btn ep-btn-primary pr-restart">
|
<button class="ep-btn ep-btn-primary pr-restart">
|
||||||
<i data-lucide="rotate-cw"></i> Новая сессия
|
<i data-lucide="rotate-cw"></i> Новая сессия
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
${strategy === 'random' ? `
|
||||||
|
<div class="pr-difficulty-note">
|
||||||
|
<i data-lucide="bar-chart-2"></i>
|
||||||
|
Задачи идут по возрастанию сложности: 1-я всегда лёгкая, к концу — сложнее.
|
||||||
|
</div>` : ''}
|
||||||
<div class="pr-progress" id="pr-progress"></div>
|
<div class="pr-progress" id="pr-progress"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -102,7 +129,11 @@
|
|||||||
wireControls();
|
wireControls();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
batch = await EP.api.getPracticeNext(examKey, { count, strategy });
|
const query = { count, strategy };
|
||||||
|
if (strategy === 'random' && excludeSlugs.size) {
|
||||||
|
query.exclude = Array.from(excludeSlugs).join(',');
|
||||||
|
}
|
||||||
|
batch = await EP.api.getPracticeNext(examKey, query);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
main.querySelector('.ep-empty').outerHTML = `
|
main.querySelector('.ep-empty').outerHTML = `
|
||||||
<div class="ep-empty">
|
<div class="ep-empty">
|
||||||
@@ -234,6 +265,77 @@
|
|||||||
}
|
}
|
||||||
const restart = main.querySelector('.pr-restart');
|
const restart = main.querySelector('.pr-restart');
|
||||||
if (restart) restart.onclick = () => loadBatch();
|
if (restart) restart.onclick = () => loadBatch();
|
||||||
|
|
||||||
|
const excludeBtn = main.querySelector('.pr-exclude-btn');
|
||||||
|
if (excludeBtn) excludeBtn.onclick = () => openExcludeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Exclude-topics modal ───────────────────────────────────── */
|
||||||
|
async function openExcludeModal() {
|
||||||
|
if (!topicSections) {
|
||||||
|
try {
|
||||||
|
topicSections = await EP.api.listTopics(examKey);
|
||||||
|
} catch (e) {
|
||||||
|
LS.toast?.('Не удалось загрузить темы', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sections = topicSections.sections || [];
|
||||||
|
const sectionsHtml = sections.map(sec => {
|
||||||
|
const items = (sec.subtopics || []).map(st => {
|
||||||
|
const checked = excludeSlugs.has(st.slug) ? 'checked' : '';
|
||||||
|
return `
|
||||||
|
<label class="pr-ex-item">
|
||||||
|
<input type="checkbox" value="${st.slug}" ${checked} />
|
||||||
|
<span class="pr-ex-title">${escapeHtml(st.title)}</span>
|
||||||
|
<span class="pr-ex-meta">${st.total || 0} задач</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
return `
|
||||||
|
<div class="pr-ex-section">
|
||||||
|
<div class="pr-ex-section-head">
|
||||||
|
<span>${escapeHtml(sec.title)}</span>
|
||||||
|
<button type="button" class="pr-ex-toggle-all" data-section="${sec.slug}">Все</button>
|
||||||
|
</div>
|
||||||
|
<div class="pr-ex-items" data-section-items="${sec.slug}">${items}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<div class="pr-ex-form">
|
||||||
|
<p class="pr-ex-hint">Отмеченные темы не попадут в случайный пул. Сложность распределения сохраняется.</p>
|
||||||
|
${sectionsHtml || '<div class="pr-ex-empty">Темы не настроены</div>'}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const m = LS.modal({
|
||||||
|
title: 'Исключить темы из пула',
|
||||||
|
content: body,
|
||||||
|
size: 'md',
|
||||||
|
actions: [
|
||||||
|
{ label: 'Сбросить', onClick: () => {
|
||||||
|
m.body.querySelectorAll('input[type="checkbox"]').forEach(c => c.checked = false);
|
||||||
|
} },
|
||||||
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
|
{ label: 'Применить', primary: true, onClick: () => {
|
||||||
|
const checked = Array.from(m.body.querySelectorAll('input[type="checkbox"]:checked')).map(c => c.value);
|
||||||
|
excludeSlugs = new Set(checked);
|
||||||
|
persistExclude(excludeSlugs);
|
||||||
|
m.close();
|
||||||
|
loadBatch();
|
||||||
|
} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Все" toggle per section
|
||||||
|
m.body.querySelectorAll('.pr-ex-toggle-all').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const items = m.body.querySelector(`[data-section-items="${btn.dataset.section}"]`);
|
||||||
|
if (!items) return;
|
||||||
|
const boxes = items.querySelectorAll('input[type="checkbox"]');
|
||||||
|
const allChecked = Array.from(boxes).every(b => b.checked);
|
||||||
|
boxes.forEach(b => b.checked = !allChecked);
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
|
|||||||
@@ -34,6 +34,24 @@
|
|||||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* topic_ref → "Учить тему" deep-link to the textbook chapter/paragraph.
|
||||||
|
ref = { slug, paragraph, title }. Paragraph is null for hub links. */
|
||||||
|
function buildRefLink(ref) {
|
||||||
|
if (!ref || !ref.slug) return '';
|
||||||
|
const href = ref.paragraph
|
||||||
|
? `/textbook/${encodeURIComponent(ref.slug)}#sec-p${ref.paragraph}`
|
||||||
|
: `/textbook/${encodeURIComponent(ref.slug)}`;
|
||||||
|
const label = ref.paragraph ? `§${ref.paragraph}` : 'учебник';
|
||||||
|
const title = ref.title ? `Учить тему: ${escapeHtml(ref.title)}` : 'Перейти к материалу';
|
||||||
|
return `<a class="tc-ref-btn" href="${href}" target="_blank" rel="noopener" title="${title}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Учить тему · ${escapeHtml(label)}</span>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildOptsBlock(taskId, opts) {
|
function buildOptsBlock(taskId, opts) {
|
||||||
const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$'));
|
const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$'));
|
||||||
const cls = isLong ? 'tc-opts tc-opts-vertical' : 'tc-opts';
|
const cls = isLong ? 'tc-opts tc-opts-vertical' : 'tc-opts';
|
||||||
@@ -101,11 +119,15 @@
|
|||||||
</div>` + verdictSlot;
|
</div>` + verdictSlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refLink = buildRefLink(task.topic_ref);
|
||||||
const solBlock = (showSol && task.solution) ? `
|
const solBlock = (showSol && task.solution) ? `
|
||||||
<div class="tc-sol-wrap">
|
<div class="tc-sol-wrap">
|
||||||
<button class="tc-sol-btn" data-tc-sol>${ICONS.chev}<span>Показать решение</span></button>
|
<div class="tc-sol-row">
|
||||||
|
<button class="tc-sol-btn" data-tc-sol>${ICONS.chev}<span>Показать решение</span></button>
|
||||||
|
${refLink}
|
||||||
|
</div>
|
||||||
<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>
|
<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>
|
||||||
</div>` : '';
|
</div>` : (refLink ? `<div class="tc-sol-wrap"><div class="tc-sol-row">${refLink}</div></div>` : '');
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="tc-head">
|
<div class="tc-head">
|
||||||
|
|||||||
Reference in New Issue
Block a user