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
+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 {