feat(dashboard): блок активности — все виды учёбы, тренд, разбивка по типам, empty-state

- Бэкенд /api/dashboard/activity: per-day агрегация активности по типам (тест/экзамен/карты/уроки/
  онлайн/домашка) из 6 таблиц за ~182 дня (раньше карта считала только тесты).
- Карта раскрашивается по доминирующему типу активности + легенда типов; интенсивность/размер по числу.
- Недельный тренд в футере («эта неделя N · +K к прошлой»).
- Тултип и попап по клику показывают разбивку дня по типам.
- Empty-state для новичков (вместо пустой сетки — призыв + CTA). Календарь «Месяц» тоже от всех активностей.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 13:06:46 +03:00
parent 7a2a07c96e
commit 8e8f54b41b
4 changed files with 144 additions and 134 deletions
+94 -133
View File
@@ -311,6 +311,14 @@
.hm-weekdays { display: flex; flex-direction: column; gap: 2px; width: 22px; flex-shrink: 0; padding-top: 1px; }
.hm-wd { font-size: 0.5rem; font-weight: 700; color: var(--text-3); height: 14px; line-height: 14px; }
.mini-heatmap { display: grid; grid-template-rows: repeat(7, 14px); grid-auto-flow: column; grid-auto-columns: 14px; gap: 2px; }
.hm-trend { font-size: 0.7rem; font-weight: 700; color: var(--text-3); }
.hm-trend.up { color: #059652; }
.hm-trend.down { color: #E0335E; }
.hm-empty { padding: 28px 16px; text-align: center; color: var(--text-3); }
.hm-empty-ic { color: var(--violet); opacity: .85; margin-bottom: 8px; }
.hm-empty-t { font-weight: 700; color: var(--text); font-size: 0.92rem; margin-bottom: 4px; }
.hm-empty-s { font-size: 0.78rem; line-height: 1.5; max-width: 320px; margin: 0 auto 12px; }
.hm-empty-cta { display: inline-block; padding: 8px 16px; border-radius: 9px; background: var(--violet); color: #fff; font-weight: 700; font-size: 0.82rem; text-decoration: none; }
.mhm-cell {
width: 14px; height: 14px; border-radius: 50%; background: rgba(15,23,42,0.05);
cursor: pointer; transition: transform 0.12s, box-shadow 0.12s;
@@ -3505,144 +3513,113 @@
}
/* ══ ACTIVITY: data structure ══════════════════════════════════════ */
let _activityRows = []; // raw history rows
let _activityDays = {}; // { 'YYYY-MM-DD': { test, exam, cards, lesson, live, homework } }
let _hmScale = 12; // weeks to show
const ACT_TYPES = {
test: { label: 'Тесты', color: '#06B6D4' },
exam: { label: 'Экзамен', color: '#9B5DE5' },
cards: { label: 'Карты', color: '#06D6A0' },
lesson: { label: 'Уроки', color: '#F59E0B' },
live: { label: 'Онлайн', color: '#EC4899' },
homework: { label: 'Домашка', color: '#22C55E' },
};
const ACT_ORDER = ['test', 'exam', 'cards', 'lesson', 'live', 'homework'];
function _dayTotal(types) { let s = 0; for (const k in (types || {})) s += types[k] || 0; return s; }
function _domType(types) { let best = null, bn = -1; for (const k in (types || {})) { if (types[k] > bn) { bn = types[k]; best = k; } } return best; }
// Build per-day aggregation from rows
function _buildDayData(rows) {
const byDay = {};
(rows || []).forEach(r => {
if (r.score === null || r.total <= 0) return;
const d = parseDate(r.started_at); d.setHours(0,0,0,0);
const key = d.toISOString().slice(0,10);
if (!byDay[key]) byDay[key] = { count: 0, sessions: [], slugs: {}, totalPct: 0 };
const pct = Math.round(r.score / r.total * 100);
byDay[key].count++;
byDay[key].totalPct += pct;
byDay[key].sessions.push(r);
byDay[key].slugs[r.subject_slug] = (byDay[key].slugs[r.subject_slug] || 0) + 1;
});
return byDay;
}
// Dominant subject for a day
function _domSubject(slugs) {
const entries = Object.entries(slugs || {});
if (!entries.length) return null;
if (entries.length > 1) return 'mix';
return entries[0][0];
}
/* ══ WIDGET: Activity heatmap (redesigned) ══════════════════════════ */
function loadActivityWidget(rows) {
_activityRows = rows || [];
/* ══ WIDGET: Activity heatmap (all study types) ══════════════════════ */
async function loadActivityWidget() {
const w = document.getElementById('w-activity');
if (!w) return;
showWidget('w-activity'); // показываем всегда (пустое состояние рисует renderHeatmap)
showWidget('w-activity');
try { const data = await LS.getActivity(); _activityDays = data.days || {}; }
catch (e) { _activityDays = {}; }
renderHeatmap();
renderStreakCalendar();
}
function renderHeatmap() {
const rows = _activityRows;
const byDay = _activityDays;
const weeks = _hmScale;
const byDay = _buildDayData(rows);
const today = new Date(); today.setHours(0,0,0,0);
const host = document.getElementById('activity-heatmap');
if (!host) return;
// Empty state (new users / no activity yet)
if (!byDay || !Object.keys(byDay).length) {
host.innerHTML = `<div class="hm-empty">
<div class="hm-empty-ic">${lci('sparkles', 26)}</div>
<div class="hm-empty-t">Здесь появится твоя активность</div>
<div class="hm-empty-s">Читай учебники, решай тесты и экзамен, проходи карточки и уроки — карта заполнится.</div>
<a class="hm-empty-cta" href="/exam-prep/math9">Начать заниматься</a>
</div>`;
if (window.lucide) lucide.createIcons();
return;
}
const totalDays = weeks * 7;
const startDay = new Date(today);
startDay.setDate(today.getDate() - totalDays + 1);
// Align to Monday
const startDow = (startDay.getDay() + 6) % 7;
const startDow = (startDay.getDay() + 6) % 7; // align to Monday
startDay.setDate(startDay.getDate() - startDow);
// Month labels
const months = [];
let lastMonth = -1;
const mNames = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
const months = []; let lastMonth = -1;
for (let col = 0; col < weeks; col++) {
const d = new Date(startDay);
d.setDate(startDay.getDate() + col * 7);
const m = d.getMonth();
if (m !== lastMonth) {
const mNames = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
months.push({ col, label: mNames[m] });
lastMonth = m;
}
const d = new Date(startDay); d.setDate(startDay.getDate() + col * 7);
if (d.getMonth() !== lastMonth) { months.push({ col, label: mNames[d.getMonth()] }); lastMonth = d.getMonth(); }
}
// Build month label row
let monthHtml = '';
months.forEach((m, i) => {
const nextCol = i < months.length - 1 ? months[i + 1].col : weeks;
const span = nextCol - m.col;
monthHtml += `<span class="hm-month-label" style="width:${span * 16}px">${m.label}</span>`;
monthHtml += `<span class="hm-month-label" style="width:${(nextCol - m.col) * 16}px">${m.label}</span>`;
});
// Weekday labels
const wdNames = ['Пн','','Ср','','Пт','',''];
// Cells
let cellsHtml = '';
let cellIdx = 0;
let totalSessions = 0, totalPctSum = 0, totalPctCount = 0;
let bestDay = null, bestDayCount = 0;
let cellsHtml = '', cellIdx = 0, totalAll = 0, thisWeek = 0, lastWeek = 0;
const wkAgo = new Date(today); wkAgo.setDate(today.getDate() - 6);
const twoWkAgo = new Date(today); twoWkAgo.setDate(today.getDate() - 13);
for (let col = 0; col < weeks; col++) {
for (let row = 0; row < 7; row++) {
const d = new Date(startDay);
d.setDate(startDay.getDate() + col * 7 + row);
const d = new Date(startDay); d.setDate(startDay.getDate() + col * 7 + row);
const key = d.toISOString().slice(0,10);
const day = byDay[key];
const n = day ? day.count : 0;
const types = byDay[key];
const n = types ? _dayTotal(types) : 0;
const isFuture = d > today;
// Stats
if (day) {
totalSessions += day.count;
totalPctSum += day.totalPct;
totalPctCount += day.count;
if (day.count > bestDayCount) { bestDayCount = day.count; bestDay = key; }
if (n && !isFuture) {
totalAll += n;
if (d >= wkAgo) thisWeek += n;
else if (d >= twoWkAgo) lastWeek += n;
}
// Color by dominant subject
const dom = day ? _domSubject(day.slugs) : null;
const subjClass = dom ? ` s-${dom} has-data` : '';
// Intensity by avg score
let style = '';
if (day && !isFuture) {
const avgPct = day.totalPct / day.count;
const alpha = 0.15 + (avgPct / 100) * 0.75;
// Size by count
const sz = Math.min(14, 8 + n * 2);
let style;
if (n && !isFuture) {
const color = (ACT_TYPES[_domType(types)] || {}).color || '#9B5DE5';
const alpha = Math.min(1, 0.32 + Math.log2(n + 1) * 0.22);
const sz = Math.min(14, 8 + Math.min(n, 3) * 2);
const margin = (14 - sz) / 2;
style = ` style="background:var(--cell-color, rgba(155,93,229,${alpha.toFixed(2)}));width:${sz}px;height:${sz}px;margin:${margin}px;animation-delay:${cellIdx * 4}ms"`;
style = ` style="background:${color};opacity:${alpha.toFixed(2)};width:${sz}px;height:${sz}px;margin:${margin}px;animation-delay:${cellIdx * 4}ms"`;
} else if (isFuture) {
style = ` style="opacity:0.2;animation-delay:${cellIdx * 4}ms"`;
style = ` style="opacity:0.15;animation-delay:${cellIdx * 4}ms"`;
} else {
style = ` style="animation-delay:${cellIdx * 4}ms"`;
}
cellsHtml += `<div class="mhm-cell${subjClass}" data-key="${key}"${style}></div>`;
cellsHtml += `<div class="mhm-cell${n && !isFuture ? ' has-data' : ''}" data-key="${key}"${style}></div>`;
cellIdx++;
}
}
// Footer stats
const avgAll = totalPctCount > 0 ? Math.round(totalPctSum / totalPctCount) : 0;
const bestLabel = bestDay ? new Date(bestDay + 'T00:00:00').toLocaleDateString('ru', {day:'numeric', month:'short'}) : '—';
// Footer: total + weekly trend + activity-type legend
const diff = thisWeek - lastWeek;
const trendTxt = diff > 0 ? `+${diff} к прошлой` : diff < 0 ? `${diff} к прошлой` : 'как на прошлой';
const trendCls = diff > 0 ? 'up' : diff < 0 ? 'down' : '';
let footerHtml = `<span><strong>${totalAll}</strong> занятий за ${weeks} нед.</span>`;
footerHtml += `<span>эта неделя <strong>${thisWeek}</strong> <span class="hm-trend ${trendCls}">${trendTxt}</span></span>`;
footerHtml += `<div class="hm-legend">` +
ACT_ORDER.map(k => `<span class="hm-legend-dot" style="background:${ACT_TYPES[k].color}"></span><span class="hm-legend-label">${ACT_TYPES[k].label}</span>`).join('') +
`</div>`;
let footerHtml = `<span><strong>${totalSessions}</strong> сессий за ${weeks} нед.</span>`;
footerHtml += `<span>средний <strong>${avgAll}%</strong></span>`;
if (bestDayCount > 1) footerHtml += `<span>рекорд <strong>${bestDayCount}</strong> — ${bestLabel}</span>`;
// Legend
footerHtml += `<div class="hm-legend">
<span class="hm-legend-dot" style="background:#9B5DE5"></span><span class="hm-legend-label">Био</span>
<span class="hm-legend-dot" style="background:#06D6A0"></span><span class="hm-legend-label">Хим</span>
<span class="hm-legend-dot" style="background:#06B6D4"></span><span class="hm-legend-label">Мат</span>
<span class="hm-legend-dot" style="background:#F59E0B"></span><span class="hm-legend-label">Физ</span>
</div>`;
document.getElementById('activity-heatmap').innerHTML =
host.innerHTML =
`<div class="hm-months">${monthHtml}</div>` +
`<div class="hm-body">` +
`<div class="hm-weekdays">${wdNames.map(w => `<div class="hm-wd">${w}</div>`).join('')}</div>` +
@@ -3650,11 +3627,7 @@
`</div>` +
`<div class="hm-footer">${footerHtml}</div>`;
// Bind events
setTimeout(() => {
initHeatmapTooltip();
initHeatmapClick();
}, 50);
setTimeout(() => { initHeatmapTooltip(); initHeatmapClick(); }, 50);
}
/* ══ Heatmap scale switch ══════════════════════════════════════════ */
@@ -3770,18 +3743,13 @@
const cell = e.target.closest('.mhm-cell');
if (!cell) { tip.classList.remove('visible'); return; }
const key = cell.dataset.key;
const byDay = _buildDayData(_activityRows);
const day = byDay[key];
const types = _activityDays[key];
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'short', day:'numeric', month:'long'});
if (day) {
const lines = day.sessions.slice(0, 4).map(s => {
const pct = Math.round(s.score / s.total * 100);
const color = SUBJ_COLORS[s.subject_slug] || '#9B5DE5';
return `${s.subject_name || ''} ${pct}%`;
});
tip.textContent = `${dateLabel}${day.count} сес. · ${lines.join(', ')}`;
if (_dayTotal(types)) {
const parts = ACT_ORDER.filter(k => types[k]).map(k => `${ACT_TYPES[k].label} ${types[k]}`);
tip.textContent = `${dateLabel}${parts.join(', ')}`;
} else {
tip.textContent = `${dateLabel} — нет сессий`;
tip.textContent = `${dateLabel} — нет активности`;
}
const r = cell.getBoundingClientRect();
tip.style.left = Math.min(r.left + r.width / 2, window.innerWidth - 180) + 'px';
@@ -3803,21 +3771,16 @@
return;
}
const key = cell.dataset.key;
const byDay = _buildDayData(_activityRows);
const day = byDay[key];
if (!day) { popup.style.display = 'none'; return; }
const types = _activityDays[key];
if (!_dayTotal(types)) { popup.style.display = 'none'; return; }
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'long', day:'numeric', month:'long'});
let html = `<div class="hdp-date">${dateLabel}</div>`;
day.sessions.forEach(s => {
const pct = Math.round(s.score / s.total * 100);
const color = SUBJ_COLORS[s.subject_slug] || '#9B5DE5';
const pc = pct >= 75 ? '#059652' : pct >= 50 ? '#F59E0B' : '#E0335E';
ACT_ORDER.filter(k => types[k]).forEach(k => {
html += `<div class="hdp-row">
<span class="hdp-dot" style="background:${color}"></span>
<span class="hdp-subj">${esc(s.subject_name || 'Тест')}</span>
<span class="hdp-mode">${MODES[s.mode] || s.mode}</span>
<span class="hdp-score" style="color:${pc}">${pct}%</span>
<span class="hdp-dot" style="background:${ACT_TYPES[k].color}"></span>
<span class="hdp-subj">${ACT_TYPES[k].label}</span>
<span class="hdp-score">${types[k]}</span>
</div>`;
});
popup.innerHTML = html;
@@ -3861,15 +3824,14 @@
}
/* ══ C2: STREAK CALENDAR ═══════════════════════════════════════════ */
function renderStreakCalendar(rows) {
function renderStreakCalendar() {
const body = document.getElementById('streak-cal-body');
if (!body || !rows || !rows.length) return;
if (!body) return;
const today = new Date(); today.setHours(0,0,0,0);
const activeDays = new Set();
rows.forEach(r => {
const d = parseDate(r.started_at); d.setHours(0,0,0,0);
activeDays.add(d.toISOString().slice(0,10));
});
for (const key in (_activityDays || {})) {
if (_dayTotal(_activityDays[key]) > 0) activeDays.add(key);
}
const year = today.getFullYear(), month = today.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
@@ -4280,14 +4242,13 @@
async function loadStudentWidgets() {
if (isTeacher) return;
loadActivityWidget(); // self-contained: own endpoint, renders heatmap + month calendar
try {
const histData = await LS.getHistory(1, 100);
const rows = histData.rows || [];
loadLastResultsWidget(rows);
loadActivityWidget(rows);
loadSubjProgressWidget(rows);
renderStreakCalendar(rows);
} catch { loadActivityWidget([]); }
} catch {}
const heroRow = document.getElementById('hero-row');
if (heroRow) heroRow.style.display = '';
loadContinueWidget();