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 = '