From 04bf8f72e1c0f0788aaf144e9339123d3cbbe7d9 Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Tue, 12 May 2026 01:28:44 -0700 Subject: [PATCH] =?UTF-8?q?Expand=20/leaderboards/stats=20overview=20?= =?UTF-8?q?=E2=80=94=20players,=20picks,=20winning=20archetypes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap #4 (post-Codex-Score). Three additions to the Overview tab: 1. Two new tiles in the totals strip: - Named players (DISTINCT username, excluding anonymous submissions) - Cards picked (SUM was_picked across run_card_choices) 2. New "Top winning cards by character" panel — five mini-decks below the ascension breakdown, one per character (Ironclad/Silent/Defect/ Necrobinder/Regent), showing the five most-frequent cards in winning runs. Always unfiltered: the per-character grid collapses to a tautology if the user narrows to one character, so the panel is hidden when stats.filters.character is set. 3. Strip expanded from 4 to 6 tiles (Runs / Wins / Losses / Win % / Players / Picks). Numbers now use .toLocaleString() for thousands separators which become readable past 10k runs. ### Backend get_stats() returns three new fields: unique_players — distinct non-NULL usernames total_cards_picked — SUM(was_picked) from run_card_choices winning_cards_by_character — { CHAR: [{card_id, count}, ...] } The winning-cards-per-character query uses SQLite's window function: ROW_NUMBER() OVER (PARTITION BY character ORDER BY COUNT(*) DESC) to pick the top 5 per character in a single query rather than 5 follow-up queries. SQLite 3.25+; the prod container is well past that. Computed on-demand alongside the rest of get_stats — same query budget. The aggregate ignores all WHERE filters intentionally; the panel is a community-meta overview and the rest of the response already gives the filtered slice. ### Frontend StatsClient interface gains unique_players, total_cards_picked, and optional winning_cards_by_character. OverviewTab threads `lp` (language prefix) through props so the new card links route through the i18n landing pages correctly. --- backend/app/services/runs_db.py | 57 ++++++++++ .../app/leaderboards/stats/StatsClient.tsx | 100 ++++++++++++++++-- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/backend/app/services/runs_db.py b/backend/app/services/runs_db.py index 2d173a4f..c90ae194 100644 --- a/backend/app/services/runs_db.py +++ b/backend/app/services/runs_db.py @@ -696,6 +696,60 @@ def get_stats( win_card_map = {r["card_id"]: r["count"] for r in win_cards} loss_card_map = {r["card_id"]: r["count"] for r in loss_cards} + # Total distinct claimed players (non-NULL usernames). Anonymous + # submissions are counted in total_runs but not here. + unique_players = ( + conn.execute( + f"SELECT COUNT(DISTINCT r.username) AS c FROM runs r {where}" + + (" AND " if where else "WHERE ") + + "r.username IS NOT NULL AND r.username != ''", + params, + ).fetchone()["c"] + or 0 + ) + + # Total card-reward decisions where the player took a card. Useful + # context for the per-card pick rates (denominator scale). + total_cards_picked = ( + conn.execute( + f""" + SELECT COALESCE(SUM(cc.was_picked), 0) AS c + FROM run_card_choices cc JOIN runs r ON cc.run_id = r.id + {where} + """, + params, + ).fetchone()["c"] + or 0 + ) + + # Top 5 most-common cards in winning decks, broken down per + # character. Intentionally ignores all WHERE filters — this is the + # community-meta archetype-overview panel; the rest of the response + # already gives the filtered view. SQLite window function + # `ROW_NUMBER() OVER (PARTITION BY character ORDER BY count DESC)` + # selects the top 5 per character in a single query. + winning_cards_by_char_raw = conn.execute(""" + WITH per_char AS ( + SELECT r.character, + rc.card_id, + COUNT(*) AS count, + ROW_NUMBER() OVER ( + PARTITION BY r.character + ORDER BY COUNT(*) DESC + ) AS rn + FROM run_cards rc JOIN runs r ON rc.run_id = r.id + WHERE r.win = 1 + GROUP BY r.character, rc.card_id + ) + SELECT character, card_id, count FROM per_char WHERE rn <= 5 + ORDER BY character, rn + """).fetchall() + winning_cards_by_character: dict[str, list[dict]] = {} + for row in winning_cards_by_char_raw: + winning_cards_by_character.setdefault(row["character"], []).append( + {"card_id": row["card_id"], "count": row["count"]} + ) + # Potion stats (filtered) try: potion_stats = conn.execute( @@ -720,6 +774,9 @@ def get_stats( "total_wins": wins, "total_abandoned": abandoned, "win_rate": round(wins / total * 100, 1) if total > 0 else 0, + "unique_players": unique_players, + "total_cards_picked": total_cards_picked, + "winning_cards_by_character": winning_cards_by_character, "filters": { "character": character, "win": win, diff --git a/frontend/app/leaderboards/stats/StatsClient.tsx b/frontend/app/leaderboards/stats/StatsClient.tsx index 2f5d9553..059d3361 100644 --- a/frontend/app/leaderboards/stats/StatsClient.tsx +++ b/frontend/app/leaderboards/stats/StatsClient.tsx @@ -54,6 +54,8 @@ interface CommunityStats { total_wins: number; total_abandoned: number; win_rate: number; + unique_players: number; + total_cards_picked: number; filters: { character: string | null; win: string | null; @@ -63,6 +65,7 @@ interface CommunityStats { }; characters: { character: string; total: number; wins: number; win_rate: number }[]; ascensions: { level: number; total: number; wins: number; win_rate: number }[]; + winning_cards_by_character?: Record; top_cards: { card_id: string; count: number; @@ -607,7 +610,14 @@ export default function StatsClient() { ) : ( <> - {tab === "overview" && } + {tab === "overview" && ( + + )} {tab === "cards" && ( void; lang: string; + lp: string; }) { const losses = (stats.total_runs || 0) - (stats.total_wins || 0) - (stats.total_abandoned || 0); @@ -679,27 +691,43 @@ function OverviewTab({ return (
-
+
-
- {stats.total_runs} +
+ {stats.total_runs.toLocaleString()}
{t("Runs", lang)}
-
{stats.total_wins}
+
+ {stats.total_wins.toLocaleString()} +
{t("Wins", lang)}
-
{losses}
+
+ {losses.toLocaleString()} +
{t("Losses", lang)}
-
+
{stats.win_rate}%
{t("Win %", lang)}
+
+
+ {(stats.unique_players ?? 0).toLocaleString()} +
+
Named players
+
+
+
+ {(stats.total_cards_picked ?? 0).toLocaleString()} +
+
Cards picked
+
@@ -799,6 +827,64 @@ function OverviewTab({
)} + + {/* Top winning cards per character — community-wide signal of + which cards anchor winning decks for each archetype. Always + unfiltered: collapses to a tautology if narrowed to one + character, so we hide it when the user has filtered down. */} + {!stats.filters.character && + stats.winning_cards_by_character && + Object.keys(stats.winning_cards_by_character).length > 0 && ( +
+

+ Top winning cards by character +

+

+ The five most-frequent cards in winning decks per character. Community-wide; filters above don't apply here. +

+
+ {CHARACTERS.map((char) => { + const rows = stats.winning_cards_by_character?.[char] ?? []; + if (rows.length === 0) return null; + const color = CHAR_COLORS[char] || "var(--text-muted)"; + return ( +
+
+ {displayName(`CHARACTER.${char}`)} +
+
    + {rows.map((c, i) => ( +
  1. + + + {i + 1}. + + {displayName(`CARD.${c.card_id}`)} + + + {c.count} + +
  2. + ))} +
+
+ ); + })} +
+
+ )}
); }