From f24bd05be8f92b6b5551d27c2ce94522fa6406b0 Mon Sep 17 00:00:00 2001 From: KoalaTrapLord <265133236+KoalaTrapLord@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:51:09 -0400 Subject: [PATCH] feat: collection stats dashboard (CMC curve, color/type/rarity/value) --- routes/collection.py | 97 ++++++++++++++++++++ ui/collection.css | 211 +++++++++++++++++++++++++++++++++++++++++++ ui/collection.js | 146 ++++++++++++++++++++++++++++++ 3 files changed, 454 insertions(+) diff --git a/routes/collection.py b/routes/collection.py index 9da7e1e..e681535 100644 --- a/routes/collection.py +++ b/routes/collection.py @@ -45,6 +45,103 @@ from services.scryfall import ( router = APIRouter(tags=["collection"]) +@router.get("/api/collection/stats") +async def collection_stats(): + """Aggregate collection statistics for the dashboard.""" + conn = _get_db_conn() + stats = {} + + # Total cards and unique cards + row = conn.execute( + "SELECT COUNT(*) as total_rows, COALESCE(SUM(quantity),0) as total_cards, " + "COALESCE(SUM(tcg_price * quantity),0) as total_value " + "FROM collection_entries" + ).fetchone() + stats["totalUniqueCards"] = row["total_rows"] + stats["totalCards"] = row["total_cards"] + stats["totalValue"] = round(row["total_value"], 2) + + # CMC distribution (0-7+) + cmc_rows = conn.execute( + "SELECT CASE WHEN cmc >= 7 THEN 7 ELSE CAST(cmc AS INTEGER) END as cmc_bucket, " + "SUM(quantity) as count FROM collection_entries GROUP BY cmc_bucket ORDER BY cmc_bucket" + ).fetchall() + stats["cmcCurve"] = {str(r["cmc_bucket"]): r["count"] for r in cmc_rows} + + # Color identity distribution + color_rows = conn.execute( + "SELECT color_identity, SUM(quantity) as count FROM collection_entries GROUP BY color_identity" + ).fetchall() + color_counts = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0} + for r in color_rows: + ci = r["color_identity"] or "[]" + try: + colors = json.loads(ci) if isinstance(ci, str) else ci + except (json.JSONDecodeError, TypeError): + colors = [] + qty = r["count"] or 0 + if not colors: + color_counts["C"] += qty + else: + for c in colors: + if c in color_counts: + color_counts[c] += qty + stats["colorDistribution"] = color_counts + + # Type distribution + type_rows = conn.execute( + "SELECT type_line, SUM(quantity) as count FROM collection_entries GROUP BY type_line" + ).fetchall() + type_counts = {} + for r in type_rows: + tl = r["type_line"] or "" + qty = r["count"] or 0 + for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]: + if t in tl: + type_counts[t] = type_counts.get(t, 0) + qty + stats["typeDistribution"] = type_counts + + # Category/role distribution + cat_rows = conn.execute( + "SELECT category, SUM(quantity) as count FROM collection_entries " + "WHERE category != '' AND category != '[]' GROUP BY category" + ).fetchall() + cat_counts = {} + for r in cat_rows: + raw = r["category"] or "[]" + qty = r["count"] or 0 + try: + cats = json.loads(raw) if isinstance(raw, str) else raw + except (json.JSONDecodeError, TypeError): + cats = [] + if isinstance(cats, list): + for c in cats: + cat_counts[c] = cat_counts.get(c, 0) + qty + stats["categoryDistribution"] = cat_counts + + # Rarity distribution + rarity_rows = conn.execute( + "SELECT LOWER(rarity) as rarity, SUM(quantity) as count FROM collection_entries " + "WHERE rarity != '' GROUP BY LOWER(rarity)" + ).fetchall() + stats["rarityDistribution"] = {r["rarity"]: r["count"] for r in rarity_rows} + + # Top 10 most valuable cards + top_value = conn.execute( + "SELECT name, tcg_price, quantity FROM collection_entries " + "WHERE tcg_price > 0 ORDER BY tcg_price DESC LIMIT 10" + ).fetchall() + stats["topValueCards"] = [{"name": r["name"], "price": r["tcg_price"], "qty": r["quantity"]} for r in top_value] + + # Average CMC (excluding lands) + avg_row = conn.execute( + "SELECT AVG(cmc) as avg_cmc FROM collection_entries WHERE type_line NOT LIKE '%Land%'" + ).fetchone() + stats["avgCmc"] = round(avg_row["avg_cmc"] or 0, 2) + + return stats + + @router.get("/api/collection/export") async def export_collection( format: str = "INTERNAL_CSV", diff --git a/ui/collection.css b/ui/collection.css index 39dce54..f16ec86 100644 --- a/ui/collection.css +++ b/ui/collection.css @@ -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); + } +} + diff --git a/ui/collection.js b/ui/collection.js index c04ecb8..1f957d8 100644 --- a/ui/collection.js +++ b/ui/collection.js @@ -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 = '
Loading stats…
'; + 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 `
+
+ ${label} +
`; + }).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 `
+ ${ci.label} +
+ ${count} +
`; + }).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 `
+ ${escapeHtml(t)} +
+ ${count} +
`; + }).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 `
+ ${r.charAt(0).toUpperCase() + r.slice(1)} +
+ ${count} +
`; + }).join(''); + + // Top value cards + const topCards = (s.topValueCards || []).map(c => + `
${escapeHtml(c.name)}$${Number(c.price).toFixed(2)}
` + ).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 `
+ ${escapeHtml(cat)} +
+ ${count} +
`; + }).join(''); + + panel.innerHTML = ` +
+
+
${(s.totalCards || 0).toLocaleString()}
Total Cards
+
${(s.totalUniqueCards || 0).toLocaleString()}
Unique
+
$${(s.totalValue || 0).toLocaleString(undefined,{minimumFractionDigits:2})}
Total Value
+
${s.avgCmc || 0}
Avg CMC
+
+
+

CMC Curve

+
${cmcBars}
+
+
+

Color Distribution

+ ${colorBars} +
+
+

Type Distribution

+ ${typeBars} +
+
+

Rarity

+ ${rarityBars} +
+
+

Role Coverage

+ ${catBars || '
No categories assigned yet
'} +
+
+

Top Value Cards

+ ${topCards || '
No priced cards
'} +
+
+ `; + } + + 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 = (() => { + ⚔ Deck Builder ⚡ Auto Gen + +
@@ -2851,5 +2996,6 @@ const Collection = (() => { popoverAddCatFromInput, reEnrich, autoClassify, + toggleStats, }; })();