Files
Learn_System/backend/src/controllers/sessionController.js
T
Maxim Dolgolyov 31a51956b6 feat: exam9 — назначение варианта как ДЗ + импорт нечётных в банк
Импорт 40 нечётных вариантов (v01, v03, ..., v79) в банк вопросов:
- 400 questions с allow_html=1, source_type='экзамен 9', year=2025
- 540 options (single-choice) + correct_text (short_answer)
- 40 tests (по 1 на вариант), title="Экзамен 9 — Вариант N"
- exam9_variant_tests маппинг для назначения

Назначение варианта как ДЗ на /exam9 (для учителей/админов):
- Кнопка «Назначить как ДЗ» под заголовком варианта (только если test_id есть)
- Модалка выбора классов + опциональный deadline
- POST /api/assignments/bulk с test_id из exam9_variant_tests

Поддержка HTML/SVG в вопросах банка через флаг questions.allow_html:
- Миграция 003: ALTER TABLE questions ADD COLUMN allow_html
- sessionController: SELECT возвращают allow_html и image
- test-run.html: рендер q.text и opt.text как HTML при allow_html=1
- test-result.html: то же для explanation и opt.text
- KaTeX: добавлены $...$ и $$...$$ delimiters в обеих страницах

Бонус-фикс: bulkSchema требовал class_id (single), контроллер ждёт
class_ids (array). Существующий вызов из classes.html был сломан;
исправлено вместе.

Команда: node backend/scripts/import-exam9.js  (--all для всех 80)
2026-05-16 13:13:06 +03:00

607 lines
24 KiB
JavaScript

