From 294b3622b51a054387a772e7a7308b2535e5ef1f Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 11:12:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(exam-prep=20F4):=20=D0=B6=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=20?= =?UTF-8?q?=E2=80=94=20streak=20+=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=20+=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81=D1=82=D1=8C=207?= =?UTF-8?q?=D0=B4=20+=20=D1=85=D0=B8=D1=82=D0=BC=D0=B0=D0=BF=20=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20+=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B1=D0=BD=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/exam-prep.js | 161 ++++++++++++++++++++++ frontend/css/exam-prep.css | 122 +++++++++++++++++ frontend/js/exam-prep/dashboard.js | 207 ++++++++++++++++++++++++++--- 3 files changed, 472 insertions(+), 18 deletions(-) diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index 0b1c5ef..68b55e3 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -164,6 +164,69 @@ const SQL = { SET status = 'finished', finished_at = ?, score = ?, total_correct = ? 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 ── @@ -344,6 +407,104 @@ router.post('/attempts', (req, res) => { 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(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + return text.length > 100 ? text.slice(0, 100) + '…' : text; +} + /* ────────────────────────────────────────────────────────────────── Mock exam (F9) ────────────────────────────────────────────────────────────────── */ diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index 30ab80f..f6eec77 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -708,6 +708,128 @@ 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 ─────────────────────────────────────────────── */ @media (max-width: 640px) { .ep-wrap { padding: 20px 16px 60px; } diff --git a/frontend/js/exam-prep/dashboard.js b/frontend/js/exam-prep/dashboard.js index 03bddbb..1c1e3a3 100644 --- a/frontend/js/exam-prep/dashboard.js +++ b/frontend/js/exam-prep/dashboard.js @@ -1,8 +1,8 @@ 'use strict'; /* ────────────────────────────────────────────────────────────────── Dashboard view — landing screen of /exam-prep/:examKey - In F1: shows track meta + global counts + first-pass user progress. - Full live dashboard (slabnik themes, streak, plan) ships in F4 / F8 / F10. + F1: track info + global counts + cumulative progress + F4: streak, last attempts, 7d accuracy, activity heatmap, recent mocks ────────────────────────────────────────────────────────────────── */ (async function () { @@ -23,12 +23,15 @@ const solvedPct = counts.total ? Math.round((progress.tasks_solved / counts.total) * 100) : 0; - const accuracy = progress.total_attempts + const accAll = progress.total_attempts ? Math.round((progress.correct_attempts / progress.total_attempts) * 100) : null; + // Fetch F4 live aggregates in parallel with rendering shell. + const dashPromise = EP.api.getDashboard(track.exam_key).catch(() => null); + main.innerHTML = ` -
+
Решено задач
${progress.tasks_solved} / ${counts.total}
@@ -36,20 +39,12 @@
${solvedPct}% от банка
-
Точность
-
${accuracy == null ? '—' : accuracy + '%'}
+
Точность (всё время)
+
${accAll == null ? '—' : accAll + '%'}
${progress.correct_attempts} верно из ${progress.total_attempts} попыток
-
-
Серия (streak)
-
-
Будет в F4
-
-
-
До экзамена
-
-
Задайте дату в F10
-
+
Серия дней
 
+
Точность 7 дней
 
+
+
+

Последние попытки

+
 
+
+
+

Активность · 28 дней

+
 
+
+
+ +
+

Пробники

+
 
+
+

Банк задач

Всего ${counts.total} задач в ${track.variants_count} вариантах.
@@ -92,13 +103,173 @@

Слабые темы

-
Топ-3 темы с худшей точностью появятся после фазы F6 (тегирование) и F8.
+
Топ-3 темы с худшей точностью появятся после F6 (тегирование) и F8.
`; 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 = ` +
Серия
+
${streak}
+
${label}${streak >= 3 ? ' · так держать!' : ''}
`; +} + +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 = ` +
Точность 7 дней
+
${a.pct == null ? '—' : a.pct + '%'}
+
${a.correct} верно из ${a.attempts} попыток
`; +} + +function renderRecent(items, examKey) { + const el = document.getElementById('dh-recent-list'); + if (!el) return; + if (!items.length) { + el.innerHTML = `
`; + return; + } + el.innerHTML = items.map(it => { + const mark = it.is_correct === 1 ? '' + : it.is_correct === 0 ? '' + : it.solution_viewed ? 'i' + : '·'; + 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 + ? `В${it.variant}·№${it.task_idx}` + : `№${it.task_idx}`; + return `
+ ${mark} + ${variantBadge} +
${escapeHtml(it.preview)}
+
${modeLabel}${when}
+
`; + }).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(`
`); + } + el.innerHTML = ` +
${cells.join('')}
+
+ меньше + + + + + + больше +
`; +} + +function renderMocks(items, examKey) { + const el = document.getElementById('dh-mocks-list'); + if (!el) return; + if (!items.length) { + el.innerHTML = `
+ +

Пробников пока не было. Запустить первый?

+
`; + 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 + ? `В процессе` + : `${m.score != null ? m.score + ' баллов' : '—'} · ${m.total_correct}/${m.total_tasks}`; + return ` + ${title} + ${score} + ${when} + `; + }).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) { return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); }