feat(gamification): Phase 3 — 38 new achievements + triggers + 'exam' group

Adds achievement coverage for every feature shipped since the original
seed: exam-prep (math9), textbooks, classroom/board, biochemistry,
live-quiz, flashcards, hangman/crossword, pet, plus a new 'social' group
for class & leaderboard wins and 'consistency' extensions (streak_100,
goal_30, early_bird, night_owl).

74 achievements now (was 36), grouped into 7 sections:
  onboarding (3) → volume (8) → mastery (16) → consistency (7) →
  exam (9) → exploration (21) → social (10)

A new top-level group 'exam' slots between consistency and exploration
in the profile UI.

What's wired in service.checkPhase3Achievements (called from
checkAchievements):
  • streak_100 — extends the existing streak track
  • goal_30 — 30 days with daily_goals fully met (SUM check)
  • early_bird / night_owl — strftime('%H', xp_log.created_at)
  • exam_first / 25 / 100 — exam_attempts where is_correct=1
  • exam_variant_clear / 5_variants — perfect mock-variant sessions
  • exam_topic_master — ≥10 attempts at ≥90% on a single subtopic
  • exam_mock_done / pass / perfect — exam_mock_sessions.score
  • tb_first_para — textbook_progress
  • fc_first_deck / 100_cards / 1000_cards — flashcard_reviews
  • bc_first_molecule / 5_challenges / 20_challenges — bio_user_*
  • game_win_5 / 25 — xp_log reason IN (hangman_win, crossword_win)
  • pet_streak_7 / 30 — users.pet_petting_streak
  • lq_first / 3_quizzes — live_answers grouped by session
  • cr_first_join / 5 / 25_lessons — classroom_attendance
  • class_5_members / 25 — teacher's biggest class
  • parent_link — parent_links presence
  • lb_top10 / lb_top1 — weekly XP rank among students

What's deferred (catalog entry only, no trigger yet):
  • tb_chapter_done / tb_book_done / tb_3_books — need to parse
    textbook_progress.paragraphs_read JSON against textbook structure

Every block is wrapped in its own try/catch so a missing table on a
legacy install can't take down the whole achievement sweep.

