feat(exam-prep F7): карта тем + тематический тренажёр (API /topics + /topics/:slug/tasks + UI)

This commit is contained in:
Maxim Dolgolyov
2026-05-29 11:35:28 +03:00
parent 90cda5129c
commit fe7d44aa83
4 changed files with 554 additions and 6 deletions
+141
View File
@@ -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
────────────────────────────────────────────────────────────────── */