Add Ranked 1v1 Leaderboard (#3008)

If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #(issue number)

@wraith4081 's pr

updates the stats modal to show both 1v1 and clan stats

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
regression is found:

w.o.n

---------

Co-authored-by: Wraith <54374743+wraith4081@users.noreply.github.com>
Co-authored-by: iamlewis <lewismmmm@gmail.com>
This commit is contained in:
Ryan
2026-02-01 22:58:54 +00:00
committed by evanpelle
parent 5aa023bba5
commit 106938c395
18 changed files with 1622 additions and 585 deletions
+81
View File
@@ -1,7 +1,11 @@
import { z } from "zod";
import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
PlayerProfile,
PlayerProfileSchema,
RankedLeaderboardResponse,
RankedLeaderboardResponseSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../core/ApiSchemas";
@@ -185,3 +189,80 @@ export async function fetchGameById(
return false;
}
}
export async function fetchClanLeaderboard(): Promise<
ClanLeaderboardResponse | false
> {
try {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
console.warn(
"fetchClanLeaderboard: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn(
"fetchClanLeaderboard: Zod validation failed",
parsed.error.toString(),
);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchClanLeaderboard: request failed", err);
return false;
}
}
export async function fetchPlayerLeaderboard(
page: number,
): Promise<RankedLeaderboardResponse | "reached_limit" | false> {
try {
const url = new URL(`${getApiBase()}/leaderboard/ranked`);
url.searchParams.set("page", String(page));
const res = await fetch(url.toString(), {
headers: { Accept: "application/json" },
});
if (!res.ok) {
// Handle "Page must be between X and Y" error as end of list
if (res.status === 400) {
const errorJson = await res.json().catch(() => null);
if (errorJson?.message?.includes("Page must be between")) {
return "reached_limit";
}
}
console.warn(
"fetchPlayerLeaderboard: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = RankedLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn(
"fetchPlayerLeaderboard: Zod validation failed",
parsed.error.toString(),
);
return false;
}
return parsed.data;
} catch (err) {
console.error("fetchPlayerLeaderboard: request failed", err);
return false;
}
}
+3 -3
View File
@@ -223,9 +223,9 @@ export class JoinPrivateLobbyModal extends BaseModal {
"Atom Bomb": "unit_type.atom_bomb",
"Hydrogen Bomb": "unit_type.hydrogen_bomb",
MIRV: "unit_type.mirv",
"Trade Ship": "stats_modal.unit.trade",
Transport: "stats_modal.unit.trans",
"MIRV Warhead": "stats_modal.unit.mirvw",
"Trade Ship": "player_stats_table.unit.trade",
Transport: "player_stats_table.unit.trans",
"MIRV Warhead": "player_stats_table.unit.mirvw",
};
return html`
+4 -1
View File
@@ -206,6 +206,9 @@ export class LangSelector extends LitElement {
"join-private-lobby-modal",
"emoji-table",
"leader-board",
"leaderboard-tabs",
"leaderboard-player-list",
"leaderboard-clan-table",
"build-menu",
"win-modal",
"game-starting-modal",
@@ -225,7 +228,7 @@ export class LangSelector extends LitElement {
"news-modal",
"news-button",
"account-modal",
"stats-modal",
"leaderboard-modal",
"flag-input-modal",
"flag-input",
"matchmaking-button",
+133
View File
@@ -0,0 +1,133 @@
import { html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { BaseModal } from "./components/BaseModal";
import "./components/leaderboard/LeaderboardClanTable";
import type { LeaderboardClanTable } from "./components/leaderboard/LeaderboardClanTable";
import "./components/leaderboard/LeaderboardPlayerList";
import type { LeaderboardPlayerList } from "./components/leaderboard/LeaderboardPlayerList";
import "./components/leaderboard/LeaderboardTabs";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
@customElement("leaderboard-modal")
export class LeaderboardModal extends BaseModal {
@state() private activeTab: "players" | "clans" = "players";
@state()
private clanDateRange: { start: string; end: string } | null = null;
@query("leaderboard-player-list")
private playerList?: LeaderboardPlayerList;
@query("leaderboard-clan-table")
private clanTable?: LeaderboardClanTable;
private loadToken = 0;
protected onOpen(): void {
this.loadActiveTabData();
}
private loadActiveTabData() {
const token = ++this.loadToken;
const run = async () => {
if (token !== this.loadToken) return;
if (this.activeTab === "players") {
await this.playerList?.ensureLoaded();
if (token !== this.loadToken) return;
this.playerList?.handleTabActivated();
} else {
await this.clanTable?.ensureLoaded();
}
queueMicrotask(() => {
if (token !== this.loadToken) return;
if (this.activeTab === "players") void this.clanTable?.ensureLoaded();
else void this.playerList?.ensureLoaded();
});
};
void (async () => {
if (!(this.activeTab === "players" ? this.playerList : this.clanTable)) {
await this.updateComplete;
}
await run();
})();
}
private handleTabChange(tab: "clans" | "players") {
this.activeTab = tab;
this.loadActiveTabData();
}
private handleClanDateRangeChange(
event: CustomEvent<{ start: string; end: string }>,
) {
this.clanDateRange = event.detail;
}
render() {
let dateRange = html``;
if (this.clanDateRange) {
const start = new Date(this.clanDateRange.start).toLocaleDateString();
const end = new Date(this.clanDateRange.end).toLocaleDateString();
dateRange = html`<span
class="text-sm font-normal text-white/40 ml-2 wrap-break-words"
>(${start} - ${end})</span
>`;
}
const content = html`
<div
class="h-full flex flex-col bg-black/80 backdrop-blur-xl rounded-2xl border border-white/10 overflow-hidden shadow-2xl"
>
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
>
${translateText("leaderboard_modal.title")}
</span>
${this.activeTab === "clans" ? dateRange : ""}
</div>
`,
onBack: this.close,
ariaLabel: translateText("common.close"),
})}
<div class="flex-1 flex flex-col min-h-0">
<leaderboard-tabs
.activeTab=${this.activeTab}
@tab-change=${(event: CustomEvent<"players" | "clans">) =>
this.handleTabChange(event.detail)}
></leaderboard-tabs>
<div class="flex-1 min-h-0">
<leaderboard-player-list
class=${this.activeTab === "players" ? "h-full" : "hidden"}
></leaderboard-player-list>
<leaderboard-clan-table
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
@date-range-change=${(
event: CustomEvent<{ start: string; end: string }>,
) => this.handleClanDateRangeChange(event)}
></leaderboard-clan-table>
</div>
</div>
</div>
`;
if (this.inline) return content;
return html`
<o-modal
id="leaderboard-modal"
?inline=${this.inline}
hideCloseButton
hideHeader
>
${content}
</o-modal>
`;
}
}
+2 -2
View File
@@ -26,6 +26,7 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
import "./LangSelector";
import { LangSelector } from "./LangSelector";
import { initLayout } from "./Layout";
import "./LeaderboardModal";
import "./Matchmaking";
import { MatchmakingModal } from "./Matchmaking";
import { initNavigation } from "./Navigation";
@@ -34,7 +35,6 @@ import "./PatternInput";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import "./StatsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
@@ -809,7 +809,7 @@ class Client {
"news-modal",
"flag-input-modal",
"account-button",
"stats-button",
"leaderboard-button",
"token-login",
"matchmaking-modal",
"lang-selector",
-417
View File
@@ -1,417 +0,0 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
ClanLeaderboardEntry,
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
} from "../core/ApiSchemas";
import { getApiBase } from "./Api";
import { translateText } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import { modalHeader } from "./components/ui/ModalHeader";
@customElement("stats-modal")
export class StatsModal extends BaseModal {
@state() private isLoading: boolean = false;
@state() private error: string | null = null;
@state() private data: ClanLeaderboardResponse | null = null;
@state() private sortBy: "rank" | "games" | "wins" | "losses" | "ratio" =
"rank";
@state() private sortOrder: "asc" | "desc" = "asc";
private hasLoaded = false;
private handleSort(column: "rank" | "games" | "wins" | "losses" | "ratio") {
if (this.sortBy === column) {
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
} else {
this.sortBy = column;
this.sortOrder = column === "rank" ? "asc" : "desc";
}
this.requestUpdate();
}
private getSortedClans(clans: ClanLeaderboardEntry[]) {
const sorted = [...clans];
sorted.sort((a, b) => {
let aVal: number, bVal: number;
switch (this.sortBy) {
case "games":
aVal = a.games;
bVal = b.games;
break;
case "wins":
aVal = a.weightedWins;
bVal = b.weightedWins;
break;
case "losses":
aVal = a.weightedLosses;
bVal = b.weightedLosses;
break;
case "ratio":
aVal = a.weightedWLRatio;
bVal = b.weightedWLRatio;
break;
case "rank":
default:
// Original order
return 0;
}
return this.sortOrder === "asc" ? aVal - bVal : bVal - aVal;
});
return sorted;
}
protected onOpen(): void {
if (!this.hasLoaded && !this.isLoading) {
void this.loadLeaderboard();
}
}
private async loadLeaderboard() {
this.isLoading = true;
this.error = null;
try {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: {
Accept: "application/json",
},
});
if (!res.ok) {
throw new Error(`Unexpected status ${res.status}`);
}
const json = await res.json();
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn(
"ClanLeaderboardModal: invalid response schema",
parsed.error,
);
throw new Error("Invalid response format");
}
this.data = parsed.data;
this.hasLoaded = true;
} catch (err) {
console.warn("ClanLeaderboardModal: failed to load leaderboard", err);
this.error = translateText("stats_modal.error");
} finally {
this.isLoading = false;
this.requestUpdate();
}
}
private renderBody() {
if (this.isLoading) {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
></div>
<p
class="text-blue-200/80 text-sm font-bold tracking-[0.2em] uppercase"
>
${translateText("stats_modal.loading")}
</p>
</div>
`;
}
if (this.error) {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-[0_0_30px_rgba(239,68,68,0.2)]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<p class="mb-8 text-center text-red-100/80 max-w-xs font-medium">
${this.error}
</p>
<button
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 hover:border-red-500/50 text-red-200 rounded-xl text-sm font-bold uppercase tracking-wider transition-all cursor-pointer hover:shadow-lg hover:shadow-red-500/10 active:scale-95"
@click=${() => this.loadLeaderboard()}
>
${translateText("stats_modal.try_again")}
</button>
</div>
`;
}
if (!this.data || this.data.clans.length === 0) {
return html`
<div
class="p-12 text-center text-white/40 flex flex-col items-center h-full justify-center"
>
<div class="bg-white/5 p-6 rounded-full mb-6 border border-white/5">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-white/20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<h3 class="text-xl font-bold text-white/60 mb-2">
${translateText("stats_modal.no_data_yet")}
</h3>
<p class="text-white/30 text-sm max-w-[200px]">
${translateText("stats_modal.no_stats")}
</p>
</div>
`;
}
const { clans } = this.data;
const maxGames = Math.max(...clans.map((c) => c.games), 1);
return html`
<div class="w-full pt-6">
<div
class="overflow-x-auto rounded-xl border border-white/5 bg-black/20"
>
<table class="w-full text-sm border-collapse">
<thead>
<tr
class="text-white/40 text-xs uppercase tracking-wider border-b border-white/5 bg-white/[0.02]"
>
<th class="py-4 px-4 text-center font-bold w-16">
${translateText("stats_modal.rank")}
</th>
<th class="py-4 px-4 text-left font-bold">
${translateText("stats_modal.clan")}
</th>
<th
@click=${() => this.handleSort("games")}
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors select-none"
>
<div class="flex items-center justify-end gap-1">
${translateText("stats_modal.games")}
${this.sortBy === "games"
? this.sortOrder === "asc"
? html`<span class="text-blue-400">↑</span>`
: html`<span class="text-blue-400">↓</span>`
: html`<span class="text-white/20">↕</span>`}
</div>
</th>
<th
@click=${() => this.handleSort("wins")}
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
title=${translateText("stats_modal.win_score_tooltip")}
>
<div class="flex items-center justify-end gap-1">
${translateText("stats_modal.win_score")}
${this.sortBy === "wins"
? this.sortOrder === "asc"
? html`<span class="text-blue-400">↑</span>`
: html`<span class="text-blue-400">↓</span>`
: html`<span class="text-white/20">↕</span>`}
</div>
</th>
<th
@click=${() => this.handleSort("losses")}
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
title=${translateText("stats_modal.loss_score_tooltip")}
>
<div class="flex items-center justify-end gap-1">
${translateText("stats_modal.loss_score")}
${this.sortBy === "losses"
? this.sortOrder === "asc"
? html`<span class="text-blue-400">↑</span>`
: html`<span class="text-blue-400">↓</span>`
: html`<span class="text-white/20">↕</span>`}
</div>
</th>
<th
@click=${() => this.handleSort("ratio")}
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors select-none"
>
<div class="flex items-center justify-end gap-1">
${translateText("stats_modal.win_loss_ratio")}
${this.sortBy === "ratio"
? this.sortOrder === "asc"
? html`<span class="text-blue-400">↑</span>`
: html`<span class="text-blue-400">↓</span>`
: html`<span class="text-white/20">↕</span>`}
</div>
</th>
</tr>
</thead>
<tbody>
${this.getSortedClans(clans).map((clan, index) => {
const rankColor =
index === 0
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
: index === 1
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
: index === 2
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
: "text-white/40 bg-white/5";
const rankIcon =
index === 0
? "👑"
: index === 1
? "🥈"
: index === 2
? "🥉"
: String(index + 1);
return html`
<tr
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
>
<td class="py-3 px-4 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
>
${rankIcon}
</div>
</td>
<td class="py-3 px-4">
<div class="flex items-center gap-3">
<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 font-bold text-xs tracking-wide group-hover:bg-blue-500/20 transition-colors"
>
${clan.clanTag}
</div>
</div>
</td>
<td class="py-3 px-4 text-right">
<div class="flex flex-col items-end gap-1">
<span class="text-white font-mono font-medium"
>${clan.games.toLocaleString()}</span
>
<div
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500/50 rounded-full"
style="width: ${(clan.games / maxGames) * 100}%"
></div>
</div>
</div>
</td>
<td
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
>
${clan.weightedWins}
</td>
<td
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
>
${clan.weightedLosses}
</td>
<td class="py-3 px-4 text-right pr-6">
<div class="inline-flex flex-col items-end">
<span
class="font-mono font-bold ${Number(
clan.weightedWLRatio,
) >= 1
? "text-green-400"
: "text-red-400"}"
>
${clan.weightedWLRatio}
</span>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("stats_modal.ratio")}</span
>
</div>
</td>
</tr>
`;
})}
</tbody>
</table>
</div>
</div>
`;
}
render() {
let dateRange = html``;
if (this.data) {
const start = new Date(this.data.start).toLocaleDateString();
const end = new Date(this.data.end).toLocaleDateString();
dateRange = html`<span
class="text-sm font-normal text-white/40 ml-2 break-words"
>(${start} - ${end})</span
>`;
}
const content = html`
<div
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
>
${modalHeader({
titleContent: html`
<div class="flex flex-wrap items-center gap-2">
<span
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
>
${translateText("stats_modal.clan_stats")}
</span>
${dateRange}
</div>
`,
onBack: this.close,
ariaLabel: translateText("common.close"),
leftClassName: "flex flex-wrap items-center gap-4 flex-1",
})}
<div
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
>
${this.renderBody()}
</div>
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
id="stats-modal"
title="${translateText("stats_modal.clan_stats")}"
?inline=${this.inline}
hideCloseButton
hideHeader
>
${content}
</o-modal>
`;
}
}
+2 -2
View File
@@ -167,8 +167,8 @@ export class DesktopNavBar extends LitElement {
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-stats"
data-i18n="main.stats"
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<div class="relative">
<button
+2 -2
View File
@@ -121,8 +121,8 @@ export class MobileNavBar extends LitElement {
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-stats"
data-i18n="main.stats"
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<div class="relative no-crazygames">
<button
@@ -0,0 +1,385 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import {
ClanLeaderboardEntry,
ClanLeaderboardResponse,
} from "../../../core/ApiSchemas";
import { fetchClanLeaderboard } from "../../Api";
import { translateText } from "../../Utils";
export type ClanSortColumn =
| "rank"
| "games"
| "winScore"
| "lossScore"
| "ratio";
export type ClanSortOrder = "asc" | "desc";
@customElement("leaderboard-clan-table")
export class LeaderboardClanTable extends LitElement {
@state() private clanData: ClanLeaderboardResponse | null = null;
@state() private isLoading = false;
@state() private error: string | null = null;
@state() private sortBy: ClanSortColumn = "rank";
@state() private sortOrder: ClanSortOrder = "asc";
private hasLoaded = false;
createRenderRoot() {
return this;
}
public async ensureLoaded() {
if (this.hasLoaded || this.isLoading) return;
await this.loadClanLeaderboard();
}
public async loadClanLeaderboard() {
this.isLoading = true;
this.error = null;
try {
const data = await fetchClanLeaderboard();
if (!data) throw new Error("Failed to load clan leaderboard");
this.clanData = data;
this.hasLoaded = true;
this.dispatchEvent(
new CustomEvent<{ start: string; end: string }>("date-range-change", {
detail: { start: data.start, end: data.end },
bubbles: true,
composed: true,
}),
);
} catch (error) {
console.error("loadClanLeaderboard: request failed", error);
this.error = translateText("leaderboard_modal.error");
} finally {
this.isLoading = false;
}
}
private handleSort(column: ClanSortColumn) {
if (this.sortBy === column) {
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
} else {
this.sortBy = column;
this.sortOrder = column === "rank" ? "asc" : "desc";
}
}
private getSortedClans(clans: ClanLeaderboardEntry[]) {
if (this.sortBy === "rank") {
const base = [...clans];
return this.sortOrder === "asc" ? base : base.reverse();
}
const sorted = [...clans];
sorted.sort((a, b) => {
let aVal: number, bVal: number;
switch (this.sortBy) {
case "games":
aVal = a.games;
bVal = b.games;
break;
case "winScore":
aVal = a.weightedWins;
bVal = b.weightedWins;
break;
case "lossScore":
aVal = a.weightedLosses;
bVal = b.weightedLosses;
break;
case "ratio":
aVal = a.weightedWLRatio;
bVal = b.weightedWLRatio;
break;
default:
return 0;
}
return this.sortOrder === "asc" ? aVal - bVal : bVal - aVal;
});
return sorted;
}
private renderLoading() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
></div>
<p class="text-blue-200/80 text-sm font-bold tracking-widest uppercase">
${translateText("leaderboard_modal.loading")}
</p>
</div>
`;
}
private renderError() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-lg shadow-red-500/10"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<p class="mb-8 text-center text-red-100/80 font-medium">
${this.error ?? translateText("leaderboard_modal.error")}
</p>
<button
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-xl text-sm font-bold uppercase transition-all active:scale-95"
@click=${() => this.loadClanLeaderboard()}
>
${translateText("leaderboard_modal.try_again")}
</button>
</div>
`;
}
private renderNoData() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white/40 h-full"
>
<div class="bg-white/5 p-6 rounded-full mb-6 border border-white/5">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-white/20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<h3 class="text-xl font-bold text-white/60 mb-2">
${translateText("leaderboard_modal.no_data_yet")}
</h3>
<p class="text-white/30 text-sm">
${translateText("leaderboard_modal.no_stats")}
</p>
</div>
`;
}
render() {
if (this.isLoading) return this.renderLoading();
if (this.error) return this.renderError();
if (!this.clanData || this.clanData.clans.length === 0)
return this.renderNoData();
const { clans } = this.clanData;
const sorted = this.getSortedClans(clans);
const maxGames = Math.max(...clans.map((c) => c.games), 1);
return html`
<div class="h-full px-6 pb-6">
<div
class="h-full overflow-y-auto overflow-x-auto rounded-xl border border-white/5 bg-black/20"
>
<table class="w-full text-sm border-collapse">
<thead>
<tr
class="text-white/40 text-[10px] uppercase tracking-wider border-b border-white/5 bg-white/2"
>
<th class="py-4 px-4 text-center font-bold w-16">
${translateText("leaderboard_modal.rank")}
</th>
<th class="py-4 px-4 text-left font-bold">
${translateText("leaderboard_modal.clan")}
</th>
<th
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors"
>
<button
@click=${() => this.handleSort("games")}
aria-sort=${this.sortBy === "games"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.games")}
${this.sortBy === "games"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
title=${translateText("leaderboard_modal.win_score_tooltip")}
>
<button
@click=${() => this.handleSort("winScore")}
aria-sort=${this.sortBy === "winScore"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.win_score")}
${this.sortBy === "winScore"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
title=${translateText("leaderboard_modal.loss_score_tooltip")}
>
<button
@click=${() => this.handleSort("lossScore")}
aria-sort=${this.sortBy === "lossScore"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.loss_score")}
${this.sortBy === "lossScore"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
<th
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors"
>
<button
@click=${() => this.handleSort("ratio")}
aria-sort=${this.sortBy === "ratio"
? this.sortOrder === "asc"
? "ascending"
: "descending"
: "none"}
>
${translateText("leaderboard_modal.win_loss_ratio")}
${this.sortBy === "ratio"
? this.sortOrder === "asc"
? "↑"
: "↓"
: "↕"}
</button>
</th>
</tr>
</thead>
<tbody>
${sorted.map((clan, index) => {
const displayRank = index + 1;
const rankColor =
displayRank === 1
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
: displayRank === 2
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
: displayRank === 3
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
: "text-white/40 bg-white/5";
const rankIcon =
displayRank === 1
? "👑"
: displayRank === 2
? "🥈"
: displayRank === 3
? "🥉"
: String(displayRank);
return html`
<tr
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
>
<td class="py-3 px-4 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
>
${rankIcon}
</div>
</td>
<td class="py-3 px-4 font-bold text-blue-300">
<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 inline-block"
>
${clan.clanTag}
</div>
</td>
<td class="py-3 px-4 text-right">
<div class="flex flex-col items-end gap-1">
<span class="text-white font-mono font-medium"
>${clan.games.toLocaleString()}</span
>
<div
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500/50 rounded-full"
style="width: ${(clan.games / maxGames) * 100}%"
></div>
</div>
</div>
</td>
<td
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
>
${clan.weightedWins.toLocaleString("fullwide", {
maximumFractionDigits: 1,
})}
</td>
<td
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
>
${clan.weightedLosses.toLocaleString("fullwide", {
maximumFractionDigits: 1,
})}
</td>
<td class="py-3 px-4 text-right pr-6">
<div class="inline-flex flex-col items-end">
<span
class="font-mono font-bold ${clan.weightedWLRatio >= 1
? "text-green-400"
: "text-red-400"}"
>${clan.weightedWLRatio.toLocaleString("fullwide", {
maximumFractionDigits: 2,
})}</span
>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("leaderboard_modal.ratio")}</span
>
</div>
</td>
</tr>
`;
})}
</tbody>
</table>
</div>
</div>
`;
}
}
@@ -0,0 +1,453 @@
import { virtualize } from "@lit-labs/virtualizer/virtualize.js";
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { PlayerLeaderboardEntry } from "../../../core/ApiSchemas";
import { fetchPlayerLeaderboard, getUserMe } from "../../Api";
import { translateText } from "../../Utils";
@customElement("leaderboard-player-list")
export class LeaderboardPlayerList extends LitElement {
@state() private playerData: PlayerLeaderboardEntry[] = [];
@state() private currentUserEntry: PlayerLeaderboardEntry | null = null;
@state() private showStickyUser = false;
@state() private isLoading = false;
@state() private error: string | null = null;
@state() private isLoadingMore = false;
@state() private loadMoreError: string | null = null;
@state() private playerHasMore = true;
private hasLoadedPlayers = false;
private readonly playerPageSize = 50;
private currentPage = 1;
private currentUserId: string | null = null;
private currentUserIdLoaded = false;
@query(".virtualizer-container") private virtualizerContainer?: HTMLElement;
createRenderRoot() {
return this;
}
public async ensureLoaded() {
if (this.hasLoadedPlayers || this.isLoading) return;
await this.loadPlayerLeaderboard(true);
}
public async loadPlayerLeaderboard(reset = false) {
if (reset) {
this.currentPage = 1;
this.playerHasMore = true;
this.loadMoreError = null;
this.playerData = [];
this.currentUserEntry = null;
this.showStickyUser = false;
} else if (!this.playerHasMore) {
return;
}
if (this.isLoading || this.isLoadingMore) return;
if (reset) {
this.isLoading = true;
this.error = null;
} else {
this.isLoadingMore = true;
this.loadMoreError = null;
}
try {
const result = await fetchPlayerLeaderboard(this.currentPage);
if (result === false) {
throw new Error("Failed to load player leaderboard");
}
if (result === "reached_limit") {
this.playerHasMore = false;
this.hasLoadedPlayers = true;
return;
}
const nextPlayers: PlayerLeaderboardEntry[] = result["1v1"].map(
(entry) => ({
rank: entry.rank,
playerId: entry.public_id,
username: entry.username,
clanTag: entry.clanTag ?? undefined,
elo: entry.elo,
games: entry.total,
wins: entry.wins,
losses: entry.losses,
winRate: entry.total > 0 ? entry.wins / entry.total : 0,
}),
);
const receivedCount = nextPlayers.length;
if (reset) {
this.playerData = nextPlayers;
} else {
const existingIds = new Set(
this.playerData.map((player) => player.playerId),
);
const deduped = nextPlayers.filter(
(player) => !existingIds.has(player.playerId),
);
this.playerData = [...this.playerData, ...deduped];
}
if (receivedCount > 0) {
this.currentPage++;
}
if (receivedCount < this.playerPageSize) {
this.playerHasMore = false;
}
if (reset && !this.currentUserIdLoaded) {
this.currentUserIdLoaded = true;
const userMe = await getUserMe();
this.currentUserId = userMe ? userMe.player.publicId : null;
}
if (this.currentUserId && !this.currentUserEntry) {
this.currentUserEntry =
nextPlayers.find(
(player) => player.playerId === this.currentUserId,
) ?? null;
}
this.hasLoadedPlayers = true;
this.scheduleStickyVisibilityCheck();
this.schedulePlayerFillCheck();
} catch (err) {
console.error("loadPlayerLeaderboard: request failed", err);
if (reset) {
this.error = translateText("leaderboard_modal.error");
} else {
this.loadMoreError = translateText("leaderboard_modal.error");
}
} finally {
if (reset) {
this.isLoading = false;
} else {
this.isLoadingMore = false;
}
}
}
public handleTabActivated() {
this.scheduleStickyVisibilityCheck();
this.schedulePlayerFillCheck();
}
// TODO: consider IntersectionObserver for better visibility detection?
private isVisible() {
return this.isConnected && this.getClientRects().length > 0;
}
private updateStickyVisibility() {
if (!this.currentUserEntry) {
this.showStickyUser = false;
return;
}
if (!this.virtualizerContainer || !this.isVisible()) {
this.showStickyUser = false;
return;
}
const currentRow = this.virtualizerContainer.querySelector(
'[data-current-user="true"]',
) as HTMLElement | null;
if (!currentRow) {
this.showStickyUser = true;
return;
}
const containerRect = this.virtualizerContainer.getBoundingClientRect();
const rowRect = currentRow.getBoundingClientRect();
const isVisible =
rowRect.top >= containerRect.top &&
rowRect.bottom <= containerRect.bottom;
this.showStickyUser = !isVisible;
}
private scheduleStickyVisibilityCheck() {
void this.updateComplete.then(() => {
requestAnimationFrame(() => this.updateStickyVisibility());
});
}
private handleScroll() {
this.updateStickyVisibility();
this.maybeLoadMorePlayers();
}
private maybeLoadMorePlayers() {
if (this.isLoading || this.isLoadingMore) return;
if (!this.playerHasMore || this.error || this.loadMoreError) return;
if (!this.virtualizerContainer || !this.isVisible()) return;
const threshold = 64 * 3;
const scrollTop = this.virtualizerContainer.scrollTop;
const containerHeight = this.virtualizerContainer.clientHeight;
const scrollHeight = this.virtualizerContainer.scrollHeight;
const nearBottom = scrollTop + containerHeight >= scrollHeight - threshold;
if (containerHeight === 0 || scrollHeight === 0) return; // guard
if (nearBottom) {
void this.loadPlayerLeaderboard();
}
}
private schedulePlayerFillCheck() {
if (!this.playerHasMore || this.error || this.loadMoreError) return;
void this.updateComplete.then(() => this.maybeLoadMorePlayers());
}
private renderPlayerRow(player: PlayerLeaderboardEntry) {
const isCurrentUser = this.currentUserEntry?.playerId === player.playerId;
const displayRank = player.rank;
const winRate = player.games > 0 ? player.wins / player.games : 0;
const rankColor =
{
1: "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20",
2: "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20",
3: "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20",
}?.[displayRank] ?? "text-white/40 bg-white/5";
const rankIcon =
{
1: "👑",
2: "🥈",
3: "🥉",
}?.[displayRank] ?? String(displayRank);
return html`
<div
data-current-user=${isCurrentUser ? "true" : "false"}
class="flex items-center border-b border-white/5 py-3 px-6 hover:bg-white/[0.07] transition-colors w-full ${isCurrentUser
? "bg-blue-500/15 border-l-4 border-l-blue-500 pl-5"
: ""}"
>
<div class="w-16 shrink-0 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
>
${rankIcon}
</div>
</div>
<div class="flex-1 flex items-center gap-3 overflow-hidden ml-4">
<span class="font-bold text-blue-300 truncate text-base"
>${player.username}</span
>
${player.clanTag
? html`<div
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-[10px] font-bold text-blue-300 shrink-0"
>
${player.clanTag}
</div>`
: ""}
</div>
<div class="flex flex-col items-end gap-1 w-32">
<div class="text-right font-mono text-white font-medium">
${player.elo}
<span class="text-[10px] text-white/30 truncate"
>${translateText("leaderboard_modal.elo")}</span
>
</div>
</div>
<div class="flex-col items-end gap-1 w-32 hidden md:flex">
<div class="text-right font-mono text-white font-medium">
${player.games}
<span class="text-[10px] text-white/30 uppercase"
>${translateText("leaderboard_modal.games")}</span
>
</div>
</div>
<div class="inline-flex flex-col items-end pr-6 w-32">
<span
class="font-mono font-bold ${winRate >= 0.5
? "text-green-400"
: "text-red-400"}"
>${(winRate * 100).toFixed(1)}%</span
>
<span
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
>${translateText("leaderboard_modal.ratio")}</span
>
</div>
</div>
`;
}
private renderPlayerFooter() {
if (this.isLoadingMore) {
return html`
<div class="flex items-center justify-center py-4 text-white/50">
<div
class="w-4 h-4 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mr-2"
></div>
<span class="text-[10px] font-bold uppercase tracking-widest">
${translateText("leaderboard_modal.loading")}
</span>
</div>
`;
}
if (this.loadMoreError) {
return html`
<div class="flex items-center justify-center py-4">
<button
class="px-6 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-xl text-xs font-bold uppercase transition-all active:scale-95"
@click=${() => this.loadPlayerLeaderboard()}
>
${translateText("leaderboard_modal.try_again")}
</button>
</div>
`;
}
return "";
}
private renderLoading() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
></div>
<p class="text-blue-200/80 text-sm font-bold tracking-widest uppercase">
${translateText("leaderboard_modal.loading")}
</p>
</div>
`;
}
private renderError() {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full"
>
<div
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-lg shadow-red-500/10"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<p class="mb-8 text-center text-red-100/80 font-medium">
${this.error ?? translateText("leaderboard_modal.error")}
</p>
<button
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-xl text-sm font-bold uppercase transition-all active:scale-95"
@click=${() => this.loadPlayerLeaderboard(true)}
>
${translateText("leaderboard_modal.try_again")}
</button>
</div>
`;
}
render() {
if (this.isLoading && this.playerData.length === 0)
return this.renderLoading();
if (this.error) return this.renderError();
return html`
<div class="flex flex-col h-full overflow-hidden">
<div
class="flex items-center text-[10px] uppercase tracking-wider text-white/40 font-bold px-6 py-4 border-b border-white/5 bg-white/2"
>
<div class="w-16 text-center">
${translateText("leaderboard_modal.rank")}
</div>
<div class="flex-1 ml-4">
${translateText("leaderboard_modal.player")}
</div>
<div class="w-32 text-right">
${translateText("leaderboard_modal.elo")}
</div>
<div class="w-32 text-right hidden md:block">
${translateText("leaderboard_modal.games")}
</div>
<div class="w-32 text-right pr-6">
${translateText("leaderboard_modal.win_loss_ratio")}
</div>
</div>
<div class="relative flex-1 min-h-0">
<div
class="virtualizer-container h-full overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 ${this
.showStickyUser
? "pb-20"
: "pb-0"}"
@scroll=${() => this.handleScroll()}
>
${virtualize({
items: this.playerData,
renderItem: (player) => this.renderPlayerRow(player),
scroller: true,
})}
${this.renderPlayerFooter()}
</div>
${this.currentUserEntry
? html`
<div class="absolute inset-x-0 bottom-0">
<div
class="bg-blue-600/90 backdrop-blur-md border-t border-blue-400/30 py-4 px-6 shadow-2xl flex items-center transition-all duration-200 ${this
.showStickyUser
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-3 pointer-events-none"}"
aria-hidden=${this.showStickyUser ? "false" : "true"}
>
<div class="w-16 text-center">
<div
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg bg-white/20 text-white"
>
${this.currentUserEntry.rank}
</div>
</div>
<div class="flex-1 flex flex-col ml-4">
<span
class="text-[10px] uppercase font-bold text-blue-200/60 leading-tight"
>${translateText(
"leaderboard_modal.your_ranking",
)}</span
>
<span class="font-bold text-white text-base"
>${this.currentUserEntry.username}</span
>
</div>
<div class="flex flex-col items-end w-32">
<div class="font-mono text-white font-bold text-lg">
${this.currentUserEntry.elo}
<span class="text-[10px] text-white/60"
>${translateText("leaderboard_modal.elo")}</span
>
</div>
</div>
</div>
</div>
`
: ""}
</div>
</div>
`;
}
}
@@ -0,0 +1,75 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../../Utils";
export type LeaderboardTab = "players" | "clans";
@customElement("leaderboard-tabs")
export class LeaderboardTabs extends LitElement {
@property({ type: String }) activeTab: LeaderboardTab = "players";
createRenderRoot() {
return this;
}
private baseTabClass =
"px-6 py-2 rounded-full text-sm font-bold uppercase tracking-wider transition-all cursor-pointer select-none";
private activeTabClass = "bg-blue-600 text-white";
private inactiveTabClass =
"text-white/40 hover:text-white/60 hover:bg-white/5";
private getTabClass(active: boolean) {
return [
this.baseTabClass,
active ? this.activeTabClass : this.inactiveTabClass,
].join(" ");
}
@state()
private playerClass = this.getTabClass(this.activeTab === "players");
@state()
private clanClass = this.getTabClass(this.activeTab === "clans");
private handleTabChange(tab: LeaderboardTab) {
this.dispatchEvent(
new CustomEvent<LeaderboardTab>("tab-change", {
detail: tab,
bubbles: true,
composed: true,
}),
);
this.playerClass = this.getTabClass(tab === "players");
this.clanClass = this.getTabClass(tab === "clans");
}
render() {
return html`
<div
role="tablist"
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-6 w-fit mx-auto mt-4"
>
<button
type="button"
role="tab"
class="${this.playerClass}"
@click=${() => this.handleTabChange("players")}
id="player-leaderboard-tab"
aria-selected=${this.activeTab === "players"}
>
${translateText("leaderboard_modal.ranked_tab")}
</button>
<button
type="button"
role="tab"
class="${this.clanClass}"
@click=${() => this.handleTabChange("clans")}
id="clan-leaderboard-tab"
aria-selected=${this.activeTab === "clans"}
>
${translateText("leaderboard_modal.clans_tab")}
</button>
</div>
`;
}
}
+46
View File
@@ -133,3 +133,49 @@ export const ClanLeaderboardResponseSchema = z.object({
export type ClanLeaderboardResponse = z.infer<
typeof ClanLeaderboardResponseSchema
>;
export const PlayerLeaderboardEntrySchema = z.object({
rank: z.number(),
playerId: z.string(),
username: z.string(),
clanTag: z.string().optional(),
flag: z.string().optional(),
elo: z.number(),
games: z.number(),
wins: z.number(),
losses: z.number(),
winRate: z.number(),
});
export type PlayerLeaderboardEntry = z.infer<
typeof PlayerLeaderboardEntrySchema
>;
export const PlayerLeaderboardResponseSchema = z.object({
players: PlayerLeaderboardEntrySchema.array(),
});
export type PlayerLeaderboardResponse = z.infer<
typeof PlayerLeaderboardResponseSchema
>;
export const RankedLeaderboardEntrySchema = z.object({
rank: z.number(),
elo: z.number(),
peakElo: z.number().nullable(),
wins: z.number(),
losses: z.number(),
total: z.number(),
public_id: z.string(),
user: DiscordUserSchema.nullable().optional(),
username: z.string(),
clanTag: z.string().nullable().optional(),
});
export type RankedLeaderboardEntry = z.infer<
typeof RankedLeaderboardEntrySchema
>;
export const RankedLeaderboardResponseSchema = z.object({
"1v1": RankedLeaderboardEntrySchema.array(),
});
export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
>;