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:
@@ -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 });
|
||||
|
||||
@@ -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 '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg">' +
|
||||
'<polyline points="' + polyline + '" fill="none" stroke="' + color + '" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
|
||||
'</svg>';
|
||||
}
|
||||
|
||||
/* ── 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 '<div class="ov-empty">Нет сессий за последние 24 часа</div>';
|
||||
}
|
||||
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 += '<div class="ov-subj-seg" style="width:' + pct + '%;background:' + col + '" title="' + r.name + ': ' + r.n + '"></div>';
|
||||
legend += '<span><span class="ov-subj-dot" style="background:' + col + '"></span>' + r.name + ' ' + r.n + '</span>';
|
||||
});
|
||||
return '<div class="ov-subj-bar-track">' + segs + '</div><div class="ov-subj-legend">' + legend + '</div>';
|
||||
}
|
||||
|
||||
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 '<tr>' +
|
||||
'<td>' + e(s.user_name || '—') + '</td>' +
|
||||
'<td>' + e(s.subject_name || '—') + '</td>' +
|
||||
'<td>' + (s.score != null ? s.score : 0) + ' / ' + (s.total != null ? s.total : 0) + '</td>' +
|
||||
'<td><span class="ov-pct ' + pctClassNum(s.percent) + '">' + (s.percent != null ? s.percent : '—') + '%</span></td>' +
|
||||
'<td style="color:var(--text-3);font-size:0.8rem">' + fmtFinished(s.finished_at) + '</td>' +
|
||||
'</tr>';
|
||||
}).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 @@
|
||||
<div class="ov-card-val">${bannedCount}</div>
|
||||
<div class="ov-card-label" style="margin-bottom: 10px">Заблокированы за неделю</div>
|
||||
<div class="ov-banned-list">
|
||||
${data.bannedThisWeek.map(u => `
|
||||
<div class="ov-banned-row">
|
||||
<span class="ov-bn-name">${e(u.name || '—')}</span>
|
||||
<span class="ov-bn-email">${e(u.email || '')}</span>
|
||||
<span class="ov-bn-date">${fmtBannedDate(u.banned_at)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${data.bannedThisWeek.map(function (u) {
|
||||
return '<div class="ov-banned-row">' +
|
||||
'<span class="ov-bn-name">' + e(u.name || '—') + '</span>' +
|
||||
'<span class="ov-bn-email">' + e(u.email || '') + '</span>' +
|
||||
'<span class="ov-bn-date">' + fmtBannedDate(u.banned_at) + '</span>' +
|
||||
'</div>';
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
@@ -169,13 +242,13 @@
|
||||
<div class="ov-card-val">${stuck.length}</div>
|
||||
<div class="ov-card-label" style="margin-bottom: 10px">Сессий висят >1ч</div>
|
||||
<div class="ov-stuck-list">
|
||||
${stuck.map(st => `
|
||||
<div class="ov-stuck-row">
|
||||
<span class="ov-st-name">${e(st.user_name || '—')}</span>
|
||||
<span class="ov-st-subj">${e(st.subject_name || '—')}</span>
|
||||
<span class="ov-st-since">${fmtFinished(st.started_at)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
${stuck.map(function (st) {
|
||||
return '<div class="ov-stuck-row">' +
|
||||
'<span class="ov-st-name">' + e(st.user_name || '—') + '</span>' +
|
||||
'<span class="ov-st-subj">' + e(st.subject_name || '—') + '</span>' +
|
||||
'<span class="ov-st-since">' + fmtFinished(st.started_at) + '</span>' +
|
||||
'</div>';
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
@@ -184,22 +257,35 @@
|
||||
<div class="ov-grid">${bannedCard}${abandonedCard}${stuckCard}</div>`;
|
||||
}
|
||||
|
||||
/* ── top-5 rows ────────────────────────────────────────────── */
|
||||
const topRowsHtml = top.length ? `
|
||||
<table class="ov-top-table">
|
||||
<thead><tr><th>Ученик</th><th>Предмет</th><th>Счёт</th><th>%</th><th>Завершён</th></tr></thead>
|
||||
<tbody>
|
||||
${top.map(s => `
|
||||
<tr>
|
||||
<td>${e(s.user_name || '—')}</td>
|
||||
<td>${e(s.subject_name || '—')}</td>
|
||||
<td>${s.score ?? 0} / ${s.total ?? 0}</td>
|
||||
<td><span class="ov-pct ${pctClassNum(s.percent)}">${s.percent ?? '—'}%</span></td>
|
||||
<td style="color:var(--text-3);font-size:0.8rem">${fmtFinished(s.finished_at)}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>` : '<div class="ov-empty">Нет завершённых сессий за последние 24 часа</div>';
|
||||
/* ── inventory section ─────────────────────────────────────── */
|
||||
const invHtml = `
|
||||
<div class="ov-section-title">Контент проекта</div>
|
||||
<div class="ov-inv-grid">
|
||||
<div class="ov-inv-item"><span class="ov-inv-n">${inv.questions != null ? inv.questions : 0}</span><span class="ov-inv-l">вопросов</span></div>
|
||||
<div class="ov-inv-item"><span class="ov-inv-n">${inv.tests != null ? inv.tests : 0}</span><span class="ov-inv-l">тестов</span></div>
|
||||
<div class="ov-inv-item"><span class="ov-inv-n">${inv.courses != null ? inv.courses : 0}</span><span class="ov-inv-l">курсов</span></div>
|
||||
<div class="ov-inv-item"><span class="ov-inv-n">${inv.classes != null ? inv.classes : 0}</span><span class="ov-inv-l">классов</span></div>
|
||||
</div>`;
|
||||
|
||||
/* ── subject bar ───────────────────────────────────────────── */
|
||||
const subjHtml = `
|
||||
<div class="ov-section-title">По предметам (24ч)</div>
|
||||
${renderSubjectBar(subjects24h)}`;
|
||||
|
||||
/* ── results tables ────────────────────────────────────────── */
|
||||
const topTableHtml = top.length
|
||||
? `<table class="ov-top-table">
|
||||
<thead><tr><th>Ученик</th><th>Предмет</th><th>Счёт</th><th>%</th><th>Завершён</th></tr></thead>
|
||||
<tbody>${renderSessionRows(top, e)}</tbody>
|
||||
</table>`
|
||||
: '<div class="ov-empty">Нет завершённых сессий за 24ч</div>';
|
||||
|
||||
const worstTableHtml = worst.length
|
||||
? `<table class="ov-top-table">
|
||||
<thead><tr><th>Ученик</th><th>Предмет</th><th>Счёт</th><th>%</th><th>Завершён</th></tr></thead>
|
||||
<tbody>${renderSessionRows(worst, e)}</tbody>
|
||||
</table>`
|
||||
: '<div class="ov-empty">Нет завершённых сессий за 24ч</div>';
|
||||
|
||||
const tsText = _lastLoadTs ? fmtAgo(_lastLoadTs) : 'только что';
|
||||
|
||||
@@ -218,16 +304,19 @@
|
||||
<div class="ov-card-icon" style="background:rgba(155,93,229,0.1);color:var(--violet)"><i data-lucide="play-circle" style="width:24px;height:24px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--violet)">${fmtNum(data.newSessions24h)}</div>
|
||||
<div class="ov-card-label">Сессий запущено</div>
|
||||
<div class="ov-card-spark">${renderSparkline(sparks.sessions, 'var(--violet)')}</div>
|
||||
</div>
|
||||
<div class="ov-card" style="--ov-top:var(--cyan)">
|
||||
<div class="ov-card-icon" style="background:rgba(6,214,224,0.1);color:var(--cyan)"><i data-lucide="user-plus" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--cyan)">${fmtNum(data.newUsers24h)}</div>
|
||||
<div class="ov-card-label">Новых регистраций</div>
|
||||
<div class="ov-card-spark">${renderSparkline(sparks.users, 'var(--cyan)')}</div>
|
||||
</div>
|
||||
<div class="ov-card" style="--ov-top:var(--green)">
|
||||
<div class="ov-card-icon" style="background:rgba(6,214,100,0.1);color:var(--green)"><i data-lucide="activity" style="width:18px;height:18px"></i></div>
|
||||
<div class="ov-card-val" style="color:var(--green)">${fmtNum(data.activeUsers24h)}</div>
|
||||
<div class="ov-card-label">Активных юзеров</div>
|
||||
<div class="ov-card-spark">${renderSparkline(sparks.active, 'var(--green)')}</div>
|
||||
</div>
|
||||
<div class="ov-card" style="--ov-top:var(--amber)">
|
||||
<div class="ov-card-icon" style="background:rgba(255,179,71,0.12);color:var(--amber)"><i data-lucide="users" style="width:18px;height:18px"></i></div>
|
||||
@@ -237,9 +326,19 @@
|
||||
</div>
|
||||
|
||||
${alertsHtml}
|
||||
${invHtml}
|
||||
${subjHtml}
|
||||
|
||||
<div class="ov-section-title">Топ-5 сессий за день</div>
|
||||
${topRowsHtml}
|
||||
<div class="ov-results-grid" style="margin-top: 28px">
|
||||
<div>
|
||||
<div class="ov-section-title" style="margin-top:0">Топ-5 сегодня</div>
|
||||
${topTableHtml}
|
||||
</div>
|
||||
<div>
|
||||
<div class="ov-section-title" style="margin-top:0">Худшие 5 сегодня</div>
|
||||
${worstTableHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ov-section-title">Быстрый переход</div>
|
||||
<div class="ov-quick-grid">
|
||||
@@ -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] });
|
||||
|
||||
Reference in New Issue
Block a user