diff --git a/CREDITS.md b/CREDITS.md index 0528ed38b..0dc86ef8c 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -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/ diff --git a/resources/icons/stats.svg b/resources/icons/stats.svg new file mode 100644 index 000000000..fc3547bbd --- /dev/null +++ b/resources/icons/stats.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 05039b365..77e6b24b5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/Main.ts b/src/client/Main.ts index c7cf0f270..871278732 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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) => { diff --git a/src/client/StatsModal.ts b/src/client/StatsModal.ts new file mode 100644 index 000000000..eff3b7125 --- /dev/null +++ b/src/client/StatsModal.ts @@ -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` +
+

+ ${translateText("stats_modal.loading")} +

+
+
+ `; + } + + if (this.error) { + return html` +
+

${this.error}

+ +
+ `; + } + + if (!this.data || this.data.clans.length === 0) { + return html` +
+

+ ${translateText("stats_modal.no_stats")} +

+
+ `; + } + + const { start, end, clans } = this.data; + const startDate = new Date(start); + const endDate = new Date(end); + + return html` +
+
+
+

+ ${translateText("stats_modal.clan_stats")} +

+

+ ${startDate.toLocaleDateString()} · + ${endDate.toLocaleDateString()} +

+
+
+ +
+ + + + + + + + + + + + ${clans.map( + (clan) => html` + + + + + + + + `, + )} + +
+ ${translateText("stats_modal.clan")} + + ${translateText("stats_modal.games")} + + ${translateText("stats_modal.win_score")} + + ${translateText("stats_modal.loss_score")} + + ${translateText("stats_modal.win_loss_ratio")} +
+ ${clan.clanTag} + + ${clan.games.toLocaleString()} + ${clan.weightedWins}${clan.weightedLosses} + ${clan.weightedWLRatio} +
+
+
+ `; + } + + render() { + return html` + + ${this.renderBody()} + + `; + } +} + +@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` +
+ +
+ + `; + } + + private open() { + this.isVisible = true; + this.requestUpdate(); + this.statsModal?.open(); + } + + public close() { + this.statsModal?.close(); + this.isVisible = false; + this.requestUpdate(); + } +} diff --git a/src/client/index.html b/src/client/index.html index e6b521d61..74e3b36c1 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -405,6 +405,7 @@ + diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 17ee8aa1a..775e86c49 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -90,3 +90,24 @@ export const PlayerProfileSchema = z.object({ stats: PlayerStatsTreeSchema, }); export type PlayerProfile = z.infer; + +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; + +export const ClanLeaderboardResponseSchema = z.object({ + start: z.iso.datetime(), + end: z.iso.datetime(), + clans: ClanLeaderboardEntrySchema.array(), +}); +export type ClanLeaderboardResponse = z.infer< + typeof ClanLeaderboardResponseSchema +>;