feat(exam-prep F4): живой дашборд — streak + последние попытки + точность 7д + хитмап активности + пробники

This commit is contained in:
Maxim Dolgolyov
2026-05-29 11:12:23 +03:00
parent 2fda4ee7f6
commit 294b3622b5
3 changed files with 472 additions and 18 deletions
+161
View File
@@ -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(/<svg[\s\S]*?<\/svg>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text.length > 100 ? text.slice(0, 100) + '…' : text;
}
/* ──────────────────────────────────────────────────────────────────
Mock exam (F9)
────────────────────────────────────────────────────────────────── */