clan stats breakdown (#3869)

## Description:

improvements to clan ui. 
<img width="788" height="290" alt="image"
src="https://github.com/user-attachments/assets/736ca147-bff4-44d8-8180-7b80a85556fe"
/>
added "expand all" and new collapsible sections.


<img width="787" height="550" alt="image"
src="https://github.com/user-attachments/assets/deb2f813-854b-46a9-a767-52c4f749f30f"
/>

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
This commit is contained in:
Ryan
2026-05-06 23:09:53 +01:00
committed by GitHub
parent 9432bb26f8
commit 005e1b6044
9 changed files with 421 additions and 66 deletions
+37 -3
View File
@@ -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<typeof setTimeout> | null = null;
private asyncGeneration = 0;
@@ -94,6 +96,14 @@ export class ClanDetailView extends LitElement {
super.disconnectedCallback();
}
protected updated() {
if (this.allStatsExpanded) {
this.querySelectorAll<ClanStatsBreakdown>("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<ClanStatsBreakdown>("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`
<div class="bg-white/5 rounded-xl border border-white/10 p-5 space-y-3">
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
${translateText("clan_modal.members")}
</h3>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
${translateText("clan_modal.members")}
</h3>
<button
type="button"
@click=${() => this.toggleAllStats()}
class="text-[10px] font-bold text-white/50 hover:text-white uppercase tracking-wider px-2 py-1 rounded-md border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors"
title=${toggleLabel}
aria-pressed=${this.allStatsExpanded}
>
${toggleLabel}
</button>
</div>
${renderMemberSearchInput(
(e: Event) => this.onSearchInput(e),
undefined,
+26 -44
View File
@@ -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 {
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
${translateText("clan_modal.statistics")}
</h3>
<div class="space-y-1.5">
${statBuckets.map(({ key, labelKey }) =>
renderWLBarRow(
translateText(labelKey),
stats.stats[key].wins,
stats.stats[key].losses,
),
)}
</div>
<clan-stats-breakdown .stats=${stats.stats}></clan-stats-breakdown>
</div>
`;
}
@@ -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}
</span>
<div
class="flex-1 flex h-5 rounded-md overflow-hidden bg-white/5 text-[11px] font-bold text-white tabular-nums"
class="relative flex-1 h-5 rounded-md overflow-hidden bg-white/5"
role="img"
aria-label="${wins} wins, ${losses} losses"
>
${wins > 0
? html`<div
class="bg-malibu-blue flex items-center px-1.5 overflow-hidden whitespace-nowrap"
style="width:${winPct}%"
>
${wins}W
</div>`
: ""}
${losses > 0
? html`<div
class="bg-red-500 flex items-center justify-end px-1.5 overflow-hidden whitespace-nowrap"
style="width:${lossPct}%"
>
${losses}L
</div>`
: ""}
<div class="absolute inset-0 flex">
${wins > 0
? html`<div
class="bg-malibu-blue h-full"
style="width:${winPct}%"
></div>`
: ""}
${losses > 0
? html`<div
class="bg-red-500 h-full"
style="width:${lossPct}%"
></div>`
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-between px-1.5 text-[11px] font-bold text-white tabular-nums whitespace-nowrap pointer-events-none"
>
<span>${wins > 0 ? `${wins}W` : ""}</span>
<span>${losses > 0 ? `${losses}L` : ""}</span>
</div>
</div>
<span
class="text-xs font-bold shrink-0 tabular-nums w-9 text-right ${rateClass}"
@@ -394,14 +382,8 @@ export function renderMemberStats(
): TemplateResult | string {
if (!stats) return "";
return html`
<div class="mt-1.5 space-y-1">
${statBuckets.map(({ key, labelKey }) =>
renderWLBarRow(
translateText(labelKey),
stats[key].wins,
stats[key].losses,
),
)}
<div class="mt-1.5">
<clan-stats-breakdown .stats=${stats}></clan-stats-breakdown>
</div>
`;
}
@@ -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`
<span
class="w-3 h-3 shrink-0 flex items-center justify-center text-white/40 transition-transform duration-150
${expand?.expanded ? "rotate-90" : ""}"
aria-hidden="true"
>
${toggleVisible
? html`<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="w-2.5 h-2.5"
>
<path d="M9 6l6 6-6 6" />
</svg>`
: ""}
</span>
`;
const padding = `${LEVEL_LEFT_PAD[level]} pr-1.5 py-0.5`;
if (!expand || expand.disabled) {
return html`
<div class="flex items-center gap-2 ${padding}">
${toggleIcon}
<div class="flex-1 min-w-0">${row}</div>
</div>
`;
}
const title = translateText(
expand.expanded ? "clan_modal.stats_collapse" : "clan_modal.stats_expand",
);
return html`
<button
type="button"
class="w-full flex items-center gap-2 ${padding} text-left rounded-md transition-colors cursor-pointer
hover:bg-white/10 focus-visible:bg-white/10 focus:outline-none
${expand.expanded ? "bg-white/5" : ""}"
@click=${expand.onToggle}
title=${title}
aria-expanded=${expand.expanded}
>
${toggleIcon}
<div class="flex-1 min-w-0">${row}</div>
</button>
`;
}
render() {
if (!this.stats) return html``;
const teamKeys = this.teamSubKeys;
const rankedKeys = this.rankedSubKeys;
return html`
<div class="space-y-0">
${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),
)}`
: ""}
`
: ""}
</div>
`;
}
}
+25
View File
@@ -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<typeof ClanMemberStatsSchema>;
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(),