From 90c8464356cae09e51b6f3102792b382c26ca2e3 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 20:19:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(gamification):=20Phase=202=20=E2=80=94=20t?= =?UTF-8?q?axonomy=20+=20grouped=20UI=20for=20achievements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/controllers/gamification/_shared.js | 116 ++++++++++-------- backend/src/controllers/gamification/api.js | 12 +- .../src/controllers/gamification/service.js | 28 ++++- .../migrations/030_achievements_grouping.sql | 92 ++++++++++++++ frontend/profile.html | 98 ++++++++++++++- 5 files changed, 282 insertions(+), 64 deletions(-) create mode 100644 backend/src/db/migrations/030_achievements_grouping.sql diff --git a/backend/src/controllers/gamification/_shared.js b/backend/src/controllers/gamification/_shared.js index f48cd3f..53e92e6 100644 --- a/backend/src/controllers/gamification/_shared.js +++ b/backend/src/controllers/gamification/_shared.js @@ -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, }; diff --git a/backend/src/controllers/gamification/api.js b/backend/src/controllers/gamification/api.js index 4191fd9..87aacf5 100644 --- a/backend/src/controllers/gamification/api.js +++ b/backend/src/controllers/gamification/api.js @@ -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); } diff --git a/backend/src/controllers/gamification/service.js b/backend/src/controllers/gamification/service.js index 8b6ff49..afaab53 100644 --- a/backend/src/controllers/gamification/service.js +++ b/backend/src/controllers/gamification/service.js @@ -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); } } diff --git a/backend/src/db/migrations/030_achievements_grouping.sql b/backend/src/db/migrations/030_achievements_grouping.sql new file mode 100644 index 0000000..3bd6dd7 --- /dev/null +++ b/backend/src/db/migrations/030_achievements_grouping.sql @@ -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) diff --git a/frontend/profile.html b/frontend/profile.html index 7af6a2f..edb6065 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -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 = `
${unlocked} / ${total} получено
` + (gam?.streak ? `
${lsIcon('flame', 14)} ${gam.streak} дн. стрик
` : ''); - // 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 ? `
${parseDate(a.unlocked_at).toLocaleDateString('ru', { day:'numeric', month:'short' })}
` : ''; - return `
+ const tierBadge = a.tier && a.tier > 1 + ? `
${'★'.repeat(a.tier)}
` : ''; + return `
${(lsIcon(a.icon || 'star', 22) || lsIcon('star', 22)).replace('fill="none"','fill="currentColor"').replace('stroke-width="2"','stroke-width="0"')}
-
${esc(a.title)}
+
${esc(a.title)}${tierBadge}
${esc(a.description)}
${dateStr}
`; - }).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(` +
+
+ ${lsIcon(g.icon, 18)} + ${esc(g.title)} + ${got} / ${items.length} +
+
${items.map(renderItem).join('')}
+
`); + 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(` +
+
+ ${esc(slug)} + ${got} / ${items.length} +
+
${items.map(renderItem).join('')}
+
`); + } + 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 {