Create clan stats modal (#2479)

Resolves #2452

## Description:

Created a Clan Stats PR to show top clans. In another PR we can show the
player leaderboard to show top players.

Based on PR from https://github.com/Geekyhobo

<img width="659" height="792" alt="Screenshot 2025-11-19 at 10 00 40 AM"
src="https://github.com/user-attachments/assets/9333b7e2-2357-47a6-a7c8-788cf81e9be3"
/>


## 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:

evan

Co-authored-by: Geekyhobo <geekyhobo@users.noreply.github.com>
This commit is contained in:
Evan
2025-11-19 10:34:23 -08:00
committed by GitHub
parent 2b2200c808
commit 0ba709c40d
7 changed files with 285 additions and 1 deletions
+3 -1
View File
@@ -25,4 +25,6 @@ Licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/)
## Icons
Icons from [The Noun Project](https://thenounproject.com/)
### [The Noun Project](https://thenounproject.com/)
Stats icon by [Meko](https://thenounproject.com/mekoda/) https://thenounproject.com/icon/stats-4942475/
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="m479.78 149.95c-4-0.015625-7.8359 1.5664-10.664 4.3906-2.8281 2.8281-4.4102 6.668-4.3945 10.668v870.1c0.042969 3.9609 1.6484 7.7422 4.4727 10.523 2.8203 2.7773 6.625 4.332 10.586 4.3164h240.5c3.9609 0.015625 7.7695-1.5391 10.59-4.3164 2.8203-2.7812 4.4258-6.5625 4.4688-10.523v-870.1c0.015625-4-1.5625-7.8398-4.3906-10.664-2.8281-2.8281-6.668-4.4102-10.668-4.3945zm14.836 30.117h210.61v839.98h-210.61z"/>
<path fill="white" d="m165.09 440.05c-3.9609-0.015626-7.7656 1.5352-10.586 4.3164-2.8203 2.7812-4.4297 6.5625-4.4727 10.523v580.21c0.042969 3.9609 1.6523 7.7422 4.4727 10.523 2.8203 2.7773 6.625 4.332 10.586 4.3164h240.5c3.9609 0.015625 7.7656-1.5391 10.586-4.3164 2.8203-2.7812 4.4297-6.5625 4.4727-10.523v-580.21c-0.042969-3.9609-1.6523-7.7422-4.4727-10.523-2.8203-2.7812-6.625-4.332-10.586-4.3164zm14.84 29.898h210.61v550.1h-210.61z"/>
<path fill="white" d="m794.47 729.94c-4-0.015625-7.8398 1.5664-10.668 4.3945-2.8281 2.8281-4.4102 6.668-4.3945 10.664v290.11c0.042969 3.9609 1.6523 7.7422 4.4727 10.523 2.8203 2.7773 6.6289 4.332 10.59 4.3164h240.5c3.9609 0.015625 7.7656-1.5391 10.586-4.3164 2.8203-2.7812 4.4297-6.5625 4.4727-10.523v-290.11c0.015625-3.9961-1.5664-7.8359-4.3945-10.664-2.8281-2.8281-6.6641-4.4102-10.664-4.3945zm14.836 30.121h210.61v259.99h-210.61z"/>
<path fill="white" d="m479.69 164.99h240.63v870.02h-240.63z"/>
<path fill="white" d="m165 455h240.63v580.01h-240.63z"/>
<path fill="white" d="m794.38 745h240.63v290h-240.63z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+12
View File
@@ -164,6 +164,18 @@
"logged_in_with_discord": "Logged in with Discord",
"recovery_email_sent": "Recovery email sent to {email}"
},
"stats_modal": {
"title": "Stats",
"clan_stats": "Clan Stats",
"loading": "Loading...",
"error": "Error loading clan stats",
"no_stats": "No clan stats available",
"clan": "Clan",
"games": "Games",
"win_score": "Win Score",
"loss_score": "Loss Score",
"win_loss_ratio": "Win/Loss"
},
"map": {
"map": "Map",
"world": "World",
+2
View File
@@ -27,6 +27,7 @@ import { NewsModal } from "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import { SinglePlayerModal } from "./SinglePlayerModal";
import "./StatsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import { SendKickPlayerIntentEvent } from "./Transport";
@@ -524,6 +525,7 @@ class Client {
"news-modal",
"flag-input-modal",
"account-button",
"stats-button",
"token-login",
"matchmaking-modal",
].forEach((tag) => {
+237
View File
@@ -0,0 +1,237 @@
import { css, html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
} from "../core/ApiSchemas";
import { getApiBase } from "./jwt";
import { translateText } from "./Utils";
@customElement("stats-modal")
export class StatsModal extends LitElement {
@query("o-modal")
private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
@state() private isLoading: boolean = false;
@state() private error: string | null = null;
@state() private data: ClanLeaderboardResponse | null = null;
private hasLoaded = false;
createRenderRoot() {
return this;
}
public open() {
this.modalEl?.open();
if (!this.hasLoaded && !this.isLoading) {
void this.loadLeaderboard();
}
}
public close() {
this.modalEl?.close();
}
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-6 text-white">
<p class="mb-2 text-lg font-semibold">
${translateText("stats_modal.loading")}
</p>
<div
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
></div>
</div>
`;
}
if (this.error) {
return html`
<div class="flex flex-col items-center justify-center p-6 text-white">
<p class="mb-4 text-center">${this.error}</p>
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium"
@click=${() => this.loadLeaderboard()}
>
Retry
</button>
</div>
`;
}
if (!this.data || this.data.clans.length === 0) {
return html`
<div class="p-6 text-center text-gray-200">
<p class="text-lg font-semibold mb-2">
${translateText("stats_modal.no_stats")}
</p>
</div>
`;
}
const { start, end, clans } = this.data;
const startDate = new Date(start);
const endDate = new Date(end);
return html`
<div class="p-4 md:p-6 text-gray-200">
<div
class="flex flex-col md:flex-row md:items-center md:justify-between mb-4 gap-2"
>
<div>
<h2 class="text-xl font-semibold">
${translateText("stats_modal.clan_stats")}
</h2>
<p class="text-xs text-gray-400 mt-1">
${startDate.toLocaleDateString()} &middot;
${endDate.toLocaleDateString()}
</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs md:text-sm">
<thead>
<tr class="border-b border-gray-700 text-gray-300">
<th class="py-2 pr-3 text-left">
${translateText("stats_modal.clan")}
</th>
<th class="py-2 px-2 text-right">
${translateText("stats_modal.games")}
</th>
<th class="py-2 px-2 text-right">
${translateText("stats_modal.win_score")}
</th>
<th class="py-2 px-2 text-right">
${translateText("stats_modal.loss_score")}
</th>
<th class="py-2 pl-2 text-right">
${translateText("stats_modal.win_loss_ratio")}
</th>
</tr>
</thead>
<tbody>
${clans.map(
(clan) => html`
<tr class="border-b border-gray-800 last:border-b-0">
<td class="py-2 pr-3 font-semibold text-left">
${clan.clanTag}
</td>
<td class="py-2 px-2 text-right">
${clan.games.toLocaleString()}
</td>
<td class="py-2 px-2 text-right">${clan.weightedWins}</td>
<td class="py-2 px-2 text-right">${clan.weightedLosses}</td>
<td class="py-2 pl-2 text-right">
${clan.weightedWLRatio}
</td>
</tr>
`,
)}
</tbody>
</table>
</div>
</div>
`;
}
render() {
return html`
<o-modal id="stats-modal" title="${translateText("stats_modal.title")}">
${this.renderBody()}
</o-modal>
`;
}
}
@customElement("stats-button")
export class StatsButton extends LitElement {
@query("stats-modal") private statsModal: StatsModal;
@state() private isVisible: boolean = true;
static styles = css`
:host {
display: block;
}
`;
constructor() {
super();
}
createRenderRoot() {
return this;
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<div class="fixed top-20 right-4 z-[9999]">
<button
@click="${this.open}"
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
title="${translateText("stats_modal.title")}"
>
<img src="/icons/stats.svg" alt="Stats" class="w-6 h-6" />
</button>
</div>
<stats-modal></stats-modal>
`;
}
private open() {
this.isVisible = true;
this.requestUpdate();
this.statsModal?.open();
}
public close() {
this.statsModal?.close();
this.isVisible = false;
this.requestUpdate();
}
}
+1
View File
@@ -405,6 +405,7 @@
<spawn-timer></spawn-timer>
<help-modal></help-modal>
<dark-mode-button></dark-mode-button>
<stats-button></stats-button>
<alert-frame></alert-frame>
<chat-modal></chat-modal>
<user-setting></user-setting>
+21
View File
@@ -90,3 +90,24 @@ export const PlayerProfileSchema = z.object({
stats: PlayerStatsTreeSchema,
});
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
export const ClanLeaderboardEntrySchema = z.object({
clanTag: z.string(),
games: z.number(),
wins: z.number(),
losses: z.number(),
playerSessions: z.number(),
weightedWins: z.number(),
weightedLosses: z.number(),
weightedWLRatio: z.number(),
});
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
export const ClanLeaderboardResponseSchema = z.object({
start: z.iso.datetime(),
end: z.iso.datetime(),
clans: ClanLeaderboardEntrySchema.array(),
});
export type ClanLeaderboardResponse = z.infer<
typeof ClanLeaderboardResponseSchema
>;