feat: collection stats dashboard (CMC curve, color/type/rarity/value)
This commit is contained in:
1 parent
e7ffccf3e8
commit
f24bd05be8
3 files changed
+454
No files matched your search
@@ -2841,3 +2841,214 @@
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ── 17. Stats Dashboard ─────────────────────────────────────── */
|
||||
|
||||
.coll-stats-btn {
|
||||
background: rgba(99,102,241,0.15);
|
||||
border: 1px solid rgba(99,102,241,0.4);
|
||||
color: var(--accent, #6366f1);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.coll-stats-btn:hover {
|
||||
background: rgba(99,102,241,0.3);
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.coll-stats-panel {
|
||||
background: var(--surface, #161b22);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
margin: 0 0 12px 0;
|
||||
animation: coll-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.coll-stats-loading {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coll-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Summary KPI cards */
|
||||
.coll-stats-summary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.coll-stat-card {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
background: var(--bg, #0d1117);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.coll-stat-num {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text, #e6edf3);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.coll-stat-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.coll-stats-section {
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.coll-stats-section h4 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
/* CMC vertical bar chart */
|
||||
.coll-stat-bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
height: 64px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.coll-stat-bar-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
.coll-stat-bar {
|
||||
width: 100%;
|
||||
background: var(--accent, #6366f1);
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-height: 2px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
.coll-stat-bar-label {
|
||||
position: absolute;
|
||||
bottom: -18px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
|
||||
/* Horizontal bar rows (colors, types, rarity, categories) */
|
||||
.coll-stat-color-bar,
|
||||
.coll-stat-type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.coll-stat-color-pip {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.coll-stat-type-label {
|
||||
width: 90px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text, #e6edf3);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coll-stat-hbar-track {
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
background: var(--bg, #0d1117);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
}
|
||||
.coll-stat-hbar {
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.coll-stat-hbar-type {
|
||||
background: var(--accent, #6366f1);
|
||||
}
|
||||
.coll-stat-hbar-cat {
|
||||
background: #2ea043;
|
||||
}
|
||||
.coll-stat-hbar-val {
|
||||
width: 36px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Top value cards list */
|
||||
.coll-stat-top-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.78rem;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
gap: 8px;
|
||||
}
|
||||
.coll-stat-top-row span:first-child {
|
||||
color: var(--text, #e6edf3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coll-stat-top-row span:last-child {
|
||||
color: #3fb950;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coll-stats-empty {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.coll-stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.coll-stats-summary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1400px) {
|
||||
.coll-stats-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +410,148 @@ const Collection = (() => {
|
||||
renderCountBadge();
|
||||
}
|
||||
|
||||
// ── Stats Dashboard ──────────────────────────────────────
|
||||
let statsData = null;
|
||||
let statsOpen = false;
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/collection/stats`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
statsData = await res.json();
|
||||
} catch (err) {
|
||||
console.error('[Collection] fetchStats error:', err);
|
||||
statsData = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStats() {
|
||||
statsOpen = !statsOpen;
|
||||
const panel = document.getElementById('coll-stats-panel');
|
||||
if (!panel) return;
|
||||
if (statsOpen) {
|
||||
if (!statsData) {
|
||||
panel.innerHTML = '<div class="coll-stats-loading">Loading stats…</div>';
|
||||
panel.style.display = 'block';
|
||||
fetchStats().then(() => renderStatsPanel());
|
||||
} else {
|
||||
renderStatsPanel();
|
||||
}
|
||||
panel.style.display = 'block';
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatsPanel() {
|
||||
const panel = document.getElementById('coll-stats-panel');
|
||||
if (!panel || !statsData) return;
|
||||
const s = statsData;
|
||||
|
||||
// CMC curve bar chart (CSS-only)
|
||||
const cmcMax = Math.max(...Object.values(s.cmcCurve || {}), 1);
|
||||
const cmcBars = [0,1,2,3,4,5,6,7].map(i => {
|
||||
const count = (s.cmcCurve || {})[String(i)] || 0;
|
||||
const pct = Math.round((count / cmcMax) * 100);
|
||||
const label = i === 7 ? '7+' : String(i);
|
||||
return `<div class="coll-stat-bar-col">
|
||||
<div class="coll-stat-bar" style="height:${pct}%" title="${count}"></div>
|
||||
<span class="coll-stat-bar-label">${label}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Color distribution
|
||||
const colorTotal = Math.max(Object.values(s.colorDistribution || {}).reduce((a,b) => a+b, 0), 1);
|
||||
const colorBars = ['W','U','B','R','G','C'].map(c => {
|
||||
const count = (s.colorDistribution || {})[c] || 0;
|
||||
const pct = Math.round((count / colorTotal) * 100);
|
||||
const ci = COLOR_MAP[c];
|
||||
return `<div class="coll-stat-color-bar">
|
||||
<span class="coll-stat-color-pip" style="background:${ci.bg};color:${ci.color}">${ci.label}</span>
|
||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${ci.bg}"></div></div>
|
||||
<span class="coll-stat-hbar-val">${count}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Type distribution
|
||||
const typeTotal = Math.max(Object.values(s.typeDistribution || {}).reduce((a,b) => a+b, 0), 1);
|
||||
const typeBars = Object.entries(s.typeDistribution || {}).sort((a,b) => b[1]-a[1]).map(([t, count]) => {
|
||||
const pct = Math.round((count / typeTotal) * 100);
|
||||
return `<div class="coll-stat-type-row">
|
||||
<span class="coll-stat-type-label">${escapeHtml(t)}</span>
|
||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-type" style="width:${pct}%"></div></div>
|
||||
<span class="coll-stat-hbar-val">${count}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Rarity distribution
|
||||
const rarityOrder = ['common','uncommon','rare','mythic'];
|
||||
const rarityColors = {common:'#706f6f', uncommon:'#859ba6', rare:'#c9a849', mythic:'#d35400'};
|
||||
const rarityTotal = Math.max(Object.values(s.rarityDistribution || {}).reduce((a,b) => a+b, 0), 1);
|
||||
const rarityBars = rarityOrder.map(r => {
|
||||
const count = (s.rarityDistribution || {})[r] || 0;
|
||||
const pct = Math.round((count / rarityTotal) * 100);
|
||||
return `<div class="coll-stat-type-row">
|
||||
<span class="coll-stat-type-label" style="color:${rarityColors[r] || '#ccc'}">${r.charAt(0).toUpperCase() + r.slice(1)}</span>
|
||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${rarityColors[r] || '#555'}"></div></div>
|
||||
<span class="coll-stat-hbar-val">${count}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Top value cards
|
||||
const topCards = (s.topValueCards || []).map(c =>
|
||||
`<div class="coll-stat-top-row"><span>${escapeHtml(c.name)}</span><span>$${Number(c.price).toFixed(2)}</span></div>`
|
||||
).join('');
|
||||
|
||||
// Category/role coverage
|
||||
const catEntries = Object.entries(s.categoryDistribution || {}).sort((a,b) => b[1]-a[1]).slice(0, 12);
|
||||
const catMax = Math.max(...catEntries.map(e => e[1]), 1);
|
||||
const catBars = catEntries.map(([cat, count]) => {
|
||||
const pct = Math.round((count / catMax) * 100);
|
||||
return `<div class="coll-stat-type-row">
|
||||
<span class="coll-stat-type-label">${escapeHtml(cat)}</span>
|
||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-cat" style="width:${pct}%"></div></div>
|
||||
<span class="coll-stat-hbar-val">${count}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="coll-stats-grid">
|
||||
<div class="coll-stats-summary">
|
||||
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalCards || 0).toLocaleString()}</div><div class="coll-stat-label">Total Cards</div></div>
|
||||
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalUniqueCards || 0).toLocaleString()}</div><div class="coll-stat-label">Unique</div></div>
|
||||
<div class="coll-stat-card"><div class="coll-stat-num">$${(s.totalValue || 0).toLocaleString(undefined,{minimumFractionDigits:2})}</div><div class="coll-stat-label">Total Value</div></div>
|
||||
<div class="coll-stat-card"><div class="coll-stat-num">${s.avgCmc || 0}</div><div class="coll-stat-label">Avg CMC</div></div>
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>CMC Curve</h4>
|
||||
<div class="coll-stat-bar-chart">${cmcBars}</div>
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Color Distribution</h4>
|
||||
${colorBars}
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Type Distribution</h4>
|
||||
${typeBars}
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Rarity</h4>
|
||||
${rarityBars}
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Role Coverage</h4>
|
||||
${catBars || '<div class="coll-stats-empty">No categories assigned yet</div>'}
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Top Value Cards</h4>
|
||||
${topCards || '<div class="coll-stats-empty">No priced cards</div>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
async function fetchCardDetail(cardId) {
|
||||
const res = await fetch(`${API_BASE}/api/collection/${cardId}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -489,10 +631,13 @@ const Collection = (() => {
|
||||
<button class="coll-export-btn" onclick="Collection.openExport()">Export</button>
|
||||
<button class="coll-autoclassify-btn" onclick="Collection.autoClassify()" title="Auto-detect categories (Ramp, Draw, Removal, etc.) for uncategorized cards">Auto-Classify</button>
|
||||
<button class="coll-reenrich-btn" onclick="Collection.reEnrich()" title="Re-fetch all card data from Scryfall">Refresh Data</button>
|
||||
<button class="coll-stats-btn" onclick="Collection.toggleStats()" title="Show collection statistics dashboard">📊 Stats</button>
|
||||
<a href="deckbuilder.html" class="coll-nav-link" style="color:var(--lab-purple);border-color:var(--lab-purple)">⚔ Deck Builder</a>
|
||||
<a href="deckgenerator.html" class="coll-nav-link" style="color:var(--lab-warning);border-color:var(--lab-warning)">⚡ Auto Gen</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Dashboard Panel -->
|
||||
<div id="coll-stats-panel" class="coll-stats-panel" style="display:none"></div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="coll-main">
|
||||
@@ -2851,5 +2996,6 @@ const Collection = (() => {
|
||||
popoverAddCatFromInput,
|
||||
reEnrich,
|
||||
autoClassify,
|
||||
toggleStats,
|
||||
};
|
||||
})();
|
||||
Reference in new issue
Block a user