From 124236db5831e47b22dedb1b7355bd21bd05480e Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sun, 17 May 2026 15:05:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin-dash):=20P1=20=E2=80=94=20sparklines?= =?UTF-8?q?,=20content=20inventory,=20subject=20distribution,=20worst-5=20?= =?UTF-8?q?sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/controllers/adminController.js | 50 ++++++ frontend/js/admin/sections/overview.js | 173 ++++++++++++++++----- 2 files changed, 186 insertions(+), 37 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index a572a4f..2377865 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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 }); diff --git a/frontend/js/admin/sections/overview.js b/frontend/js/admin/sections/overview.js index 845dcd9..499a3c7 100644 --- a/frontend/js/admin/sections/overview.js +++ b/frontend/js/admin/sections/overview.js @@ -26,6 +26,7 @@ .ov-card-val { font-family: 'Unbounded', sans-serif; font-size: 1.9rem; font-weight: 800; line-height: 1.1; margin-bottom: 4px; } .ov-card.hero .ov-card-val { font-size: 2.6rem; } .ov-card-label { font-size: 0.82rem; color: var(--text-3); font-weight: 600; } + .ov-card-spark { margin-top: 6px; opacity: 0.7; } .ov-zero { color: var(--text-3); opacity: 0.55; } .ov-card.warn { border-color: rgba(255,179,71,0.4); } .ov-card.warn::before { background: var(--amber); } @@ -52,7 +53,19 @@ .ov-stuck-row .ov-st-name { font-weight: 600; flex: 1; } .ov-stuck-row .ov-st-subj { color: var(--text-3); font-size: 0.78rem; } .ov-stuck-row .ov-st-since { margin-left: auto; color: var(--text-3); font-size: 0.76rem; white-space: nowrap; } - /* ── top-5 table ────────────────────────────────────────────── */ + /* ── content inventory ──────────────────────────────────────── */ + .ov-inv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 28px; } + .ov-inv-item { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 16px; display: flex; flex-direction: column; gap: 2px; } + .ov-inv-n { font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 800; color: var(--text); } + .ov-inv-l { font-size: 0.76rem; color: var(--text-3); font-weight: 600; } + /* ── subject distribution bar ───────────────────────────────── */ + .ov-subj-bar-track { height: 10px; border-radius: 5px; overflow: hidden; display: flex; margin-bottom: 10px; background: var(--border); } + .ov-subj-seg { height: 100%; transition: width .3s; } + .ov-subj-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; font-size: 0.78rem; color: var(--text-3); } + .ov-subj-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; } + /* ── top/worst tables side by side ──────────────────────────── */ + .ov-results-grid { display: grid; grid-template-columns: 1fr; gap: 28px; } + @media (min-width: 1100px) { .ov-results-grid { grid-template-columns: 1fr 1fr; } } .ov-top-table { width: 100%; border-collapse: collapse; } .ov-top-table th { text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-3); font-weight: 700; padding: 8px 10px; border-bottom: 1px solid var(--border); } .ov-top-table td { padding: 10px; font-size: 0.86rem; border-bottom: 1px solid var(--border); } @@ -72,6 +85,50 @@ document.head.appendChild(s); } + /* ── sparkline renderer ──────────────────────────────────────────────── */ + /* Takes array of {d: 'YYYY-MM-DD', n: N}, fills 7-day window, returns SVG */ + function renderSparkline(rawData, color) { + const W = 50, H = 18, PAD = 2; + // Build date map for last 7 days + const map = {}; + (rawData || []).forEach(function (r) { map[r.d] = r.n; }); + const points = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + points.push(map[key] || 0); + } + const max = Math.max.apply(null, points) || 1; + const xs = points.map(function (_, i) { return PAD + (i / 6) * (W - 2 * PAD); }); + const ys = points.map(function (v) { return H - PAD - (v / max) * (H - 2 * PAD); }); + const polyline = xs.map(function (x, i) { return x.toFixed(1) + ',' + ys[i].toFixed(1); }).join(' '); + return '' + + '' + + ''; + } + + /* ── subject bar colors (hue cycle) ─────────────────────────────────── */ + var SUBJ_COLORS = [ + '#9B5DE5','#06D6E0','#06D664','#FFB347','#F15BB5', + '#4FC3F7','#81C784','#FFD54F','#FF8A65','#BA68C8', + ]; + + function renderSubjectBar(subjects) { + if (!subjects || !subjects.length) { + return '
Нет сессий за последние 24 часа
'; + } + const total = subjects.reduce(function (s, r) { return s + r.n; }, 0) || 1; + let segs = '', legend = ''; + subjects.forEach(function (r, i) { + const pct = (r.n / total * 100).toFixed(1); + const col = SUBJ_COLORS[i % SUBJ_COLORS.length]; + segs += '
'; + legend += '' + r.name + ' ' + r.n + ''; + }); + return '
' + segs + '
' + legend + '
'; + } + function pctClassNum(p) { if (p === null || p === undefined) return ''; return p >= 75 ? 'hi' : p >= 50 ? 'mid' : 'lo'; @@ -88,7 +145,7 @@ try { const d = new Date(s.replace(' ', 'T') + 'Z'); return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' }); - } catch { return ''; } + } catch (e) { return ''; } } function fmtFinished(s) { @@ -96,7 +153,7 @@ try { const d = new Date(s.replace(' ', 'T') + 'Z'); return d.toLocaleString('ru', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); - } catch { return s; } + } catch (e) { return s; } } function fmtAgo(ms) { @@ -122,6 +179,18 @@ else window.location.hash = hash; } + function renderSessionRows(sessions, e) { + return sessions.map(function (s) { + return '' + + '' + e(s.user_name || '—') + '' + + '' + e(s.subject_name || '—') + '' + + '' + (s.score != null ? s.score : 0) + ' / ' + (s.total != null ? s.total : 0) + '' + + '' + (s.percent != null ? s.percent : '—') + '%' + + '' + fmtFinished(s.finished_at) + '' + + ''; + }).join(''); + } + function render(data) { const el = document.getElementById('overview-content'); if (!el) return; @@ -130,8 +199,12 @@ const e = LS.esc; const bannedCount = Array.isArray(data.bannedThisWeek) ? data.bannedThisWeek.length : 0; const top = Array.isArray(data.topSessions24h) ? data.topSessions24h : []; + const worst = Array.isArray(data.worstSessions24h) ? data.worstSessions24h : []; const stuck = Array.isArray(data.stuckSessions) ? data.stuckSessions : []; const abandoned = data.abandonedSessions24h || 0; + const sparks = data.sparks || {}; + const inv = data.inventory || {}; + const subjects24h = Array.isArray(data.sessionsBySubject24h) ? data.sessionsBySubject24h : []; /* ── alerts section ────────────────────────────────────────── */ let alertsHtml = ''; @@ -146,13 +219,13 @@
${bannedCount}
Заблокированы за неделю
- ${data.bannedThisWeek.map(u => ` -
- ${e(u.name || '—')} - ${e(u.email || '')} - ${fmtBannedDate(u.banned_at)} -
- `).join('')} + ${data.bannedThisWeek.map(function (u) { + return '
' + + '' + e(u.name || '—') + '' + + '' + e(u.email || '') + '' + + '' + fmtBannedDate(u.banned_at) + '' + + '
'; + }).join('')}
` : ''; @@ -169,13 +242,13 @@
${stuck.length}
Сессий висят >1ч
- ${stuck.map(st => ` -
- ${e(st.user_name || '—')} - ${e(st.subject_name || '—')} - ${fmtFinished(st.started_at)} -
- `).join('')} + ${stuck.map(function (st) { + return '
' + + '' + e(st.user_name || '—') + '' + + '' + e(st.subject_name || '—') + '' + + '' + fmtFinished(st.started_at) + '' + + '
'; + }).join('')}
` : ''; @@ -184,22 +257,35 @@
${bannedCard}${abandonedCard}${stuckCard}
`; } - /* ── top-5 rows ────────────────────────────────────────────── */ - const topRowsHtml = top.length ? ` - - - - ${top.map(s => ` - - - - - - - - `).join('')} - -
УченикПредметСчёт%Завершён
${e(s.user_name || '—')}${e(s.subject_name || '—')}${s.score ?? 0} / ${s.total ?? 0}${s.percent ?? '—'}%${fmtFinished(s.finished_at)}
` : '
Нет завершённых сессий за последние 24 часа
'; + /* ── inventory section ─────────────────────────────────────── */ + const invHtml = ` +
Контент проекта
+
+
${inv.questions != null ? inv.questions : 0}вопросов
+
${inv.tests != null ? inv.tests : 0}тестов
+
${inv.courses != null ? inv.courses : 0}курсов
+
${inv.classes != null ? inv.classes : 0}классов
+
`; + + /* ── subject bar ───────────────────────────────────────────── */ + const subjHtml = ` +
По предметам (24ч)
+ ${renderSubjectBar(subjects24h)}`; + + /* ── results tables ────────────────────────────────────────── */ + const topTableHtml = top.length + ? ` + + ${renderSessionRows(top, e)} +
УченикПредметСчёт%Завершён
` + : '
Нет завершённых сессий за 24ч
'; + + const worstTableHtml = worst.length + ? ` + + ${renderSessionRows(worst, e)} +
УченикПредметСчёт%Завершён
` + : '
Нет завершённых сессий за 24ч
'; const tsText = _lastLoadTs ? fmtAgo(_lastLoadTs) : 'только что'; @@ -218,16 +304,19 @@
${fmtNum(data.newSessions24h)}
Сессий запущено
+
${renderSparkline(sparks.sessions, 'var(--violet)')}
${fmtNum(data.newUsers24h)}
Новых регистраций
+
${renderSparkline(sparks.users, 'var(--cyan)')}
${fmtNum(data.activeUsers24h)}
Активных юзеров
+
${renderSparkline(sparks.active, 'var(--green)')}
@@ -237,9 +326,19 @@
${alertsHtml} + ${invHtml} + ${subjHtml} -
Топ-5 сессий за день
- ${topRowsHtml} +
+
+
Топ-5 сегодня
+ ${topTableHtml} +
+
+
Худшие 5 сегодня
+ ${worstTableHtml} +
+
Быстрый переход
@@ -259,8 +358,8 @@ `; /* ── wire quick-links via event delegation ───────────────── */ - el.querySelectorAll('.ov-quick-btn[data-go]').forEach(btn => { - btn.addEventListener('click', () => navigateTo(btn.dataset.go)); + el.querySelectorAll('.ov-quick-btn[data-go]').forEach(function (btn) { + btn.addEventListener('click', function () { navigateTo(btn.dataset.go); }); }); if (window.lucide) lucide.createIcons({ nodes: [el] });