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:
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
+115
-2
@@ -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 @@
|
||||
<div class="tr-sub">Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.</div>
|
||||
</div>
|
||||
|
||||
<div class="tr-overall" id="tr-overall"></div>
|
||||
|
||||
<div class="tr-mode">
|
||||
<button class="tr-mode-btn on" id="tr-smart-btn" type="button">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/></svg>
|
||||
Умная тренировка
|
||||
</button>
|
||||
<span class="tr-session" id="tr-session"></span>
|
||||
<button class="tr-mode-btn" id="tr-analytics-btn" type="button" style="display:none;margin-left:auto">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><rect x="7" y="10" width="3" height="7"/><rect x="13" y="6" width="3" height="11"/></svg>
|
||||
Аналитика класса
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tr-modal" id="tr-analytics" style="display:none">
|
||||
<div class="tr-modal-card">
|
||||
<div class="tr-modal-head">
|
||||
<span>Аналитика класса</span>
|
||||
<button class="tr-modal-x" id="tr-an-close" type="button" aria-label="Закрыть">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="tr-an-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tr-topics" id="tr-topics"></div>
|
||||
@@ -413,7 +457,7 @@
|
||||
function submitAttempt(correct) {
|
||||
if (!LS.practiceSubmit) return;
|
||||
LS.practiceSubmit(currentSkill(), correct).then(function (r) {
|
||||
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); }
|
||||
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); updateOverall(); }
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
@@ -515,7 +559,75 @@
|
||||
updateStats();
|
||||
}
|
||||
|
||||
// ── общий прогресс (лёгкая геймификация) ──
|
||||
function updateOverall() {
|
||||
var solvedTotal = 0, mastered = 0;
|
||||
for (var k in prog) {
|
||||
if (k === '__ms' || !Object.prototype.hasOwnProperty.call(prog, k)) continue;
|
||||
var p = prog[k]; if (!p) continue;
|
||||
solvedTotal += (p.solved || 0);
|
||||
if (p.mastered) mastered++;
|
||||
}
|
||||
var el = $('tr-overall');
|
||||
if (el) el.textContent = solvedTotal ? ('Освоено навыков: ' + mastered + ' из ' + gens.length + ' · решено всего: ' + solvedTotal) : '';
|
||||
}
|
||||
|
||||
// ── учительская аналитика класса ──
|
||||
var _anClasses = [], _anCur = null;
|
||||
function skillTitle(id) { var g = TG.get ? TG.get(id) : null; return g ? g.title : id; }
|
||||
function anPicker() {
|
||||
if (_anClasses.length <= 1) return '';
|
||||
return '<div class="tr-an-picker">' + _anClasses.map(function (c) {
|
||||
return '<button class="tr-an-cls' + (c.id === _anCur ? ' on' : '') + '" type="button" data-cid="' + c.id + '">' + esc(c.name || ('Класс ' + c.id)) + '</button>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
function renderHeatmap(data) {
|
||||
if (!data.skills || !data.skills.length) return '<div class="tr-an-empty">Пока нет данных — ученики ещё не решали задачи.</div>';
|
||||
var head = '<tr><th>Ученик</th>' + data.skills.map(function (s) { return '<th title="' + esc(s) + '">' + esc(skillTitle(s)) + '</th>'; }).join('') + '</tr>';
|
||||
var rows = data.students.map(function (st) {
|
||||
var cells = data.skills.map(function (s) {
|
||||
var c = st.perSkill[s];
|
||||
if (!c) return '<td class="tr-hm-none">—</td>';
|
||||
if (c.mastered) return '<td class="tr-hm-cell" style="background:#16a34a" title="освоено">' + ICON.star + '</td>';
|
||||
var bg = c.accuracy >= 70 ? '#bbf7d0' : c.accuracy >= 40 ? '#fef9c3' : '#fecaca';
|
||||
return '<td class="tr-hm-cell" style="background:' + bg + '" title="' + c.solved + ' из ' + c.attempts + '">' + c.accuracy + '%</td>';
|
||||
}).join('');
|
||||
return '<tr><td class="tr-hm-name">' + esc(st.name) + '</td>' + cells + '</tr>';
|
||||
}).join('');
|
||||
var sumCells = data.skills.map(function (s) {
|
||||
var ps = data.perSkill.filter(function (x) { return x.skill === s; })[0];
|
||||
return '<td class="tr-hm-sum">' + (ps ? ps.accuracy + '%' : '') + '</td>';
|
||||
}).join('');
|
||||
return '<div class="tr-hm-wrap"><table class="tr-hm">' + head + rows + '<tr><td class="tr-hm-name">Класс</td>' + sumCells + '</tr></table></div>';
|
||||
}
|
||||
function showStats(classId) {
|
||||
_anCur = classId;
|
||||
$('tr-an-body').innerHTML = anPicker() + '<div class="tr-an-empty">Загрузка…</div>';
|
||||
LS.practiceClassStats(classId).then(function (data) {
|
||||
$('tr-an-body').innerHTML = anPicker() + renderHeatmap(data);
|
||||
}).catch(function () {
|
||||
$('tr-an-body').innerHTML = anPicker() + '<div class="tr-an-empty">Не удалось загрузить аналитику.</div>';
|
||||
});
|
||||
}
|
||||
function openAnalytics() {
|
||||
$('tr-analytics').style.display = 'flex';
|
||||
$('tr-an-body').innerHTML = '<div class="tr-an-empty">Загрузка…</div>';
|
||||
(LS.getClasses ? LS.getClasses() : Promise.resolve([])).then(function (r) {
|
||||
var list = Array.isArray(r) ? r : (r && (r.classes || r.items)) || [];
|
||||
_anClasses = list;
|
||||
if (!list.length) { $('tr-an-body').innerHTML = '<div class="tr-an-empty">У вас пока нет классов.</div>'; return; }
|
||||
showStats(list[0].id);
|
||||
}).catch(function () { $('tr-an-body').innerHTML = '<div class="tr-an-empty">Не удалось загрузить классы.</div>'; });
|
||||
}
|
||||
|
||||
// ── события ──
|
||||
$('tr-analytics-btn').addEventListener('click', openAnalytics);
|
||||
$('tr-an-close').addEventListener('click', function () { $('tr-analytics').style.display = 'none'; });
|
||||
$('tr-analytics').addEventListener('click', function (e) { if (e.target === $('tr-analytics')) $('tr-analytics').style.display = 'none'; });
|
||||
$('tr-an-body').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.tr-an-cls'); if (!b) return;
|
||||
showStats(+b.getAttribute('data-cid'));
|
||||
});
|
||||
$('tr-topics').addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.tr-chip'); if (!b) return;
|
||||
var t = topics[+b.getAttribute('data-ti')]; if (!t) return;
|
||||
@@ -561,7 +673,8 @@
|
||||
curGen = ss[0] || gens[0];
|
||||
for (var si = 0; si < ss.length; si++) { var p = prog[skillKey(ss[si])]; if (!(p && p.mastered)) { curGen = ss[si]; break; } }
|
||||
if (smart) pickNext(null); // адаптивный первый навык (last=null — можно взять текущий)
|
||||
renderTopics(); renderSkills(); updateSession(); newProblem();
|
||||
renderTopics(); renderSkills(); updateSession(); updateOverall(); newProblem();
|
||||
if (isTeacher) $('tr-analytics-btn').style.display = '';
|
||||
}
|
||||
(LS.practiceProgressList ? LS.practiceProgressList() : Promise.resolve(null))
|
||||
.then(function (r) { if (r && r.progress) r.progress.forEach(function (row) { prog[row.skill] = row; }); })
|
||||
|
||||
@@ -1184,7 +1184,7 @@ window.LS = {
|
||||
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
||||
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||
gameProgressList, gameProgressSubmit,
|
||||
practiceProgressList, practiceSubmit, practicePool, practiceGenerate,
|
||||
practiceProgressList, practiceSubmit, practicePool, practiceGenerate, practiceClassStats,
|
||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||
@@ -1424,6 +1424,7 @@ async function practiceProgressList() { return req('GET', '/practice/progre
|
||||
async function practiceSubmit(skill, correct) { return req('POST', '/practice/attempt', { skill, correct: !!correct }); }
|
||||
async function practicePool(skill) { return req('GET', '/practice/pool' + (skill ? ('?skill=' + encodeURIComponent(skill)) : '')); }
|
||||
async function practiceGenerate(topic) { return req('POST', '/practice/generate', { topic: topic || 'word-linear' }); }
|
||||
async function practiceClassStats(classId) { return req('GET', '/practice/class-stats?class_id=' + encodeURIComponent(classId)); }
|
||||
async function assistantContext() { return req('GET', '/assistant/context'); }
|
||||
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
||||
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
||||
|
||||
@@ -128,8 +128,19 @@ T13 latex). **Осталось (стретч):** неравенства (нуж
|
||||
- **Acceptance:** квадратное уравнение принимает оба корня в любом порядке; `(x+1)^2` ≡
|
||||
`x^2+2x+1` через сэмплинг; неравенство принимает `x>3` и эквивалент.
|
||||
|
||||
## Phase 6 — Геймификация, аналитика, UX
|
||||
## Phase 6 — Геймификация, аналитика, UX — DONE (частично)
|
||||
|
||||
**Сделано:** **учительская аналитика** — `GET /api/practice/class-stats?class_id=`
|
||||
(`classStats`, владелец класса/админ): агрегаты по навыкам (attempted/mastered/accuracy)
|
||||
+ матрица ученик×навык. Клиент: кнопка «Аналитика класса» (учителю) → модалка с
|
||||
**тепловой картой** (ученики × навыки, цвет по точности, ✓ освоено) + пикер классов.
|
||||
`LS.practiceClassStats`. **Лёгкая геймификация**: строка общего прогресса «Освоено
|
||||
навыков M из N · решено всего K» (из агрегатов `practice_progress`), бейджи мастерства
|
||||
на чипах (P2). Тесты practice.test.js +4 (владелец видит, чужой/ученик→403, без id→400).
|
||||
Смоук страницы 27/27. **Осталось (стретч):** XP в общую геймификацию, виртуальная
|
||||
клавиатура, сократические подсказки — не вошло (отдельные крупные направления).
|
||||
|
||||
Изначальный список:
|
||||
- XP/энергия/стрики (reuse инфраструктуры Квантика), бейджи мастерства на чипах (есть основа).
|
||||
- Учительская аналитика: кто на каком навыке застрял, тепловая карта класса, отчёты.
|
||||
- UX: виртуальная клавиатура для дробей/степеней, «почему неверно» (разбор ошибки),
|
||||
|
||||
Reference in New Issue
Block a user