Verified end-to-end: admin user picked up 7 new unlocks on first
checkAchievements call after seed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-29 20:26:59 +03:00
parent 90c8464356
commit b005226e2c
4 changed files with 324 additions and 1 deletions
@@ -191,6 +191,209 @@ function checkAchievements(userId) {
if (assignCount >= 1) unlockAchievement(userId, 'assign_first');
if (assignCount >= 10) unlockAchievement(userId, 'assign_10');
} catch (e) { console.error('[achievements] assignment check:', e.message); }
// Phase 3 — extended triggers from feature-specific tables
checkPhase3Achievements(userId, row);
}
/* Phase 3 triggers — pulls counts from feature-specific tables.
Each block is wrapped in its own try/catch so a missing table on an
older install can't take down the whole sweep. */
function checkPhase3Achievements(userId, userRow) {
// ── streak_100 + extended streaks ─────────────────────────────
const streak = userRow.streak_current || 0;
if (streak >= 100) unlockAchievement(userId, 'streak_100');
// ── goal_30 — 30 days of fully-met daily goals ────────────────
try {
const n = db.prepare(`
SELECT COUNT(*) AS n FROM daily_goals
WHERE user_id = ?
AND tests_target > 0 AND tests_done >= tests_target
AND xp_target > 0 AND xp_earned >= xp_target
`).get(userId)?.n || 0;
if (n >= 30) unlockAchievement(userId, 'goal_30');
} catch (e) { /* table missing on legacy install */ }
// ── early_bird / night_owl — first activity at hour 0-8 / 23 ─
try {
const eb = db.prepare(`
SELECT 1 FROM xp_log
WHERE user_id = ? AND CAST(strftime('%H', created_at) AS INTEGER) < 9
LIMIT 1
`).get(userId);
if (eb) unlockAchievement(userId, 'early_bird');
const no = db.prepare(`
SELECT 1 FROM xp_log
WHERE user_id = ? AND CAST(strftime('%H', created_at) AS INTEGER) >= 23
LIMIT 1
`).get(userId);
if (no) unlockAchievement(userId, 'night_owl');
} catch (e) { /* xp_log missing — shouldn't happen */ }
// ── exam-prep attempts ────────────────────────────────────────
try {
const examCorrect = db.prepare(`
SELECT COUNT(*) AS n FROM exam_attempts WHERE user_id = ? AND is_correct = 1
`).get(userId)?.n || 0;
if (examCorrect >= 1) unlockAchievement(userId, 'exam_first');
if (examCorrect >= 25) unlockAchievement(userId, 'exam_25_attempts');
if (examCorrect >= 100) unlockAchievement(userId, 'exam_100_attempts');
// ── exam_variant_clear / exam_5_variants — perfect mock variants
const cleared = db.prepare(`
SELECT COUNT(DISTINCT variant) AS n FROM exam_mock_sessions
WHERE user_id = ? AND source = 'variant' AND status = 'finished'
AND total_tasks > 0 AND total_correct = total_tasks
`).get(userId)?.n || 0;
if (cleared >= 1) unlockAchievement(userId, 'exam_variant_clear');
if (cleared >= 5) unlockAchievement(userId, 'exam_5_variants');
// ── exam_topic_master — any subtopic with ≥10 attempts at ≥90% acc.
const master = db.prepare(`
SELECT 1 FROM (
SELECT t.subtopic,
COUNT(*) AS attempts,
SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS correct
FROM exam_attempts a
JOIN exam_tasks t ON t.id = a.exam_task_id
WHERE a.user_id = ? AND a.is_correct IS NOT NULL AND t.subtopic IS NOT NULL
GROUP BY t.subtopic
HAVING attempts >= 10
AND (CAST(correct AS REAL) / attempts) >= 0.9
) LIMIT 1
`).get(userId);
if (master) unlockAchievement(userId, 'exam_topic_master');
// ── mock sessions: done / pass / perfect
const mockRow = db.prepare(`
SELECT COUNT(*) AS done,
COALESCE(MAX(score), 0) AS best
FROM exam_mock_sessions
WHERE user_id = ? AND status = 'finished'
`).get(userId);
if (mockRow.done >= 1) unlockAchievement(userId, 'exam_mock_done');
if (mockRow.best >= 7) unlockAchievement(userId, 'exam_mock_pass');
if (mockRow.best >= 10) unlockAchievement(userId, 'exam_mock_perfect');
} catch (e) { console.error('[ach] exam:', e.message); }
// ── textbook progress: tb_first_para only (chapter/book aggregates
// require parsing paragraphs_read JSON — left for a later phase). */
try {
const tb = db.prepare(`
SELECT 1 FROM textbook_progress WHERE user_id = ? LIMIT 1
`).get(userId);
if (tb) unlockAchievement(userId, 'tb_first_para');
} catch (e) { /* table missing */ }
// ── flashcards: total reviews ──────────────────────────────────
try {
const fc = db.prepare(`
SELECT
COUNT(*) AS reviews,
COUNT(DISTINCT card_id) AS unique_cards
FROM flashcard_reviews WHERE user_id = ?
`).get(userId);
if (fc.reviews >= 1) unlockAchievement(userId, 'fc_first_deck');
if (fc.reviews >= 100) unlockAchievement(userId, 'fc_100_cards');
if (fc.reviews >= 1000) unlockAchievement(userId, 'fc_1000_cards');
} catch (e) { /* table missing */ }
// ── biochem ───────────────────────────────────────────────────
try {
const mol = db.prepare('SELECT COUNT(*) AS n FROM bio_user_molecules WHERE user_id = ?').get(userId)?.n || 0;
if (mol >= 1) unlockAchievement(userId, 'bc_first_molecule');
const chal = db.prepare('SELECT COUNT(*) AS n FROM bio_user_challenges WHERE user_id = ?').get(userId)?.n || 0;
if (chal >= 5) unlockAchievement(userId, 'bc_5_challenges');
if (chal >= 20) unlockAchievement(userId, 'bc_20_challenges');
} catch (e) { /* tables missing */ }
// ── minigame wins via xp_log reasons ──────────────────────────
try {
const wins = db.prepare(`
SELECT COUNT(*) AS n FROM xp_log
WHERE user_id = ? AND reason IN ('hangman_win', 'crossword_win')
`).get(userId)?.n || 0;
if (wins >= 5) unlockAchievement(userId, 'game_win_5');
if (wins >= 25) unlockAchievement(userId, 'game_win_25');
} catch (e) { /* xp_log missing */ }
// ── pet petting streak ────────────────────────────────────────
try {
const ps = db.prepare('SELECT pet_petting_streak AS s FROM users WHERE id = ?').get(userId)?.s || 0;
if (ps >= 7) unlockAchievement(userId, 'pet_streak_7');
if (ps >= 30) unlockAchievement(userId, 'pet_streak_30');
} catch (e) { /* column missing on very old install */ }
// ── live-quiz participation (count distinct sessions answered) ─
try {
const lq = db.prepare(`
SELECT COUNT(DISTINCT session_id) AS n FROM live_answers WHERE user_id = ?
`).get(userId)?.n || 0;
if (lq >= 1) unlockAchievement(userId, 'lq_first');
if (lq >= 3) unlockAchievement(userId, 'lq_3_quizzes');
} catch (e) { /* tables missing */ }
// ── classroom attendance ──────────────────────────────────────
try {
const cr = db.prepare(`
SELECT COUNT(DISTINCT session_id) AS n FROM classroom_attendance WHERE user_id = ?
`).get(userId)?.n || 0;
if (cr >= 1) unlockAchievement(userId, 'cr_first_join');
if (cr >= 5) unlockAchievement(userId, 'cr_5_lessons');
if (cr >= 25) unlockAchievement(userId, 'cr_25_lessons');
} catch (e) { /* table missing */ }
// ── teacher: largest class membership ─────────────────────────
try {
const cm = db.prepare(`
SELECT COALESCE(MAX(member_count), 0) AS n FROM (
SELECT c.id, COUNT(cm.user_id) AS member_count
FROM classes c
LEFT JOIN class_members cm ON cm.class_id = c.id
WHERE c.teacher_id = ?
GROUP BY c.id
)
`).get(userId)?.n || 0;
if (cm >= 5) unlockAchievement(userId, 'class_5_members');
if (cm >= 25) unlockAchievement(userId, 'class_25_members');
} catch (e) { /* table missing */ }
// ── parent link ───────────────────────────────────────────────
try {
const pl = db.prepare('SELECT 1 FROM parent_links WHERE student_id = ? LIMIT 1').get(userId);
if (pl) unlockAchievement(userId, 'parent_link');
} catch (e) { /* table missing */ }
// ── leaderboard placement (weekly XP) ─────────────────────────
// Compute on the fly: rank user by week XP among all students.
try {
const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString();
const rank = db.prepare(`
SELECT (1 + (
SELECT COUNT(*) FROM (
SELECT u2.id, COALESCE(SUM(xl2.amount), 0) AS wxp
FROM users u2
LEFT JOIN xp_log xl2 ON xl2.user_id = u2.id AND xl2.created_at >= ?
WHERE u2.role = 'student' AND u2.id != ?
GROUP BY u2.id
HAVING wxp > (
SELECT COALESCE(SUM(amount), 0) FROM xp_log
WHERE user_id = ? AND created_at >= ?
)
)
)) AS r
`).get(weekAgo, userId, userId, weekAgo)?.r;
// Only credit if the user actually contributed XP this week.
const myXp = db.prepare(`
SELECT COALESCE(SUM(amount), 0) AS s FROM xp_log
WHERE user_id = ? AND created_at >= ?
`).get(userId, weekAgo)?.s || 0;
if (myXp > 0) {
if (rank <= 10) unlockAchievement(userId, 'lb_top10');
if (rank === 1) unlockAchievement(userId, 'lb_top1');
}
} catch (e) { console.error('[ach] leaderboard:', e.message); }
}
function checkRedBookAchievements(userId) {