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. + ))} +
+
+ ); + })} +
+
+ )}
); }