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 = ?
|
||||
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)
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user