feat(admin-dash): P1 — sparklines, content inventory, subject distribution, worst-5 sessions

- 7d sparkline per 3 main metric cards (inline SVG polyline, renderSparkline helper)
- "Контент проекта" row: questions/tests/courses/classes totals (compact .ov-inv-grid)
- Per-subject stacked bar (24h) with hue-cycle colors and legend below
- "Худшие 5 сегодня" mirrors top-5 table; both side-by-side ≥1100px via .ov-results-grid
- renderSessionRows() shared helper for top/worst table rows

Backend: 5 new prepared statements (worstSessions24h, sparkUsers7d, sparkSessions7d,
sparkActiveUsers7d, inventory, sessionsBySubject24h)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-17 15:05:57 +03:00
parent 64112e56ed
commit 124236db58
2 changed files with 186 additions and 37 deletions
@@ -73,6 +73,48 @@ const overviewStmts = {
ORDER BY (CAST(ts.score AS REAL) / ts.total) DESC, ts.finished_at DESC
LIMIT 5
`),
worstSessions24h: db.prepare(`
SELECT ts.id, u.name AS user_name, s.name AS subject_name,
ts.score, ts.total,
ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent,
ts.finished_at
FROM test_sessions ts
JOIN users u ON u.id = ts.user_id
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.status = 'completed'
AND ts.finished_at >= datetime('now', '-24 hours')
AND ts.total > 0
ORDER BY (CAST(ts.score AS REAL) / ts.total) ASC, ts.finished_at DESC
LIMIT 5
`),
sparkUsers7d: db.prepare(`
SELECT date(created_at) AS d, COUNT(*) AS n FROM users
WHERE created_at >= date('now', '-6 days')
GROUP BY d ORDER BY d
`),
sparkSessions7d: db.prepare(`
SELECT date(started_at) AS d, COUNT(*) AS n FROM test_sessions
WHERE started_at >= date('now', '-6 days')
GROUP BY d ORDER BY d
`),
sparkActiveUsers7d: db.prepare(`
SELECT date(last_login) AS d, COUNT(DISTINCT id) AS n FROM users
WHERE last_login IS NOT NULL AND last_login >= date('now', '-6 days')
GROUP BY d ORDER BY d
`),
inventory: db.prepare(`
SELECT
(SELECT COUNT(*) FROM questions) AS questions,
(SELECT COUNT(*) FROM tests) AS tests,
(SELECT COUNT(*) FROM courses) AS courses,
(SELECT COUNT(*) FROM classes) AS classes
`),
sessionsBySubject24h: db.prepare(`
SELECT s.slug, s.name, COUNT(*) AS n FROM test_sessions ts
JOIN subjects s ON s.id = ts.subject_id
WHERE ts.started_at >= datetime('now', '-24 hours')
GROUP BY s.id ORDER BY n DESC
`),
};
/* ── GET /api/admin/overview ──────────────────────────────────────────── */
@@ -87,6 +129,14 @@ function getOverview(_req, res) {
stuckSessions: overviewStmts.stuckSessions.all(),
bannedThisWeek: overviewStmts.bannedThisWeek.all(),
topSessions24h: overviewStmts.topSessions24h.all(),
worstSessions24h: overviewStmts.worstSessions24h.all(),
inventory: overviewStmts.inventory.get(),
sessionsBySubject24h: overviewStmts.sessionsBySubject24h.all(),
sparks: {
users: overviewStmts.sparkUsers7d.all(),
sessions: overviewStmts.sparkSessions7d.all(),
active: overviewStmts.sparkActiveUsers7d.all(),
},
});
} catch (err) {
res.status(500).json({ error: err.message });