Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions backend/app/services/runs_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
100 changes: 93 additions & 7 deletions frontend/app/leaderboards/stats/StatsClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, { card_id: string; count: number }[]>;
top_cards: {
card_id: string;
count: number;
Expand Down Expand Up @@ -607,7 +610,14 @@ export default function StatsClient() {
</div>
) : (
<>
{tab === "overview" && <OverviewTab stats={stats} onCharacterClick={setCharacter} lang={lang} />}
{tab === "overview" && (
<OverviewTab
stats={stats}
onCharacterClick={setCharacter}
lang={lang}
lp={lp}
/>
)}
{tab === "cards" && (
<CardsTab
rows={filteredCards}
Expand Down Expand Up @@ -666,10 +676,12 @@ function OverviewTab({
stats,
onCharacterClick,
lang,
lp,
}: {
stats: CommunityStats;
onCharacterClick: (c: string) => void;
lang: string;
lp: string;
}) {
const losses =
(stats.total_runs || 0) - (stats.total_wins || 0) - (stats.total_abandoned || 0);
Expand All @@ -679,27 +691,43 @@ function OverviewTab({
return (
<div className="space-y-4">
<div className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-subtle)] p-5">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 text-center">
<div className="bg-[var(--bg-primary)] rounded-lg p-3">
<div className="text-2xl font-bold text-[var(--text-primary)]">
{stats.total_runs}
<div className="text-2xl font-bold text-[var(--text-primary)] tabular-nums">
{stats.total_runs.toLocaleString()}
</div>
<div className="text-xs text-[var(--text-muted)]">{t("Runs", lang)}</div>
</div>
<div className="bg-[var(--bg-primary)] rounded-lg p-3">
<div className="text-2xl font-bold text-emerald-400">{stats.total_wins}</div>
<div className="text-2xl font-bold text-emerald-400 tabular-nums">
{stats.total_wins.toLocaleString()}
</div>
<div className="text-xs text-[var(--text-muted)]">{t("Wins", lang)}</div>
</div>
<div className="bg-[var(--bg-primary)] rounded-lg p-3">
<div className="text-2xl font-bold text-red-400">{losses}</div>
<div className="text-2xl font-bold text-red-400 tabular-nums">
{losses.toLocaleString()}
</div>
<div className="text-xs text-[var(--text-muted)]">{t("Losses", lang)}</div>
</div>
<div className="bg-[var(--bg-primary)] rounded-lg p-3">
<div className="text-2xl font-bold text-[var(--accent-gold)]">
<div className="text-2xl font-bold text-[var(--accent-gold)] tabular-nums">
{stats.win_rate}%
</div>
<div className="text-xs text-[var(--text-muted)]">{t("Win %", lang)}</div>
</div>
<div className="bg-[var(--bg-primary)] rounded-lg p-3">
<div className="text-2xl font-bold text-[var(--text-primary)] tabular-nums">
{(stats.unique_players ?? 0).toLocaleString()}
</div>
<div className="text-xs text-[var(--text-muted)]">Named players</div>
</div>
<div className="bg-[var(--bg-primary)] rounded-lg p-3">
<div className="text-2xl font-bold text-[var(--text-primary)] tabular-nums">
{(stats.total_cards_picked ?? 0).toLocaleString()}
</div>
<div className="text-xs text-[var(--text-muted)]">Cards picked</div>
</div>
</div>
</div>

Expand Down Expand Up @@ -799,6 +827,64 @@ function OverviewTab({
</table>
</div>
)}

{/* 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 && (
<div className="bg-[var(--bg-card)] rounded-xl border border-[var(--border-subtle)] p-5">
<h2 className="text-sm font-semibold text-[var(--text-muted)] uppercase tracking-wide mb-1">
Top winning cards by character
</h2>
<p className="text-xs text-[var(--text-muted)] mb-3">
The five most-frequent cards in winning decks per character. Community-wide; filters above don&apos;t apply here.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
{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 (
<div
key={char}
className="bg-[var(--bg-primary)] rounded-lg p-3 border border-[var(--border-subtle)]"
>
<div
className="text-xs font-semibold uppercase tracking-wider mb-2"
style={{ color }}
>
{displayName(`CHARACTER.${char}`)}
</div>
<ol className="space-y-1 text-sm">
{rows.map((c, i) => (
<li
key={c.card_id}
className="flex items-baseline justify-between gap-2"
>
<Link
href={`${lp ? `/${lp}` : ""}/cards/${c.card_id.toLowerCase()}`}
className="text-[var(--text-secondary)] hover:text-[var(--accent-gold)] hover:underline truncate"
>
<span className="text-[var(--text-muted)] mr-1">
{i + 1}.
</span>
{displayName(`CARD.${c.card_id}`)}
</Link>
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums shrink-0">
{c.count}
</span>
</li>
))}
</ol>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
Expand Down
Loading