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:
@@ -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;
|
||||||
@@ -194,6 +194,7 @@ app.use('/api/access', accessRoutes);
|
|||||||
app.use('/api/teacher-students', teacherStudentsRoutes);
|
app.use('/api/teacher-students', teacherStudentsRoutes);
|
||||||
app.use('/api/lab', labRoutes);
|
app.use('/api/lab', labRoutes);
|
||||||
app.use('/api/materials', require('./routes/materials'));
|
app.use('/api/materials', require('./routes/materials'));
|
||||||
|
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||||
|
|
||||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||||
const _featDb = require('./db/db');
|
const _featDb = require('./db/db');
|
||||||
|
|||||||
+94
-133
@@ -311,6 +311,14 @@
|
|||||||
.hm-weekdays { display: flex; flex-direction: column; gap: 2px; width: 22px; flex-shrink: 0; padding-top: 1px; }
|
.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; }
|
.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; }
|
.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 {
|
.mhm-cell {
|
||||||
width: 14px; height: 14px; border-radius: 50%; background: rgba(15,23,42,0.05);
|
width: 14px; height: 14px; border-radius: 50%; background: rgba(15,23,42,0.05);
|
||||||
cursor: pointer; transition: transform 0.12s, box-shadow 0.12s;
|
cursor: pointer; transition: transform 0.12s, box-shadow 0.12s;
|
||||||
@@ -3505,144 +3513,113 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ══ ACTIVITY: data structure ══════════════════════════════════════ */
|
/* ══ 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
|
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
|
/* ══ WIDGET: Activity heatmap (all study types) ══════════════════════ */
|
||||||
function _buildDayData(rows) {
|
async function loadActivityWidget() {
|
||||||
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 || [];
|
|
||||||
const w = document.getElementById('w-activity');
|
const w = document.getElementById('w-activity');
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
showWidget('w-activity'); // показываем всегда (пустое состояние рисует renderHeatmap)
|
showWidget('w-activity');
|
||||||
|
try { const data = await LS.getActivity(); _activityDays = data.days || {}; }
|
||||||
|
catch (e) { _activityDays = {}; }
|
||||||
renderHeatmap();
|
renderHeatmap();
|
||||||
|
renderStreakCalendar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHeatmap() {
|
function renderHeatmap() {
|
||||||
const rows = _activityRows;
|
const byDay = _activityDays;
|
||||||
const weeks = _hmScale;
|
const weeks = _hmScale;
|
||||||
const byDay = _buildDayData(rows);
|
|
||||||
const today = new Date(); today.setHours(0,0,0,0);
|
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 totalDays = weeks * 7;
|
||||||
const startDay = new Date(today);
|
const startDay = new Date(today);
|
||||||
startDay.setDate(today.getDate() - totalDays + 1);
|
startDay.setDate(today.getDate() - totalDays + 1);
|
||||||
// Align to Monday
|
const startDow = (startDay.getDay() + 6) % 7; // align to Monday
|
||||||
const startDow = (startDay.getDay() + 6) % 7;
|
|
||||||
startDay.setDate(startDay.getDate() - startDow);
|
startDay.setDate(startDay.getDate() - startDow);
|
||||||
|
|
||||||
// Month labels
|
const mNames = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
|
||||||
const months = [];
|
const months = []; let lastMonth = -1;
|
||||||
let lastMonth = -1;
|
|
||||||
for (let col = 0; col < weeks; col++) {
|
for (let col = 0; col < weeks; col++) {
|
||||||
const d = new Date(startDay);
|
const d = new Date(startDay); d.setDate(startDay.getDate() + col * 7);
|
||||||
d.setDate(startDay.getDate() + col * 7);
|
if (d.getMonth() !== lastMonth) { months.push({ col, label: mNames[d.getMonth()] }); lastMonth = d.getMonth(); }
|
||||||
const m = d.getMonth();
|
|
||||||
if (m !== lastMonth) {
|
|
||||||
const mNames = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
|
|
||||||
months.push({ col, label: mNames[m] });
|
|
||||||
lastMonth = m;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build month label row
|
|
||||||
let monthHtml = '';
|
let monthHtml = '';
|
||||||
months.forEach((m, i) => {
|
months.forEach((m, i) => {
|
||||||
const nextCol = i < months.length - 1 ? months[i + 1].col : weeks;
|
const nextCol = i < months.length - 1 ? months[i + 1].col : weeks;
|
||||||
const span = nextCol - m.col;
|
monthHtml += `<span class="hm-month-label" style="width:${(nextCol - m.col) * 16}px">${m.label}</span>`;
|
||||||
monthHtml += `<span class="hm-month-label" style="width:${span * 16}px">${m.label}</span>`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Weekday labels
|
|
||||||
const wdNames = ['Пн','','Ср','','Пт','',''];
|
const wdNames = ['Пн','','Ср','','Пт','',''];
|
||||||
|
|
||||||
// Cells
|
let cellsHtml = '', cellIdx = 0, totalAll = 0, thisWeek = 0, lastWeek = 0;
|
||||||
let cellsHtml = '';
|
const wkAgo = new Date(today); wkAgo.setDate(today.getDate() - 6);
|
||||||
let cellIdx = 0;
|
const twoWkAgo = new Date(today); twoWkAgo.setDate(today.getDate() - 13);
|
||||||
let totalSessions = 0, totalPctSum = 0, totalPctCount = 0;
|
|
||||||
let bestDay = null, bestDayCount = 0;
|
|
||||||
|
|
||||||
for (let col = 0; col < weeks; col++) {
|
for (let col = 0; col < weeks; col++) {
|
||||||
for (let row = 0; row < 7; row++) {
|
for (let row = 0; row < 7; row++) {
|
||||||
const d = new Date(startDay);
|
const d = new Date(startDay); d.setDate(startDay.getDate() + col * 7 + row);
|
||||||
d.setDate(startDay.getDate() + col * 7 + row);
|
|
||||||
const key = d.toISOString().slice(0,10);
|
const key = d.toISOString().slice(0,10);
|
||||||
const day = byDay[key];
|
const types = byDay[key];
|
||||||
const n = day ? day.count : 0;
|
const n = types ? _dayTotal(types) : 0;
|
||||||
const isFuture = d > today;
|
const isFuture = d > today;
|
||||||
|
if (n && !isFuture) {
|
||||||
// Stats
|
totalAll += n;
|
||||||
if (day) {
|
if (d >= wkAgo) thisWeek += n;
|
||||||
totalSessions += day.count;
|
else if (d >= twoWkAgo) lastWeek += n;
|
||||||
totalPctSum += day.totalPct;
|
|
||||||
totalPctCount += day.count;
|
|
||||||
if (day.count > bestDayCount) { bestDayCount = day.count; bestDay = key; }
|
|
||||||
}
|
}
|
||||||
|
let style;
|
||||||
// Color by dominant subject
|
if (n && !isFuture) {
|
||||||
const dom = day ? _domSubject(day.slugs) : null;
|
const color = (ACT_TYPES[_domType(types)] || {}).color || '#9B5DE5';
|
||||||
const subjClass = dom ? ` s-${dom} has-data` : '';
|
const alpha = Math.min(1, 0.32 + Math.log2(n + 1) * 0.22);
|
||||||
|
const sz = Math.min(14, 8 + Math.min(n, 3) * 2);
|
||||||
// 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);
|
|
||||||
const margin = (14 - sz) / 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) {
|
} else if (isFuture) {
|
||||||
style = ` style="opacity:0.2;animation-delay:${cellIdx * 4}ms"`;
|
style = ` style="opacity:0.15;animation-delay:${cellIdx * 4}ms"`;
|
||||||
} else {
|
} else {
|
||||||
style = ` style="animation-delay:${cellIdx * 4}ms"`;
|
style = ` style="animation-delay:${cellIdx * 4}ms"`;
|
||||||
}
|
}
|
||||||
|
cellsHtml += `<div class="mhm-cell${n && !isFuture ? ' has-data' : ''}" data-key="${key}"${style}></div>`;
|
||||||
cellsHtml += `<div class="mhm-cell${subjClass}" data-key="${key}"${style}></div>`;
|
|
||||||
cellIdx++;
|
cellIdx++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer stats
|
// Footer: total + weekly trend + activity-type legend
|
||||||
const avgAll = totalPctCount > 0 ? Math.round(totalPctSum / totalPctCount) : 0;
|
const diff = thisWeek - lastWeek;
|
||||||
const bestLabel = bestDay ? new Date(bestDay + 'T00:00:00').toLocaleDateString('ru', {day:'numeric', month:'short'}) : '—';
|
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>`;
|
host.innerHTML =
|
||||||
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 =
|
|
||||||
`<div class="hm-months">${monthHtml}</div>` +
|
`<div class="hm-months">${monthHtml}</div>` +
|
||||||
`<div class="hm-body">` +
|
`<div class="hm-body">` +
|
||||||
`<div class="hm-weekdays">${wdNames.map(w => `<div class="hm-wd">${w}</div>`).join('')}</div>` +
|
`<div class="hm-weekdays">${wdNames.map(w => `<div class="hm-wd">${w}</div>`).join('')}</div>` +
|
||||||
@@ -3650,11 +3627,7 @@
|
|||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="hm-footer">${footerHtml}</div>`;
|
`<div class="hm-footer">${footerHtml}</div>`;
|
||||||
|
|
||||||
// Bind events
|
setTimeout(() => { initHeatmapTooltip(); initHeatmapClick(); }, 50);
|
||||||
setTimeout(() => {
|
|
||||||
initHeatmapTooltip();
|
|
||||||
initHeatmapClick();
|
|
||||||
}, 50);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══ Heatmap scale switch ══════════════════════════════════════════ */
|
/* ══ Heatmap scale switch ══════════════════════════════════════════ */
|
||||||
@@ -3770,18 +3743,13 @@
|
|||||||
const cell = e.target.closest('.mhm-cell');
|
const cell = e.target.closest('.mhm-cell');
|
||||||
if (!cell) { tip.classList.remove('visible'); return; }
|
if (!cell) { tip.classList.remove('visible'); return; }
|
||||||
const key = cell.dataset.key;
|
const key = cell.dataset.key;
|
||||||
const byDay = _buildDayData(_activityRows);
|
const types = _activityDays[key];
|
||||||
const day = byDay[key];
|
|
||||||
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'short', day:'numeric', month:'long'});
|
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'short', day:'numeric', month:'long'});
|
||||||
if (day) {
|
if (_dayTotal(types)) {
|
||||||
const lines = day.sessions.slice(0, 4).map(s => {
|
const parts = ACT_ORDER.filter(k => types[k]).map(k => `${ACT_TYPES[k].label} ${types[k]}`);
|
||||||
const pct = Math.round(s.score / s.total * 100);
|
tip.textContent = `${dateLabel} — ${parts.join(', ')}`;
|
||||||
const color = SUBJ_COLORS[s.subject_slug] || '#9B5DE5';
|
|
||||||
return `${s.subject_name || ''} ${pct}%`;
|
|
||||||
});
|
|
||||||
tip.textContent = `${dateLabel} — ${day.count} сес. · ${lines.join(', ')}`;
|
|
||||||
} else {
|
} else {
|
||||||
tip.textContent = `${dateLabel} — нет сессий`;
|
tip.textContent = `${dateLabel} — нет активности`;
|
||||||
}
|
}
|
||||||
const r = cell.getBoundingClientRect();
|
const r = cell.getBoundingClientRect();
|
||||||
tip.style.left = Math.min(r.left + r.width / 2, window.innerWidth - 180) + 'px';
|
tip.style.left = Math.min(r.left + r.width / 2, window.innerWidth - 180) + 'px';
|
||||||
@@ -3803,21 +3771,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = cell.dataset.key;
|
const key = cell.dataset.key;
|
||||||
const byDay = _buildDayData(_activityRows);
|
const types = _activityDays[key];
|
||||||
const day = byDay[key];
|
if (!_dayTotal(types)) { popup.style.display = 'none'; return; }
|
||||||
if (!day) { popup.style.display = 'none'; return; }
|
|
||||||
|
|
||||||
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'long', day:'numeric', month:'long'});
|
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'long', day:'numeric', month:'long'});
|
||||||
let html = `<div class="hdp-date">${dateLabel}</div>`;
|
let html = `<div class="hdp-date">${dateLabel}</div>`;
|
||||||
day.sessions.forEach(s => {
|
ACT_ORDER.filter(k => types[k]).forEach(k => {
|
||||||
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';
|
|
||||||
html += `<div class="hdp-row">
|
html += `<div class="hdp-row">
|
||||||
<span class="hdp-dot" style="background:${color}"></span>
|
<span class="hdp-dot" style="background:${ACT_TYPES[k].color}"></span>
|
||||||
<span class="hdp-subj">${esc(s.subject_name || 'Тест')}</span>
|
<span class="hdp-subj">${ACT_TYPES[k].label}</span>
|
||||||
<span class="hdp-mode">${MODES[s.mode] || s.mode}</span>
|
<span class="hdp-score">${types[k]}</span>
|
||||||
<span class="hdp-score" style="color:${pc}">${pct}%</span>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
popup.innerHTML = html;
|
popup.innerHTML = html;
|
||||||
@@ -3861,15 +3824,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ══ C2: STREAK CALENDAR ═══════════════════════════════════════════ */
|
/* ══ C2: STREAK CALENDAR ═══════════════════════════════════════════ */
|
||||||
function renderStreakCalendar(rows) {
|
function renderStreakCalendar() {
|
||||||
const body = document.getElementById('streak-cal-body');
|
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 today = new Date(); today.setHours(0,0,0,0);
|
||||||
const activeDays = new Set();
|
const activeDays = new Set();
|
||||||
rows.forEach(r => {
|
for (const key in (_activityDays || {})) {
|
||||||
const d = parseDate(r.started_at); d.setHours(0,0,0,0);
|
if (_dayTotal(_activityDays[key]) > 0) activeDays.add(key);
|
||||||
activeDays.add(d.toISOString().slice(0,10));
|
}
|
||||||
});
|
|
||||||
const year = today.getFullYear(), month = today.getMonth();
|
const year = today.getFullYear(), month = today.getMonth();
|
||||||
const firstDay = new Date(year, month, 1);
|
const firstDay = new Date(year, month, 1);
|
||||||
const lastDay = new Date(year, month + 1, 0);
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
@@ -4280,14 +4242,13 @@
|
|||||||
|
|
||||||
async function loadStudentWidgets() {
|
async function loadStudentWidgets() {
|
||||||
if (isTeacher) return;
|
if (isTeacher) return;
|
||||||
|
loadActivityWidget(); // self-contained: own endpoint, renders heatmap + month calendar
|
||||||
try {
|
try {
|
||||||
const histData = await LS.getHistory(1, 100);
|
const histData = await LS.getHistory(1, 100);
|
||||||
const rows = histData.rows || [];
|
const rows = histData.rows || [];
|
||||||
loadLastResultsWidget(rows);
|
loadLastResultsWidget(rows);
|
||||||
loadActivityWidget(rows);
|
|
||||||
loadSubjProgressWidget(rows);
|
loadSubjProgressWidget(rows);
|
||||||
renderStreakCalendar(rows);
|
} catch {}
|
||||||
} catch { loadActivityWidget([]); }
|
|
||||||
const heroRow = document.getElementById('hero-row');
|
const heroRow = document.getElementById('hero-row');
|
||||||
if (heroRow) heroRow.style.display = '';
|
if (heroRow) heroRow.style.display = '';
|
||||||
loadContinueWidget();
|
loadContinueWidget();
|
||||||
|
|||||||
@@ -1048,7 +1048,7 @@ window.LS = {
|
|||||||
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
||||||
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
||||||
crAdminGetAllHistory, crAdminGetTeachersList,
|
crAdminGetAllHistory, crAdminGetTeachersList,
|
||||||
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial,
|
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
||||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||||
fcListDecks, fcCreateDeck, fcAddCard,
|
fcListDecks, fcCreateDeck, fcAddCard,
|
||||||
escapeHtml, esc,
|
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 updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); }
|
||||||
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
|
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
|
||||||
async function shareMaterial(id, d) { return req('POST', `/materials/${id}/share`, d); }
|
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 createMaterialCollection(d) { return req('POST', '/materials/collections', d); }
|
||||||
async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); }
|
async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); }
|
||||||
async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); }
|
async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); }
|
||||||
|
|||||||
Reference in New Issue
Block a user