Files
Maxim Dolgolyov ecce4b013a fix(analytics): «% ошибок» больше не превышает 100% (двойное ×100)
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>
2026-06-03 17:24:17 +03:00

668 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>