mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
Add Ranked 1v1 Leaderboard (#3008)
If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #(issue number) ## Description: @wraith4081 's pr updates the stats modal to show both 1v1 and clan stats ## Please complete the following: - [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 ## Please put your Discord username so you can be contacted if a bug or 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:
+9
-9
@@ -133,14 +133,14 @@
|
||||
|
||||
<div
|
||||
id="mobile-menu-backdrop"
|
||||
class="lg:!hidden [.in-game_&]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/60 [&.open]:z-[40000] transition-opacity"
|
||||
class="lg:hidden! in-[.in-game]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/60 [&.open]:z-[40000] transition-opacity"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<mobile-nav-bar
|
||||
id="sidebar-menu"
|
||||
class="peer [.in-game_&]:hidden z-[40001] fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/60 backdrop-blur-md transition-transform duration-500 ease-out transform -translate-x-full w-[80%] [&.open]:translate-x-0 lg:hidden"
|
||||
class="peer in-[.in-game]:hidden z-40001 fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/60 backdrop-blur-md transition-transform duration-500 ease-out transform -translate-x-full w-[80%] [&.open]:translate-x-0 lg:hidden"
|
||||
role="dialog"
|
||||
data-i18n-aria-label="main.menu"
|
||||
aria-hidden="true"
|
||||
@@ -148,14 +148,14 @@
|
||||
|
||||
<!-- MAIN CONTENT AREA -->
|
||||
<div
|
||||
class="[.in-game_&]:hidden flex-1 relative overflow-hidden h-full transition-[margin] duration-500 ease-out will-change-[margin-left] flex flex-col"
|
||||
class="in-[.in-game]:hidden flex-1 relative overflow-hidden h-full transition-[margin] duration-500 ease-out will-change-[margin-left] flex flex-col"
|
||||
>
|
||||
<!-- Desktop Top Bar -->
|
||||
<desktop-nav-bar></desktop-nav-bar>
|
||||
|
||||
<div
|
||||
id="turnstile-container"
|
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[99999]"
|
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-99999"
|
||||
></div>
|
||||
|
||||
<gutter-ads></gutter-ads>
|
||||
@@ -194,16 +194,16 @@
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></user-setting>
|
||||
<leaderboard-modal
|
||||
id="page-leaderboard"
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></leaderboard-modal>
|
||||
<troubleshooting-modal
|
||||
id="page-troubleshooting"
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></troubleshooting-modal>
|
||||
<stats-modal
|
||||
id="page-stats"
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></stats-modal>
|
||||
<account-modal
|
||||
id="page-account"
|
||||
inline
|
||||
|
||||
Generated
+32
-6
@@ -7,6 +7,7 @@
|
||||
"name": "openfront-client",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@lit-labs/virtualizer": "^2.1.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.200.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.200.0",
|
||||
@@ -1188,6 +1189,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -1231,6 +1233,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2100,14 +2103,22 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
|
||||
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@lit-labs/virtualizer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/virtualizer/-/virtualizer-2.1.1.tgz",
|
||||
"integrity": "sha512-JWxMwnlouLdwpw8spLTuax53WMnSP3xt0dCyxAS7GJr5Otda9MGgR/ghAdfwhSY75TmjbE1T2TqChwoGCw3ggw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"lit": "^3.2.0",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@lit/reactive-element": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.0.tgz",
|
||||
"integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.2.0"
|
||||
@@ -2156,6 +2167,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -4550,6 +4562,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz",
|
||||
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4657,7 +4670,6 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
@@ -4716,6 +4728,7 @@
|
||||
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.34.1",
|
||||
"@typescript-eslint/types": "8.34.1",
|
||||
@@ -5195,6 +5208,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5594,6 +5608,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001718",
|
||||
"electron-to-chromium": "^1.5.160",
|
||||
@@ -5747,6 +5762,7 @@
|
||||
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
@@ -6668,6 +6684,7 @@
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -7237,6 +7254,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8571,6 +8589,7 @@
|
||||
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.28",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -9114,7 +9133,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
|
||||
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
@@ -9126,7 +9144,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.0.tgz",
|
||||
"integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.2.0",
|
||||
@@ -9138,7 +9155,6 @@
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.0.tgz",
|
||||
"integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
@@ -10169,6 +10185,7 @@
|
||||
"integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6",
|
||||
"@types/css-font-loading-module": "^0.0.12",
|
||||
@@ -10213,6 +10230,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -10315,6 +10333,7 @@
|
||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -11137,6 +11156,7 @@
|
||||
"integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sinonjs/commons": "^3.0.1",
|
||||
"@sinonjs/fake-timers": "^15.1.0",
|
||||
@@ -11558,6 +11578,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11800,6 +11821,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -11868,6 +11890,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12014,6 +12037,7 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12763,6 +12787,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12776,6 +12801,7 @@
|
||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.16",
|
||||
"@vitest/mocker": "4.0.16",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@lit-labs/virtualizer": "^2.1.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.200.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.200.0",
|
||||
|
||||
+13
-5
@@ -50,6 +50,7 @@
|
||||
"settings": "Settings",
|
||||
"keys": "Keys",
|
||||
"stats": "Stats",
|
||||
"leaderboard": "Leaderboard",
|
||||
"account": "Account",
|
||||
"help": "Help",
|
||||
"menu": "Menu",
|
||||
@@ -238,15 +239,21 @@
|
||||
"enter_email_address": "Please enter an email address",
|
||||
"personal_player_id": "Personal Player ID:"
|
||||
},
|
||||
"stats_modal": {
|
||||
"title": "Stats",
|
||||
"leaderboard_modal": {
|
||||
"title": "Leaderboard",
|
||||
"title_plural": "Leaderboards",
|
||||
"clan_stats": "Clan Stats",
|
||||
"player_stats": "1v1 Ranked Stats",
|
||||
"ranked_tab": "1v1 Ranked",
|
||||
"clans_tab": "Clans",
|
||||
"loading": "Loading...",
|
||||
"error": "Error loading clan stats",
|
||||
"no_stats": "No clan stats available",
|
||||
"error": "Error loading leaderboard",
|
||||
"no_stats": "No stats available",
|
||||
"no_data_yet": "No Data Yet",
|
||||
"clan": "Clan",
|
||||
"player": "Player",
|
||||
"games": "Games",
|
||||
"elo": "ELO",
|
||||
"win_score": "Win Score",
|
||||
"win_score_tooltip": "Weighted wins based on clan participation and match difficulty",
|
||||
"loss_score": "Loss Score",
|
||||
@@ -254,7 +261,8 @@
|
||||
"win_loss_ratio": "Win/Loss",
|
||||
"ratio": "Ratio",
|
||||
"rank": "Rank",
|
||||
"try_again": "Try Again"
|
||||
"try_again": "Try Again",
|
||||
"your_ranking": "Your Ranking"
|
||||
},
|
||||
"game_info_modal": {
|
||||
"title": "Game info",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,9 +225,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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
@@ -810,7 +810,7 @@ class Client {
|
||||
"news-modal",
|
||||
"flag-input-modal",
|
||||
"account-button",
|
||||
"stats-button",
|
||||
"leaderboard-button",
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
"lang-selector",
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@lit-labs/virtualizer/virtualize.js", async () => {
|
||||
const { html } = await import("lit");
|
||||
return {
|
||||
virtualize: vi.fn(() => html``),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/client/Utils", () => ({
|
||||
translateText: vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"leaderboard_modal.win_score_tooltip":
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
"leaderboard_modal.loss_score_tooltip":
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
"leaderboard_modal.title": "Leaderboard",
|
||||
"leaderboard_modal.ranked_tab": "Ranked",
|
||||
"leaderboard_modal.clans_tab": "Clans",
|
||||
"leaderboard_modal.error": "Something went wrong",
|
||||
"leaderboard_modal.rank": "Rank",
|
||||
"leaderboard_modal.clan": "Clan",
|
||||
"leaderboard_modal.games": "Games",
|
||||
"leaderboard_modal.win_score": "Win Score",
|
||||
"leaderboard_modal.loss_score": "Loss Score",
|
||||
"leaderboard_modal.win_loss_ratio": "W/L",
|
||||
"leaderboard_modal.ratio": "Ratio",
|
||||
"leaderboard_modal.elo": "Elo",
|
||||
"leaderboard_modal.player": "Player",
|
||||
"leaderboard_modal.loading": "Loading",
|
||||
"leaderboard_modal.try_again": "Try Again",
|
||||
"leaderboard_modal.no_data_yet": "No data yet",
|
||||
"leaderboard_modal.no_stats": "No stats",
|
||||
"leaderboard_modal.your_ranking": "Your ranking",
|
||||
"common.close": "Close",
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/client/Api", () => {
|
||||
const getApiBase = () => "http://localhost:3000";
|
||||
return {
|
||||
getApiBase: vi.fn(getApiBase),
|
||||
getUserMe: vi.fn(async () => false),
|
||||
fetchClanLeaderboard: vi.fn(async () => {
|
||||
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
return res.json();
|
||||
}),
|
||||
fetchPlayerLeaderboard: vi.fn(async (page: number) => {
|
||||
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) {
|
||||
if (res.status === 400) {
|
||||
const errorJson = await res.json().catch(() => null);
|
||||
if (errorJson?.message?.includes("Page must be between")) {
|
||||
return "reached_limit";
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const jsonRes = (data: any, ok = true, status = 200) => ({
|
||||
ok,
|
||||
status,
|
||||
json: async () => data,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: any) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : (input?.url ?? String(input));
|
||||
|
||||
if (url.includes("/public/clans/leaderboard")) {
|
||||
return jsonRes({ start: "...", end: "...", clans: [] });
|
||||
}
|
||||
if (url.includes("/leaderboard/ranked")) {
|
||||
return jsonRes({ "1v1": [] });
|
||||
}
|
||||
return jsonRes({}, false, 404);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
import { LeaderboardModal } from "../../src/client/LeaderboardModal";
|
||||
|
||||
describe("LeaderboardModal", () => {
|
||||
let modal: LeaderboardModal;
|
||||
const awaitChildUpdate = async (selector: string) => {
|
||||
const el = modal.querySelector(selector) as {
|
||||
updateComplete?: Promise<unknown>;
|
||||
} | null;
|
||||
if (el?.updateComplete) {
|
||||
await el.updateComplete;
|
||||
}
|
||||
};
|
||||
const getClanTable = () =>
|
||||
modal.querySelector("leaderboard-clan-table") as {
|
||||
loadClanLeaderboard: () => Promise<void>;
|
||||
updateComplete: Promise<unknown>;
|
||||
} | null;
|
||||
const getPlayerList = () =>
|
||||
modal.querySelector("leaderboard-player-list") as {
|
||||
loadPlayerLeaderboard: (reset?: boolean) => Promise<void>;
|
||||
updateComplete: Promise<unknown>;
|
||||
playerData: Array<Record<string, unknown>>;
|
||||
currentUserEntry?: { playerId: string } | null;
|
||||
} | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
if (!customElements.get("leaderboard-modal")) {
|
||||
customElements.define("leaderboard-modal", LeaderboardModal);
|
||||
}
|
||||
modal = document.createElement("leaderboard-modal") as LeaderboardModal;
|
||||
document.body.appendChild(modal);
|
||||
await modal.updateComplete;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(modal);
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Tooltip Implementation - Issue #2508", () => {
|
||||
it("should render Win Score and Loss Score columns with title attributes", async () => {
|
||||
// Mock fetch to return sample clan leaderboard data
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
start: "2025-01-01T00:00:00Z",
|
||||
end: "2025-01-07T23:59:59Z",
|
||||
clans: [
|
||||
{
|
||||
clanTag: "[TEST]",
|
||||
games: 10,
|
||||
wins: 8,
|
||||
losses: 2,
|
||||
playerSessions: 25,
|
||||
weightedWins: 8.5,
|
||||
weightedLosses: 1.5,
|
||||
weightedWLRatio: 5.67,
|
||||
},
|
||||
{
|
||||
clanTag: "[DEMO]",
|
||||
games: 8,
|
||||
wins: 6,
|
||||
losses: 2,
|
||||
playerSessions: 20,
|
||||
weightedWins: 6.0,
|
||||
weightedLosses: 2.0,
|
||||
weightedWLRatio: 3.0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
(modal as unknown as { activeTab: string }).activeTab = "clans";
|
||||
const clanTable = getClanTable();
|
||||
expect(clanTable).toBeTruthy();
|
||||
await clanTable!.loadClanLeaderboard();
|
||||
await clanTable!.updateComplete;
|
||||
|
||||
const allHeaders = modal.querySelectorAll("th");
|
||||
let winScoreHeader: Element | null = null;
|
||||
let lossScoreHeader: Element | null = null;
|
||||
|
||||
// Find the headers by their text content and title attribute
|
||||
allHeaders.forEach((th) => {
|
||||
const title = th.getAttribute("title");
|
||||
if (title?.includes("Weighted wins")) {
|
||||
winScoreHeader = th;
|
||||
} else if (title?.includes("Weighted losses")) {
|
||||
lossScoreHeader = th;
|
||||
}
|
||||
});
|
||||
|
||||
// Assert that headers exist with correct tooltip text
|
||||
expect(winScoreHeader).toBeTruthy();
|
||||
expect(lossScoreHeader).toBeTruthy();
|
||||
|
||||
expect(winScoreHeader!.getAttribute("title")).toBe(
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
);
|
||||
expect(lossScoreHeader!.getAttribute("title")).toBe(
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
);
|
||||
});
|
||||
|
||||
it("should use translateText for tooltip internationalization", async () => {
|
||||
// Verify translation keys are correct
|
||||
const { translateText } = await import("../../src/client/Utils");
|
||||
|
||||
expect(translateText("leaderboard_modal.win_score_tooltip")).toBe(
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
);
|
||||
expect(translateText("leaderboard_modal.loss_score_tooltip")).toBe(
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Player Data Mapping", () => {
|
||||
it("should map ranked leaderboard data and set current user entry", async () => {
|
||||
const { getUserMe } = await import("../../src/client/Api");
|
||||
(getUserMe as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
player: { publicId: "player-2" },
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
"1v1": [
|
||||
{
|
||||
rank: 1,
|
||||
elo: 1200,
|
||||
peakElo: 1300,
|
||||
wins: 6,
|
||||
losses: 4,
|
||||
total: 10,
|
||||
public_id: "player-1",
|
||||
username: "Alpha",
|
||||
clanTag: "[AAA]",
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
elo: 1100,
|
||||
peakElo: 1250,
|
||||
wins: 4,
|
||||
losses: 6,
|
||||
total: 10,
|
||||
public_id: "player-2",
|
||||
username: "Bravo",
|
||||
clanTag: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const playerList = getPlayerList();
|
||||
expect(playerList).toBeTruthy();
|
||||
await playerList!.loadPlayerLeaderboard(true);
|
||||
await playerList!.updateComplete;
|
||||
|
||||
const playerData = playerList!.playerData;
|
||||
|
||||
expect(playerData).toHaveLength(2);
|
||||
expect(playerData[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
playerId: "player-1",
|
||||
username: "Alpha",
|
||||
clanTag: "[AAA]",
|
||||
elo: 1200,
|
||||
games: 10,
|
||||
wins: 6,
|
||||
losses: 4,
|
||||
winRate: 0.6,
|
||||
}),
|
||||
);
|
||||
expect(playerData[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
playerId: "player-2",
|
||||
username: "Bravo",
|
||||
clanTag: undefined,
|
||||
winRate: 0.4,
|
||||
}),
|
||||
);
|
||||
expect(playerList!.currentUserEntry?.playerId).toBe("player-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal Functionality", () => {
|
||||
it("should initialize with default state", () => {
|
||||
expect(modal).toBeTruthy();
|
||||
expect((modal as unknown as { activeTab: string }).activeTab).toBe(
|
||||
"players",
|
||||
);
|
||||
});
|
||||
|
||||
it("should be a custom element", () => {
|
||||
expect(modal).toBeInstanceOf(LeaderboardModal);
|
||||
expect(modal.tagName.toLowerCase()).toBe("leaderboard-modal");
|
||||
});
|
||||
|
||||
it("should close on Escape when open", () => {
|
||||
const mockModalEl = { open: vi.fn(), close: vi.fn() };
|
||||
Object.defineProperty(modal, "modalEl", {
|
||||
get: () => mockModalEl,
|
||||
configurable: true,
|
||||
});
|
||||
(modal as unknown as { onOpen: () => void }).onOpen = vi.fn();
|
||||
|
||||
modal.open();
|
||||
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
|
||||
false,
|
||||
);
|
||||
expect(mockModalEl.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal Interaction", () => {
|
||||
it("should switch to clans tab and request clan leaderboard data", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
start: "2025-01-01T00:00:00Z",
|
||||
end: "2025-01-07T23:59:59Z",
|
||||
clans: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const tab = modal.querySelector("#clan-leaderboard-tab");
|
||||
expect(tab).toBeTruthy();
|
||||
|
||||
tab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect((modal as unknown as { activeTab: string }).activeTab).toBe(
|
||||
"clans",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/public/clans/leaderboard",
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
await Promise.resolve();
|
||||
await modal.updateComplete;
|
||||
await awaitChildUpdate("leaderboard-clan-table");
|
||||
});
|
||||
|
||||
it("should render a no data state for empty clan leaderboard", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
start: "2025-01-01T00:00:00Z",
|
||||
end: "2025-01-07T23:59:59Z",
|
||||
clans: [],
|
||||
}),
|
||||
});
|
||||
|
||||
(modal as unknown as { activeTab: string }).activeTab = "clans";
|
||||
const clanTable = getClanTable();
|
||||
expect(clanTable).toBeTruthy();
|
||||
await clanTable!.loadClanLeaderboard();
|
||||
await clanTable!.updateComplete;
|
||||
|
||||
expect(modal.textContent).toContain("No data yet");
|
||||
expect(modal.textContent).toContain("No stats");
|
||||
});
|
||||
|
||||
it("should render an error state when clan leaderboard fails", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
(modal as unknown as { activeTab: string }).activeTab = "clans";
|
||||
const clanTable = getClanTable();
|
||||
expect(clanTable).toBeTruthy();
|
||||
await clanTable!.loadClanLeaderboard();
|
||||
await clanTable!.updateComplete;
|
||||
|
||||
expect(modal.textContent).toContain("Something went wrong");
|
||||
expect(modal.textContent).toContain("Try Again");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { StatsModal } from "../../src/client/StatsModal";
|
||||
|
||||
// Mock the translateText function
|
||||
vi.mock("../../src/client/Utils", () => ({
|
||||
translateText: vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"stats_modal.win_score_tooltip":
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
"stats_modal.loss_score_tooltip":
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../src/client/Api", () => ({
|
||||
getApiBase: vi.fn(() => "http://localhost:3000"),
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe("StatsModal", () => {
|
||||
let modal: StatsModal;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Define the custom element if not already defined
|
||||
if (!customElements.get("stats-modal")) {
|
||||
customElements.define("stats-modal", StatsModal);
|
||||
}
|
||||
modal = document.createElement("stats-modal") as StatsModal;
|
||||
document.body.appendChild(modal);
|
||||
await modal.updateComplete;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(modal);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Tooltip Implementation - Issue #2508", () => {
|
||||
it("should render Win Score and Loss Score columns with title attributes", async () => {
|
||||
// Mock fetch to return sample clan leaderboard data
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
start: "2025-01-01T00:00:00Z",
|
||||
end: "2025-01-07T23:59:59Z",
|
||||
clans: [
|
||||
{
|
||||
clanTag: "[TEST]",
|
||||
games: 10,
|
||||
wins: 8,
|
||||
losses: 2,
|
||||
playerSessions: 25,
|
||||
weightedWins: 8.5,
|
||||
weightedLosses: 1.5,
|
||||
weightedWLRatio: 5.67,
|
||||
},
|
||||
{
|
||||
clanTag: "[DEMO]",
|
||||
games: 8,
|
||||
wins: 6,
|
||||
losses: 2,
|
||||
playerSessions: 20,
|
||||
weightedWins: 6.0,
|
||||
weightedLosses: 2.0,
|
||||
weightedWLRatio: 3.0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock the modal element's open method
|
||||
const mockModalEl = { open: vi.fn(), close: vi.fn() };
|
||||
Object.defineProperty(modal, "modalEl", {
|
||||
get: () => mockModalEl,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Trigger modal to load and render data
|
||||
modal.open();
|
||||
await modal.updateComplete;
|
||||
|
||||
// Wait for async loadLeaderboard to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await modal.updateComplete;
|
||||
|
||||
// Query the rendered DOM for table headers (StatsModal uses light DOM via createRenderRoot)
|
||||
const allHeaders = modal.querySelectorAll("th");
|
||||
let winScoreHeader: Element | null = null;
|
||||
let lossScoreHeader: Element | null = null;
|
||||
|
||||
// Find the headers by their text content and title attribute
|
||||
allHeaders.forEach((th) => {
|
||||
const title = th.getAttribute("title");
|
||||
if (title?.includes("Weighted wins")) {
|
||||
winScoreHeader = th;
|
||||
} else if (title?.includes("Weighted losses")) {
|
||||
lossScoreHeader = th;
|
||||
}
|
||||
});
|
||||
|
||||
// Assert that headers exist with correct tooltip text
|
||||
expect(winScoreHeader).toBeTruthy();
|
||||
expect(lossScoreHeader).toBeTruthy();
|
||||
|
||||
expect(winScoreHeader!.getAttribute("title")).toBe(
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
);
|
||||
expect(lossScoreHeader!.getAttribute("title")).toBe(
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
);
|
||||
});
|
||||
|
||||
it("should use translateText for tooltip internationalization", async () => {
|
||||
// Verify translation keys are correct
|
||||
const { translateText } = await import("../../src/client/Utils");
|
||||
|
||||
expect(translateText("stats_modal.win_score_tooltip")).toBe(
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
);
|
||||
expect(translateText("stats_modal.loss_score_tooltip")).toBe(
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal Functionality", () => {
|
||||
it("should initialize with default state", () => {
|
||||
expect(modal).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should be a custom element", () => {
|
||||
expect(modal).toBeInstanceOf(StatsModal);
|
||||
expect(modal.tagName.toLowerCase()).toBe("stats-modal");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user