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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user