ecce4b013a
errorRate приходит из API уже в процентах (SUM(is_correct=0)*100/COUNT в analyticsController), а фронт умножал ещё раз на 100 → 4130%. Убрал лишнее ×100; заодно корректно работают пороги цвета (>=35 / >=60). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
668 lines
28 KiB
HTML
668 lines
28 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Аналитика — LearnSpace</title>
|
||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="/css/ls.css" />
|
||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||
<style>
|
||
.sb-content { background: #f4f5f8; overflow-y: auto; }
|
||
|
||
/* ── page header ── */
|
||
.an-header {
|
||
background: linear-gradient(140deg, #0a0220 0%, #1a0a3c 60%, #080818 100%);
|
||
padding: 32px 28px 28px; position: relative; overflow: hidden;
|
||
}
|
||
.an-header-dots {
|
||
position: absolute; inset: 0;
|
||
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
|
||
background-size: 22px 22px; pointer-events: none;
|
||
}
|
||
.an-header-inner { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto; }
|
||
.an-header-back {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
font-size: 0.78rem; font-weight: 700; color: rgba(255,255,255,0.45);
|
||
text-decoration: none; margin-bottom: 14px; transition: color 0.15s;
|
||
}
|
||
.an-header-back:hover { color: rgba(255,255,255,0.8); }
|
||
.an-title-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||
.an-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800;
|
||
color: #fff; flex: 1; display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.an-class-select {
|
||
padding: 8px 14px; border: 1.5px solid rgba(255,255,255,0.15); border-radius: 10px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 600;
|
||
color: #fff; background: rgba(255,255,255,0.08); cursor: pointer;
|
||
}
|
||
.an-class-select option { color: #0F172A; background: #fff; }
|
||
|
||
/* ── summary chips ── */
|
||
.an-summary {
|
||
display: flex; gap: 12px; flex-wrap: wrap; max-width: 1200px;
|
||
margin: -20px auto 0; padding: 0 28px; position: relative; z-index: 2;
|
||
}
|
||
.an-chip {
|
||
flex: 1; min-width: 160px; background: #fff;
|
||
border: 1.5px solid rgba(15,23,42,0.07);
|
||
border-radius: 16px; padding: 16px 20px;
|
||
box-shadow: 0 2px 8px rgba(15,23,42,0.06);
|
||
}
|
||
.an-chip-val {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.5rem; font-weight: 800;
|
||
}
|
||
.an-chip-label { font-size: 0.72rem; color: var(--text-3); font-weight: 600; margin-top: 4px; }
|
||
|
||
/* ── content container ── */
|
||
.an-container { max-width: 1200px; margin: 0 auto; padding: 28px 28px 80px; }
|
||
|
||
/* ── section title ── */
|
||
.an-section-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
|
||
color: #0F172A; margin: 0 0 14px; display: flex; align-items: center; gap: 8px;
|
||
}
|
||
|
||
/* ── card wrapper ── */
|
||
.an-card {
|
||
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
||
border-radius: 20px; padding: 22px 24px;
|
||
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
/* ── chart area ── */
|
||
.chart-wrap { position: relative; height: 240px; }
|
||
|
||
/* ── hard questions table ── */
|
||
.hq-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
||
.hq-table thead th {
|
||
padding: 10px 14px; text-align: left;
|
||
background: #f8f9fc; border-bottom: 1.5px solid rgba(15,23,42,0.08);
|
||
font-size: 0.7rem; font-weight: 700; color: var(--text-3);
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
}
|
||
.hq-table tbody td {
|
||
padding: 10px 14px; border-bottom: 1px solid rgba(15,23,42,0.05);
|
||
color: #3D4F6B; font-weight: 500;
|
||
}
|
||
.hq-table tbody tr:last-child td { border-bottom: none; }
|
||
.hq-table tbody tr:hover td { background: rgba(155,93,229,0.02); }
|
||
.hq-rank {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
|
||
color: #0F172A; text-align: center;
|
||
}
|
||
.hq-text { max-width: 320px; }
|
||
.hq-text-inner {
|
||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||
overflow: hidden; font-weight: 600; color: #0F172A;
|
||
}
|
||
.hq-topic { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
|
||
.diff-badge {
|
||
display: inline-flex; align-items: center;
|
||
padding: 3px 10px; border-radius: 999px;
|
||
font-size: 0.7rem; font-weight: 700; white-space: nowrap;
|
||
}
|
||
.diff-1 { background: rgba(6,214,160,0.12); color: #059652; }
|
||
.diff-2 { background: rgba(255,179,71,0.15); color: #B8860B; }
|
||
.diff-3 { background: rgba(239,71,111,0.12); color: #EF476F; }
|
||
.hq-pct { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; }
|
||
.hq-pct-hi { color: #EF476F; }
|
||
.hq-pct-mid { color: #B8860B; }
|
||
.hq-pct-lo { color: #059652; }
|
||
|
||
/* ── heatmap ── */
|
||
.heatmap-wrap { overflow-x: auto; }
|
||
.heatmap-grid {
|
||
display: grid; grid-template-rows: repeat(7, 14px);
|
||
grid-auto-flow: column; gap: 3px;
|
||
grid-auto-columns: 14px;
|
||
min-width: max-content;
|
||
}
|
||
.hm-cell {
|
||
width: 14px; height: 14px; border-radius: 3px;
|
||
background: rgba(155,93,229,0.08);
|
||
transition: transform 0.12s;
|
||
cursor: default;
|
||
position: relative;
|
||
}
|
||
.hm-cell:hover { transform: scale(1.3); z-index: 10; }
|
||
.hm-0 { background: rgba(155,93,229,0.07); }
|
||
.hm-1 { background: rgba(155,93,229,0.20); }
|
||
.hm-2 { background: rgba(155,93,229,0.38); }
|
||
.hm-3 { background: rgba(155,93,229,0.56); }
|
||
.hm-4 { background: rgba(155,93,229,0.80); }
|
||
.heatmap-legend {
|
||
display: flex; align-items: center; gap: 6px;
|
||
margin-top: 10px; font-size: 0.7rem; color: var(--text-3); font-weight: 600;
|
||
}
|
||
.hm-legend-cell {
|
||
width: 12px; height: 12px; border-radius: 2px;
|
||
}
|
||
.heatmap-days {
|
||
display: grid; grid-template-rows: repeat(7, 14px); gap: 3px;
|
||
font-size: 0.62rem; color: var(--text-3); font-weight: 700;
|
||
margin-right: 8px; flex-shrink: 0;
|
||
}
|
||
.heatmap-row { display: flex; align-items: flex-start; }
|
||
.heatmap-months {
|
||
display: flex; margin-left: 30px; margin-bottom: 4px;
|
||
font-size: 0.65rem; color: var(--text-3); font-weight: 700;
|
||
}
|
||
.hm-month { flex-shrink: 0; }
|
||
|
||
/* ── assignments progress ── */
|
||
.asgn-list { display: flex; flex-direction: column; gap: 12px; }
|
||
.asgn-item { display: flex; align-items: center; gap: 14px; }
|
||
.asgn-title {
|
||
min-width: 180px; max-width: 240px; font-size: 0.82rem; font-weight: 600;
|
||
color: #0F172A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
flex-shrink: 0;
|
||
}
|
||
.asgn-bar-wrap {
|
||
flex: 1; height: 10px; background: rgba(155,93,229,0.1);
|
||
border-radius: 999px; overflow: hidden;
|
||
}
|
||
.asgn-bar-fill {
|
||
height: 100%; border-radius: 999px;
|
||
background: linear-gradient(90deg, #9B5DE5, #06D6E0);
|
||
transition: width 0.5s ease;
|
||
}
|
||
.asgn-pct {
|
||
min-width: 42px; text-align: right;
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
|
||
color: var(--violet);
|
||
}
|
||
.asgn-deadline {
|
||
font-size: 0.7rem; color: var(--text-3); flex-shrink: 0; min-width: 80px;
|
||
}
|
||
|
||
/* ── empty state ── */
|
||
.an-empty {
|
||
text-align: center; padding: 80px 20px; color: var(--text-3); font-size: 0.9rem;
|
||
}
|
||
.an-empty-icon { margin-bottom: 14px; opacity: 0.25; }
|
||
|
||
/* ── nav ── */
|
||
.nav-active {
|
||
background: rgba(155,93,229,0.08) !important;
|
||
border-color: var(--violet) !important;
|
||
color: var(--violet) !important;
|
||
cursor: default; pointer-events: none;
|
||
}
|
||
.notif-badge {
|
||
position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px;
|
||
padding: 0 4px; background: var(--pink); color: #fff; border-radius: 99px;
|
||
font-size: 0.65rem; font-weight: 700;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.notif-drop-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 14px 16px 10px; border-bottom: 1px solid var(--border);
|
||
}
|
||
.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; }
|
||
.notif-read-all {
|
||
background: none; border: none; font-size: 0.74rem; color: var(--violet);
|
||
cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600;
|
||
}
|
||
.notif-item {
|
||
display: flex; gap: 10px; padding: 11px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer; transition: background var(--tr);
|
||
text-decoration: none; color: inherit;
|
||
}
|
||
.notif-item:last-child { border-bottom: none; }
|
||
.notif-item:hover { background: rgba(155,93,229,0.04); }
|
||
.notif-item.unread { background: rgba(155,93,229,0.05); }
|
||
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; }
|
||
.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); }
|
||
.notif-msg { font-size: 0.80rem; line-height: 1.45; flex: 1; }
|
||
.notif-time { font-size: 0.70rem; color: var(--text-3); margin-top: 2px; }
|
||
.notif-empty { padding: 28px 16px; text-align: center; color: var(--text-3); font-size: 0.84rem; }
|
||
|
||
@media (max-width: 768px) {
|
||
.an-header { padding: 20px 16px 36px; }
|
||
.an-title { font-size: 1rem; }
|
||
.an-class-select { width: 100%; }
|
||
.an-summary { padding: 0 14px; gap: 8px; margin-top: -24px; }
|
||
.an-chip { min-width: 0; flex: 1 1 calc(50% - 8px); padding: 12px 14px; }
|
||
.an-chip-val { font-size: 1.1rem; }
|
||
.an-container { padding: 18px 12px 80px; }
|
||
.hq-text { max-width: 160px; }
|
||
.asgn-title { min-width: 100px; max-width: 120px; }
|
||
}
|
||
@media (max-width: 480px) {
|
||
.an-chip { flex: 1 1 100%; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-layout">
|
||
<aside class="sidebar" id="app-sidebar"></aside>
|
||
<div class="notif-drop" id="notif-drop"></div>
|
||
<div class="sb-content">
|
||
|
||
<!-- Header -->
|
||
<div class="an-header">
|
||
<div class="an-header-dots"></div>
|
||
<div class="an-header-inner">
|
||
<a href="/classes" class="an-header-back"><i data-lucide="arrow-left" style="width:14px;height:14px"></i> Классы</a>
|
||
<div class="an-title-row">
|
||
<div class="an-title"><i data-lucide="bar-chart-2" style="width:22px;height:22px;opacity:0.5"></i> Аналитика</div>
|
||
<select class="an-class-select" id="class-select" onchange="onClassChange()">
|
||
<option value="">Выберите класс…</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Summary chips -->
|
||
<div class="an-summary" id="an-summary" style="display:none">
|
||
<div class="an-chip">
|
||
<div class="an-chip-val" id="chip-students" style="color:var(--violet)">0</div>
|
||
<div class="an-chip-label">Учеников</div>
|
||
</div>
|
||
<div class="an-chip">
|
||
<div class="an-chip-val" id="chip-sessions" style="color:#06B6D4">0</div>
|
||
<div class="an-chip-label">Сессий</div>
|
||
</div>
|
||
<div class="an-chip">
|
||
<div class="an-chip-val" id="chip-avg" style="color:#06D6A0">—</div>
|
||
<div class="an-chip-label">Средний балл</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div class="an-container" id="an-container">
|
||
<div class="an-empty" id="an-empty">
|
||
<div class="an-empty-icon"><i data-lucide="bar-chart-2" style="width:56px;height:56px"></i></div>
|
||
<div style="font-family:'Unbounded',sans-serif;font-weight:800;font-size:1rem;color:#0F172A;margin-bottom:6px">Выберите класс</div>
|
||
Выберите класс из списка выше, чтобы увидеть аналитику
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/api.js"></script>
|
||
<script src="/js/sidebar.js"></script>
|
||
<script>
|
||
if (!LS.requireAuth()) throw new Error();
|
||
|
||
const user = LS.getUser();
|
||
document.getElementById('nav-user').textContent = user?.name || user?.email || '';
|
||
LS.renderNavAvatar(document.getElementById('nav-avatar'), user);
|
||
|
||
const isTeacher = ['admin', 'teacher'].includes(user?.role);
|
||
if (!isTeacher) { location.href = '/dashboard'; throw new Error(); }
|
||
if (user?.role === 'admin') document.getElementById('btn-admin').style.display = '';
|
||
|
||
/* ── helpers ── */
|
||
function fmtDate(s) {
|
||
if (!s) return '—';
|
||
const d = new Date(s.includes('T') ? s : s + 'T00:00:00');
|
||
return d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
}
|
||
function fmtTime(s) {
|
||
const d = new Date(s && s.includes('T') ? s : (s || '').replace(' ', 'T') + 'Z');
|
||
const diff = Date.now() - d.getTime();
|
||
if (diff < 60000) return 'только что';
|
||
if (diff < 3600000) return Math.floor(diff / 60000) + ' мин назад';
|
||
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
|
||
}
|
||
|
||
/* ── sidebar ── */
|
||
function toggleSidebar() {
|
||
const layout = document.querySelector('.app-layout');
|
||
const collapsed = layout.classList.toggle('sb-collapsed');
|
||
localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '');
|
||
lucide.createIcons();
|
||
}
|
||
if (localStorage.getItem('ls_sb_collapsed'))
|
||
document.querySelector('.app-layout').classList.add('sb-collapsed');
|
||
|
||
/* ── notifications ── */
|
||
function toggleNotifDrop() {
|
||
const btn = document.getElementById('notif-btn');
|
||
const drop = document.getElementById('notif-drop');
|
||
const r = btn.getBoundingClientRect();
|
||
drop.style.left = (r.right + 8) + 'px';
|
||
drop.style.top = r.top + 'px';
|
||
if (drop.classList.toggle('open')) loadNotifs();
|
||
}
|
||
async function loadNotifs() {
|
||
const drop = document.getElementById('notif-drop');
|
||
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div><div class="notif-empty">Загрузка…</div>';
|
||
try {
|
||
const data = await LS.api('/api/notifications?limit=20');
|
||
const items = data.items || [];
|
||
const badge = document.getElementById('notif-badge');
|
||
const unread = items.filter(n => !n.is_read).length;
|
||
badge.textContent = unread; badge.style.display = unread ? '' : 'none';
|
||
if (!items.length) { drop.querySelector('.notif-empty').textContent = 'Нет уведомлений'; return; }
|
||
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div>' +
|
||
items.map(n => `<a class="notif-item${n.is_read ? '' : ' unread'}" href="${LS.safeHref(n.link)}" onclick="markRead(${n.id})">
|
||
<div class="notif-dot${n.is_read ? ' read' : ''}"></div>
|
||
<div><div class="notif-msg">${esc(n.message)}</div><div class="notif-time">${fmtTime(n.created_at)}</div></div>
|
||
</a>`).join('');
|
||
} catch {}
|
||
}
|
||
async function markRead(id) { try { await LS.api('/api/notifications/' + id + '/read', { method: 'POST' }); } catch {} }
|
||
async function readAllNotifs() { try { await LS.api('/api/notifications/read-all', { method: 'POST' }); loadNotifs(); } catch {} }
|
||
document.addEventListener('click', e => {
|
||
const drop = document.getElementById('notif-drop');
|
||
if (drop.classList.contains('open') && !drop.contains(e.target) && !document.getElementById('notif-btn').contains(e.target))
|
||
drop.classList.remove('open');
|
||
});
|
||
|
||
/* ── load classes ── */
|
||
async function loadClasses() {
|
||
try {
|
||
const classes = await LS.api('/api/classes');
|
||
const sel = document.getElementById('class-select');
|
||
(classes || []).forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = c.name;
|
||
sel.appendChild(opt);
|
||
});
|
||
const urlClass = new URLSearchParams(location.search).get('classId');
|
||
if (urlClass) { sel.value = urlClass; onClassChange(); }
|
||
} catch {}
|
||
}
|
||
|
||
let trendChart = null;
|
||
|
||
function onClassChange() {
|
||
const classId = document.getElementById('class-select').value;
|
||
if (!classId) {
|
||
document.getElementById('an-summary').style.display = 'none';
|
||
document.getElementById('an-container').innerHTML = `
|
||
<div class="an-empty" id="an-empty">
|
||
<div class="an-empty-icon"><i data-lucide="bar-chart-2" style="width:56px;height:56px"></i></div>
|
||
<div style="font-family:'Unbounded',sans-serif;font-weight:800;font-size:1rem;color:#0F172A;margin-bottom:6px">Выберите класс</div>
|
||
Выберите класс из списка выше, чтобы увидеть аналитику
|
||
</div>`;
|
||
lucide.createIcons();
|
||
return;
|
||
}
|
||
loadAnalytics(classId);
|
||
}
|
||
|
||
async function loadAnalytics(classId) {
|
||
document.getElementById('an-container').innerHTML = '<div class="spinner" style="margin:80px auto"></div>';
|
||
document.getElementById('an-summary').style.display = 'none';
|
||
try {
|
||
const data = await LS.api('/api/analytics/teacher?classId=' + classId);
|
||
renderAnalytics(data);
|
||
} catch (e) {
|
||
document.getElementById('an-container').innerHTML = `<div class="an-empty"><div class="an-empty-icon"><i data-lucide="alert-circle" style="width:48px;height:48px"></i></div>${esc(e.message || 'Ошибка загрузки')}</div>`;
|
||
lucide.createIcons();
|
||
}
|
||
}
|
||
|
||
function renderAnalytics(data) {
|
||
const { overview = {}, scoreByWeek = [], hardQuestions = [], heatmap = [], assignments = [] } = data;
|
||
|
||
// chips
|
||
const summary = document.getElementById('an-summary');
|
||
summary.style.display = '';
|
||
document.getElementById('chip-students').textContent = overview.students ?? 0;
|
||
document.getElementById('chip-sessions').textContent = overview.sessions ?? 0;
|
||
document.getElementById('chip-avg').textContent = overview.avgScore != null ? (overview.avgScore + '%') : '—';
|
||
|
||
// destroy old chart
|
||
if (trendChart) { trendChart.destroy(); trendChart = null; }
|
||
|
||
let html = '';
|
||
|
||
// ── Score trend chart ──
|
||
html += `<div class="an-section-title"><i data-lucide="trending-up" style="width:15px;height:15px;opacity:0.5"></i> Динамика среднего балла</div>
|
||
<div class="an-card">
|
||
<div class="chart-wrap"><canvas id="trend-canvas"></canvas></div>
|
||
</div>`;
|
||
|
||
// ── Hard questions table ──
|
||
html += `<div class="an-section-title" style="margin-top:8px"><i data-lucide="alert-triangle" style="width:15px;height:15px;opacity:0.5"></i> Сложные вопросы</div>
|
||
<div class="an-card" style="padding:0;overflow:hidden">`;
|
||
if (hardQuestions.length) {
|
||
html += `<div style="overflow-x:auto"><table class="hq-table">
|
||
<thead><tr>
|
||
<th style="width:40px;text-align:center">#</th>
|
||
<th>Вопрос</th>
|
||
<th>Тема</th>
|
||
<th>Сложность</th>
|
||
<th>% ошибок</th>
|
||
<th>Попыток</th>
|
||
</tr></thead><tbody>`;
|
||
hardQuestions.forEach((q, i) => {
|
||
// errorRate приходит из API уже в процентах (0–100), не умножаем повторно
|
||
const errPct = Math.round(q.errorRate || 0);
|
||
const errCls = errPct >= 60 ? 'hq-pct-hi' : errPct >= 35 ? 'hq-pct-mid' : 'hq-pct-lo';
|
||
const diffLabel = q.difficulty === 1 ? 'Лёгкий' : q.difficulty === 2 ? 'Средний' : 'Сложный';
|
||
const diffCls = q.difficulty === 1 ? 'diff-1' : q.difficulty === 2 ? 'diff-2' : 'diff-3';
|
||
html += `<tr>
|
||
<td class="hq-rank">${i + 1}</td>
|
||
<td class="hq-text">
|
||
<div class="hq-text-inner">${esc(q.text)}</div>
|
||
</td>
|
||
<td><span style="font-size:0.75rem;color:var(--text-3);font-weight:600">${esc(q.topic || '—')}</span></td>
|
||
<td><span class="diff-badge ${diffCls}">${diffLabel}</span></td>
|
||
<td><span class="hq-pct ${errCls}">${errPct}%</span></td>
|
||
<td style="font-weight:600">${q.attempts || 0}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
} else {
|
||
html += '<div style="padding:40px;text-align:center;color:var(--text-3);font-size:0.88rem">Нет данных о сложных вопросах</div>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// ── Activity heatmap ──
|
||
html += `<div class="an-section-title" style="margin-top:8px"><i data-lucide="calendar" style="width:15px;height:15px;opacity:0.5"></i> Активность за 13 недель</div>
|
||
<div class="an-card">
|
||
<div id="heatmap-area"></div>
|
||
<div class="heatmap-legend">
|
||
<span>Меньше</span>
|
||
<div class="hm-legend-cell hm-0"></div>
|
||
<div class="hm-legend-cell hm-1"></div>
|
||
<div class="hm-legend-cell hm-2"></div>
|
||
<div class="hm-legend-cell hm-3"></div>
|
||
<div class="hm-legend-cell hm-4"></div>
|
||
<span>Больше</span>
|
||
</div>
|
||
</div>`;
|
||
|
||
// ── Assignment completion ──
|
||
html += `<div class="an-section-title" style="margin-top:8px"><i data-lucide="check-circle" style="width:15px;height:15px;opacity:0.5"></i> Выполнение заданий</div>
|
||
<div class="an-card">`;
|
||
if (assignments.length) {
|
||
html += '<div class="asgn-list">';
|
||
assignments.forEach(a => {
|
||
const pct = a.total > 0 ? Math.round((a.done / a.total) * 100) : 0;
|
||
html += `<div class="asgn-item">
|
||
<div class="asgn-title" title="${esc(a.title)}">${esc(a.title)}</div>
|
||
<div class="asgn-bar-wrap"><div class="asgn-bar-fill" style="width:${pct}%"></div></div>
|
||
<div class="asgn-pct">${pct}%</div>
|
||
<div class="asgn-deadline">${a.done}/${a.total} · ${fmtDate(a.deadline)}</div>
|
||
</div>`;
|
||
});
|
||
html += '</div>';
|
||
} else {
|
||
html += '<div style="text-align:center;color:var(--text-3);padding:30px;font-size:0.88rem">Нет заданий</div>';
|
||
}
|
||
html += '</div>';
|
||
|
||
document.getElementById('an-container').innerHTML = html;
|
||
lucide.createIcons();
|
||
|
||
// render chart
|
||
requestAnimationFrame(() => {
|
||
const canvas = document.getElementById('trend-canvas');
|
||
if (canvas && scoreByWeek.length) {
|
||
trendChart = new Chart(canvas, {
|
||
type: 'line',
|
||
data: {
|
||
labels: scoreByWeek.map(w => w.week || ''),
|
||
datasets: [{
|
||
label: 'Средний балл',
|
||
data: scoreByWeek.map(w => w.avg),
|
||
borderColor: '#9B5DE5',
|
||
backgroundColor: 'rgba(155,93,229,0.1)',
|
||
borderWidth: 2.5,
|
||
pointBackgroundColor: '#9B5DE5',
|
||
pointRadius: 4,
|
||
pointHoverRadius: 6,
|
||
tension: 0.4,
|
||
fill: true,
|
||
}, {
|
||
label: 'Сессии',
|
||
data: scoreByWeek.map(w => w.sessions),
|
||
borderColor: '#06D6E0',
|
||
backgroundColor: 'rgba(6,214,224,0.07)',
|
||
borderWidth: 2,
|
||
pointBackgroundColor: '#06D6E0',
|
||
pointRadius: 3,
|
||
pointHoverRadius: 5,
|
||
tension: 0.4,
|
||
fill: true,
|
||
yAxisID: 'y2',
|
||
}],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
labels: { font: { family: 'Manrope', weight: '600', size: 12 }, color: '#3D4F6B', boxWidth: 14 },
|
||
},
|
||
tooltip: { mode: 'index', intersect: false },
|
||
},
|
||
scales: {
|
||
x: {
|
||
grid: { color: 'rgba(15,23,42,0.05)' },
|
||
ticks: { font: { family: 'Manrope', size: 11 }, color: '#56687A' },
|
||
},
|
||
y: {
|
||
min: 0, max: 100,
|
||
grid: { color: 'rgba(15,23,42,0.05)' },
|
||
ticks: {
|
||
font: { family: 'Manrope', size: 11 }, color: '#56687A',
|
||
callback: v => v + '%',
|
||
},
|
||
},
|
||
y2: {
|
||
position: 'right',
|
||
grid: { display: false },
|
||
ticks: { font: { family: 'Manrope', size: 11 }, color: '#56687A' },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
} else if (canvas) {
|
||
canvas.parentElement.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);font-size:0.88rem">Нет данных за последние недели</div>';
|
||
}
|
||
|
||
// render heatmap
|
||
renderHeatmap(heatmap);
|
||
});
|
||
}
|
||
|
||
function renderHeatmap(heatmapData) {
|
||
const area = document.getElementById('heatmap-area');
|
||
if (!area) return;
|
||
|
||
// build map by date string
|
||
const dataMap = {};
|
||
heatmapData.forEach(d => { dataMap[d.day] = d.sessions; });
|
||
|
||
// figure out max for scale
|
||
const maxVal = Math.max(...heatmapData.map(d => d.sessions), 1);
|
||
|
||
// build 13 weeks × 7 days grid (ending today)
|
||
const WEEKS = 13;
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
// start from Sunday of the week 13 weeks ago
|
||
const startDate = new Date(today);
|
||
startDate.setDate(startDate.getDate() - (WEEKS * 7) + 1);
|
||
|
||
const days = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||
const months = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
|
||
|
||
let cells = [];
|
||
let monthLabels = [];
|
||
let lastMonth = -1;
|
||
let colIdx = 0;
|
||
|
||
const cur = new Date(startDate);
|
||
while (cur <= today) {
|
||
const dateStr = cur.toISOString().slice(0, 10);
|
||
const sessions = dataMap[dateStr] || 0;
|
||
const ratio = sessions / maxVal;
|
||
const lvl = sessions === 0 ? 0 : ratio < 0.25 ? 1 : ratio < 0.5 ? 2 : ratio < 0.75 ? 3 : 4;
|
||
|
||
if (cur.getMonth() !== lastMonth) {
|
||
monthLabels.push({ col: colIdx, name: months[cur.getMonth()] });
|
||
lastMonth = cur.getMonth();
|
||
}
|
||
|
||
cells.push({ dateStr, sessions, lvl, dow: cur.getDay() });
|
||
if (cur.getDay() === 6) colIdx++;
|
||
cur.setDate(cur.getDate() + 1);
|
||
}
|
||
|
||
// build month label bar
|
||
let monthHtml = '';
|
||
if (monthLabels.length) {
|
||
monthLabels.forEach((ml, i) => {
|
||
const nextCol = monthLabels[i + 1]?.col ?? (colIdx + 1);
|
||
const width = (nextCol - ml.col) * (14 + 3);
|
||
monthHtml += `<div class="hm-month" style="width:${width}px">${ml.name}</div>`;
|
||
});
|
||
}
|
||
|
||
// build grid: 7 rows per column, columns = weeks
|
||
const totalCols = WEEKS;
|
||
const gridCells = Array.from({ length: totalCols * 7 }, () => ({ sessions: 0, lvl: 0, dateStr: '' }));
|
||
let colTrack = 0;
|
||
cells.forEach((c, idx) => {
|
||
const col = Math.floor(idx / 7);
|
||
const row = c.dow;
|
||
const gridIdx = col * 7 + row;
|
||
if (gridIdx < gridCells.length) gridCells[gridIdx] = c;
|
||
});
|
||
|
||
let gridHtml = '';
|
||
gridCells.forEach(c => {
|
||
gridHtml += `<div class="hm-cell hm-${c.lvl}" title="${c.dateStr ? c.dateStr + ': ' + c.sessions + ' сессий' : ''}"></div>`;
|
||
});
|
||
|
||
area.innerHTML = `
|
||
<div style="display:flex;gap:0">
|
||
<div class="heatmap-days">
|
||
${days.map((d, i) => `<div style="display:flex;align-items:center;font-size:0.62rem;color:var(--text-3);font-weight:700">${i % 2 === 1 ? d : ''}</div>`).join('')}
|
||
</div>
|
||
<div>
|
||
<div class="heatmap-months" style="margin-left:0">${monthHtml}</div>
|
||
<div class="heatmap-wrap">
|
||
<div class="heatmap-grid" style="grid-template-columns: repeat(${totalCols}, 14px)">
|
||
${gridHtml}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
lucide.createIcons();
|
||
loadClasses();
|
||
</script>
|
||
<script src="/js/notifications.js"></script>
|
||
<script src="/js/search.js"></script>
|
||
<script src="/js/mobile.js"></script>
|
||
</body>
|
||
</html>
|