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:
+94
-133
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user