feat(gamification): Phase 2 — taxonomy + grouped UI for achievements

Achievements gain four new columns: group_slug, track, tier, sort_order.
Existing 36 are backfilled into 5 groups (onboarding/volume/mastery/
consistency/exploration) by migration 030; 'social' stays empty until
Phase 3 adds class/leaderboard/live-quiz tracks.

Tracks bundle escalating thresholds into one progression (tests_10/50/
100 → track='tests', tiers 1-3), so the UI can show '★★★' on the top
tier and the user understands the relationship. sort_order is reserved
in blocks of 10 inside groups of 100, leaving room for inserts without
renumbering.

Backend:
  • migration 030 adds the columns + index + backfill UPDATEs
  • _shared.ACHIEVEMENT_DEFS gains group/track/tier/sort_order per row
  • _shared exports new ACHIEVEMENT_GROUPS metadata for the UI
  • service.seedAchievements writes the new fields on insert AND
    backfills them via UPDATE on existing rows (fresh installs +
    pre-migration installs both end up consistent)
  • _shared.stmts.getAllAchs SELECT updated, ORDER BY sort_order
  • gamification/api.getAchievements forwards the new fields

Frontend:
  • profile.html groups achievements by group_slug with a per-section
    header (icon + title + 'unlocked / total' chip) and a tier-star
    badge (★★ etc.) on tier ≥ 2 items
  • Hard-coded ACH_GROUPS mirror of the backend list (small, stable)
  • New CSS for .ach-group / .ach-group-head / .ach-tier

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-29 20:19:46 +03:00
parent 660e7e2747
commit 90c8464356
5 changed files with 282 additions and 64 deletions
@@ -81,17 +81,33 @@ function updateStreak(userId) {
/* ── Achievements ──────────────────────────────────────────────────── */
function seedAchievements() {
// INSERT for missing rows — supplies legacy + new taxonomy fields.
const ins = db.prepare(`
INSERT OR IGNORE INTO achievements (slug, title, icon, category, description)
VALUES (?, ?, ?, ?, ?)
INSERT OR IGNORE INTO achievements
(slug, title, icon, category, description, group_slug, track, tier, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
// UPDATE for existing rows — keep title/icon/desc/category fresh AND
// backfill the taxonomy columns added by migration 030 (so installs
// that ran the SEED before the migration get fixed on next boot).
const upd = db.prepare(`
UPDATE achievements SET icon = ?, category = ?, title = ?, description = ?
WHERE slug = ? AND (icon IS NULL OR icon = '' OR icon != ?)
UPDATE achievements SET
icon = ?,
category = ?,
title = ?,
description= ?,
group_slug = COALESCE(?, group_slug),
track = COALESCE(?, track),
tier = COALESCE(?, tier),
sort_order = ?
WHERE slug = ?
`);
for (const a of ACHIEVEMENT_DEFS) {
ins.run(a.slug, a.title, a.icon, a.cat, a.desc);
upd.run(a.icon, a.cat, a.title, a.desc, a.slug, a.icon);
ins.run(a.slug, a.title, a.icon, a.cat, a.desc,
a.group ?? null, a.track ?? null, a.tier ?? null, a.sort_order ?? 0);
upd.run(a.icon, a.cat, a.title, a.desc,
a.group ?? null, a.track ?? null, a.tier ?? null, a.sort_order ?? 0,
a.slug);
}
}