diff --git a/resources/lang/en.json b/resources/lang/en.json index 89c79d5625..220b39ae89 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -331,6 +331,11 @@ "pending_requests_count": "{count, plural, one {# pending request} other {# pending requests}}", "per_page": "Per page", "promote": "Promote", + "recent_24h_empty": "No games in the past 24 hours.", + "recent_24h_games": "{count, plural, one {# game} other {# games}}", + "recent_24h_note": "Public Team games only (unranked, excluding Humans vs Nations).", + "recent_24h_ratio_tooltip": "Win score divided by loss score", + "recent_24h_title": "Past 24 Hours", "request_approved": "Request approved!", "request_denied": "Request denied.", "request_invite": "Request Invite", diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts index 240b7b88c9..fd6b6967ad 100644 --- a/src/client/ClanApi.ts +++ b/src/client/ClanApi.ts @@ -15,6 +15,8 @@ import { ClanMembersResponseSchema, type ClanRequestsResponse, ClanRequestsResponseSchema, + type ClanWindowStatsResponse, + ClanWindowStatsResponseSchema, DiscordInviteResponseSchema, JoinClanResponseSchema, } from "../core/ClanApiSchemas"; @@ -22,6 +24,8 @@ import { getApiBase, getUserMe } from "./Api"; import { getAuthHeader } from "./Auth"; const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; +const CLAN_RECENT_STATS_FETCH_TIMEOUT_MS = 5000; +const CLAN_RECENT_STATS_WINDOW_MS = 24 * 60 * 60 * 1000; export type { ClanBan, ClanBansResponse, @@ -39,6 +43,8 @@ export type { ClanMemberStats, ClanMemberWL, ClanRequestsResponse, + ClanWindowStats, + ClanWindowStatsResponse, } from "../core/ClanApiSchemas"; async function clanFetch( @@ -148,6 +154,38 @@ export async function fetchClanExists(tag: string): Promise { } } +// Rolling 24-hour aggregate for one clan from the public (no-auth) endpoint. +// The API rejects windows longer than one day, so `start` is exactly 24h +// before `end`. Returns false on any failure — the overview card is +// supplementary and simply stays hidden. +export async function fetchClanRecentStats( + tag: string, +): Promise { + try { + const end = new Date(); + const start = new Date(end.getTime() - CLAN_RECENT_STATS_WINDOW_MS); + const params = new URLSearchParams({ + start: start.toISOString(), + end: end.toISOString(), + }); + // Uppercased to the canonical form so it matches the server's route. + const path = `/public/clan/${encodeURIComponent(tag.toUpperCase())}?${params}`; + const res = await fetch(`${getApiBase()}${path}`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(CLAN_RECENT_STATS_FETCH_TIMEOUT_MS), + }); + if (!res.ok) return false; + const parsed = ClanWindowStatsResponseSchema.safeParse(await res.json()); + if (!parsed.success) { + console.warn("fetchClanRecentStats: Zod validation failed", parsed.error); + return false; + } + return parsed.data; + } catch { + return false; + } +} + /** * Client-side mirror of the server's clan-tag ownership rule (resolveClanTag in * Privilege.ts), for instant inline feedback. Returns the tag to submit (null diff --git a/src/client/components/clan/ClanDetailView.ts b/src/client/components/clan/ClanDetailView.ts index f217df6545..12da651875 100644 --- a/src/client/components/clan/ClanDetailView.ts +++ b/src/client/components/clan/ClanDetailView.ts @@ -1,4 +1,4 @@ -import { html, LitElement } from "lit"; +import { html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { invalidateUserMe } from "../../Api"; import { @@ -7,8 +7,10 @@ import { type ClanMember, type ClanMemberOrder, type ClanMemberSort, + type ClanWindowStats, fetchClanDetail, fetchClanMembers, + fetchClanRecentStats, fetchDiscordInvite, joinClan, leaveClan, @@ -26,6 +28,7 @@ import { renderMemberSearchInput, renderMemberSortControl, renderStat, + renderWLBarRow, showToast, } from "./ClanShared"; import { ClanStatsBreakdown } from "./ClanStatsBreakdown"; @@ -55,6 +58,8 @@ export class ClanDetailView extends LitElement { @property({ type: Object }) cachedClan: ClanInfo | null = null; @state() private selectedClan: ClanInfo | null = null; @state() private discordMeta: ClanDiscord | null = null; + @state() private recentStats: ClanWindowStats | null = null; + @state() private recentStatsLoading = false; @state() private myRole: ClanRole | null = null; @state() private members: ClanMember[] = []; @state() private membersTotal = 0; @@ -97,6 +102,9 @@ export class ClanDetailView extends LitElement { this.asyncGeneration, ); } + // Deliberately not part of `cachedDetail` — a rolling 24h window goes + // stale, so it is refetched on every mount (like the Discord metadata). + void this.loadRecentStats(this.clanTag, this.asyncGeneration); } // Fetches live Discord invite metadata (server name, icon, counts) for the @@ -108,6 +116,18 @@ export class ClanDetailView extends LitElement { this.discordMeta = meta; } + // Fetches the clan's rolling 24-hour aggregate for the Overview card from + // the public endpoint. Floating; guarded by asyncGeneration + tag like + // loadDiscordMeta. A failure leaves `recentStats` null, which hides the card. + private async loadRecentStats(tag: string, gen: number) { + this.recentStats = null; + this.recentStatsLoading = true; + const res = await fetchClanRecentStats(tag); + if (gen !== this.asyncGeneration || this.clanTag !== tag) return; + this.recentStatsLoading = false; + this.recentStats = res === false ? null : res.clan; + } + disconnectedCallback() { if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce); this.memberLoadSeq++; @@ -129,6 +149,9 @@ export class ClanDetailView extends LitElement { this.myRole = null; this.pendingRequestCount = 0; this.memberSearch = ""; + // Independent of the clan detail fetch, so start it in parallel rather + // than adding its RTT to the visible loading time. + void this.loadRecentStats(this.clanTag, gen); // When the user lands directly on the Members tab (deep link / cached // activeTab), fire both fetches in parallel — otherwise sequencing @@ -436,6 +459,7 @@ export class ClanDetailView extends LitElement {
${this.renderStatTiles(clan)}
+ ${this.renderRecentStatsCard()}
${this.renderDiscordCard(clan.discordUrl)} @@ -450,7 +474,7 @@ export class ClanDetailView extends LitElement {
${this.renderDescriptionCard(clan)}
${this.renderStatTiles(clan)}
- ${actions} + ${this.renderRecentStatsCard()} ${actions}
`; } @@ -606,6 +630,111 @@ export class ClanDetailView extends LitElement { `; } + // Rolling 24-hour activity for the Overview tab. The card is omitted (rather + // than showing an error) when the public endpoint is unavailable — it's + // supplementary, and the rest of the overview stands on its own. + private renderRecentStatsCard(): TemplateResult | string { + if (this.recentStatsLoading) { + return html` +
+
+
+
+
+
+
+ `; + } + const stats = this.recentStats; + if (stats === null) return ""; + + return html` +
+
+

+ ${translateText("clan_modal.recent_24h_title")} +

+ ${stats.games > 0 + ? html` + ${translateText("clan_modal.recent_24h_games", { + count: stats.games, + })} + ` + : ""} +
+ ${stats.games === 0 + ? html`

+ ${translateText("clan_modal.recent_24h_empty")} +

` + : html` + ${renderWLBarRow( + translateText("clan_modal.stats_total"), + stats.wins, + stats.losses, + )} +
+ ${this.renderRecentMetric( + translateText("leaderboard_modal.win_score"), + stats.weightedWins.toLocaleString(undefined, { + maximumFractionDigits: 1, + }), + translateText("leaderboard_modal.win_score_tooltip"), + "text-green-400/90", + )} + ${this.renderRecentMetric( + translateText("leaderboard_modal.loss_score"), + stats.weightedLosses.toLocaleString(undefined, { + maximumFractionDigits: 1, + }), + translateText("leaderboard_modal.loss_score_tooltip"), + "text-red-400/90", + )} + ${this.renderRecentMetric( + translateText("leaderboard_modal.ratio"), + stats.weightedWLRatio.toLocaleString(undefined, { + maximumFractionDigits: 2, + }), + translateText("clan_modal.recent_24h_ratio_tooltip"), + stats.weightedWLRatio >= 1 + ? "text-green-400" + : "text-red-400", + )} +
+ `} +
+ `; + } + + private renderRecentMetric( + label: string, + value: string, + tooltip: string, + valueClass: string, + ): TemplateResult { + return html` +
+ +
+ ${label} +
+
+ ${value} +
+
+ `; + } + // Renders immediately from the stored URL (placeholder name + working join // button) and fills in name/icon/counts when the Discord lookup resolves. private renderDiscordCard(url: string) { diff --git a/src/core/ClanApiSchemas.ts b/src/core/ClanApiSchemas.ts index 66343c1b12..68272070c0 100644 --- a/src/core/ClanApiSchemas.ts +++ b/src/core/ClanApiSchemas.ts @@ -32,6 +32,36 @@ export type ClanLeaderboardResponse = z.infer< typeof ClanLeaderboardResponseSchema >; +// Aggregate for one clan over a bounded time window, as served by the public +// (unauthenticated) GET /public/clan/:tag?start=&end= endpoint. The window is +// capped at one day server-side, so this is what the clan overview's "past 24 +// hours" card reads. Only public Team games (unranked, excluding HvN) are +// counted — the same bucket the clan leaderboard uses. The per-team-type and +// per-team-count breakdowns the endpoint also returns are intentionally not +// modelled here; Zod strips them. +export const ClanWindowStatsSchema = z.object({ + clanTag: RequiredClanTagSchema, + games: z.number(), + // Sum of participating clan members across those games, so a single game + // with four members on the roster counts four sessions. + playerSessions: z.number(), + wins: z.number(), + losses: z.number(), + weightedWins: z.number(), + weightedLosses: z.number(), + weightedWLRatio: z.number(), +}); +export type ClanWindowStats = z.infer; + +export const ClanWindowStatsResponseSchema = z.object({ + start: z.iso.datetime(), + end: z.iso.datetime(), + clan: ClanWindowStatsSchema, +}); +export type ClanWindowStatsResponse = z.infer< + typeof ClanWindowStatsResponseSchema +>; + export const ClanInfoSchema = z.object({ name: z.string().max(35), tag: RequiredClanTagSchema, diff --git a/tests/client/clan/ClanModalTestUtils.ts b/tests/client/clan/ClanModalTestUtils.ts index a51f445958..2e1141e42f 100644 --- a/tests/client/clan/ClanModalTestUtils.ts +++ b/tests/client/clan/ClanModalTestUtils.ts @@ -70,6 +70,9 @@ export function clanApiMockFactory() { // ClanDetailView calls this when a clan has a discordUrl; mock the degraded // plain-link result so view tests never reach the real Discord network. fetchDiscordInvite: vi.fn(async (url: string) => ({ url, valid: true })), + // The Overview tab's rolling 24h card. Default to the unavailable result + // (card hidden) so existing view tests keep their previous markup. + fetchClanRecentStats: vi.fn(async () => false), }; } diff --git a/tests/client/clan/ClanRecentStats.test.ts b/tests/client/clan/ClanRecentStats.test.ts new file mode 100644 index 0000000000..a08fa12964 --- /dev/null +++ b/tests/client/clan/ClanRecentStats.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../src/client/Api", () => ({ + getApiBase: vi.fn(() => "http://localhost:3000"), + getUserMe: vi.fn(), +})); + +vi.mock("../../../src/client/Auth", () => ({ + getAuthHeader: vi.fn(async () => "Bearer test-token"), +})); + +import { fetchClanRecentStats } from "../../../src/client/ClanApi"; +import { ClanWindowStatsResponseSchema } from "../../../src/core/ClanApiSchemas"; + +const clanStats = (overrides: Record = {}) => ({ + clanTag: "UN", + games: 12, + playerSessions: 30, + wins: 5, + losses: 7, + weightedWins: 8.25, + weightedLosses: 4.5, + weightedWLRatio: 1.83, + ...overrides, +}); + +const windowResponse = (clan = clanStats()) => ({ + start: "2026-08-12T18:00:00.000Z", + end: "2026-08-13T18:00:00.000Z", + clan, +}); + +const okJson = (data: unknown) => ({ + ok: true, + status: 200, + json: async () => data, +}); + +beforeEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("fetchClanRecentStats", () => { + it("requests the public endpoint with a 24h window and the uppercased tag", async () => { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => + okJson(windowResponse()), + ); + vi.stubGlobal("fetch", fetchMock); + + const before = Date.now(); + await fetchClanRecentStats("un"); + const after = Date.now(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.origin + url.pathname).toBe( + "http://localhost:3000/public/clan/UN", + ); + + const start = new Date(url.searchParams.get("start")!); + const end = new Date(url.searchParams.get("end")!); + // The API rejects windows longer than one day, so this must be exact. + expect(end.getTime() - start.getTime()).toBe(24 * 60 * 60 * 1000); + expect(end.getTime()).toBeGreaterThanOrEqual(before); + expect(end.getTime()).toBeLessThanOrEqual(after); + // Both bounds must be ISO-8601 with a Z offset for the server's parser. + expect(url.searchParams.get("start")).toBe(start.toISOString()); + expect(url.searchParams.get("end")).toBe(end.toISOString()); + }); + + it("sends no Authorization header (public endpoint)", async () => { + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => + okJson(windowResponse()), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchClanRecentStats("UN"); + + expect(fetchMock.mock.calls[0][1]?.headers).toEqual({ + Accept: "application/json", + }); + }); + + it("returns the parsed window aggregate", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => okJson(windowResponse())), + ); + + const res = await fetchClanRecentStats("UN"); + expect(res).not.toBe(false); + if (res === false) return; + expect(res.clan.games).toBe(12); + expect(res.clan.wins).toBe(5); + expect(res.clan.weightedWLRatio).toBe(1.83); + }); + + it("returns false on a non-OK status", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 400, json: async () => ({}) })), + ); + await expect(fetchClanRecentStats("UN")).resolves.toBe(false); + }); + + it("returns false when the payload fails validation", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => okJson({ start: "nope", end: "nope", clan: {} })), + ); + await expect(fetchClanRecentStats("UN")).resolves.toBe(false); + }); + + it("returns false when the request throws (offline / timeout)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }), + ); + await expect(fetchClanRecentStats("UN")).resolves.toBe(false); + }); +}); + +describe("ClanWindowStatsResponseSchema", () => { + it("accepts the live payload and strips the breakdown maps", () => { + const result = ClanWindowStatsResponseSchema.safeParse( + windowResponse( + clanStats({ + teamTypeWL: { Duos: { wl: [1, 2], weightedWL: [1.5, 0.5] } }, + teamCountWL: { "2": { wl: [1, 2], weightedWL: [1.5, 0.5] } }, + }), + ), + ); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.clan).not.toHaveProperty("teamTypeWL"); + expect(result.data.clan).not.toHaveProperty("teamCountWL"); + }); + + it("accepts a zero-activity window", () => { + const result = ClanWindowStatsResponseSchema.safeParse( + windowResponse( + clanStats({ + games: 0, + playerSessions: 0, + wins: 0, + losses: 0, + weightedWins: 0, + weightedLosses: 0, + // The API reports a ratio of 1 when there are no weighted losses. + weightedWLRatio: 1, + }), + ), + ); + expect(result.success).toBe(true); + }); + + it("rejects non-ISO window bounds", () => { + const result = ClanWindowStatsResponseSchema.safeParse({ + ...windowResponse(), + start: "August 12, 2026", + }); + expect(result.success).toBe(false); + }); + + it("rejects a missing counter", () => { + const clan = clanStats(); + delete (clan as Record).weightedWLRatio; + const result = ClanWindowStatsResponseSchema.safeParse( + windowResponse(clan), + ); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/client/clan/ClanRecentStatsCard.test.ts b/tests/client/clan/ClanRecentStatsCard.test.ts new file mode 100644 index 0000000000..03f3e9542c --- /dev/null +++ b/tests/client/clan/ClanRecentStatsCard.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + apiMockFactory, + authMockFactory, + clanApiMockFactory, + crazyGamesSdkMockFactory, + setState, + stubLocalStorage, + utilsMockFactory, + virtualizerMockFactory, + waitForSubComponent, +} from "./ClanModalTestUtils"; + +vi.mock("@lit-labs/virtualizer/virtualize.js", () => virtualizerMockFactory()); +vi.mock("../../../src/client/Api", () => apiMockFactory()); +vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory()); +vi.mock("../../../src/client/Utils", () => utilsMockFactory()); +vi.mock("../../../src/client/Auth", () => authMockFactory()); +vi.mock("../../../src/client/CrazyGamesSDK", () => crazyGamesSdkMockFactory()); + +stubLocalStorage(); + +import { ClanModal } from "../../../src/client/ClanModal"; + +const windowResponse = (clan: Record) => ({ + start: "2026-08-12T18:00:00.000Z", + end: "2026-08-13T18:00:00.000Z", + clan: { + clanTag: "TST", + games: 0, + playerSessions: 0, + wins: 0, + losses: 0, + weightedWins: 0, + weightedLosses: 0, + weightedWLRatio: 1, + ...clan, + }, +}); + +describe("ClanDetailView — past 24 hours card", () => { + let modal: ClanModal; + + const mockRecentStats = async (value: unknown) => { + const { fetchClanRecentStats } = + await import("../../../src/client/ClanApi"); + (fetchClanRecentStats as ReturnType).mockResolvedValue(value); + }; + + const openDetail = async () => { + setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never); + setState(modal, "view" as keyof ClanModal, "detail" as never); + return waitForSubComponent(modal, "clan-detail-view"); + }; + + beforeEach(async () => { + if (!customElements.get("clan-modal")) { + customElements.define("clan-modal", ClanModal); + } + modal = document.createElement("clan-modal") as ClanModal; + modal.setAttribute("inline", ""); + document.body.appendChild(modal); + await modal.updateComplete; + }); + + afterEach(() => { + document.body.removeChild(modal); + vi.clearAllMocks(); + }); + + it("fetches the 24h window for the clan being viewed", async () => { + await mockRecentStats(windowResponse({ games: 0 })); + await openDetail(); + + const { fetchClanRecentStats } = + await import("../../../src/client/ClanApi"); + expect(fetchClanRecentStats).toHaveBeenCalledWith("TST"); + }); + + it("renders the win/loss bar and weighted metrics when games were played", async () => { + await mockRecentStats( + windowResponse({ + games: 12, + playerSessions: 30, + wins: 5, + losses: 7, + weightedWins: 8.4, + weightedLosses: 4.5, + weightedWLRatio: 1.87, + }), + ); + const view = await openDetail(); + + expect(view.textContent).toContain("clan_modal.recent_24h_title"); + expect(view.textContent).not.toContain("clan_modal.recent_24h_empty"); + // renderWLBarRow labels, plus the weighted figures. + expect(view.textContent).toContain("5W"); + expect(view.textContent).toContain("7L"); + expect(view.textContent).toContain("8.4"); + expect(view.textContent).toContain("4.5"); + expect(view.textContent).toContain("1.87"); + + const { translateText } = await import("../../../src/client/Utils"); + const gamesCall = ( + translateText as ReturnType + ).mock.calls.find((c) => c[0] === "clan_modal.recent_24h_games"); + expect(gamesCall?.[1]).toEqual({ count: 12 }); + }); + + it("shows the empty message instead of metrics when no games were played", async () => { + await mockRecentStats(windowResponse({ games: 0 })); + const view = await openDetail(); + + expect(view.textContent).toContain("clan_modal.recent_24h_title"); + expect(view.textContent).toContain("clan_modal.recent_24h_empty"); + expect(view.textContent).not.toContain("leaderboard_modal.win_score"); + }); + + it("hides the card entirely when the public endpoint is unavailable", async () => { + await mockRecentStats(false); + const view = await openDetail(); + + expect(view.textContent).not.toContain("clan_modal.recent_24h_title"); + // The rest of the overview still renders. + expect(view.textContent).toContain("clan_modal.members"); + }); +});