const db = require('../db/db');
const { pushNotif } = require('../utils/notifications');
const { onTestFinished, updateDailyGoal, awardXP, updateChallenges } = require('./gamificationController');
const { COMBO_BONUSES } = require('../constants');
/* ── Prepared statements (avoid re-parsing on every request) ──────────── */
const stmts = {
/* stats: 7 queries → 2 via CTE + json_group_array */
statsMega: db.prepare(`
WITH base AS (
SELECT ts.id, ts.score, ts.total, ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = @uid AND ts.status = 'completed'
),
hm AS (
SELECT date(ts.started_at) AS day, COUNT(*) AS cnt
FROM test_sessions ts
WHERE ts.user_id = @uid AND ts.started_at >= date('now', '-90 days')
GROUP BY day ORDER BY day
),
sd AS (
SELECT DISTINCT date(ts.started_at) AS d
FROM test_sessions ts WHERE ts.user_id = @uid
ORDER BY d DESC LIMIT 90
)
SELECT
(SELECT COALESCE(json_group_array(json_object(
'week', week, 'sessions', sessions, 'avg_pct', avg_pct)), '[]')
FROM (SELECT strftime('%Y-%W', finished_at) AS week,
COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
FROM base WHERE finished_at >= date('now', '-84 days')
GROUP BY week ORDER BY week)) AS weekly,
(SELECT COALESCE(json_group_array(json_object('day', day, 'count', cnt)), '[]')
FROM hm) AS heatmap,
(SELECT COALESCE(json_group_array(json_object(
'slug', subject_slug, 'name', subject_name,
'sessions', sessions, 'avg_pct', avg_pct,
'total_correct', total_correct, 'total_questions', total_questions)), '[]')
FROM (SELECT subject_slug, subject_name, COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct,
SUM(score) AS total_correct, SUM(total) AS total_questions
FROM base GROUP BY subject_slug ORDER BY sessions DESC)) AS bySubject,
(SELECT COALESCE(json_group_array(json_object(
'id', id, 'score', score, 'total', total,
'finished_at', finished_at, 'subject_slug', subject_slug)), '[]')
FROM (SELECT id, score, total, finished_at, subject_slug
FROM base ORDER BY finished_at DESC LIMIT 20)) AS trend,
(SELECT json_object(
'sessions', COUNT(*),
'correct', COALESCE(SUM(score), 0),
'questions',COALESCE(SUM(total), 0),
'avg_pct', COALESCE(AVG(CASE WHEN total>0 THEN score*100.0/total END), 0))
FROM base) AS totals,
(SELECT COALESCE(json_group_array(d), '[]') FROM sd) AS streakDays
`),
courseProgress: db.prepare(`
SELECT c.id, c.title, c.cover_emoji, c.subject_slug,
COUNT(l.id) AS total_lessons, COUNT(lp.id) AS done_lessons
FROM courses c JOIN lessons l ON l.course_id = c.id AND l.is_published = 1
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = @uid
WHERE c.is_published = 1 GROUP BY c.id HAVING done_lessons > 0
ORDER BY done_lessons * 1.0 / total_lessons DESC`),
sessionCount: db.prepare('SELECT COUNT(*) AS total FROM test_sessions WHERE user_id = ?'),
};
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
const VALID_MODES = new Set(['exam', 'practice']);
/* ── POST /api/sessions ───────────────────────────────────────────────── */
function start(req, res, next) {
const { subject_slug, topic_id, test_id } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
if (!subject_slug) return res.status(400).json({ error: 'subject_slug is required' });
if (!VALID_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam or practice' });
if (!Number.isInteger(count) || count < 1 || count > 200)
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
const subject = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subject) return res.status(404).json({ error: 'Subject not found' });
let ids;
let testTimeLimitSec = null;
if (test_id) {
const tRow = db.prepare('SELECT time_limit FROM tests WHERE id = ?').get(Number(test_id));
if (tRow?.time_limit) testTimeLimitSec = tRow.time_limit * 60;
const tq = db.prepare(
'SELECT question_id AS id FROM test_questions WHERE test_id = ? ORDER BY order_index'
).all(Number(test_id));
if (!tq.length) return res.status(404).json({ error: 'Test has no questions' });
ids = shuffle(tq.map(r => r.id));
} else {
const rows = topic_id
? db.prepare('SELECT id FROM questions WHERE subject_id = ? AND topic_id = ?').all(subject.id, Number(topic_id))
: db.prepare('SELECT id FROM questions WHERE subject_id = ?').all(subject.id);
if (!rows.length) return res.status(404).json({ error: 'No questions found' });
ids = shuffle(rows.map(r => r.id)).slice(0, count);
}
const total = ids.length;
const createSession = db.transaction(() => {
const { lastInsertRowid: session_id } = db.prepare(
'INSERT INTO test_sessions (user_id, subject_id, mode, total) VALUES (?, ?, ?, ?)'
).run(req.user.id, subject.id, mode, total);
const insertSQ = db.prepare(
'INSERT INTO session_questions (session_id, question_id, order_index) VALUES (?, ?, ?)'
);
ids.forEach((qid, i) => insertSQ.run(session_id, qid, i));
return session_id;
});
try {
const session_id = createSession();
const questions = loadQuestionsForSession(ids);
res.status(201).json({ session_id, total, mode, questions, time_limit_sec: testTimeLimitSec });
} catch (err) {
next(err);
}
}
/* ── POST /api/sessions/:id/answer ───────────────────────────────────── */
function answer(req, res) {
const session_id = Number(req.params.id);
const { question_id, option_id, time_spent_sec, answer_text, chosen_options } = req.body;
const session = db.prepare(
'SELECT id, mode, status FROM test_sessions WHERE id = ? AND user_id = ?'
).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
if (session.status !== 'in_progress')
return res.status(400).json({ error: 'Session already finished' });
// Verify question belongs to this session — prevents answering questions from other sessions
const sq = db.prepare(
'SELECT 1 FROM session_questions WHERE session_id = ? AND question_id = ?'
).get(session_id, question_id);
if (!sq) return res.status(400).json({ error: 'Question not in this session' });
const q = db.prepare('SELECT id, type, correct_text FROM questions WHERE id = ?').get(question_id);
if (!q) return res.status(400).json({ error: 'Invalid question' });
let isCorrect = 0;
let chosenOptionId = null;
let storedAnswerText = null;
if (q.type === 'short_answer') {
const userAns = String(answer_text || '').trim().toLowerCase().replace(/\s+/g, ' ');
const correct = String(q.correct_text || '').trim().toLowerCase().replace(/\s+/g, ' ');
isCorrect = userAns === correct ? 1 : 0;
storedAnswerText = String(answer_text || '').trim();
} else if (q.type === 'multi') {
const selected = Array.isArray(chosen_options) ? chosen_options.map(Number) : [];
const allOpts = db.prepare('SELECT id, is_correct FROM options WHERE question_id = ?').all(question_id);
const correctIds = allOpts.filter(o => o.is_correct).map(o => o.id);
const wrongIds = allOpts.filter(o => !o.is_correct).map(o => o.id);
isCorrect = (
correctIds.every(id => selected.includes(id)) &&
wrongIds.every(id => !selected.includes(id))
) ? 1 : 0;
storedAnswerText = JSON.stringify(selected);
} else if (q.type === 'matching') {
const pairs = (() => { try { return JSON.parse(answer_text || '{}'); } catch (e) { console.error('[answer] matching parse error:', e.message); return {}; } })();
const allOpts = db.prepare('SELECT id, match_pair FROM options WHERE question_id = ?').all(question_id);
isCorrect = allOpts.length > 0 && allOpts.every(opt => pairs[String(opt.id)] === opt.match_pair) ? 1 : 0;
storedAnswerText = answer_text;
} else {
// single / true_false
const opt = db.prepare(
'SELECT id, is_correct FROM options WHERE id = ? AND question_id = ?'
).get(option_id, question_id);
if (!opt) return res.status(400).json({ error: 'Invalid option' });
isCorrect = opt.is_correct;
chosenOptionId = opt.id;
}
db.prepare(`
INSERT INTO user_answers (session_id, question_id, chosen_option_id, answer_text, is_correct, time_spent_sec)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, question_id)
DO UPDATE SET chosen_option_id = excluded.chosen_option_id,
answer_text = excluded.answer_text,
is_correct = excluded.is_correct,
time_spent_sec = excluded.time_spent_sec,
answered_at = datetime('now')
`).run(session_id, question_id, chosenOptionId, storedAnswerText, isCorrect, time_spent_sec ?? null);
const response = { is_correct: isCorrect === 1 };
if (session.mode === 'practice') {
if (q.type === 'short_answer') {
response.correct_text = q.correct_text;
} else {
response.correct_options = db.prepare(
'SELECT id, text FROM options WHERE question_id = ? AND is_correct = 1'
).all(question_id);
}
}
res.json(response);
}
/* ── POST /api/sessions/:id/finish ───────────────────────────────────── */
function finish(req, res) {
const session_id = Number(req.params.id);
const session = db.prepare(
'SELECT * FROM test_sessions WHERE id = ? AND user_id = ?'
).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
// Atomically mark as completed — guard against concurrent finish() calls
let score;
try {
score = db.transaction(() => {
const { score: s } = db.prepare(
'SELECT COUNT(*) AS score FROM user_answers WHERE session_id = ? AND is_correct = 1'
).get(session_id);
const upd = db.prepare(
"UPDATE test_sessions SET status = 'completed', score = ?, finished_at = datetime('now') WHERE id = ? AND status = 'in_progress'"
).run(s, session_id);
if (upd.changes === 0) throw Object.assign(new Error('already_finished'), { code: 'ALREADY_FINISHED' });
return s;
})();
} catch (e) {
if (e.code === 'ALREADY_FINISHED') return res.status(400).json({ error: 'Session already finished' });
throw e;
}
// Compute max combo (consecutive correct answers in order)
let maxCombo = 0;
try {
const ansRows = db.prepare(
'SELECT is_correct FROM user_answers WHERE session_id = ? ORDER BY rowid'
).all(session_id);
let streak = 0;
for (const a of ansRows) {
streak = a.is_correct ? streak + 1 : 0;
if (streak > maxCombo) maxCombo = streak;
}
} catch (e) { console.error('[finish] combo calc:', e.message); }
// Gamification: award XP, update streak, check achievements
try {
const timeSec = Math.round((Date.now() - new Date(session.started_at).getTime()) / 1000);
// Check if linked to a test with time_limit
let testTimeLimitSec = null;
try {
const tl = db.prepare(`
SELECT t.time_limit FROM assignment_sessions ases
JOIN assignments a ON a.id = ases.assignment_id
JOIN tests t ON t.id = a.test_id
WHERE ases.session_id = ?
`).get(session_id);
if (tl?.time_limit) testTimeLimitSec = tl.time_limit * 60;
} catch (e) { console.error('[finish] time_limit fetch:', e.message); }
// Combo bonus XP (thresholds from constants)
let comboBonus = 0;
for (const [min, xp] of COMBO_BONUSES) {
if (maxCombo >= min) { comboBonus = xp; break; }
}
onTestFinished(req.user.id, score, session.total, timeSec, testTimeLimitSec);
if (comboBonus > 0) {
try { awardXP(req.user.id, comboBonus, `Комбо x${maxCombo}`); } catch (e) { console.error('[finish] comboBonus awardXP:', e.message); }
}
updateDailyGoal(req.user.id, 1, score * 10 + 50 + comboBonus);
// Update personal challenges
try {
const subj = db.prepare('SELECT s.slug FROM subjects s WHERE s.id = ?').get(session.subject_id);
const topicIds = db.prepare(`
SELECT DISTINCT q.topic_id FROM session_questions sq
JOIN questions q ON q.id = sq.question_id
WHERE sq.session_id = ? AND q.topic_id IS NOT NULL
`).all(session_id).map(r => r.topic_id);
const slug = subj ? subj.slug : null;
for (const tid of (topicIds.length ? topicIds : [null])) {
updateChallenges(req.user.id, score, session.total, slug, tid);
}
} catch (e) { console.error('[finish] updateChallenges:', e.message); }
} catch (e) { console.error('[finish] gamification:', e.message); }
// Notify teacher if session linked to a class assignment
try {
const link = db.prepare(`
SELECT a.title, COALESCE(c.teacher_id, a.created_by) AS teacher_id, u.name AS student_name
FROM assignment_sessions ass
JOIN assignments a ON a.id = ass.assignment_id
LEFT JOIN classes c ON c.id = a.class_id
JOIN users u ON u.id = ?
WHERE ass.session_id = ?
`).get(req.user.id, session_id);
if (link) {
const pct = Math.round((score / session.total) * 100);
pushNotif(link.teacher_id, 'session', ${link.student_name}» сдал «${link.title}» — ${pct}%`, '/classes');
}
} catch (e) { console.error('[finish] pushNotif teacher:', e.message); }
res.json({
session_id,
score,
total: session.total,
percent: session.total ? Math.round((score / session.total) * 100) : 0,
time_sec: Math.round((Date.now() - new Date(session.started_at)) / 1000),
maxCombo,
review: buildReview(session_id),
});
}
/* ── GET /api/sessions/:id/result ────────────────────────────────────── */
function result(req, res) {
const session_id = Number(req.params.id);
const session = db.prepare(
'SELECT * FROM test_sessions WHERE id = ? AND user_id = ?'
).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
if (session.status !== 'completed')
return res.status(400).json({ error: 'Session not finished yet' });
// Check if session is linked to an assignment using a test with show_answers setting
let show_answers = 1;
try {
const assnSess = db.prepare(`
SELECT t.show_answers FROM assignment_sessions ases
JOIN assignments a ON a.id = ases.assignment_id
JOIN tests t ON t.id = a.test_id
WHERE ases.session_id = ?
`).get(session_id);
if (assnSess) show_answers = assnSess.show_answers;
} catch {}
// Compute max combo for display
let maxCombo = 0;
try {
const ansRows = db.prepare(
'SELECT is_correct FROM user_answers WHERE session_id = ? ORDER BY rowid'
).all(session_id);
let streak = 0;
for (const a of ansRows) {
streak = a.is_correct ? streak + 1 : 0;
if (streak > maxCombo) maxCombo = streak;
}
} catch {}
res.json({
session_id,
score: session.score,
total: session.total,
percent: session.total ? Math.round((session.score / session.total) * 100) : 0,
show_answers,
maxCombo,
review: buildReview(session_id),
});
}
/* ── GET /api/sessions/history ───────────────────────────────────────── */
function history(req, res) {
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
const cursor = Number(req.query.cursor) || 0;
// Cursor-based: if cursor provided, use it; otherwise fall back to offset pagination
if (cursor) {
const rows = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ? AND ts.id < ?
ORDER BY ts.id DESC
LIMIT ?
`).all(req.user.id, cursor, limit);
const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null;
return res.json({ rows, nextCursor, limit });
}
// Offset-based (legacy)
const page = Math.max(1, Number(req.query.page) || 1);
const offset = (page - 1) * limit;
const { total } = db.prepare('SELECT COUNT(*) AS total FROM test_sessions WHERE user_id = ?').get(req.user.id);
const rows = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ?
ORDER BY ts.started_at DESC
LIMIT ? OFFSET ?
`).all(req.user.id, limit, offset);
res.json({ rows, total, page, limit });
}
/* ── helpers ─────────────────────────────────────────────────────────── */
function _placeholders(n) { return Array(n).fill('?').join(','); }
function loadQuestionsForSession(ids) {
if (!ids.length) return [];
const ph = _placeholders(ids.length);
const questions = db.prepare(
`SELECT id, text, type, difficulty, allow_html, image FROM questions WHERE id IN (${ph})`
).all(...ids);
const allOptions = db.prepare(
`SELECT question_id, id, text, match_pair FROM options WHERE question_id IN (${ph}) ORDER BY question_id, order_index`
).all(...ids);
const optsByQ = {};
for (const o of allOptions) (optsByQ[o.question_id] ??= []).push(o);
// Restore caller-expected order
const qMap = {};
for (const q of questions) qMap[q.id] = q;
return ids.map(id => {
const q = qMap[id];
if (!q) return null;
q.options = q.type !== 'short_answer' ? (optsByQ[id] || []) : [];
return q;
}).filter(Boolean);
}
function buildReview(session_id) {
const sqRows = db.prepare(
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
).all(session_id);
if (!sqRows.length) return [];
const ids = sqRows.map(r => r.question_id);
const ph = _placeholders(ids.length);
const questions = db.prepare(
`SELECT id, text, type, explanation, correct_text, allow_html, image FROM questions WHERE id IN (${ph})`
).all(...ids);
const answers = db.prepare(
`SELECT question_id, chosen_option_id, answer_text, is_correct FROM user_answers WHERE session_id = ? AND question_id IN (${ph})`
).all(session_id, ...ids);
const options = db.prepare(
`SELECT question_id, id, text, is_correct, match_pair FROM options WHERE question_id IN (${ph}) ORDER BY question_id, order_index`
).all(...ids);
const ansMap = {};
for (const a of answers) ansMap[a.question_id] = a;
const optsByQ = {};
for (const o of options) (optsByQ[o.question_id] ??= []).push(o);
const qMap = {};
for (const q of questions) qMap[q.id] = q;
return ids.map(id => {
const q = qMap[id];
const ua = ansMap[id];
return {
...q,
options: optsByQ[id] || [],
chosen_option_id: ua?.chosen_option_id ?? null,
answer_text: ua?.answer_text ?? null,
is_correct: ua ? ua.is_correct === 1 : null,
};
});
}
/* ── GET /api/sessions/weak-topics ───────────────────────────────────── */
function weakTopics(req, res) {
const rows = db.prepare(`
SELECT t.id AS topic_id,
t.name AS topic,
s.name AS subject_name,
s.slug AS subject_slug,
COUNT(ua.id) AS total,
SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong,
ROUND(
CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL)
/ COUNT(ua.id) * 100
, 0) AS error_pct
FROM user_answers ua
JOIN test_sessions ts ON ts.id = ua.session_id
JOIN questions q ON q.id = ua.question_id
JOIN topics t ON t.id = q.topic_id
JOIN subjects s ON s.id = q.subject_id
WHERE ts.user_id = ? AND ts.status = 'completed' AND q.topic_id IS NOT NULL
GROUP BY q.topic_id
HAVING total >= 2
ORDER BY error_pct DESC, wrong DESC
LIMIT 8
`).all(req.user.id);
res.json(rows);
}
/* ── GET /api/sessions/:id/questions ── resume existing session ─────── */
function getSessionQuestions(req, res) {
const session_id = Number(req.params.id);
const session = db.prepare(`
SELECT ts.id, ts.mode, ts.total, ts.status, ts.started_at, s.slug AS subject_slug
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.id = ? AND ts.user_id = ?
`).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
if (session.status !== 'in_progress') return res.status(400).json({ error: 'Session already finished' });
const ids = db.prepare(
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
).all(session_id).map(r => r.question_id);
// Resolve time limit from linked test (if any)
let time_limit_sec = null;
try {
const tl = db.prepare(`
SELECT t.time_limit FROM assignment_sessions ases
JOIN assignments a ON a.id = ases.assignment_id
JOIN tests t ON t.id = a.test_id
WHERE ases.session_id = ? AND t.time_limit IS NOT NULL
`).get(session_id);
if (tl?.time_limit) time_limit_sec = tl.time_limit * 60;
} catch {}
const questions = loadQuestionsForSession(ids);
res.json({
session_id,
total: session.total,
mode: session.mode,
subject_slug: session.subject_slug,
questions,
time_limit_sec,
started_at: session.started_at,
});
}
/* ── GET /api/sessions/stats ── student dashboard charts ────────────── */
function stats(req, res) {
const uid = req.user.id;
// 2 queries instead of 7: mega-CTE returns all session data as JSON columns
const row = stmts.statsMega.get({ uid });
const weekly = JSON.parse(row.weekly);
const heatmap = JSON.parse(row.heatmap);
const bySubject = JSON.parse(row.bySubject);
const trend = JSON.parse(row.trend).reverse();
const totals = JSON.parse(row.totals);
const dayKeys = new Set(JSON.parse(row.streakDays));
const courseProgress = stmts.courseProgress.all({ uid });
// Streak calculation (JS side, uses pre-fetched day set)
let streak = 0;
const now = new Date();
for (let i = 0; i <= 90; i++) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const key = d.toISOString().slice(0, 10);
if (dayKeys.has(key)) streak++;
else if (i > 0) break;
}
res.json({
weekly: weekly.map(r => ({ week: r.week, sessions: r.sessions, avgPct: Math.round(r.avg_pct || 0) })),
heatmap: heatmap.map(r => ({ day: r.day, count: r.count })),
bySubject: bySubject.map(r => ({
slug: r.slug, name: r.name, sessions: r.sessions,
avgPct: Math.round(r.avg_pct || 0),
correct: r.total_correct, questions: r.total_questions,
})),
trend: trend.map(r => ({
pct: r.total > 0 ? Math.round(r.score * 100 / r.total) : 0,
date: r.finished_at, subject: r.subject_slug,
})),
streak,
totals: {
sessions: totals.sessions || 0,
correct: totals.correct || 0,
questions:totals.questions|| 0,
avgPct: Math.round(totals.avg_pct || 0),
},
courseProgress: courseProgress.map(r => ({
id: r.id, title: r.title, emoji: r.cover_emoji,
subjectSlug: r.subject_slug,
done: r.done_lessons, total: r.total_lessons,
pct: r.total_lessons > 0 ? Math.round(r.done_lessons * 100 / r.total_lessons) : 0,
})),
});
}
module.exports = { start, answer, finish, result, history, weakTopics, getSessionQuestions, stats };