diff --git a/backend/src/controllers/practiceController.js b/backend/src/controllers/practiceController.js index 3646aba..9a6e4da 100644 --- a/backend/src/controllers/practiceController.js +++ b/backend/src/controllers/practiceController.js @@ -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 }; diff --git a/backend/src/routes/practice.js b/backend/src/routes/practice.js index 4a77911..53d1896 100644 --- a/backend/src/routes/practice.js +++ b/backend/src/routes/practice.js @@ -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; diff --git a/backend/tests/practice.test.js b/backend/tests/practice.test.js index 480172b..c20ef24 100644 --- a/backend/tests/practice.test.js +++ b/backend/tests/practice.test.js @@ -7,7 +7,7 @@ */ const { describe, it, before } = require('node:test'); const assert = require('node:assert/strict'); -const { app, inject, getToken, cleanup } = require('./setup'); +const { app, db, inject, getToken, cleanup } = require('./setup'); // Mount /api/practice on the shared test app (setup.js не монтирует новые роуты). app.use('/api/practice', require('../src/routes/practice')); @@ -114,3 +114,49 @@ describe('/api/practice progress', () => { assert.equal(res.status, 400, `got ${res.status}`); }); }); + +describe('/api/practice/class-stats (аналитика класса)', () => { + let teacher, other, s1, s2, classId; + + before(async () => { + teacher = await getToken('teacher'); + other = await getToken('teacher'); + s1 = await getToken('student'); + s2 = await getToken('student'); + // класс учителя + два ученика в нём + const info = db.prepare("INSERT INTO classes (name, teacher_id, invite_code) VALUES ('P6 класс', ?, ?)").run(teacher.userId, 'P6CODE'); + classId = info.lastInsertRowid; + db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s1.userId); + db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s2.userId); + // прогресс: s1 решил lin-basic верно, s2 ошибся на lin-basic + await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: true }, s1.token); + await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: false }, s2.token); + await inject('POST', '/api/practice/attempt', { skill: 'lin-paren', correct: true }, s1.token); + }); + + it('владелец класса видит агрегаты и матрицу', async () => { + const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, teacher.token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.students.length, 2, 'два ученика'); + assert.ok(res.body.skills.includes('lin-basic'), 'навык в списке'); + const lb = res.body.perSkill.find(s => s.skill === 'lin-basic'); + assert.ok(lb, 'агрегат по lin-basic есть'); + assert.equal(lb.attempted, 2, 'оба пробовали lin-basic'); + assert.equal(lb.accuracy, 50, '1 верный из 2 попыток → 50%'); + }); + + it('чужой класс → 403', async () => { + const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, other.token); + assert.equal(res.status, 403, `got ${res.status}`); + }); + + it('ученику запрещено (требуется роль) → 403', async () => { + const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, s1.token); + assert.equal(res.status, 403, `got ${res.status}`); + }); + + it('без class_id → 400', async () => { + const res = await inject('GET', '/api/practice/class-stats', null, teacher.token); + assert.equal(res.status, 400, `got ${res.status}`); + }); +}); diff --git a/frontend/trainer.html b/frontend/trainer.html index aab3e0a..2387832 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -103,6 +103,32 @@ .tr-badge-n { margin-left: 7px; font-size: .7rem; font-weight: 800; color: #94a3b8; background: rgba(148,163,184,0.16); border-radius: 99px; padding: 1px 7px; } .tr-chip.on .tr-badge-n { color: #e0e7ff; background: rgba(255,255,255,0.2); } + /* ── общий прогресс (лёгкая геймификация) ── */ + .tr-overall { color: #6366f1; font-size: .84rem; font-weight: 600; margin: -2px 0 14px; } + .tr-overall:empty { display: none; } + + /* ── модалка аналитики + тепловая карта ── */ + .tr-modal { position: fixed; inset: 0; z-index: 50; background: rgba(15,23,42,0.5); display: flex; align-items: center; justify-content: center; padding: 20px; } + .tr-modal-card { background: #fff; border-radius: 16px; max-width: 920px; width: 100%; max-height: 86vh; overflow: auto; box-shadow: 0 24px 60px rgba(0,0,0,0.3); } + .tr-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid rgba(148,163,184,0.2); font-weight: 800; font-family: 'Manrope', sans-serif; font-size: 1.05rem; position: sticky; top: 0; background: #fff; } + .tr-modal-x { background: none; border: none; cursor: pointer; color: #64748b; padding: 4px; border-radius: 8px; } + .tr-modal-x:hover { background: rgba(148,163,184,0.15); color: #1e293b; } + .tr-modal-x .ic { width: 18px; height: 18px; } + #tr-an-body { padding: 18px 20px; } + .tr-an-picker { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; } + .tr-an-cls { font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; padding: 7px 13px; border-radius: 99px; border: 1px solid rgba(148,163,184,0.32); background: #fff; color: #475569; } + .tr-an-cls:hover, .tr-an-cls.on { border-color: #818cf8; color: #4338ca; background: #eef2ff; } + .tr-an-empty { color: #94a3b8; padding: 20px; text-align: center; } + .tr-hm-wrap { overflow-x: auto; } + table.tr-hm { border-collapse: collapse; font-size: .8rem; } + table.tr-hm th, table.tr-hm td { border: 1px solid rgba(148,163,184,0.22); padding: 6px 8px; text-align: center; white-space: nowrap; } + table.tr-hm th { background: #f8fafc; color: #475569; font-weight: 700; position: sticky; top: 0; } + .tr-hm-name { text-align: left !important; font-weight: 600; color: #334155; background: #f8fafc; position: sticky; left: 0; } + .tr-hm-none { color: #cbd5e1; } + .tr-hm-cell { font-weight: 700; color: #334155; } + .tr-hm-cell .ic { width: 14px; height: 14px; color: #fff; } + .tr-hm-sum { font-weight: 800; color: #4f46e5; background: #eef2ff; } + /* ── режим (умная тренировка) ── */ .tr-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } .tr-mode-btn { @@ -145,12 +171,30 @@
| Класс | ' + sumCells + '