LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+711
View File
@@ -0,0 +1,711 @@
<!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: #8898AA; 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: #8898AA;
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: #8898AA; 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: #8898AA; 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: #8898AA; 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: #8898AA; 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: #8898AA; flex-shrink: 0; min-width: 80px;
}
/* ── empty state ── */
.an-empty {
text-align: center; padding: 80px 20px; color: #8898AA; 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">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" onclick="toggleSidebar()" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link nav-active"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding: 4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="toggleNotifDrop()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</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>
if (!LS.requireAuth()) throw new Error();
const user = LS.getUser();
document.getElementById('nav-user').textContent = user?.name || user?.email || '';
document.getElementById('nav-avatar').textContent =
(user?.name || 'LS').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
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) => {
const errPct = Math.round((q.errorRate || 0) * 100);
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:#8898AA;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:#8898AA;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:#8898AA;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: '#8898AA' },
},
y: {
min: 0, max: 100,
grid: { color: 'rgba(15,23,42,0.05)' },
ticks: {
font: { family: 'Manrope', size: 11 }, color: '#8898AA',
callback: v => v + '%',
},
},
y2: {
position: 'right',
grid: { display: false },
ticks: { font: { family: 'Manrope', size: 11 }, color: '#8898AA' },
},
},
},
});
} else if (canvas) {
canvas.parentElement.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#8898AA;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:#8898AA;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>