Phase 20: achievements tab and unlock toasts

New Achievements tab (tab-achievements) shows all 18 achievement cards in
a responsive grid. Each card displays: icon, title, rarity badge (color-
coded: common=grey, rare=blue, epic=purple), description, reward coins, and
either a green "Unlocked <date>" label or a dimmed "Locked" state. Unlocked
achievements sort first (newest-first); locked sort alphabetically after.

Header nav button shows earned count badge next to "Achievements".

Match result handler reads achievements_unlocked from the Core response and
fires one toast per newly unlocked achievement immediately after match
submission. Level-up toasts continue to work independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 18:05:16 -07:00
parent 44c3a0ce5b
commit 335398df72
2 changed files with 67 additions and 1 deletions
+66 -1
View File
@@ -275,6 +275,7 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
<button onclick="showTab('market')">Market</button>
<button onclick="showTab('events')">Events</button>
<button onclick="showTab('notifications')">Notifications <span id="notif-badge"></span></button>
<button onclick="showTab('achievements')">Achievements <span id="ach-badge"></span></button>
</nav>
<main>
@@ -639,6 +640,15 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
</div>
<!-- ── Notifications tab ─────────────────────────────────────────────────── -->
<div id="tab-achievements" class="tab-panel">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">Achievements <span id="ach-progress" style="font-size:0.85rem;color:#8b949e;font-weight:400"></span></h2>
</div>
<div id="ach-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px">
<span class="loading">Loading…</span>
</div>
</div>
<div id="tab-notifications" class="tab-panel">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">Notifications <span id="notif-unread-label" style="font-size:0.85rem;color:#8b949e;font-weight:400"></span></h2>
@@ -698,7 +708,7 @@ function showTab(name) {
market: loadMarket, events: loadEvents, matches: loadMatches,
store: loadStore, sbc: loadSbc, statistics: loadStatistics,
catalog: loadCatalog, settings: loadSettings,
notifications: loadNotifications })[name]?.();
notifications: loadNotifications, achievements: loadAchievements })[name]?.();
}
function rarityClass(r) {
@@ -1189,6 +1199,8 @@ async function submitMatch() {
if (levelUps.length) {
showToast(`Level Up! You are now level ${levelUps[levelUps.length - 1].new_level}`);
}
const achUnlocked = r.achievements_unlocked || [];
achUnlocked.forEach(a => showToast(`Achievement unlocked: ${a.icon || ''} ${a.title}`));
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
await loadMatchHistory();
} catch (e) { showToast(e.message, true); }
@@ -2080,6 +2092,59 @@ async function toggleEvent(eventId, activate, btn) {
// ── Notifications ─────────────────────────────────────────────────────────────
// ── Achievements ─────────────────────────────────────────────────────────────
const ACH_RARITY_COLOR = { common: '#8b949e', rare: '#58a6ff', epic: '#bc8cff', legendary: '#f5a623' };
async function loadAchievements() {
try {
const data = await api('GET', '/achievements');
const achs = data.achievements || [];
const earned = data.earned || 0;
const total = data.total || achs.length;
el('ach-progress').textContent = `${earned} / ${total} earned`;
el('ach-badge').textContent = earned > 0 ? ` (${earned})` : '';
if (!achs.length) {
el('ach-grid').innerHTML = '<span class="empty">No achievements defined.</span>';
return;
}
// Sort: unlocked first (by unlock date desc), then locked alphabetically
const unlocked = achs.filter(a => a.unlocked).sort((a, b) => (b.unlocked_at || '').localeCompare(a.unlocked_at || ''));
const locked = achs.filter(a => !a.unlocked).sort((a, b) => a.title.localeCompare(b.title));
el('ach-grid').innerHTML = [...unlocked, ...locked].map(a => {
const rc = ACH_RARITY_COLOR[a.rarity] || '#8b949e';
const opacity = a.unlocked ? '1' : '0.45';
const badge = a.unlocked
? `<span style="color:#3fb950;font-size:0.72rem">✓ Unlocked${a.unlocked_at ? ' ' + new Date(a.unlocked_at).toLocaleDateString() : ''}</span>`
: `<span style="color:#6e7681;font-size:0.72rem">Locked</span>`;
return `
<div style="background:#161b22;border-radius:8px;padding:14px 16px;
border:1px solid ${a.unlocked ? rc : '#30363d'};opacity:${opacity};
display:flex;gap:14px;align-items:flex-start">
<span style="font-size:1.8rem;min-width:36px;text-align:center">${a.icon}</span>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<strong style="color:${rc}">${a.title}</strong>
<span style="font-size:0.65rem;background:${rc}22;color:${rc};
padding:1px 6px;border-radius:4px;text-transform:uppercase">${a.rarity}</span>
</div>
<div style="font-size:0.8rem;color:#8b949e;margin:3px 0">${a.description}</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:4px">
${badge}
<span style="font-size:0.72rem;color:#f5a623">+${(a.reward_coins||0).toLocaleString()} coins</span>
</div>
</div>
</div>`;
}).join('');
} catch (e) { el('ach-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
// ── Notifications ─────────────────────────────────────────────────────────────
const NOTIF_ICONS = {
level_up: '⬆',
objective_complete: '✓',
+1
View File
@@ -251,6 +251,7 @@ async fn test_dashboard_contains_key_sections() {
assert!(html.contains("tab-catalog"), "card catalog tab missing");
assert!(html.contains("tab-settings"), "settings tab missing");
assert!(html.contains("tab-notifications"), "notifications tab missing");
assert!(html.contains("tab-achievements"), "achievements tab missing");
}
#[tokio::test]