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
+66 -50
View File
@@ -32,55 +32,66 @@ const GOAL_TIERS = {
hard: { tests: 5, xp: 500, bonus: 100, label: 'Тяжёлая' },
};
/* ── Achievement definitions (seed via seedAchievements()) ── */
/* ── Achievement definitions (seed via seedAchievements()) ──
Each row carries the legacy `cat` for back-compat AND the new
taxonomy fields used by the profile UI:
group — top-level bucket (onboarding/volume/mastery/...)
track — sub-track inside a group (e.g. 'tests', 'streak')
tier — 1..5 within a track (NULL for singletons)
sort_order — fixed display order; new entries pick gaps. */
const ACHIEVEMENT_DEFS = [
// First steps
{ slug: 'first_test', title: 'Первый тест', icon: 'target', cat: 'start', desc: 'Пройти свой первый тест' },
{ slug: 'first_perfect', title: 'Идеальный результат', icon: 'hundred', cat: 'start', desc: 'Получить 100% на тесте' },
{ slug: 'first_class', title: 'Вступил в класс', icon: 'school', cat: 'start', desc: 'Присоединиться к классу' },
// Streaks
{ slug: 'streak_3', title: 'Три дня подряд', icon: 'flame', cat: 'streak', desc: '3 дня активности подряд' },
{ slug: 'streak_7', title: 'Неделя подряд', icon: 'flame', cat: 'streak', desc: '7 дней активности подряд' },
{ slug: 'streak_30', title: 'Месяц подряд', icon: 'flame', cat: 'streak', desc: '30 дней активности подряд' },
// Volume
{ slug: 'tests_10', title: '10 тестов', icon: 'file-text', cat: 'volume', desc: 'Завершить 10 тестов' },
{ slug: 'tests_50', title: '50 тестов', icon: 'books', cat: 'volume', desc: 'Завершить 50 тестов' },
{ slug: 'tests_100', title: '100 тестов', icon: 'trophy', cat: 'volume', desc: 'Завершить 100 тестов' },
// Mastery
{ slug: 'score_90', title: 'Отличник', icon: 'star', cat: 'mastery', desc: '5 тестов подряд на 90%+' },
{ slug: 'speed_demon', title: 'Скорострел', icon: 'zap', cat: 'mastery', desc: 'Тест на 90%+ за <50% времени' },
// Levels
{ slug: 'level_5', title: 'Ученик', icon: 'book-open', cat: 'level', desc: 'Достичь 5 уровня' },
{ slug: 'level_10', title: 'Знаток', icon: 'brain', cat: 'level', desc: 'Достичь 10 уровня' },
{ slug: 'level_20', title: 'Мастер', icon: 'crown', cat: 'level', desc: 'Достичь 20 уровня' },
{ slug: 'level_3', title: 'Начинающий', icon: 'book-open', cat: 'level', desc: 'Достичь 3 уровня' },
// XP milestones
{ slug: 'xp_1000', title: '1000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 1000 XP' },
{ slug: 'xp_5000', title: '5000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 5000 XP' },
{ slug: 'xp_10000', title: '10 000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 10 000 XP' },
// Lab / experiments
{ slug: 'lab_first', title: 'Первый опыт', icon: 'flask-conical', cat: 'lab', desc: 'Провести первый эксперимент в лаборатории' },
{ slug: 'lab_5', title: 'Юный химик', icon: 'flask-conical', cat: 'lab', desc: 'Провести 5 экспериментов' },
{ slug: 'lab_20', title: 'Лаборант', icon: 'test-tubes', cat: 'lab', desc: 'Провести 20 экспериментов' },
{ slug: 'lab_50', title: 'Исследователь', icon: 'microscope', cat: 'lab', desc: 'Провести 50 экспериментов' },
{ slug: 'lab_reactions_10',title: '10 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 10 различных реакций' },
{ slug: 'lab_reactions_30',title: '30 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 30 различных реакций' },
// Red Book
{ slug: 'rb_first', title: 'Первый вид КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть первый вид Красной книги' },
{ slug: 'rb_10', title: '10 видов КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть 10 видов Красной книги' },
{ slug: 'rb_25', title: 'Четверть коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 25 видов Красной книги' },
{ slug: 'rb_50', title: 'Половина коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 50 видов Красной книги' },
{ slug: 'rb_all_cr', title: 'Защитник природы', icon: 'shield-check', cat: 'redbook', desc: 'Открыть все CR-виды Красной книги' },
{ slug: 'rb_quest_first', title: 'Первый квест КК', icon: 'map', cat: 'redbook', desc: 'Выполнить первый квест Красной книги' },
{ slug: 'rb_quest_5', title: 'Квестмастер КК', icon: 'map-pin', cat: 'redbook', desc: 'Выполнить 5 квестов Красной книги' },
{ slug: 'rb_sighting', title: 'Наблюдатель', icon: 'eye', cat: 'redbook', desc: 'Добавить первое наблюдение вида' },
// Theory / Library
{ slug: 'theory_first', title: 'Первый урок', icon: 'book-open', cat: 'theory', desc: 'Прочитать первый урок' },
{ slug: 'theory_10', title: 'Читатель', icon: 'library', cat: 'theory', desc: 'Прочитать 10 уроков' },
{ slug: 'theory_course', title: 'Завершил курс', icon: 'graduation-cap',cat: 'theory', desc: 'Пройти полный курс целиком' },
// Assignments
{ slug: 'assign_first', title: 'Первое задание', icon: 'clipboard', cat: 'assign', desc: 'Сдать первое задание' },
{ slug: 'assign_10', title: '10 заданий', icon: 'clipboard-check', cat: 'assign', desc: 'Сдать 10 заданий' },
// Onboarding
{ slug: 'first_test', title: 'Первый тест', icon: 'target', cat: 'start', group: 'onboarding', track: 'first_test', tier: 1, sort_order: 110, desc: 'Пройти свой первый тест' },
{ slug: 'first_perfect', title: 'Идеальный результат', icon: 'hundred', cat: 'start', group: 'onboarding', track: 'first_perfect', tier: 1, sort_order: 120, desc: 'Получить 100% на тесте' },
{ slug: 'first_class', title: 'Вступил в класс', icon: 'school', cat: 'start', group: 'onboarding', track: 'first_class', tier: 1, sort_order: 130, desc: 'Присоединиться к классу' },
// Volume — quantity-driven
{ slug: 'tests_10', title: '10 тестов', icon: 'file-text', cat: 'volume', group: 'volume', track: 'tests', tier: 1, sort_order: 210, desc: 'Завершить 10 тестов' },
{ slug: 'tests_50', title: '50 тестов', icon: 'books', cat: 'volume', group: 'volume', track: 'tests', tier: 2, sort_order: 220, desc: 'Завершить 50 тестов' },
{ slug: 'tests_100', title: '100 тестов', icon: 'trophy', cat: 'volume', group: 'volume', track: 'tests', tier: 3, sort_order: 230, desc: 'Завершить 100 тестов' },
{ slug: 'assign_first', title: 'Первое задание', icon: 'clipboard', cat: 'assign', group: 'volume', track: 'assign', tier: 1, sort_order: 240, desc: 'Сдать первое задание' },
{ slug: 'assign_10', title: '10 заданий', icon: 'clipboard-check', cat: 'assign', group: 'volume', track: 'assign', tier: 2, sort_order: 250, desc: 'Сдать 10 заданий' },
{ slug: 'xp_1000', title: '1000 XP', icon: 'diamond', cat: 'xp', group: 'volume', track: 'xp_total', tier: 1, sort_order: 260, desc: 'Набрать 1000 XP' },
{ slug: 'xp_5000', title: '5000 XP', icon: 'diamond', cat: 'xp', group: 'volume', track: 'xp_total', tier: 2, sort_order: 270, desc: 'Набрать 5000 XP' },
{ slug: 'xp_10000', title: '10 000 XP', icon: 'diamond', cat: 'xp', group: 'volume', track: 'xp_total', tier: 3, sort_order: 280, desc: 'Набрать 10 000 XP' },
// Mastery — quality / completion
{ slug: 'level_3', title: 'Начинающий', icon: 'book-open', cat: 'level', group: 'mastery', track: 'level', tier: 1, sort_order: 310, desc: 'Достичь 3 уровня' },
{ slug: 'level_5', title: 'Ученик', icon: 'book-open', cat: 'level', group: 'mastery', track: 'level', tier: 2, sort_order: 320, desc: 'Достичь 5 уровня' },
{ slug: 'level_10', title: 'Знаток', icon: 'brain', cat: 'level', group: 'mastery', track: 'level', tier: 3, sort_order: 330, desc: 'Достичь 10 уровня' },
{ slug: 'level_20', title: 'Мастер', icon: 'crown', cat: 'level', group: 'mastery', track: 'level', tier: 4, sort_order: 340, desc: 'Достичь 20 уровня' },
{ slug: 'score_90', title: 'Отличник', icon: 'star', cat: 'mastery', group: 'mastery', track: 'score', tier: 1, sort_order: 350, desc: '5 тестов подряд на 90%+' },
{ slug: 'speed_demon', title: 'Скорострел', icon: 'zap', cat: 'mastery', group: 'mastery', track: 'speed', tier: 1, sort_order: 360, desc: 'Тест на 90%+ за <50% времени' },
{ slug: 'theory_first', title: 'Первый урок', icon: 'book-open', cat: 'theory', group: 'mastery', track: 'theory', tier: 1, sort_order: 370, desc: 'Прочитать первый урок' },
{ slug: 'theory_10', title: 'Читатель', icon: 'library', cat: 'theory', group: 'mastery', track: 'theory', tier: 2, sort_order: 380, desc: 'Прочитать 10 уроков' },
{ slug: 'theory_course', title: 'Завершил курс', icon: 'graduation-cap', cat: 'theory', group: 'mastery', track: 'theory', tier: 3, sort_order: 390, desc: 'Пройти полный курс целиком' },
// Consistency — streaks, goals, plan
{ slug: 'streak_3', title: 'Три дня подряд', icon: 'flame', cat: 'streak', group: 'consistency', track: 'streak', tier: 1, sort_order: 410, desc: '3 дня активности подряд' },
{ slug: 'streak_7', title: 'Неделя подряд', icon: 'flame', cat: 'streak', group: 'consistency', track: 'streak', tier: 2, sort_order: 420, desc: '7 дней активности подряд' },
{ slug: 'streak_30', title: 'Месяц подряд', icon: 'flame', cat: 'streak', group: 'consistency', track: 'streak', tier: 3, sort_order: 430, desc: '30 дней активности подряд' },
// Exploration — feature discovery
{ slug: 'lab_first', title: 'Первый опыт', icon: 'flask-conical', cat: 'lab', group: 'exploration', track: 'lab', tier: 1, sort_order: 510, desc: 'Провести первый эксперимент в лаборатории' },
{ slug: 'lab_5', title: 'Юный химик', icon: 'flask-conical', cat: 'lab', group: 'exploration', track: 'lab', tier: 2, sort_order: 520, desc: 'Провести 5 экспериментов' },
{ slug: 'lab_20', title: 'Лаборант', icon: 'test-tubes', cat: 'lab', group: 'exploration', track: 'lab', tier: 3, sort_order: 530, desc: 'Провести 20 экспериментов' },
{ slug: 'lab_50', title: 'Исследователь', icon: 'microscope', cat: 'lab', group: 'exploration', track: 'lab', tier: 4, sort_order: 540, desc: 'Провести 50 экспериментов' },
{ slug: 'lab_reactions_10',title: '10 реакций', icon: 'atom', cat: 'lab', group: 'exploration', track: 'lab_reactions', tier: 1, sort_order: 550, desc: 'Обнаружить 10 различных реакций' },
{ slug: 'lab_reactions_30',title: '30 реакций', icon: 'atom', cat: 'lab', group: 'exploration', track: 'lab_reactions', tier: 2, sort_order: 560, desc: 'Обнаружить 30 различных реакций' },
{ slug: 'rb_first', title: 'Первый вид КК', icon: 'leaf', cat: 'redbook', group: 'exploration', track: 'red_book', tier: 1, sort_order: 570, desc: 'Открыть первый вид Красной книги' },
{ slug: 'rb_10', title: '10 видов КК', icon: 'leaf', cat: 'redbook', group: 'exploration', track: 'red_book', tier: 2, sort_order: 580, desc: 'Открыть 10 видов Красной книги' },
{ slug: 'rb_25', title: 'Четверть коллекции', icon: 'trees', cat: 'redbook', group: 'exploration', track: 'red_book', tier: 3, sort_order: 590, desc: 'Открыть 25 видов Красной книги' },
{ slug: 'rb_50', title: 'Половина коллекции', icon: 'trees', cat: 'redbook', group: 'exploration', track: 'red_book', tier: 4, sort_order: 600, desc: 'Открыть 50 видов Красной книги' },
{ slug: 'rb_all_cr', title: 'Защитник природы', icon: 'shield-check', cat: 'redbook', group: 'exploration', track: 'red_book', tier: 5, sort_order: 610, desc: 'Открыть все CR-виды Красной книги' },
{ slug: 'rb_quest_first', title: 'Первый квест КК', icon: 'map', cat: 'redbook', group: 'exploration', track: 'red_book_quest', tier: 1, sort_order: 620, desc: 'Выполнить первый квест Красной книги' },
{ slug: 'rb_quest_5', title: 'Квестмастер КК', icon: 'map-pin', cat: 'redbook', group: 'exploration', track: 'red_book_quest', tier: 2, sort_order: 630, desc: 'Выполнить 5 квестов Красной книги' },
{ slug: 'rb_sighting', title: 'Наблюдатель', icon: 'eye', cat: 'redbook', group: 'exploration', track: 'red_book_obs', tier: 1, sort_order: 640, desc: 'Добавить первое наблюдение вида' },
];
/* Display metadata for top-level groups. Order = render order in UI. */
const ACHIEVEMENT_GROUPS = [
{ slug: 'onboarding', title: 'Старт', icon: 'flag', sort_order: 100 },
{ slug: 'volume', title: 'Объём', icon: 'bar-chart-2', sort_order: 200 },
{ slug: 'mastery', title: 'Качество', icon: 'award', sort_order: 300 },
{ slug: 'consistency', title: 'Постоянство', icon: 'flame', sort_order: 400 },
{ slug: 'exploration', title: 'Исследование', icon: 'compass', sort_order: 500 },
{ slug: 'social', title: 'Социальное', icon: 'users', sort_order: 600 },
];
/* ── Avatar frames (unlocked by achievement slug) ── */
@@ -158,7 +169,12 @@ const stmts = {
checkFrameUnlock: db.prepare('SELECT a.id FROM achievements a JOIN user_achievements ua ON ua.achievement_id = a.id WHERE a.slug = ? AND ua.user_id = ?'),
setUserFrame: db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?'),
setUserGoalTier: db.prepare('UPDATE users SET goal_tier = ? WHERE id = ?'),
getAllAchs: db.prepare('SELECT id, slug, title, icon, category, description FROM achievements ORDER BY id'),
getAllAchs: db.prepare(`
SELECT id, slug, title, icon, category, description,
group_slug, track, tier, sort_order
FROM achievements
ORDER BY sort_order, id
`),
getUserAchs: db.prepare('SELECT achievement_id, unlocked_at FROM user_achievements WHERE user_id = ?'),
xpHistory: db.prepare('SELECT amount, reason, created_at FROM xp_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'),
@@ -222,7 +238,7 @@ function invalidateGamificationCache() {
module.exports = {
xpToLevel, levelMinXp, levelMaxXp, rankName, RANKS,
GOAL_TIERS, ACHIEVEMENT_DEFS, AVATAR_FRAMES,
GOAL_TIERS, ACHIEVEMENT_DEFS, ACHIEVEMENT_GROUPS, AVATAR_FRAMES,
stmts,
isGamificationEnabled, invalidateGamificationCache,
};
+10 -2
View File
@@ -53,13 +53,21 @@ function setGoalTier(req, res) {
res.json({ tier, ...GOAL_TIERS[tier] });
}
/* GET /api/gamification/achievements — all achievements + user unlocks */
/* GET /api/gamification/achievements — all achievements + user unlocks.
Response stays an array (backward compatible). Each item now carries
`group_slug` / `track` / `tier` / `sort_order` so the UI can group +
order without an extra endpoint. Group display titles are hard-coded
client-side (small + stable). */
function getAchievements(req, res) {
const all = stmts.getAllAchs.all();
const unlocked = stmts.getUserAchs.all(req.user.id);
const unlockedMap = {};
for (const u of unlocked) unlockedMap[u.achievement_id] = u.unlocked_at;
const result = all.map(a => ({ ...a, unlocked: !!unlockedMap[a.id], unlocked_at: unlockedMap[a.id] || null }));
const result = all.map(a => ({
...a,
unlocked: !!unlockedMap[a.id],
unlocked_at: unlockedMap[a.id] || null,
}));
res.json(result);
}
@@ -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);
}
}
@@ -0,0 +1,92 @@
-- ═══════════════════════════════════════════════════════════════
-- 030: Two-level taxonomy for achievements
--
-- The existing `category` field stays for backward compatibility, but
-- the UI now groups achievements by `group_slug` (six top-level
-- buckets) and orders them by `(group_slug, sort_order)`. Inside a
-- bucket related achievements with escalating thresholds share a
-- `track` (e.g. tests_10/50/100 → track='tests') and a `tier`
-- (1..5). Singletons leave tier NULL.
--
-- New groups:
-- onboarding — first steps in the app
-- volume — quantity (tests, assignments, XP totals)
-- mastery — quality: streaks/levels were folded into consistency
-- and mastery respectively; pure mastery = score/speed
-- /perfect/course completion
-- consistency — streaks, daily goals, plan adherence
-- exploration — feature discovery: lab, biochem, red book
-- social — class membership, leaderboard (filled in Phase 3)
--
-- Sort order convention: groups are spaced by 100s (onboarding=100,
-- volume=200, ...), achievements inside use offsets so we can insert
-- new ones later without renumbering.
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE achievements ADD COLUMN group_slug TEXT;
ALTER TABLE achievements ADD COLUMN track TEXT;
ALTER TABLE achievements ADD COLUMN tier INTEGER;
ALTER TABLE achievements ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_ach_group_sort ON achievements(group_slug, sort_order);
-- ── onboarding (group=100) ─────────────────────────────────────
UPDATE achievements SET group_slug='onboarding', track='first_test', tier=1, sort_order=110 WHERE slug='first_test';
UPDATE achievements SET group_slug='onboarding', track='first_perfect', tier=1, sort_order=120 WHERE slug='first_perfect';
UPDATE achievements SET group_slug='onboarding', track='first_class', tier=1, sort_order=130 WHERE slug='first_class';
-- ── volume (group=200): quantity of things done ────────────────
-- track=tests (10/50/100)
UPDATE achievements SET group_slug='volume', track='tests', tier=1, sort_order=210 WHERE slug='tests_10';
UPDATE achievements SET group_slug='volume', track='tests', tier=2, sort_order=220 WHERE slug='tests_50';
UPDATE achievements SET group_slug='volume', track='tests', tier=3, sort_order=230 WHERE slug='tests_100';
-- track=assign (first/10)
UPDATE achievements SET group_slug='volume', track='assign', tier=1, sort_order=240 WHERE slug='assign_first';
UPDATE achievements SET group_slug='volume', track='assign', tier=2, sort_order=250 WHERE slug='assign_10';
-- track=xp_total (1000/5000/10000)
UPDATE achievements SET group_slug='volume', track='xp_total', tier=1, sort_order=260 WHERE slug='xp_1000';
UPDATE achievements SET group_slug='volume', track='xp_total', tier=2, sort_order=270 WHERE slug='xp_5000';
UPDATE achievements SET group_slug='volume', track='xp_total', tier=3, sort_order=280 WHERE slug='xp_10000';
-- ── mastery (group=300): quality / completion ──────────────────
-- track=level (3/5/10/20)
UPDATE achievements SET group_slug='mastery', track='level', tier=1, sort_order=310 WHERE slug='level_3';
UPDATE achievements SET group_slug='mastery', track='level', tier=2, sort_order=320 WHERE slug='level_5';
UPDATE achievements SET group_slug='mastery', track='level', tier=3, sort_order=330 WHERE slug='level_10';
UPDATE achievements SET group_slug='mastery', track='level', tier=4, sort_order=340 WHERE slug='level_20';
-- track=score (90% streak, perfect, speed)
UPDATE achievements SET group_slug='mastery', track='score', tier=1, sort_order=350 WHERE slug='score_90';
UPDATE achievements SET group_slug='mastery', track='speed', tier=1, sort_order=360 WHERE slug='speed_demon';
-- track=theory_progress (first/10/course)
UPDATE achievements SET group_slug='mastery', track='theory', tier=1, sort_order=370 WHERE slug='theory_first';
UPDATE achievements SET group_slug='mastery', track='theory', tier=2, sort_order=380 WHERE slug='theory_10';
UPDATE achievements SET group_slug='mastery', track='theory', tier=3, sort_order=390 WHERE slug='theory_course';
-- ── consistency (group=400): streaks, plan, goals ──────────────
UPDATE achievements SET group_slug='consistency', track='streak', tier=1, sort_order=410 WHERE slug='streak_3';
UPDATE achievements SET group_slug='consistency', track='streak', tier=2, sort_order=420 WHERE slug='streak_7';
UPDATE achievements SET group_slug='consistency', track='streak', tier=3, sort_order=430 WHERE slug='streak_30';
-- ── exploration (group=500): feature discovery ─────────────────
-- track=lab_experiments (first/5/20/50)
UPDATE achievements SET group_slug='exploration', track='lab', tier=1, sort_order=510 WHERE slug='lab_first';
UPDATE achievements SET group_slug='exploration', track='lab', tier=2, sort_order=520 WHERE slug='lab_5';
UPDATE achievements SET group_slug='exploration', track='lab', tier=3, sort_order=530 WHERE slug='lab_20';
UPDATE achievements SET group_slug='exploration', track='lab', tier=4, sort_order=540 WHERE slug='lab_50';
-- track=lab_reactions (10/30)
UPDATE achievements SET group_slug='exploration', track='lab_reactions', tier=1, sort_order=550 WHERE slug='lab_reactions_10';
UPDATE achievements SET group_slug='exploration', track='lab_reactions', tier=2, sort_order=560 WHERE slug='lab_reactions_30';
-- track=red_book (first/10/25/50/all)
UPDATE achievements SET group_slug='exploration', track='red_book', tier=1, sort_order=570 WHERE slug='rb_first';
UPDATE achievements SET group_slug='exploration', track='red_book', tier=2, sort_order=580 WHERE slug='rb_10';
UPDATE achievements SET group_slug='exploration', track='red_book', tier=3, sort_order=590 WHERE slug='rb_25';
UPDATE achievements SET group_slug='exploration', track='red_book', tier=4, sort_order=600 WHERE slug='rb_50';
UPDATE achievements SET group_slug='exploration', track='red_book', tier=5, sort_order=610 WHERE slug='rb_all_cr';
-- track=red_book_quests (first/5)
UPDATE achievements SET group_slug='exploration', track='red_book_quest',tier=1, sort_order=620 WHERE slug='rb_quest_first';
UPDATE achievements SET group_slug='exploration', track='red_book_quest',tier=2, sort_order=630 WHERE slug='rb_quest_5';
-- singletons
UPDATE achievements SET group_slug='exploration', track='red_book_obs', tier=1, sort_order=640 WHERE slug='rb_sighting';
-- ── social (group=600): empty for now, filled in Phase 3 ───────
-- (no UPDATEs here — placeholder for class/leaderboard/live-quiz tracks)
+92 -6
View File
@@ -366,7 +366,40 @@
.p-info-val { font-size: 0.86rem; font-weight: 600; color: var(--text); }
/* ── Achievements ── */
.ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
/* The outer container now hosts multiple .ach-group sections; each
group has its own inner grid. */
.ach-grid { display: flex; flex-direction: column; gap: 20px; }
.ach-group { display: flex; flex-direction: column; gap: 10px; }
.ach-group-head {
display: flex; align-items: center; gap: 10px;
padding: 6px 4px 4px;
border-bottom: 1.5px solid var(--border);
}
.ach-group-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 8px;
background: rgba(155,93,229,0.10); color: var(--violet);
}
.ach-group-title {
font-family: 'Unbounded', sans-serif;
font-size: 0.85rem; font-weight: 800; letter-spacing: 0.02em;
color: var(--text); flex: 1;
}
.ach-group-count {
font-size: 0.72rem; font-weight: 700; color: var(--text-3);
font-family: 'Manrope', sans-serif;
}
.ach-group-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
/* Tier stars inline with the title — only shown for tier >= 2. */
.ach-tier {
display: inline-block; margin-left: 6px;
font-size: 0.62rem; color: #FFB347; vertical-align: middle;
letter-spacing: 1px;
}
.ach-item {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-radius: 14px;
@@ -1624,24 +1657,77 @@
document.getElementById('ach-summary').innerHTML =
`<div class="ach-sum-chip">${unlocked} / ${total} получено</div>` +
(gam?.streak ? `<div class="ach-sum-chip">${lsIcon('flame', 14)} ${gam.streak} дн. стрик</div>` : '');
// Grid
document.getElementById('ach-grid').innerHTML = achs.map(a => {
// Grouped render: sort once by sort_order, partition by group_slug,
// render a section heading + grid per group. Order of groups is fixed
// in ACH_GROUPS (matches backend ACHIEVEMENT_GROUPS).
const sorted = achs.slice().sort((a, b) =>
(a.sort_order || 0) - (b.sort_order || 0));
const byGroup = new Map();
for (const a of sorted) {
const key = a.group_slug || 'other';
if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key).push(a);
}
const renderItem = a => {
const cls = a.unlocked ? 'unlocked' : 'locked';
const dateStr = a.unlocked_at
? `<div class="ach-date">${parseDate(a.unlocked_at).toLocaleDateString('ru', { day:'numeric', month:'short' })}</div>`
: '';
return `<div class="ach-item ${cls}" data-cat="${a.category || 'start'}">
const tierBadge = a.tier && a.tier > 1
? `<div class="ach-tier" title="Уровень ${a.tier}">${'★'.repeat(a.tier)}</div>` : '';
return `<div class="ach-item ${cls}" data-cat="${a.category || 'start'}" data-group="${a.group_slug || ''}">
<div class="ach-icon">${(lsIcon(a.icon || 'star', 22) || lsIcon('star', 22)).replace('fill="none"','fill="currentColor"').replace('stroke-width="2"','stroke-width="0"')}</div>
<div class="ach-body">
<div class="ach-title">${esc(a.title)}</div>
<div class="ach-title">${esc(a.title)}${tierBadge}</div>
<div class="ach-desc">${esc(a.description)}</div>
${dateStr}
</div>
</div>`;
}).join('');
};
const html = [];
for (const g of ACH_GROUPS) {
const items = byGroup.get(g.slug);
if (!items || !items.length) continue;
const got = items.filter(i => i.unlocked).length;
html.push(`
<div class="ach-group" data-group="${g.slug}">
<div class="ach-group-head">
<span class="ach-group-icon">${lsIcon(g.icon, 18)}</span>
<span class="ach-group-title">${esc(g.title)}</span>
<span class="ach-group-count">${got} / ${items.length}</span>
</div>
<div class="ach-group-grid">${items.map(renderItem).join('')}</div>
</div>`);
byGroup.delete(g.slug);
}
// Any leftover groups (e.g. server added a new one client doesn't
// know about) get rendered at the end so nothing silently vanishes.
for (const [slug, items] of byGroup) {
const got = items.filter(i => i.unlocked).length;
html.push(`
<div class="ach-group" data-group="${slug}">
<div class="ach-group-head">
<span class="ach-group-title">${esc(slug)}</span>
<span class="ach-group-count">${got} / ${items.length}</span>
</div>
<div class="ach-group-grid">${items.map(renderItem).join('')}</div>
</div>`);
}
document.getElementById('ach-grid').innerHTML = html.join('');
} catch {}
}
/* Display metadata mirrored from backend ACHIEVEMENT_GROUPS.
Order = render order. Keep in sync with gamification/_shared.js. */
const ACH_GROUPS = [
{ slug: 'onboarding', title: 'Старт', icon: 'flag' },
{ slug: 'volume', title: 'Объём', icon: 'bar-chart-2' },
{ slug: 'mastery', title: 'Качество', icon: 'award' },
{ slug: 'consistency', title: 'Постоянство', icon: 'flame' },
{ slug: 'exploration', title: 'Исследование', icon: 'compass' },
{ slug: 'social', title: 'Социальное', icon: 'users' },
];
/* ── Avatar Frames ── */
async function loadFrames() {
try {