feat(trainer): P6 — учительская аналитика класса + общий прогресс

- GET /api/practice/class-stats (classStats): агрегаты по навыкам + матрица ученик×навык; доступ владелец класса/админ
- клиент: кнопка «Аналитика класса» (учителю) → модалка с тепловой картой (точность/освоено) + пикер классов; LS.practiceClassStats
- лёгкая геймификация: строка «Освоено навыков M из N · решено всего K» из агрегатов practice_progress
- тесты practice.test.js +4 (владелец видит; чужой/ученик → 403; без class_id → 400); смоук страницы 27/27; план P6 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 14:24:05 +03:00
parent 7cc2a9d526
commit d003a0e100
6 changed files with 222 additions and 6 deletions
+43 -1
View File
@@ -131,4 +131,46 @@ async function generateProblem(req, res) {
res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts });
}
module.exports = { listProgress, submitAttempt, listPool, generateProblem };
/* GET /api/practice/class-stats?class_id= — аналитика класса для учителя.
* Возвращает агрегаты по навыкам (кто застрял) + матрицу ученик×навык для
* тепловой карты. Доступ: владелец класса (teacher_id) или админ. */
function classStats(req, res) {
const uid = req.user.id, role = req.user.role;
const classId = parseInt((req.query && req.query.class_id), 10);
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
if (role !== 'admin') {
const own = db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, uid);
if (!own) return res.status(403).json({ error: 'не ваш класс' });
}
const students = db.prepare(
'SELECT u.id, u.name FROM class_members cm JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name'
).all(classId);
if (!students.length) return res.json({ students: [], skills: [], perSkill: [] });
const ids = students.map(s => s.id);
const ph = ids.map(() => '?').join(',');
const rows = db.prepare(
`SELECT user_id, skill, solved, attempts, mastered FROM practice_progress WHERE user_id IN (${ph})`
).all(...ids);
const bySkill = {}, byStudent = {};
for (const r of rows) {
const s = bySkill[r.skill] || (bySkill[r.skill] = { skill: r.skill, attempted: 0, solved: 0, attempts: 0, mastered: 0 });
s.attempted++; s.solved += r.solved; s.attempts += r.attempts; if (r.mastered) s.mastered++;
const st = byStudent[r.user_id] || (byStudent[r.user_id] = {});
st[r.skill] = { solved: r.solved, attempts: r.attempts, mastered: r.mastered ? 1 : 0,
accuracy: r.attempts ? Math.round(100 * r.solved / r.attempts) : 0 };
}
const skills = Object.keys(bySkill).sort();
const perSkill = skills.map(k => {
const s = bySkill[k];
return { skill: k, attempted: s.attempted, mastered: s.mastered,
accuracy: s.attempts ? Math.round(100 * s.solved / s.attempts) : 0 };
});
const studentRows = students.map(s => ({ id: s.id, name: s.name, perSkill: byStudent[s.id] || {} }));
res.json({ students: studentRows, skills, perSkill });
}
module.exports = { listProgress, submitAttempt, listPool, generateProblem, classStats };
+3
View File
@@ -17,4 +17,7 @@ router.post('/attempt', c.submitAttempt);
router.get('/pool', c.listPool);
router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem);
// Аналитика класса — только учитель/админ (владение проверяется в хендлере).
router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats);
module.exports = router;