feat(exam-prep F4): живой дашборд — streak + последние попытки + точность 7д + хитмап активности + пробники
This commit is contained in:
@@ -164,6 +164,69 @@ const SQL = {
|
|||||||
SET status = 'finished', finished_at = ?, score = ?, total_correct = ?
|
SET status = 'finished', finished_at = ?, score = ?, total_correct = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`),
|
`),
|
||||||
|
|
||||||
|
/* ── Dashboard aggregates (F4) ──────────────────────────────── */
|
||||||
|
|
||||||
|
/* Distinct calendar dates (UTC) on which the user had ≥1 correct attempt
|
||||||
|
for tasks of a specific track, last 60 days. We compute streak from this in JS. */
|
||||||
|
streakDays: db.prepare(`
|
||||||
|
SELECT DISTINCT DATE(a.created_at / 1000, 'unixepoch') AS day
|
||||||
|
FROM exam_attempts a
|
||||||
|
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||||||
|
WHERE a.user_id = ? AND t.exam_key = ?
|
||||||
|
AND a.is_correct = 1
|
||||||
|
AND a.created_at >= ?
|
||||||
|
ORDER BY day DESC
|
||||||
|
`),
|
||||||
|
|
||||||
|
/* Accuracy over a recent time window. */
|
||||||
|
accuracyWindow: db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS attempts,
|
||||||
|
COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct
|
||||||
|
FROM exam_attempts a
|
||||||
|
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||||||
|
WHERE a.user_id = ? AND t.exam_key = ?
|
||||||
|
AND a.is_correct IS NOT NULL
|
||||||
|
AND a.created_at >= ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
/* Recent attempts (last N) with task metadata. */
|
||||||
|
recentAttempts: db.prepare(`
|
||||||
|
SELECT
|
||||||
|
a.id, a.exam_task_id, a.is_correct, a.solution_viewed, a.mode,
|
||||||
|
a.user_answer, a.created_at,
|
||||||
|
t.variant, t.task_idx, t.task_type, t.text_html
|
||||||
|
FROM exam_attempts a
|
||||||
|
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||||||
|
WHERE a.user_id = ? AND t.exam_key = ?
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
/* Activity heatmap — attempts per calendar day, last 28 days. */
|
||||||
|
activityHeatmap: db.prepare(`
|
||||||
|
SELECT
|
||||||
|
DATE(a.created_at / 1000, 'unixepoch') AS day,
|
||||||
|
COUNT(*) AS attempts,
|
||||||
|
COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct
|
||||||
|
FROM exam_attempts a
|
||||||
|
JOIN exam_tasks t ON t.id = a.exam_task_id
|
||||||
|
WHERE a.user_id = ? AND t.exam_key = ?
|
||||||
|
AND a.created_at >= ?
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day
|
||||||
|
`),
|
||||||
|
|
||||||
|
/* Recent mock sessions for "your last attempts" card on dashboard. */
|
||||||
|
recentMocks: db.prepare(`
|
||||||
|
SELECT id, variant, source, started_at, finished_at, status,
|
||||||
|
score, total_correct, total_tasks
|
||||||
|
FROM exam_mock_sessions
|
||||||
|
WHERE user_id = ? AND exam_key = ?
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── GET /api/exam-prep/tracks ──
|
/* ── GET /api/exam-prep/tracks ──
|
||||||
@@ -344,6 +407,104 @@ router.post('/attempts', (req, res) => {
|
|||||||
res.json({ id: Number(result.lastInsertRowid) });
|
res.json({ id: Number(result.lastInsertRowid) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────────
|
||||||
|
Dashboard (F4)
|
||||||
|
────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Compute streak: consecutive calendar days ending today (or yesterday — a
|
||||||
|
one-day grace prevents the streak from being lost overnight) on which the
|
||||||
|
user had ≥1 correct attempt. Returns an integer >= 0.
|
||||||
|
`days` — array of date strings 'YYYY-MM-DD' sorted descending. */
|
||||||
|
function computeStreak(days) {
|
||||||
|
if (!days.length) return 0;
|
||||||
|
const set = new Set(days);
|
||||||
|
const dayMs = 86400000;
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = toIsoDate(today);
|
||||||
|
const yesterdayStr = toIsoDate(new Date(today.getTime() - dayMs));
|
||||||
|
|
||||||
|
// Anchor: start from today if today's correct exists, else yesterday (grace).
|
||||||
|
// If neither — streak is 0.
|
||||||
|
let cursor;
|
||||||
|
if (set.has(todayStr)) cursor = new Date(today);
|
||||||
|
else if (set.has(yesterdayStr)) cursor = new Date(today.getTime() - dayMs);
|
||||||
|
else return 0;
|
||||||
|
|
||||||
|
let streak = 0;
|
||||||
|
while (set.has(toIsoDate(cursor))) {
|
||||||
|
streak++;
|
||||||
|
cursor = new Date(cursor.getTime() - dayMs);
|
||||||
|
}
|
||||||
|
return streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDate(d) {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/exam-prep/:examKey/dashboard ──
|
||||||
|
Live aggregates for the dashboard view: streak, recent attempts,
|
||||||
|
accuracy over the last 7 days, 28-day activity heatmap, recent mocks. */
|
||||||
|
// @public-by-design: router-level authMiddleware (line 6) covers this route
|
||||||
|
router.get('/:examKey/dashboard', (req, res) => {
|
||||||
|
const { examKey } = req.params;
|
||||||
|
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const dayMs = 86400000;
|
||||||
|
const sixtyDaysAgo = now - 60 * dayMs;
|
||||||
|
const sevenDaysAgo = now - 7 * dayMs;
|
||||||
|
const twentyEightDaysAgo = now - 28 * dayMs;
|
||||||
|
|
||||||
|
const dayRows = SQL.streakDays.all(req.user.id, examKey, sixtyDaysAgo);
|
||||||
|
const streak = computeStreak(dayRows.map(r => r.day));
|
||||||
|
|
||||||
|
const acc7 = SQL.accuracyWindow.get(req.user.id, examKey, sevenDaysAgo);
|
||||||
|
const recent = SQL.recentAttempts.all(req.user.id, examKey, 8);
|
||||||
|
const heat = SQL.activityHeatmap.all(req.user.id, examKey, twentyEightDaysAgo);
|
||||||
|
const mocks = SQL.recentMocks.all(req.user.id, examKey, 3);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
streak,
|
||||||
|
accuracy_7d: {
|
||||||
|
attempts: acc7.attempts,
|
||||||
|
correct: acc7.correct,
|
||||||
|
pct: acc7.attempts ? Math.round((acc7.correct / acc7.attempts) * 100) : null,
|
||||||
|
},
|
||||||
|
recent_attempts: recent.map(r => ({
|
||||||
|
task_id: r.exam_task_id,
|
||||||
|
variant: r.variant,
|
||||||
|
task_idx: r.task_idx,
|
||||||
|
task_type: r.task_type,
|
||||||
|
is_correct: r.is_correct,
|
||||||
|
solution_viewed: r.solution_viewed,
|
||||||
|
mode: r.mode,
|
||||||
|
user_answer: r.user_answer,
|
||||||
|
preview: stripPreview(r.text_html),
|
||||||
|
created_at: r.created_at,
|
||||||
|
})),
|
||||||
|
heatmap: heat.map(h => ({ day: h.day, attempts: h.attempts, correct: h.correct })),
|
||||||
|
recent_mocks: mocks.map(m => ({
|
||||||
|
id: m.id, variant: m.variant, source: m.source,
|
||||||
|
started_at: m.started_at, finished_at: m.finished_at, status: m.status,
|
||||||
|
score: m.score, total_correct: m.total_correct, total_tasks: m.total_tasks,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Lightweight preview: strip tags, normalize whitespace, truncate. */
|
||||||
|
function stripPreview(html) {
|
||||||
|
const text = String(html || '')
|
||||||
|
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return text.length > 100 ? text.slice(0, 100) + '…' : text;
|
||||||
|
}
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────────────
|
/* ──────────────────────────────────────────────────────────────────
|
||||||
Mock exam (F9)
|
Mock exam (F9)
|
||||||
────────────────────────────────────────────────────────────────── */
|
────────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -708,6 +708,128 @@
|
|||||||
letter-spacing: -.01em;
|
letter-spacing: -.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
Dashboard widgets (`dh-*`) — used by exam-prep.html (F4)
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.dh-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.dh-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recent attempts list */
|
||||||
|
.dh-recent { display: flex; flex-direction: column; }
|
||||||
|
.dh-recent-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.dh-recent-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px 70px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: .85rem;
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
.dh-recent-item:hover { background: rgba(155,93,229,.05); }
|
||||||
|
|
||||||
|
.dh-mark {
|
||||||
|
width: 22px; height: 22px; border-radius: 50%;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: .78rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dh-mark-ok { background: rgba(6,214,160,.18); color: #06D6A0; }
|
||||||
|
.dh-mark-bad { background: rgba(230,57,70,.16); color: #E63946; }
|
||||||
|
.dh-mark-view { background: rgba(155,93,229,.14); color: var(--violet); }
|
||||||
|
.dh-mark-pending { background: rgba(15,23,42,.05); color: var(--text-3); }
|
||||||
|
|
||||||
|
.dh-recent-loc {
|
||||||
|
font-family: 'Manrope', sans-serif; font-weight: 700; color: var(--violet);
|
||||||
|
font-size: .78rem;
|
||||||
|
text-decoration: none; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dh-recent-loc:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.dh-recent-preview {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: .85rem;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dh-recent-meta {
|
||||||
|
display: flex; flex-direction: column; align-items: flex-end;
|
||||||
|
gap: 1px;
|
||||||
|
font-size: .7rem; color: var(--text-3);
|
||||||
|
}
|
||||||
|
.dh-mode {
|
||||||
|
text-transform: uppercase; letter-spacing: .04em; font-weight: 700;
|
||||||
|
color: var(--violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activity heatmap */
|
||||||
|
.dh-heatmap-card { display: flex; flex-direction: column; }
|
||||||
|
.dh-heatmap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-auto-rows: minmax(20px, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.dh-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(15,23,42,.06);
|
||||||
|
cursor: default;
|
||||||
|
transition: filter .12s;
|
||||||
|
}
|
||||||
|
.dh-cell:hover { filter: brightness(1.05); }
|
||||||
|
.dh-cell.dh-l0 { background: rgba(15,23,42,.06); }
|
||||||
|
.dh-cell.dh-l1 { background: rgba(155,93,229,.20); }
|
||||||
|
.dh-cell.dh-l2 { background: rgba(155,93,229,.40); }
|
||||||
|
.dh-cell.dh-l3 { background: rgba(155,93,229,.65); }
|
||||||
|
.dh-cell.dh-l4 { background: rgba(155,93,229,.90); }
|
||||||
|
.dh-heatmap-legend {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
font-size: .7rem; color: var(--text-3);
|
||||||
|
}
|
||||||
|
.dh-heatmap-legend .dh-cell {
|
||||||
|
width: 12px; height: 12px; aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recent mocks rows */
|
||||||
|
.dh-mock-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
transition: background .12s;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.dh-mock-row:hover { background: rgba(155,93,229,.06); }
|
||||||
|
.dh-mock-title {
|
||||||
|
font-family: 'Manrope', sans-serif; font-weight: 700; font-size: .9rem;
|
||||||
|
}
|
||||||
|
.dh-mock-score {
|
||||||
|
font-size: .82rem; color: var(--text-2);
|
||||||
|
}
|
||||||
|
.dh-mock-active {
|
||||||
|
font-size: .78rem; font-weight: 800; letter-spacing: .03em;
|
||||||
|
color: #F8961E; text-transform: uppercase;
|
||||||
|
padding: 3px 8px; border-radius: 6px;
|
||||||
|
background: rgba(248,150,30,.12);
|
||||||
|
}
|
||||||
|
.dh-mock-when {
|
||||||
|
font-size: .72rem; color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mobile tweaks ─────────────────────────────────────────────── */
|
/* ── Mobile tweaks ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.ep-wrap { padding: 20px 16px 60px; }
|
.ep-wrap { padding: 20px 16px 60px; }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
/* ──────────────────────────────────────────────────────────────────
|
/* ──────────────────────────────────────────────────────────────────
|
||||||
Dashboard view — landing screen of /exam-prep/:examKey
|
Dashboard view — landing screen of /exam-prep/:examKey
|
||||||
In F1: shows track meta + global counts + first-pass user progress.
|
F1: track info + global counts + cumulative progress
|
||||||
Full live dashboard (slabnik themes, streak, plan) ships in F4 / F8 / F10.
|
F4: streak, last attempts, 7d accuracy, activity heatmap, recent mocks
|
||||||
────────────────────────────────────────────────────────────────── */
|
────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
(async function () {
|
(async function () {
|
||||||
@@ -23,12 +23,15 @@
|
|||||||
const solvedPct = counts.total
|
const solvedPct = counts.total
|
||||||
? Math.round((progress.tasks_solved / counts.total) * 100)
|
? Math.round((progress.tasks_solved / counts.total) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
const accuracy = progress.total_attempts
|
const accAll = progress.total_attempts
|
||||||
? Math.round((progress.correct_attempts / progress.total_attempts) * 100)
|
? Math.round((progress.correct_attempts / progress.total_attempts) * 100)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Fetch F4 live aggregates in parallel with rendering shell.
|
||||||
|
const dashPromise = EP.api.getDashboard(track.exam_key).catch(() => null);
|
||||||
|
|
||||||
main.innerHTML = `
|
main.innerHTML = `
|
||||||
<div class="ep-stats">
|
<div class="ep-stats" id="dh-top-stats">
|
||||||
<div class="ep-stat">
|
<div class="ep-stat">
|
||||||
<div class="ep-stat-label">Решено задач</div>
|
<div class="ep-stat-label">Решено задач</div>
|
||||||
<div class="ep-stat-value ep-violet">${progress.tasks_solved} <span style="font-size:.7em;color:var(--text-3);font-weight:600">/ ${counts.total}</span></div>
|
<div class="ep-stat-value ep-violet">${progress.tasks_solved} <span style="font-size:.7em;color:var(--text-3);font-weight:600">/ ${counts.total}</span></div>
|
||||||
@@ -36,20 +39,12 @@
|
|||||||
<div class="ep-stat-sub">${solvedPct}% от банка</div>
|
<div class="ep-stat-sub">${solvedPct}% от банка</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ep-stat">
|
<div class="ep-stat">
|
||||||
<div class="ep-stat-label">Точность</div>
|
<div class="ep-stat-label">Точность (всё время)</div>
|
||||||
<div class="ep-stat-value ${accuracy == null ? '' : accuracy >= 70 ? 'ep-good' : 'ep-warn'}">${accuracy == null ? '—' : accuracy + '%'}</div>
|
<div class="ep-stat-value ${accAll == null ? '' : accAll >= 70 ? 'ep-good' : 'ep-warn'}">${accAll == null ? '—' : accAll + '%'}</div>
|
||||||
<div class="ep-stat-sub">${progress.correct_attempts} верно из ${progress.total_attempts} попыток</div>
|
<div class="ep-stat-sub">${progress.correct_attempts} верно из ${progress.total_attempts} попыток</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ep-stat">
|
<div class="ep-stat" id="dh-streak"><div class="ep-stat-label">Серия дней</div><div class="ep-stat-value"><span class="ep-skel" style="width:42px;height:1em"> </span></div></div>
|
||||||
<div class="ep-stat-label">Серия (streak)</div>
|
<div class="ep-stat" id="dh-acc7"><div class="ep-stat-label">Точность 7 дней</div><div class="ep-stat-value"><span class="ep-skel" style="width:42px;height:1em"> </span></div></div>
|
||||||
<div class="ep-stat-value">—</div>
|
|
||||||
<div class="ep-stat-sub">Будет в F4</div>
|
|
||||||
</div>
|
|
||||||
<div class="ep-stat">
|
|
||||||
<div class="ep-stat-label">До экзамена</div>
|
|
||||||
<div class="ep-stat-value">—</div>
|
|
||||||
<div class="ep-stat-sub">Задайте дату в F10</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ep-card">
|
<div class="ep-card">
|
||||||
@@ -57,7 +52,7 @@
|
|||||||
<div class="ep-card-hint">${escapeHtml(stripTags(track.intro_html || ''))}</div>
|
<div class="ep-card-hint">${escapeHtml(stripTags(track.intro_html || ''))}</div>
|
||||||
<div class="ep-cta-row">
|
<div class="ep-cta-row">
|
||||||
<a class="ep-btn ep-btn-primary" href="/exam-prep/${track.exam_key}/practice">
|
<a class="ep-btn ep-btn-primary" href="/exam-prep/${track.exam_key}/practice">
|
||||||
<i data-lucide="play"></i> Начать тренировку
|
<i data-lucide="play"></i> Тренировка
|
||||||
</a>
|
</a>
|
||||||
<a class="ep-btn" href="/exam-prep/${track.exam_key}/variants">
|
<a class="ep-btn" href="/exam-prep/${track.exam_key}/variants">
|
||||||
<i data-lucide="layout-grid"></i> Все варианты
|
<i data-lucide="layout-grid"></i> Все варианты
|
||||||
@@ -68,6 +63,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="dh-row">
|
||||||
|
<div class="ep-card dh-recent">
|
||||||
|
<h3>Последние попытки</h3>
|
||||||
|
<div id="dh-recent-list" class="dh-recent-list"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ep-card dh-heatmap-card">
|
||||||
|
<h3>Активность · 28 дней</h3>
|
||||||
|
<div id="dh-heatmap"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ep-card" id="dh-mocks">
|
||||||
|
<h3>Пробники</h3>
|
||||||
|
<div id="dh-mocks-list"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ep-card">
|
<div class="ep-card">
|
||||||
<h3>Банк задач</h3>
|
<h3>Банк задач</h3>
|
||||||
<div class="ep-card-hint">Всего ${counts.total} задач в ${track.variants_count} вариантах.</div>
|
<div class="ep-card-hint">Всего ${counts.total} задач в ${track.variants_count} вариантах.</div>
|
||||||
@@ -92,13 +103,173 @@
|
|||||||
|
|
||||||
<div class="ep-card" style="opacity:.7">
|
<div class="ep-card" style="opacity:.7">
|
||||||
<h3>Слабые темы</h3>
|
<h3>Слабые темы</h3>
|
||||||
<div class="ep-card-hint">Топ-3 темы с худшей точностью появятся после фазы F6 (тегирование) и F8.</div>
|
<div class="ep-card-hint">Топ-3 темы с худшей точностью появятся после F6 (тегирование) и F8.</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
|
// Stitch in live aggregates once they arrive.
|
||||||
|
const dash = await dashPromise;
|
||||||
|
if (dash) {
|
||||||
|
renderStreak(dash.streak);
|
||||||
|
renderAcc7(dash.accuracy_7d);
|
||||||
|
renderRecent(dash.recent_attempts, track.exam_key);
|
||||||
|
renderHeatmap(dash.heatmap);
|
||||||
|
renderMocks(dash.recent_mocks, track.exam_key);
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
Widgets
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
function renderStreak(streak) {
|
||||||
|
const el = document.getElementById('dh-streak');
|
||||||
|
if (!el) return;
|
||||||
|
const label = streak === 0 ? 'Нет серии'
|
||||||
|
: streak === 1 ? '1 день подряд'
|
||||||
|
: `${streak} ${pluralRu(streak, ['день', 'дня', 'дней'])} подряд`;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="ep-stat-label">Серия</div>
|
||||||
|
<div class="ep-stat-value ${streak >= 3 ? 'ep-good' : ''}">${streak}</div>
|
||||||
|
<div class="ep-stat-sub">${label}${streak >= 3 ? ' · так держать!' : ''}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAcc7(a) {
|
||||||
|
const el = document.getElementById('dh-acc7');
|
||||||
|
if (!el) return;
|
||||||
|
const valClass = a.pct == null ? '' : a.pct >= 70 ? 'ep-good' : 'ep-warn';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="ep-stat-label">Точность 7 дней</div>
|
||||||
|
<div class="ep-stat-value ${valClass}">${a.pct == null ? '—' : a.pct + '%'}</div>
|
||||||
|
<div class="ep-stat-sub">${a.correct} верно из ${a.attempts} попыток</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecent(items, examKey) {
|
||||||
|
const el = document.getElementById('dh-recent-list');
|
||||||
|
if (!el) return;
|
||||||
|
if (!items.length) {
|
||||||
|
el.innerHTML = `<div class="ep-empty" style="padding:20px">
|
||||||
|
<i data-lucide="hourglass"></i>
|
||||||
|
<p>Ещё нет попыток. Откройте <a href="/exam-prep/${examKey}/practice" style="color:var(--violet)">тренажёр</a>.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = items.map(it => {
|
||||||
|
const mark = it.is_correct === 1 ? '<span class="dh-mark dh-mark-ok">✓</span>'
|
||||||
|
: it.is_correct === 0 ? '<span class="dh-mark dh-mark-bad">✗</span>'
|
||||||
|
: it.solution_viewed ? '<span class="dh-mark dh-mark-view">i</span>'
|
||||||
|
: '<span class="dh-mark dh-mark-pending">·</span>';
|
||||||
|
const when = relativeTime(it.created_at);
|
||||||
|
const modeLabel = it.mode === 'mock' ? 'Пробник'
|
||||||
|
: it.mode === 'variant' ? 'Вариант'
|
||||||
|
: it.mode === 'practice' ? 'Тренажёр'
|
||||||
|
: it.mode === 'topic' ? 'Тема'
|
||||||
|
: '';
|
||||||
|
const variantBadge = it.variant != null
|
||||||
|
? `<a class="dh-recent-loc" href="/exam-prep/${examKey}/variants?v=${it.variant}" title="Открыть вариант ${it.variant}">В${it.variant}·№${it.task_idx}</a>`
|
||||||
|
: `<span class="dh-recent-loc">№${it.task_idx}</span>`;
|
||||||
|
return `<div class="dh-recent-item">
|
||||||
|
${mark}
|
||||||
|
${variantBadge}
|
||||||
|
<div class="dh-recent-preview">${escapeHtml(it.preview)}</div>
|
||||||
|
<div class="dh-recent-meta"><span class="dh-mode">${modeLabel}</span><span class="dh-time">${when}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeatmap(items) {
|
||||||
|
const el = document.getElementById('dh-heatmap');
|
||||||
|
if (!el) return;
|
||||||
|
// Build 28 cells ending today.
|
||||||
|
const byDay = new Map(items.map(d => [d.day, d]));
|
||||||
|
const cells = [];
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 27; i >= 0; i--) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setUTCDate(today.getUTCDate() - i);
|
||||||
|
const key = toIsoDate(d);
|
||||||
|
const v = byDay.get(key);
|
||||||
|
const a = v ? v.attempts : 0;
|
||||||
|
let lvl = 0;
|
||||||
|
if (a >= 1) lvl = 1;
|
||||||
|
if (a >= 5) lvl = 2;
|
||||||
|
if (a >= 12) lvl = 3;
|
||||||
|
if (a >= 25) lvl = 4;
|
||||||
|
const tip = v ? `${key}: ${v.attempts} попыток, ${v.correct} верно` : `${key}: 0`;
|
||||||
|
cells.push(`<div class="dh-cell dh-l${lvl}" title="${tip}"></div>`);
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="dh-heatmap">${cells.join('')}</div>
|
||||||
|
<div class="dh-heatmap-legend">
|
||||||
|
<span>меньше</span>
|
||||||
|
<span class="dh-cell dh-l0"></span>
|
||||||
|
<span class="dh-cell dh-l1"></span>
|
||||||
|
<span class="dh-cell dh-l2"></span>
|
||||||
|
<span class="dh-cell dh-l3"></span>
|
||||||
|
<span class="dh-cell dh-l4"></span>
|
||||||
|
<span>больше</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMocks(items, examKey) {
|
||||||
|
const el = document.getElementById('dh-mocks-list');
|
||||||
|
if (!el) return;
|
||||||
|
if (!items.length) {
|
||||||
|
el.innerHTML = `<div class="ep-empty" style="padding:20px">
|
||||||
|
<i data-lucide="timer-off"></i>
|
||||||
|
<p>Пробников пока не было. <a href="/exam-prep/${examKey}/mock" style="color:var(--violet)">Запустить первый</a>?</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = items.map(m => {
|
||||||
|
const isActive = m.status === 'active';
|
||||||
|
const title = m.source === 'variant' ? `Вариант ${m.variant}` : `Случайные ${m.total_tasks}`;
|
||||||
|
const when = relativeTime(m.started_at);
|
||||||
|
const score = isActive
|
||||||
|
? `<span class="dh-mock-active">В процессе</span>`
|
||||||
|
: `<span class="dh-mock-score">${m.score != null ? m.score + ' баллов' : '—'} · ${m.total_correct}/${m.total_tasks}</span>`;
|
||||||
|
return `<a class="dh-mock-row" href="/exam-prep/${examKey}/mock/${m.id}">
|
||||||
|
<span class="dh-mock-title">${title}</span>
|
||||||
|
${score}
|
||||||
|
<span class="dh-mock-when">${when}</span>
|
||||||
|
</a>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
Utils
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
function pluralRu(n, forms) {
|
||||||
|
const mod10 = n % 10;
|
||||||
|
const mod100 = n % 100;
|
||||||
|
if (mod100 >= 11 && mod100 <= 14) return forms[2];
|
||||||
|
if (mod10 === 1) return forms[0];
|
||||||
|
if (mod10 >= 2 && mod10 <= 4) return forms[1];
|
||||||
|
return forms[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
const diff = (Date.now() - Number(ts)) / 1000;
|
||||||
|
if (diff < 60) return 'только что';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff/60)} мин назад`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff/3600)} ч назад`;
|
||||||
|
if (diff < 7 * 86400) return `${Math.floor(diff/86400)} ${pluralRu(Math.floor(diff/86400), ['день','дня','дней'])} назад`;
|
||||||
|
const d = new Date(Number(ts));
|
||||||
|
return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDate(d) {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user