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}`); }