From 005e1b60449eeeafaacd0b200413aae1bf9ccf94 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 6 May 2026 23:09:53 +0100 Subject: [PATCH] clan stats breakdown (#3869) ## Description: improvements to clan ui. image added "expand all" and new collapsible sections. image which changes to collapse all when expanded also adds more info about team (d,t,q,2,3,4,5,6,7 team) ## 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 --- resources/lang/en.json | 8 + src/client/components/clan/ClanDetailView.ts | 40 +++- src/client/components/clan/ClanShared.ts | 70 +++--- .../components/clan/ClanStatsBreakdown.ts | 199 ++++++++++++++++++ src/core/ClanApiSchemas.ts | 25 +++ tests/client/clan/ClanApiQueries.test.ts | 9 + tests/client/clan/ClanApiSchemas.test.ts | 18 ++ tests/client/clan/ClanModalTestUtils.ts | 9 + tests/client/clan/ClanShared.test.ts | 109 ++++++++-- 9 files changed, 421 insertions(+), 66 deletions(-) create mode 100644 src/client/components/clan/ClanStatsBreakdown.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index eba44de3c..812cd0733 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -292,6 +292,14 @@ "stats_hvn": "HvN", "stats_ranked": "Ranked", "stats_1v1": "1v1", + "stats_duos": "Duos", + "stats_trios": "Trios", + "stats_quads": "Quads", + "stats_team_count": "{count} Teams", + "stats_expand": "Show breakdown", + "stats_collapse": "Hide breakdown", + "stats_expand_all": "Expand all", + "stats_collapse_all": "Collapse all", "no_description": "No description", "saving": "Saving...", "join_request_cancelled": "Join request cancelled.", diff --git a/src/client/components/clan/ClanDetailView.ts b/src/client/components/clan/ClanDetailView.ts index f1e547805..8bb2fe371 100644 --- a/src/client/components/clan/ClanDetailView.ts +++ b/src/client/components/clan/ClanDetailView.ts @@ -29,6 +29,7 @@ import { renderStat, showToast, } from "./ClanShared"; +import { ClanStatsBreakdown } from "./ClanStatsBreakdown"; @customElement("clan-detail-view") export class ClanDetailView extends LitElement { @@ -65,6 +66,7 @@ export class ClanDetailView extends LitElement { @state() private clanStats: ClanStats | null = null; @state() private loading = false; @state() private actionPending = false; + @state() private allStatsExpanded = false; private memberSearch = ""; private memberSearchDebounce: ReturnType | null = null; private asyncGeneration = 0; @@ -94,6 +96,14 @@ export class ClanDetailView extends LitElement { super.disconnectedCallback(); } + protected updated() { + if (this.allStatsExpanded) { + this.querySelectorAll("clan-stats-breakdown").forEach( + (el) => el.setAllExpanded(true), + ); + } + } + private async loadDetail() { const gen = ++this.asyncGeneration; this.loading = true; @@ -401,13 +411,37 @@ export class ClanDetailView extends LitElement { `; } + private toggleAllStats() { + this.allStatsExpanded = !this.allStatsExpanded; + const target = this.allStatsExpanded; + this.querySelectorAll("clan-stats-breakdown").forEach( + (el) => el.setAllExpanded(target), + ); + } + private renderMembersList() { const filtered = filterMembersBySearch(this.members, this.memberSearch); + const toggleLabel = translateText( + this.allStatsExpanded + ? "clan_modal.stats_collapse_all" + : "clan_modal.stats_expand_all", + ); return html`
-

- ${translateText("clan_modal.members")} -

+
+

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

+ +
${renderMemberSearchInput( (e: Event) => this.onSearchInput(e), undefined, diff --git a/src/client/components/clan/ClanShared.ts b/src/client/components/clan/ClanShared.ts index b6b0622d5..26c6a9365 100644 --- a/src/client/components/clan/ClanShared.ts +++ b/src/client/components/clan/ClanShared.ts @@ -8,6 +8,7 @@ import type { ClanStats, } from "../../ClanApi"; import { showToast, translateText } from "../../Utils"; +import "./ClanStatsBreakdown"; export { renderLoadingSpinner } from "../BaseModal"; export { showToast }; @@ -88,15 +89,7 @@ export function renderClanWL(stats: ClanStats): TemplateResult | string {

${translateText("clan_modal.statistics")}

-
- ${statBuckets.map(({ key, labelKey }) => - renderWLBarRow( - translateText(labelKey), - stats.stats[key].wins, - stats.stats[key].losses, - ), - )} -
+
`; } @@ -326,16 +319,7 @@ export function renderMemberPagination( `; } -const statBuckets = [ - { key: "total" as const, labelKey: "clan_modal.stats_total" }, - { key: "ffa" as const, labelKey: "clan_modal.stats_ffa" }, - { key: "team" as const, labelKey: "clan_modal.stats_team" }, - { key: "hvn" as const, labelKey: "clan_modal.stats_hvn" }, - { key: "ranked" as const, labelKey: "clan_modal.stats_ranked" }, - { key: "1v1" as const, labelKey: "clan_modal.stats_1v1" }, -]; - -function renderWLBarRow( +export function renderWLBarRow( label: string, wins: number, losses: number, @@ -359,26 +343,30 @@ function renderWLBarRow( ${label} - ${statBuckets.map(({ key, labelKey }) => - renderWLBarRow( - translateText(labelKey), - stats[key].wins, - stats[key].losses, - ), - )} +
+
`; } diff --git a/src/client/components/clan/ClanStatsBreakdown.ts b/src/client/components/clan/ClanStatsBreakdown.ts new file mode 100644 index 000000000..c14db0863 --- /dev/null +++ b/src/client/components/clan/ClanStatsBreakdown.ts @@ -0,0 +1,199 @@ +import { html, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { + RANKED_BREAKDOWN_KEYS, + TEAM_BREAKDOWN_KEYS, + type ClanMemberStats, + type ClanMemberWL, +} from "../../../core/ClanApiSchemas"; +import { translateText } from "../../Utils"; +import { renderWLBarRow } from "./ClanShared"; + +type SubKey = + | (typeof TEAM_BREAKDOWN_KEYS)[number] + | (typeof RANKED_BREAKDOWN_KEYS)[number]; + +const LEVEL_LEFT_PAD: Record<0 | 1 | 2, string> = { + 0: "pl-1.5", + 1: "pl-5", + 2: "pl-9", +}; + +function labelForSubKey(key: SubKey): string { + switch (key) { + case "duos": + return translateText("clan_modal.stats_duos"); + case "trios": + return translateText("clan_modal.stats_trios"); + case "quads": + return translateText("clan_modal.stats_quads"); + case "1v1": + return translateText("clan_modal.stats_1v1"); + default: + return translateText("clan_modal.stats_team_count", { count: key }); + } +} + +function hasGames(wl: ClanMemberWL): boolean { + return wl.wins > 0 || wl.losses > 0; +} + +@customElement("clan-stats-breakdown") +export class ClanStatsBreakdown extends LitElement { + @property({ type: Object }) stats!: ClanMemberStats; + @state() private expandedTotal = false; + @state() private expandedTeam = false; + @state() private expandedRanked = false; + + createRenderRoot() { + return this; + } + + private get teamSubKeys(): readonly (typeof TEAM_BREAKDOWN_KEYS)[number][] { + return TEAM_BREAKDOWN_KEYS.filter((k) => hasGames(this.stats[k])); + } + + private get rankedSubKeys(): readonly (typeof RANKED_BREAKDOWN_KEYS)[number][] { + return RANKED_BREAKDOWN_KEYS.filter((k) => hasGames(this.stats[k])); + } + + public setAllExpanded(expanded: boolean) { + this.expandedTotal = expanded; + this.expandedTeam = expanded; + this.expandedRanked = expanded; + } + + private toggleTotal = () => { + this.expandedTotal = !this.expandedTotal; + }; + + private toggleTeam = () => { + this.expandedTeam = !this.expandedTeam; + }; + + private toggleRanked = () => { + this.expandedRanked = !this.expandedRanked; + }; + + private renderRow( + label: string, + wl: ClanMemberWL, + level: 0 | 1 | 2, + expand?: { expanded: boolean; onToggle: () => void; disabled: boolean }, + ): TemplateResult { + const row = renderWLBarRow(label, wl.wins, wl.losses); + const toggleVisible = !!expand && !expand.disabled; + const toggleIcon = html` + + `; + const padding = `${LEVEL_LEFT_PAD[level]} pr-1.5 py-0.5`; + if (!expand || expand.disabled) { + return html` +
+ ${toggleIcon} +
${row}
+
+ `; + } + const title = translateText( + expand.expanded ? "clan_modal.stats_collapse" : "clan_modal.stats_expand", + ); + return html` + + `; + } + + render() { + if (!this.stats) return html``; + const teamKeys = this.teamSubKeys; + const rankedKeys = this.rankedSubKeys; + return html` +
+ ${this.renderRow( + translateText("clan_modal.stats_total"), + this.stats.total, + 0, + { + expanded: this.expandedTotal, + onToggle: this.toggleTotal, + disabled: false, + }, + )} + ${this.expandedTotal + ? html` + ${this.renderRow( + translateText("clan_modal.stats_ffa"), + this.stats.ffa, + 1, + )} + ${this.renderRow( + translateText("clan_modal.stats_team"), + this.stats.team, + 1, + { + expanded: this.expandedTeam, + onToggle: this.toggleTeam, + disabled: teamKeys.length === 0, + }, + )} + ${this.expandedTeam + ? html`${teamKeys.map((k) => + this.renderRow(labelForSubKey(k), this.stats[k], 2), + )}` + : ""} + ${this.renderRow( + translateText("clan_modal.stats_hvn"), + this.stats.hvn, + 1, + )} + ${this.renderRow( + translateText("clan_modal.stats_ranked"), + this.stats.ranked, + 1, + { + expanded: this.expandedRanked, + onToggle: this.toggleRanked, + disabled: rankedKeys.length === 0, + }, + )} + ${this.expandedRanked + ? html`${rankedKeys.map((k) => + this.renderRow(labelForSubKey(k), this.stats[k], 2), + )}` + : ""} + ` + : ""} +
+ `; + } +} diff --git a/src/core/ClanApiSchemas.ts b/src/core/ClanApiSchemas.ts index 4367d3bf4..427f49768 100644 --- a/src/core/ClanApiSchemas.ts +++ b/src/core/ClanApiSchemas.ts @@ -55,11 +55,36 @@ export const ClanMemberStatsSchema = z.object({ ffa: ClanMemberWLSchema, team: ClanMemberWLSchema, hvn: ClanMemberWLSchema, + duos: ClanMemberWLSchema, + trios: ClanMemberWLSchema, + quads: ClanMemberWLSchema, + "2": ClanMemberWLSchema, + "3": ClanMemberWLSchema, + "4": ClanMemberWLSchema, + "5": ClanMemberWLSchema, + "6": ClanMemberWLSchema, + "7": ClanMemberWLSchema, ranked: ClanMemberWLSchema, "1v1": ClanMemberWLSchema, }); export type ClanMemberStats = z.infer; +export const TEAM_BREAKDOWN_KEYS = [ + "duos", + "trios", + "quads", + "2", + "3", + "4", + "5", + "6", + "7", +] as const satisfies readonly (keyof ClanMemberStats)[]; + +export const RANKED_BREAKDOWN_KEYS = [ + "1v1", +] as const satisfies readonly (keyof ClanMemberStats)[]; + export const ClanMemberSchema = z.object({ role: z.enum(["leader", "officer", "member"]), joinedAt: z.iso.datetime(), diff --git a/tests/client/clan/ClanApiQueries.test.ts b/tests/client/clan/ClanApiQueries.test.ts index 191a05a6a..b3f016c14 100644 --- a/tests/client/clan/ClanApiQueries.test.ts +++ b/tests/client/clan/ClanApiQueries.test.ts @@ -83,6 +83,15 @@ describe("fetchClanStats", () => { ffa: { wins: 7, losses: 3 }, team: { wins: 4, losses: 1 }, hvn: { wins: 1, losses: 0 }, + duos: { wins: 2, losses: 0 }, + trios: { wins: 1, losses: 1 }, + quads: { wins: 1, losses: 0 }, + "2": { wins: 2, losses: 0 }, + "3": { wins: 1, losses: 1 }, + "4": { wins: 1, losses: 0 }, + "5": { wins: 0, losses: 0 }, + "6": { wins: 0, losses: 0 }, + "7": { wins: 0, losses: 0 }, ranked: { wins: 3, losses: 1 }, "1v1": { wins: 3, losses: 1 }, }, diff --git a/tests/client/clan/ClanApiSchemas.test.ts b/tests/client/clan/ClanApiSchemas.test.ts index ffd31cbe7..7b99678e9 100644 --- a/tests/client/clan/ClanApiSchemas.test.ts +++ b/tests/client/clan/ClanApiSchemas.test.ts @@ -93,6 +93,15 @@ describe("ClanMemberSchema", () => { ffa: { wins: 2, losses: 4 }, team: { wins: 5, losses: 1 }, hvn: { wins: 0, losses: 0 }, + duos: { wins: 1, losses: 0 }, + trios: { wins: 2, losses: 0 }, + quads: { wins: 2, losses: 1 }, + "2": { wins: 1, losses: 0 }, + "3": { wins: 2, losses: 0 }, + "4": { wins: 2, losses: 1 }, + "5": { wins: 0, losses: 0 }, + "6": { wins: 0, losses: 0 }, + "7": { wins: 0, losses: 0 }, ranked: { wins: 1, losses: 3 }, "1v1": { wins: 1, losses: 3 }, }, @@ -155,6 +164,15 @@ describe("ClanStatsSchema", () => { ffa: { wins: 3, losses: 2 }, team: { wins: 2, losses: 1 }, hvn: { wins: 1, losses: 0 }, + duos: { wins: 1, losses: 0 }, + trios: { wins: 0, losses: 1 }, + quads: { wins: 1, losses: 0 }, + "2": { wins: 1, losses: 0 }, + "3": { wins: 0, losses: 1 }, + "4": { wins: 1, losses: 0 }, + "5": { wins: 0, losses: 0 }, + "6": { wins: 0, losses: 0 }, + "7": { wins: 0, losses: 0 }, ranked: { wins: 1, losses: 0 }, "1v1": { wins: 1, losses: 0 }, }, diff --git a/tests/client/clan/ClanModalTestUtils.ts b/tests/client/clan/ClanModalTestUtils.ts index 21b86f63c..b95de2e24 100644 --- a/tests/client/clan/ClanModalTestUtils.ts +++ b/tests/client/clan/ClanModalTestUtils.ts @@ -41,6 +41,15 @@ export function clanApiMockFactory() { ffa: { wins: 3, losses: 2 }, team: { wins: 2, losses: 1 }, hvn: { wins: 1, losses: 0 }, + duos: { wins: 1, losses: 0 }, + trios: { wins: 0, losses: 1 }, + quads: { wins: 1, losses: 0 }, + "2": { wins: 1, losses: 0 }, + "3": { wins: 0, losses: 1 }, + "4": { wins: 1, losses: 0 }, + "5": { wins: 0, losses: 0 }, + "6": { wins: 0, losses: 0 }, + "7": { wins: 0, losses: 0 }, ranked: { wins: 1, losses: 0 }, "1v1": { wins: 1, losses: 0 }, }, diff --git a/tests/client/clan/ClanShared.test.ts b/tests/client/clan/ClanShared.test.ts index e420f3692..83a72d711 100644 --- a/tests/client/clan/ClanShared.test.ts +++ b/tests/client/clan/ClanShared.test.ts @@ -57,66 +57,137 @@ describe("filterMembersBySearch", () => { }); describe("renderMemberStats", () => { + const ZERO = { wins: 0, losses: 0 } as const; const stats: ClanMemberStats = { total: { wins: 7, losses: 5 }, ffa: { wins: 2, losses: 4 }, team: { wins: 5, losses: 1 }, - hvn: { wins: 0, losses: 0 }, - ranked: { wins: 0, losses: 0 }, - "1v1": { wins: 0, losses: 0 }, + hvn: { ...ZERO }, + duos: { wins: 1, losses: 0 }, + trios: { wins: 4, losses: 1 }, + quads: { ...ZERO }, + "2": { ...ZERO }, + "3": { ...ZERO }, + "4": { ...ZERO }, + "5": { ...ZERO }, + "6": { ...ZERO }, + "7": { ...ZERO }, + ranked: { ...ZERO }, + "1v1": { ...ZERO }, }; - function renderTo(result: ReturnType): HTMLElement { + async function renderTo( + result: ReturnType, + ): Promise { const host = document.createElement("div"); render(result, host); + document.body.appendChild(host); + // Allow Lit to upgrade the custom element. + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); return host; } - it("renders nothing when stats is undefined", () => { - const host = renderTo(renderMemberStats(undefined)); + function findExpandableButton( + host: HTMLElement, + labelKey: string, + ): HTMLButtonElement | undefined { + return Array.from( + host.querySelectorAll("button[aria-expanded]"), + ).find((b) => (b.textContent ?? "").includes(labelKey)); + } + + async function expandTotal(host: HTMLElement) { + const btn = findExpandableButton(host, "clan_modal.stats_total"); + btn!.click(); + await new Promise((r) => setTimeout(r, 0)); + } + + it("renders nothing when stats is undefined", async () => { + const host = await renderTo(renderMemberStats(undefined)); expect(host.textContent?.trim()).toBe(""); }); - it("renders W/L labels inside bar segments and the win-rate per bucket", () => { - const host = renderTo(renderMemberStats(stats)); + it("collapses everything except the total row by default", async () => { + const host = await renderTo(renderMemberStats(stats)); + const text = host.textContent ?? ""; + expect(text).toContain("clan_modal.stats_total"); + expect(text).not.toContain("clan_modal.stats_ffa"); + expect(text).not.toContain("clan_modal.stats_team"); + expect(text).not.toContain("clan_modal.stats_hvn"); + expect(text).not.toContain("clan_modal.stats_ranked"); + }); + + it("renders W/L labels inside bar segments and the win-rate per bucket", async () => { + const host = await renderTo(renderMemberStats(stats)); + await expandTotal(host); const text = host.textContent?.replace(/\s+/g, " ") ?? ""; - // Each bucket with games shows `{wins}W` and `{losses}L` inside segments expect(text).toContain("2W"); expect(text).toContain("4L"); expect(text).toContain("5W"); expect(text).toContain("1L"); - // Win-rate, and em-dash placeholder for empty bucket expect(text).toContain("33%"); expect(text).toContain("83%"); expect(text).toContain("—"); }); - it("renders a proportional win-loss bar when there are games", () => { - const host = renderTo(renderMemberStats(stats)); + it("renders a proportional win-loss bar when there are games", async () => { + const host = await renderTo(renderMemberStats(stats)); + await expandTotal(host); const bars = host.querySelectorAll("[style*='width']"); - // Two segments per bucket with games (total: 2, ffa: 2, team: 2). Ranked - // and 1v1 have 0 games → no segments. + // Top-level rows after expanding Total: total, ffa, team, hvn, ranked (5). + // Ranked and hvn have 0 games → no segments. Others contribute 2 each. expect(bars.length).toBe(6); const widths = Array.from(bars).map((b) => (b.getAttribute("style") ?? "").replace(/\s+/g, ""), ); - // total: 7/12 ≈ 58.3% wins, 41.7% losses expect(widths[0]).toContain("width:58.33"); expect(widths[1]).toContain("width:41.66"); - // ffa: 2/6 ≈ 33.3% wins, 66.7% losses expect(widths[2]).toContain("width:33.33"); expect(widths[3]).toContain("width:66.66"); }); - it("includes all six translated bucket labels", () => { - const host = renderTo(renderMemberStats(stats)); + it("includes the visible top-level translated bucket labels", async () => { + const host = await renderTo(renderMemberStats(stats)); + await expandTotal(host); const text = host.textContent ?? ""; expect(text).toContain("clan_modal.stats_total"); expect(text).toContain("clan_modal.stats_ffa"); expect(text).toContain("clan_modal.stats_team"); expect(text).toContain("clan_modal.stats_hvn"); expect(text).toContain("clan_modal.stats_ranked"); - expect(text).toContain("clan_modal.stats_1v1"); + // 1v1 lives under the ranked dropdown — hidden until expanded. + expect(text).not.toContain("clan_modal.stats_1v1"); + }); + + it("reveals team sub-buckets when the team row is expanded", async () => { + const host = await renderTo(renderMemberStats(stats)); + await expandTotal(host); + const teamButton = findExpandableButton(host, "clan_modal.stats_team"); + expect(teamButton).toBeDefined(); + expect(teamButton!.disabled).toBe(false); + teamButton!.click(); + await new Promise((r) => setTimeout(r, 0)); + const text = host.textContent ?? ""; + expect(text).toContain("clan_modal.stats_duos"); + expect(text).toContain("clan_modal.stats_trios"); + // Buckets with no games are hidden. + expect(text).not.toContain("clan_modal.stats_quads"); + }); + + it("does not render an expandable button for ranked when no breakdown has games", async () => { + const host = await renderTo(renderMemberStats(stats)); + await expandTotal(host); + const expandableLabels = Array.from( + host.querySelectorAll("button[aria-expanded]"), + ).map((b) => b.textContent ?? ""); + expect( + expandableLabels.some((t) => t.includes("clan_modal.stats_ranked")), + ).toBe(false); + // Sanity: team is still expandable since it has sub-bucket games. + expect( + expandableLabels.some((t) => t.includes("clan_modal.stats_team")), + ).toBe(true); }); });