mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-19 09:45:45 +00:00
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:
@@ -292,6 +292,14 @@
|
|||||||
"stats_hvn": "HvN",
|
"stats_hvn": "HvN",
|
||||||
"stats_ranked": "Ranked",
|
"stats_ranked": "Ranked",
|
||||||
"stats_1v1": "1v1",
|
"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",
|
"no_description": "No description",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"join_request_cancelled": "Join request cancelled.",
|
"join_request_cancelled": "Join request cancelled.",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
renderStat,
|
renderStat,
|
||||||
showToast,
|
showToast,
|
||||||
} from "./ClanShared";
|
} from "./ClanShared";
|
||||||
|
import { ClanStatsBreakdown } from "./ClanStatsBreakdown";
|
||||||
|
|
||||||
@customElement("clan-detail-view")
|
@customElement("clan-detail-view")
|
||||||
export class ClanDetailView extends LitElement {
|
export class ClanDetailView extends LitElement {
|
||||||
@@ -65,6 +66,7 @@ export class ClanDetailView extends LitElement {
|
|||||||
@state() private clanStats: ClanStats | null = null;
|
@state() private clanStats: ClanStats | null = null;
|
||||||
@state() private loading = false;
|
@state() private loading = false;
|
||||||
@state() private actionPending = false;
|
@state() private actionPending = false;
|
||||||
|
@state() private allStatsExpanded = false;
|
||||||
private memberSearch = "";
|
private memberSearch = "";
|
||||||
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
|
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
private asyncGeneration = 0;
|
private asyncGeneration = 0;
|
||||||
@@ -94,6 +96,14 @@ export class ClanDetailView extends LitElement {
|
|||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected updated() {
|
||||||
|
if (this.allStatsExpanded) {
|
||||||
|
this.querySelectorAll<ClanStatsBreakdown>("clan-stats-breakdown").forEach(
|
||||||
|
(el) => el.setAllExpanded(true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loadDetail() {
|
private async loadDetail() {
|
||||||
const gen = ++this.asyncGeneration;
|
const gen = ++this.asyncGeneration;
|
||||||
this.loading = true;
|
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() {
|
private renderMembersList() {
|
||||||
const filtered = filterMembersBySearch(this.members, this.memberSearch);
|
const filtered = filterMembersBySearch(this.members, this.memberSearch);
|
||||||
|
const toggleLabel = translateText(
|
||||||
|
this.allStatsExpanded
|
||||||
|
? "clan_modal.stats_collapse_all"
|
||||||
|
: "clan_modal.stats_expand_all",
|
||||||
|
);
|
||||||
return html`
|
return html`
|
||||||
<div class="bg-white/5 rounded-xl border border-white/10 p-5 space-y-3">
|
<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">
|
<div class="flex items-center justify-between gap-2">
|
||||||
${translateText("clan_modal.members")}
|
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
|
||||||
</h3>
|
${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(
|
${renderMemberSearchInput(
|
||||||
(e: Event) => this.onSearchInput(e),
|
(e: Event) => this.onSearchInput(e),
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ClanStats,
|
ClanStats,
|
||||||
} from "../../ClanApi";
|
} from "../../ClanApi";
|
||||||
import { showToast, translateText } from "../../Utils";
|
import { showToast, translateText } from "../../Utils";
|
||||||
|
import "./ClanStatsBreakdown";
|
||||||
export { renderLoadingSpinner } from "../BaseModal";
|
export { renderLoadingSpinner } from "../BaseModal";
|
||||||
export { showToast };
|
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">
|
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
|
||||||
${translateText("clan_modal.statistics")}
|
${translateText("clan_modal.statistics")}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-1.5">
|
<clan-stats-breakdown .stats=${stats.stats}></clan-stats-breakdown>
|
||||||
${statBuckets.map(({ key, labelKey }) =>
|
|
||||||
renderWLBarRow(
|
|
||||||
translateText(labelKey),
|
|
||||||
stats.stats[key].wins,
|
|
||||||
stats.stats[key].losses,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -326,16 +319,7 @@ export function renderMemberPagination(
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statBuckets = [
|
export function renderWLBarRow(
|
||||||
{ 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(
|
|
||||||
label: string,
|
label: string,
|
||||||
wins: number,
|
wins: number,
|
||||||
losses: number,
|
losses: number,
|
||||||
@@ -359,26 +343,30 @@ function renderWLBarRow(
|
|||||||
${label}
|
${label}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<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"
|
role="img"
|
||||||
aria-label="${wins} wins, ${losses} losses"
|
aria-label="${wins} wins, ${losses} losses"
|
||||||
>
|
>
|
||||||
${wins > 0
|
<div class="absolute inset-0 flex">
|
||||||
? html`<div
|
${wins > 0
|
||||||
class="bg-malibu-blue flex items-center px-1.5 overflow-hidden whitespace-nowrap"
|
? html`<div
|
||||||
style="width:${winPct}%"
|
class="bg-malibu-blue h-full"
|
||||||
>
|
style="width:${winPct}%"
|
||||||
${wins}W
|
></div>`
|
||||||
</div>`
|
: ""}
|
||||||
: ""}
|
${losses > 0
|
||||||
${losses > 0
|
? html`<div
|
||||||
? html`<div
|
class="bg-red-500 h-full"
|
||||||
class="bg-red-500 flex items-center justify-end px-1.5 overflow-hidden whitespace-nowrap"
|
style="width:${lossPct}%"
|
||||||
style="width:${lossPct}%"
|
></div>`
|
||||||
>
|
: ""}
|
||||||
${losses}L
|
</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>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="text-xs font-bold shrink-0 tabular-nums w-9 text-right ${rateClass}"
|
class="text-xs font-bold shrink-0 tabular-nums w-9 text-right ${rateClass}"
|
||||||
@@ -394,14 +382,8 @@ export function renderMemberStats(
|
|||||||
): TemplateResult | string {
|
): TemplateResult | string {
|
||||||
if (!stats) return "";
|
if (!stats) return "";
|
||||||
return html`
|
return html`
|
||||||
<div class="mt-1.5 space-y-1">
|
<div class="mt-1.5">
|
||||||
${statBuckets.map(({ key, labelKey }) =>
|
<clan-stats-breakdown .stats=${stats}></clan-stats-breakdown>
|
||||||
renderWLBarRow(
|
|
||||||
translateText(labelKey),
|
|
||||||
stats[key].wins,
|
|
||||||
stats[key].losses,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,11 +55,36 @@ export const ClanMemberStatsSchema = z.object({
|
|||||||
ffa: ClanMemberWLSchema,
|
ffa: ClanMemberWLSchema,
|
||||||
team: ClanMemberWLSchema,
|
team: ClanMemberWLSchema,
|
||||||
hvn: ClanMemberWLSchema,
|
hvn: ClanMemberWLSchema,
|
||||||
|
duos: ClanMemberWLSchema,
|
||||||
|
trios: ClanMemberWLSchema,
|
||||||
|
quads: ClanMemberWLSchema,
|
||||||
|
"2": ClanMemberWLSchema,
|
||||||
|
"3": ClanMemberWLSchema,
|
||||||
|
"4": ClanMemberWLSchema,
|
||||||
|
"5": ClanMemberWLSchema,
|
||||||
|
"6": ClanMemberWLSchema,
|
||||||
|
"7": ClanMemberWLSchema,
|
||||||
ranked: ClanMemberWLSchema,
|
ranked: ClanMemberWLSchema,
|
||||||
"1v1": ClanMemberWLSchema,
|
"1v1": ClanMemberWLSchema,
|
||||||
});
|
});
|
||||||
export type ClanMemberStats = z.infer<typeof ClanMemberStatsSchema>;
|
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({
|
export const ClanMemberSchema = z.object({
|
||||||
role: z.enum(["leader", "officer", "member"]),
|
role: z.enum(["leader", "officer", "member"]),
|
||||||
joinedAt: z.iso.datetime(),
|
joinedAt: z.iso.datetime(),
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ describe("fetchClanStats", () => {
|
|||||||
ffa: { wins: 7, losses: 3 },
|
ffa: { wins: 7, losses: 3 },
|
||||||
team: { wins: 4, losses: 1 },
|
team: { wins: 4, losses: 1 },
|
||||||
hvn: { wins: 1, losses: 0 },
|
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 },
|
ranked: { wins: 3, losses: 1 },
|
||||||
"1v1": { wins: 3, losses: 1 },
|
"1v1": { wins: 3, losses: 1 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ describe("ClanMemberSchema", () => {
|
|||||||
ffa: { wins: 2, losses: 4 },
|
ffa: { wins: 2, losses: 4 },
|
||||||
team: { wins: 5, losses: 1 },
|
team: { wins: 5, losses: 1 },
|
||||||
hvn: { wins: 0, losses: 0 },
|
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 },
|
ranked: { wins: 1, losses: 3 },
|
||||||
"1v1": { wins: 1, losses: 3 },
|
"1v1": { wins: 1, losses: 3 },
|
||||||
},
|
},
|
||||||
@@ -155,6 +164,15 @@ describe("ClanStatsSchema", () => {
|
|||||||
ffa: { wins: 3, losses: 2 },
|
ffa: { wins: 3, losses: 2 },
|
||||||
team: { wins: 2, losses: 1 },
|
team: { wins: 2, losses: 1 },
|
||||||
hvn: { wins: 1, losses: 0 },
|
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 },
|
ranked: { wins: 1, losses: 0 },
|
||||||
"1v1": { wins: 1, losses: 0 },
|
"1v1": { wins: 1, losses: 0 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ export function clanApiMockFactory() {
|
|||||||
ffa: { wins: 3, losses: 2 },
|
ffa: { wins: 3, losses: 2 },
|
||||||
team: { wins: 2, losses: 1 },
|
team: { wins: 2, losses: 1 },
|
||||||
hvn: { wins: 1, losses: 0 },
|
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 },
|
ranked: { wins: 1, losses: 0 },
|
||||||
"1v1": { wins: 1, losses: 0 },
|
"1v1": { wins: 1, losses: 0 },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,66 +57,137 @@ describe("filterMembersBySearch", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("renderMemberStats", () => {
|
describe("renderMemberStats", () => {
|
||||||
|
const ZERO = { wins: 0, losses: 0 } as const;
|
||||||
const stats: ClanMemberStats = {
|
const stats: ClanMemberStats = {
|
||||||
total: { wins: 7, losses: 5 },
|
total: { wins: 7, losses: 5 },
|
||||||
ffa: { wins: 2, losses: 4 },
|
ffa: { wins: 2, losses: 4 },
|
||||||
team: { wins: 5, losses: 1 },
|
team: { wins: 5, losses: 1 },
|
||||||
hvn: { wins: 0, losses: 0 },
|
hvn: { ...ZERO },
|
||||||
ranked: { wins: 0, losses: 0 },
|
duos: { wins: 1, losses: 0 },
|
||||||
"1v1": { wins: 0, 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<typeof renderMemberStats>): HTMLElement {
|
async function renderTo(
|
||||||
|
result: ReturnType<typeof renderMemberStats>,
|
||||||
|
): Promise<HTMLElement> {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
render(result, host);
|
render(result, host);
|
||||||
|
document.body.appendChild(host);
|
||||||
|
// Allow Lit to upgrade the <clan-stats-breakdown> custom element.
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
return host;
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("renders nothing when stats is undefined", () => {
|
function findExpandableButton(
|
||||||
const host = renderTo(renderMemberStats(undefined));
|
host: HTMLElement,
|
||||||
|
labelKey: string,
|
||||||
|
): HTMLButtonElement | undefined {
|
||||||
|
return Array.from(
|
||||||
|
host.querySelectorAll<HTMLButtonElement>("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("");
|
expect(host.textContent?.trim()).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders W/L labels inside bar segments and the win-rate per bucket", () => {
|
it("collapses everything except the total row by default", async () => {
|
||||||
const host = renderTo(renderMemberStats(stats));
|
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, " ") ?? "";
|
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("2W");
|
||||||
expect(text).toContain("4L");
|
expect(text).toContain("4L");
|
||||||
expect(text).toContain("5W");
|
expect(text).toContain("5W");
|
||||||
expect(text).toContain("1L");
|
expect(text).toContain("1L");
|
||||||
// Win-rate, and em-dash placeholder for empty bucket
|
|
||||||
expect(text).toContain("33%");
|
expect(text).toContain("33%");
|
||||||
expect(text).toContain("83%");
|
expect(text).toContain("83%");
|
||||||
expect(text).toContain("—");
|
expect(text).toContain("—");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a proportional win-loss bar when there are games", () => {
|
it("renders a proportional win-loss bar when there are games", async () => {
|
||||||
const host = renderTo(renderMemberStats(stats));
|
const host = await renderTo(renderMemberStats(stats));
|
||||||
|
await expandTotal(host);
|
||||||
const bars = host.querySelectorAll<HTMLDivElement>("[style*='width']");
|
const bars = host.querySelectorAll<HTMLDivElement>("[style*='width']");
|
||||||
// Two segments per bucket with games (total: 2, ffa: 2, team: 2). Ranked
|
// Top-level rows after expanding Total: total, ffa, team, hvn, ranked (5).
|
||||||
// and 1v1 have 0 games → no segments.
|
// Ranked and hvn have 0 games → no segments. Others contribute 2 each.
|
||||||
expect(bars.length).toBe(6);
|
expect(bars.length).toBe(6);
|
||||||
const widths = Array.from(bars).map((b) =>
|
const widths = Array.from(bars).map((b) =>
|
||||||
(b.getAttribute("style") ?? "").replace(/\s+/g, ""),
|
(b.getAttribute("style") ?? "").replace(/\s+/g, ""),
|
||||||
);
|
);
|
||||||
// total: 7/12 ≈ 58.3% wins, 41.7% losses
|
|
||||||
expect(widths[0]).toContain("width:58.33");
|
expect(widths[0]).toContain("width:58.33");
|
||||||
expect(widths[1]).toContain("width:41.66");
|
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[2]).toContain("width:33.33");
|
||||||
expect(widths[3]).toContain("width:66.66");
|
expect(widths[3]).toContain("width:66.66");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes all six translated bucket labels", () => {
|
it("includes the visible top-level translated bucket labels", async () => {
|
||||||
const host = renderTo(renderMemberStats(stats));
|
const host = await renderTo(renderMemberStats(stats));
|
||||||
|
await expandTotal(host);
|
||||||
const text = host.textContent ?? "";
|
const text = host.textContent ?? "";
|
||||||
expect(text).toContain("clan_modal.stats_total");
|
expect(text).toContain("clan_modal.stats_total");
|
||||||
expect(text).toContain("clan_modal.stats_ffa");
|
expect(text).toContain("clan_modal.stats_ffa");
|
||||||
expect(text).toContain("clan_modal.stats_team");
|
expect(text).toContain("clan_modal.stats_team");
|
||||||
expect(text).toContain("clan_modal.stats_hvn");
|
expect(text).toContain("clan_modal.stats_hvn");
|
||||||
expect(text).toContain("clan_modal.stats_ranked");
|
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<HTMLButtonElement>("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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user