Phase 15: add Matches, Pack Store, SBC, Statistics tabs to dashboard

Matches — difficulty selector, generate opponent (shows AI squad rating
and formation), quick-score buttons (3-0 / 1-1 / 0-2), submit custom
score via POST /matches/result, inline reward display (coins, XP,
objective triggers, season points), live match history list.

Pack Store — lists all purchasable definitions from GET /packs/store
(name, description, cost, card count) with one-click Buy that calls
POST /packs/buy; coin balance refreshes after purchase.

SBC — renders all challenges with requirements as pills (min OVR,
required nations/leagues/clubs, same-club counts), card picker
(select from owned collection, click pill to remove), Submit fires
POST /sbc/submit and shows reward or validation failures; cards
re-loaded from Core after a successful submission.

Statistics — career stat grid (matches, W/D/L, win rate, goals, coin
totals, streaks, packs, SBCs), goals-by-position bar chart, paginated
match log with outcome badges and date, sourced from
GET /statistics and GET /statistics/history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 17:27:45 -07:00
parent aabbe775b3
commit 67b84abe7c
2 changed files with 427 additions and 1 deletions
+423 -1
View File
@@ -173,6 +173,38 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
.sell-form select, .sell-form input { width: 100%; background: #0d1117; border: 1px solid #30363d;
color: #c9d1d9; padding: 8px 10px; border-radius: 6px; font-size: 0.85rem; margin: 6px 0 12px; }
/* ── Matches ── */
.match-form { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; max-width: 560px; }
.score-row { display: flex; align-items: center; gap: 12px; margin: 12px 0; }
.score-input { width: 64px; background: #0d1117; border: 1px solid #30363d; color: #f5a623;
font-size: 1.6rem; font-weight: 800; text-align: center; border-radius: 6px; padding: 4px; }
.score-sep { font-size: 1.4rem; color: #8b949e; }
.opponent-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px; margin: 12px 0; }
.opponent-rating { font-size: 2rem; font-weight: 800; color: #f5a623; }
.match-history-row { display: flex; align-items: center; gap: 10px; padding: 8px 0;
border-bottom: 1px solid #21262d; font-size: 0.85rem; }
.outcome-badge { width: 28px; height: 28px; border-radius: 4px; font-weight: 700; font-size: 0.75rem;
display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.outcome-win { background: #14532d; color: #4ade80; }
.outcome-draw { background: #374151; color: #d1d5db; }
.outcome-loss { background: #7f1d1d; color: #f87171; }
/* ── SBC ── */
.sbc-list { display: grid; gap: 12px; margin-top: 12px; }
.sbc-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
.sbc-card.completed { border-color: #238636; opacity: 0.7; }
.sbc-name { font-size: 0.95rem; font-weight: 600; margin-bottom: 4px; }
.sbc-desc { font-size: 0.8rem; color: #8b949e; margin-bottom: 10px; }
.sbc-reqs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.req-pill { font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: #21262d; color: #c9d1d9; }
.sbc-reward { font-size: 0.8rem; color: #f5a623; margin-bottom: 10px; }
.sbc-picker { margin-top: 10px; }
.sbc-picker select { width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
padding: 6px; border-radius: 6px; font-size: 0.8rem; margin: 3px 0; }
.sbc-selected { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; min-height: 24px; }
.sbc-sel-pill { font-size: 0.7rem; background: #1c3250; color: #93c5fd; padding: 2px 8px;
border-radius: 10px; cursor: pointer; }
/* ── Events ── */
.events-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-top: 12px; }
.event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
@@ -203,6 +235,10 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
<button onclick="showTab('objectives')">Objectives</button>
<button onclick="showTab('division')">Division</button>
<button onclick="showTab('champs')">FUT Champs</button>
<button onclick="showTab('matches')">Matches</button>
<button onclick="showTab('store')">Pack Store</button>
<button onclick="showTab('sbc')">SBC</button>
<button onclick="showTab('statistics')">Statistics</button>
<button onclick="showTab('squad')">Squad</button>
<button onclick="showTab('draft')">Draft</button>
<button onclick="showTab('market')">Market</button>
@@ -310,6 +346,82 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
</div>
</div>
<!-- ── Matches tab ───────────────────────────────────────────────────────── -->
<div id="tab-matches" class="tab-panel">
<div class="two-col">
<div>
<h2>Simulate Match</h2>
<div class="match-form">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
<label style="font-size:0.8rem;color:#8b949e">Difficulty:</label>
<select id="match-difficulty" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 8px;border-radius:6px;font-size:0.8rem">
<option value="beginner">Beginner</option>
<option value="professional" selected>Professional</option>
<option value="world_class">World Class</option>
<option value="legendary">Legendary</option>
<option value="ultimate">Ultimate</option>
</select>
<button class="btn btn-secondary btn-sm" onclick="generateOpponent()">Generate Opponent</button>
</div>
<div id="opponent-display"></div>
<div class="score-row">
<input id="goals-for" class="score-input" type="number" min="0" max="20" value="0">
<span class="score-sep"></span>
<input id="goals-against" class="score-input" type="number" min="0" max="20" value="0">
</div>
<div style="font-size:0.75rem;color:#8b949e;margin-bottom:12px">Your goals — Opponent goals</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" onclick="quickScore(3,0)">Win 3-0</button>
<button class="btn btn-secondary btn-sm" onclick="quickScore(1,1)">Draw 1-1</button>
<button class="btn btn-secondary btn-sm" onclick="quickScore(0,2)">Loss 0-2</button>
</div>
<div style="margin-top:16px">
<button class="btn btn-primary" onclick="submitMatch()">Submit Result</button>
</div>
<div id="match-result-display" style="margin-top:14px"></div>
</div>
</div>
<div>
<h2>Match History</h2>
<div id="match-history"><span class="loading">Loading…</span></div>
</div>
</div>
</div>
<!-- ── Pack Store tab ─────────────────────────────────────────────────────── -->
<div id="tab-store" class="tab-panel">
<p style="font-size:0.85rem;color:#8b949e;margin-bottom:16px">
Purchase packs with your coins. Bought packs appear in the Packs tab ready to open.
</p>
<div id="store-grid" class="pack-grid"><span class="loading">Loading…</span></div>
</div>
<!-- ── SBC tab ────────────────────────────────────────────────────────────── -->
<div id="tab-sbc" class="tab-panel">
<p style="font-size:0.85rem;color:#8b949e;margin-bottom:16px">
Complete Squad Building Challenges to earn coins, XP, and packs. Cards submitted are consumed.
</p>
<div id="sbc-list" class="sbc-list"><span class="loading">Loading…</span></div>
</div>
<!-- ── Statistics tab ────────────────────────────────────────────────────── -->
<div id="tab-statistics" class="tab-panel">
<div class="two-col">
<div>
<h2>Career Stats</h2>
<div id="career-stats"><span class="loading">Loading…</span></div>
</div>
<div>
<h2>Goals by Position</h2>
<div id="position-goals"><span class="loading">Loading…</span></div>
</div>
</div>
<div class="section" style="margin-top:24px">
<h2>Recent Match Log</h2>
<div id="stats-history"><span class="loading">Loading…</span></div>
</div>
</div>
<!-- ── Squad tab ─────────────────────────────────────────────────────────── -->
<div id="tab-squad" class="tab-panel">
<div class="two-col">
@@ -443,7 +555,8 @@ function showTab(name) {
({ club: loadClub, collection: loadCollection, packs: loadPacks,
objectives: loadObjectives, division: loadDivision,
champs: loadChamps, squad: loadSquad, draft: loadDraft,
market: loadMarket, events: loadEvents,
market: loadMarket, events: loadEvents, matches: loadMatches,
store: loadStore, sbc: loadSbc, statistics: loadStatistics,
notifications: loadNotifications })[name]?.();
}
@@ -843,6 +956,315 @@ async function claimChampsReward(sessionId) {
} catch (e) { showToast(e.message, true); }
}
// ── Matches ───────────────────────────────────────────────────────────────────
let currentOpponent = null;
async function loadMatches() {
await loadMatchHistory();
}
async function generateOpponent() {
const diff = el('match-difficulty').value;
try {
const data = await api('GET', `/matches/opponent?difficulty=${diff}`);
currentOpponent = data;
const rating = data.squad_rating ?? '—';
const name = data.opponent_name ?? 'AI Opponent';
const formation = data.formation ?? '—';
el('opponent-display').innerHTML = `
<div class="opponent-card">
<div style="font-size:0.8rem;color:#8b949e;margin-bottom:2px">${diff.replace('_', ' ').toUpperCase()}</div>
<div style="font-weight:600">${name}</div>
<div style="display:flex;gap:16px;align-items:baseline;margin-top:4px">
<span class="opponent-rating">${rating}</span>
<span class="formation-badge">${formation}</span>
</div>
</div>`;
} catch (e) { showToast(e.message, true); }
}
function quickScore(gf, ga) {
el('goals-for').value = gf;
el('goals-against').value = ga;
}
async function submitMatch() {
const gf = parseInt(el('goals-for').value, 10) || 0;
const ga = parseInt(el('goals-against').value, 10) || 0;
const diff = el('match-difficulty').value;
const opponentName = currentOpponent?.opponent_name ?? `${diff.replace('_',' ')} Bot`;
try {
const r = await api('POST', '/matches/result', {
squad_id: 'dashboard',
opponent_name: opponentName,
goals_for: gf,
goals_against: ga,
mode: 'squad_battles',
});
const outcome = gf > ga ? 'Win' : gf === ga ? 'Draw' : 'Loss';
const outcomeColor = gf > ga ? '#3fb950' : gf === ga ? '#8b949e' : '#f85149';
el('match-result-display').innerHTML = `
<div style="background:#0d1117;border-radius:6px;padding:12px;border-left:3px solid ${outcomeColor}">
<div style="font-weight:700;color:${outcomeColor}">${outcome} ${gf}${ga}</div>
<div style="font-size:0.8rem;margin-top:4px">
+${(r.coins_awarded || 0).toLocaleString()} coins · +${r.xp_awarded || 0} XP
${(r.objectives_triggered || []).length ? `<br>Objectives: ${r.objectives_triggered.join(', ')}` : ''}
${r.season ? `<br>Season pts: ${r.season.season_points}` : ''}
</div>
</div>`;
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
await loadMatchHistory();
} catch (e) { showToast(e.message, true); }
}
async function loadMatchHistory() {
try {
const data = await api('GET', '/matches?limit=20');
const matches = data.matches || [];
if (!matches.length) {
el('match-history').innerHTML = '<span class="empty">No matches played yet.</span>';
return;
}
el('match-history').innerHTML = matches.map(m => {
const cls = m.outcome === 'win' ? 'win' : m.outcome === 'draw' ? 'draw' : 'loss';
const lbl = m.outcome === 'win' ? 'W' : m.outcome === 'draw' ? 'D' : 'L';
return `<div class="match-history-row">
<span class="outcome-badge outcome-${cls}">${lbl}</span>
<span style="flex:1">${m.opponent_name}</span>
<span style="font-weight:600">${m.goals_for}${m.goals_against}</span>
<span style="color:#f5a623;font-size:0.75rem">+${(m.coins_awarded||0).toLocaleString()}</span>
<span style="color:#8b949e;font-size:0.72rem">${m.mode || 'squad_battles'}</span>
</div>`;
}).join('');
} catch (e) { el('match-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
// ── Pack Store ────────────────────────────────────────────────────────────────
async function loadStore() {
try {
const data = await api('GET', '/packs/store');
const packs = data.packs || [];
if (!packs.length) {
el('store-grid').innerHTML = '<span class="empty">No packs available.</span>';
return;
}
el('store-grid').innerHTML = packs.map(p => `
<div class="pack-item">
<h3>${p.name}</h3>
<div class="pack-id" style="word-break:normal">${p.description}</div>
<div style="margin:8px 0">
<span class="market-price">${(p.cost_coins || 0).toLocaleString()} coins</span>
<span style="font-size:0.75rem;color:#8b949e;margin-left:6px">${p.total_cards} cards</span>
</div>
<button class="btn btn-primary btn-sm" onclick="buyStorePack('${p.id}', this)">Buy</button>
</div>`).join('');
} catch (e) { el('store-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
async function buyStorePack(defId, btn) {
btn.disabled = true; btn.textContent = 'Buying…';
try {
await api('POST', '/packs/buy', { pack_definition_id: defId });
showToast('Pack purchased! Open it in the Packs tab.');
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
btn.disabled = false; btn.textContent = 'Buy';
} catch (e) { showToast(e.message, true); btn.disabled = false; btn.textContent = 'Buy'; }
}
// ── SBC ───────────────────────────────────────────────────────────────────────
// Per-SBC selected owned card IDs: { sbcId: [ownedCardId, ...] }
const sbcSelections = {};
async function loadSbc() {
if (!allCards.length) {
try { const d = await api('GET', '/collection'); allCards = d.collection || []; } catch (_) {}
}
try {
const data = await api('GET', '/sbc');
const sbcs = data.sbcs || [];
if (!sbcs.length) {
el('sbc-list').innerHTML = '<span class="empty">No SBC challenges defined.</span>';
return;
}
el('sbc-list').innerHTML = sbcs.map(s => renderSbcCard(s)).join('');
} catch (e) { el('sbc-list').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
function renderSbcCard(s) {
const r = s.requirements || {};
const reward = s.reward || {};
const reqs = [
r.squad_size ? `${r.squad_size} players` : '',
r.min_overall ? `Min OVR ${r.min_overall}` : '',
r.max_overall ? `Max OVR ${r.max_overall}` : '',
r.min_chemistry ? `Min Chem ${r.min_chemistry}` : '',
...(r.required_nations || []).map(n => `Nation: ${n}`),
...(r.required_leagues || []).map(l => `League: ${l}`),
...(r.required_clubs || []).map(c => `Club: ${c}`),
r.min_players_from_same_league ? `${r.min_players_from_same_league}+ same league` : '',
r.min_players_from_same_nation ? `${r.min_players_from_same_nation}+ same nation` : '',
r.min_players_from_same_club ? `${r.min_players_from_same_club}+ same club` : '',
].filter(Boolean);
const rewardStr = [
reward.coins ? `${reward.coins.toLocaleString()} coins` : '',
reward.xp ? `${reward.xp} XP` : '',
reward.pack_id ? reward.pack_id.replace(/_/g,' ') : '',
].filter(Boolean).join(' + ');
const sel = sbcSelections[s.id] || [];
const needed = r.squad_size || 11;
const cardOpts = allCards
.filter(i => !i.is_loan)
.sort((a, b) => (b.effective_overall ?? b.card.overall) - (a.effective_overall ?? a.card.overall))
.map(i => {
const c = i.card;
const ovr = i.effective_overall ?? c.overall;
return `<option value="${i.owned_card_id}">${ovr} ${c.name} (${c.position}, ${c.club})</option>`;
}).join('');
const selPills = sel.map(id => {
const item = allCards.find(i => i.owned_card_id === id);
const label = item ? `${item.card.name}` : id;
return `<span class="sbc-sel-pill" onclick="removeSbcCard('${s.id}','${id}')" title="Click to remove">${label} ×</span>`;
}).join('');
return `<div class="sbc-card" id="sbc-${s.id}">
<div class="sbc-name">${s.name}${s.repeatable ? ' <span style="font-size:0.7rem;color:#8b949e">(repeatable)</span>' : ''}</div>
<div class="sbc-desc">${s.description}</div>
<div class="sbc-reqs">${reqs.map(r => `<span class="req-pill">${r}</span>`).join('')}</div>
<div class="sbc-reward">Reward: ${rewardStr || '—'}</div>
<div class="sbc-picker">
<div style="font-size:0.78rem;color:#8b949e;margin-bottom:4px">
Selected: ${sel.length}/${needed} cards
</div>
<div class="sbc-selected" id="sbc-sel-${s.id}">${selPills}</div>
${sel.length < needed ? `
<select onchange="addSbcCard('${s.id}', this)">
<option value="">+ Add card…</option>
${cardOpts}
</select>` : ''}
${sel.length >= needed ? `
<button class="btn btn-primary btn-sm" onclick="submitSbc('${s.id}')">Submit SBC</button>` : ''}
</div>
</div>`;
}
function addSbcCard(sbcId, selectEl) {
const val = selectEl.value;
if (!val) return;
if (!sbcSelections[sbcId]) sbcSelections[sbcId] = [];
if (!sbcSelections[sbcId].includes(val)) sbcSelections[sbcId].push(val);
selectEl.value = '';
refreshSbcCard(sbcId);
}
function removeSbcCard(sbcId, ownedCardId) {
if (sbcSelections[sbcId]) {
sbcSelections[sbcId] = sbcSelections[sbcId].filter(id => id !== ownedCardId);
}
refreshSbcCard(sbcId);
}
async function refreshSbcCard(sbcId) {
try {
const data = await api('GET', '/sbc');
const sbc = (data.sbcs || []).find(s => s.id === sbcId);
if (sbc) {
const el_card = document.getElementById(`sbc-${sbcId}`);
if (el_card) el_card.outerHTML = renderSbcCard(sbc);
}
} catch (_) {}
}
async function submitSbc(sbcId) {
const owned = sbcSelections[sbcId] || [];
if (!owned.length) { showToast('Select cards first', true); return; }
try {
const r = await api('POST', '/sbc/submit', { sbc_id: sbcId, owned_card_ids: owned });
if (r.passed) {
delete sbcSelections[sbcId];
const reward = r.reward || {};
showToast(`SBC complete! ${reward.coins ? reward.coins.toLocaleString() + ' coins' : ''} ${reward.pack_id ? '+ ' + reward.pack_id : ''}`);
allCards = [];
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
await loadSbc();
} else {
showToast('Failed: ' + (r.failures || []).join('; '), true);
}
} catch (e) { showToast(e.message, true); }
}
// ── Statistics ────────────────────────────────────────────────────────────────
async function loadStatistics() {
try {
const s = await api('GET', '/statistics');
const pg = s.position_goals || {};
el('career-stats').innerHTML = `
<div class="info-row"><span class="lbl">Matches</span><strong>${s.matches_played ?? 0}</strong></div>
<div class="info-row"><span class="lbl">Wins</span><span style="color:#3fb950">${s.wins ?? 0}</span></div>
<div class="info-row"><span class="lbl">Draws</span>${s.draws ?? 0}</div>
<div class="info-row"><span class="lbl">Losses</span><span style="color:#f85149">${s.losses ?? 0}</span></div>
<div class="info-row"><span class="lbl">Win rate</span>${s.matches_played > 0 ? Math.round(((s.wins??0)/s.matches_played)*100) : 0}%</div>
<div class="info-row"><span class="lbl">Goals scored</span>${s.goals_scored ?? 0}</div>
<div class="info-row"><span class="lbl">Goals conceded</span>${s.goals_conceded ?? 0}</div>
<div class="info-row"><span class="lbl">Avg goals/game</span>${s.matches_played > 0 ? ((s.goals_scored??0)/s.matches_played).toFixed(1) : '0.0'}</div>
<div class="info-row"><span class="lbl">Win streak</span>${s.current_win_streak ?? 0}</div>
<div class="info-row"><span class="lbl">Best streak</span>${s.best_win_streak ?? 0}</div>
<div class="info-row"><span class="lbl">Packs opened</span>${s.packs_opened ?? 0}</div>
<div class="info-row"><span class="lbl">SBCs done</span>${s.sbcs_completed ?? 0}</div>
<div class="info-row"><span class="lbl">Coins earned</span>${(s.total_coins_earned ?? 0).toLocaleString()}</div>
`;
const posEntries = Object.entries(pg).sort((a, b) => b[1] - a[1]);
el('position-goals').innerHTML = posEntries.length
? posEntries.map(([pos, goals]) => {
const max = posEntries[0][1] || 1;
const pct = Math.round((goals / max) * 100);
return `<div class="stat-bar-row" style="font-size:0.8rem">
<span class="stat-bar-label" style="width:36px">${pos}</span>
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:${pct}%"></div></div>
<span style="font-size:0.75rem;width:22px;text-align:right">${goals}</span>
</div>`;
}).join('')
: '<span class="empty">No goal data yet.</span>';
} catch (e) { el('career-stats').innerHTML = `<div class="error-msg">${e.message}</div>`; }
try {
const h = await api('GET', '/statistics/history?limit=20');
const matches = h.matches || [];
if (!matches.length) {
el('stats-history').innerHTML = '<span class="empty">No match history yet.</span>';
return;
}
const summ = h.summary || {};
el('stats-history').innerHTML = `
<div style="display:flex;gap:24px;font-size:0.85rem;margin-bottom:16px;flex-wrap:wrap">
<span>Last ${matches.length} matches</span>
<span style="color:#3fb950">${summ.wins ?? 0}W</span>
<span>${summ.draws ?? 0}D</span>
<span style="color:#f85149">${summ.losses ?? 0}L</span>
<span>Avg goals: ${summ.avg_goals_per_game ?? 0}</span>
<span>Win rate: ${((summ.win_rate ?? 0)*100).toFixed(0)}%</span>
</div>` +
matches.map(m => {
const cls = m.outcome === 'win' ? 'win' : m.outcome === 'draw' ? 'draw' : 'loss';
const lbl = { win: 'W', draw: 'D', loss: 'L' }[m.outcome] || '?';
return `<div class="match-history-row">
<span class="outcome-badge outcome-${cls}">${lbl}</span>
<span style="flex:1">${m.opponent_name}</span>
<span style="font-weight:600">${m.goals_for}${m.goals_against}</span>
<span style="color:#f5a623;font-size:0.75rem">+${(m.coins_awarded||0).toLocaleString()}</span>
<span style="color:#8b949e;font-size:0.72rem">${new Date(m.played_at).toLocaleDateString()}</span>
</div>`;
}).join('');
} catch (e) { el('stats-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
// ── Squad ─────────────────────────────────────────────────────────────────────
async function loadSquad() {