feat(exam-prep F7): карта тем + тематический тренажёр (API /topics + /topics/:slug/tasks + UI)
This commit is contained in:
@@ -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
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user