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