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