-
- ${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}
- ${wins > 0
- ? html`
- ${wins}W
-
`
- : ""}
- ${losses > 0
- ? html`
- ${losses}L
-
`
- : ""}
+
+ ${wins > 0
+ ? html`
`
+ : ""}
+ ${losses > 0
+ ? html`
`
+ : ""}
+
+
+ ${wins > 0 ? `${wins}W` : ""}
+ ${losses > 0 ? `${losses}L` : ""}
+
- ${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`
+
+ ${toggleVisible
+ ? 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);
});
});