From 8e8f54b41b83f790b82652b26edade38a5104200 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 13:06:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=D0=B1=D0=BB=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=81=D0=B5=20=D0=B2=D0=B8=D0=B4=D1=8B=20?= =?UTF-8?q?=D1=83=D1=87=D1=91=D0=B1=D1=8B,=20=D1=82=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4,=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=82=D0=B8=D0=BF=D0=B0=D0=BC,=20empty-state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Бэкенд /api/dashboard/activity: per-day агрегация активности по типам (тест/экзамен/карты/уроки/ онлайн/домашка) из 6 таблиц за ~182 дня (раньше карта считала только тесты). - Карта раскрашивается по доминирующему типу активности + легенда типов; интенсивность/размер по числу. - Недельный тренд в футере («эта неделя N · +K к прошлой»). - Тултип и попап по клику показывают разбивку дня по типам. - Empty-state для новичков (вместо пустой сетки — призыв + CTA). Календарь «Месяц» тоже от всех активностей. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/dashboard.js | 47 +++++++ backend/src/server.js | 1 + frontend/dashboard.html | 227 +++++++++++++------------------- js/api.js | 3 +- 4 files changed, 144 insertions(+), 134 deletions(-) create mode 100644 backend/src/routes/dashboard.js diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js new file mode 100644 index 0000000..dfff1e8 --- /dev/null +++ b/backend/src/routes/dashboard.js @@ -0,0 +1,47 @@ +'use strict'; +/* Dashboard aggregates. /api/dashboard/activity — per-day study activity of the + * current user across ALL engagement types (not just tests), for the activity + * heatmap. Each source contributes a per-day count by type. */ +const express = require('express'); +const router = express.Router(); +const db = require('../db/db'); +const { authMiddleware } = require('../middleware/auth'); + +router.use(authMiddleware); + +const WINDOW_DAYS = 182; // ~6 months (max heatmap scale) + +/* GET /api/dashboard/activity → { days: { 'YYYY-MM-DD': { test, exam, cards, lesson, live, homework } } } */ +router.get('/activity', (req, res) => { + const uid = req.user.id; + const days = {}; + const add = (d, type, n) => { + if (!d || !n) return; + (days[d] || (days[d] = {}))[type] = (days[d][type] || 0) + n; + }; + // TEXT datetime sources use a SQL cutoff; exam_attempts stores epoch-ms. + const txtCut = `datetime('now','-${WINDOW_DAYS} days')`; + const epochCut = Date.now() - WINDOW_DAYS * 86400000; + + const sources = [ + ['test', `SELECT DATE(started_at) d, COUNT(*) n FROM test_sessions + WHERE user_id=? AND status='completed' AND started_at >= ${txtCut} GROUP BY d`, [uid]], + ['exam', `SELECT DATE(created_at/1000,'unixepoch') d, COUNT(*) n FROM exam_attempts + WHERE user_id=? AND created_at >= ? GROUP BY d`, [uid, epochCut]], + ['cards', `SELECT DATE(last_reviewed) d, COUNT(*) n FROM flashcard_reviews + WHERE user_id=? AND last_reviewed IS NOT NULL AND last_reviewed >= ${txtCut} GROUP BY d`, [uid]], + ['lesson', `SELECT DATE(updated_at) d, COUNT(*) n FROM lesson_progress + WHERE user_id=? AND completed=1 AND updated_at >= ${txtCut} GROUP BY d`, [uid]], + ['live', `SELECT DATE(joined_at) d, COUNT(*) n FROM classroom_attendance + WHERE user_id=? AND joined_at >= ${txtCut} GROUP BY d`, [uid]], + ['homework', `SELECT DATE(completed_at) d, COUNT(*) n FROM assignment_completion + WHERE user_id=? AND completed_at >= ${txtCut} GROUP BY d`, [uid]], + ]; + for (const [type, sql, args] of sources) { + try { db.prepare(sql).all(...args).forEach(r => add(r.d, type, r.n)); } + catch (e) { /* tolerate a missing/legacy table — skip that source */ } + } + res.json({ days }); +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 2e26525..22942e0 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -194,6 +194,7 @@ app.use('/api/access', accessRoutes); app.use('/api/teacher-students', teacherStudentsRoutes); app.use('/api/lab', labRoutes); app.use('/api/materials', require('./routes/materials')); +app.use('/api/dashboard', require('./routes/dashboard')); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ const _featDb = require('./db/db'); diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 9ac66d2..1ac6b27 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -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 = `
+
${lci('sparkles', 26)}
+
Здесь появится твоя активность
+
Читай учебники, решай тесты и экзамен, проходи карточки и уроки — карта заполнится.
+ Начать заниматься +
`; + 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 += `${m.label}`; + monthHtml += `${m.label}`; }); - // 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 += `
`; + cellsHtml += `
`; 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 = `${totalAll} занятий за ${weeks} нед.`; + footerHtml += `эта неделя ${thisWeek} ${trendTxt}`; + footerHtml += `
` + + ACT_ORDER.map(k => `${ACT_TYPES[k].label}`).join('') + + `
`; - let footerHtml = `${totalSessions} сессий за ${weeks} нед.`; - footerHtml += `средний ${avgAll}%`; - if (bestDayCount > 1) footerHtml += `рекорд ${bestDayCount} — ${bestLabel}`; - // Legend - footerHtml += `
- Био - Хим - Мат - Физ -
`; - - document.getElementById('activity-heatmap').innerHTML = + host.innerHTML = `
${monthHtml}
` + `
` + `
${wdNames.map(w => `
${w}
`).join('')}
` + @@ -3650,11 +3627,7 @@ `
` + ``; - // 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 = `
${dateLabel}
`; - 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 += `
- - ${esc(s.subject_name || 'Тест')} - ${MODES[s.mode] || s.mode} - ${pct}% + + ${ACT_TYPES[k].label} + ${types[k]}
`; }); 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(); diff --git a/js/api.js b/js/api.js index 98e543f..d567ab6 100644 --- a/js/api.js +++ b/js/api.js @@ -1048,7 +1048,7 @@ window.LS = { crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession, crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory, crAdminGetAllHistory, crAdminGetTeachersList, - listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, + listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity, createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, fcListDecks, fcCreateDeck, fcAddCard, escapeHtml, esc, @@ -1250,6 +1250,7 @@ async function saveMaterial(data) { return req('POST', '/materials', data) async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); } async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); } async function shareMaterial(id, d) { return req('POST', `/materials/${id}/share`, d); } +async function getActivity() { return req('GET', '/dashboard/activity'); } async function createMaterialCollection(d) { return req('POST', '/materials/collections', d); } async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); } async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); }