Clan Game History (#3988)

## Description:

Adds 
<img width="1046" height="901" alt="image"
src="https://github.com/user-attachments/assets/930b0d27-4707-4836-b068-620346e7e3a7"
/>

continuation of infra https://github.com/openfrontio/infra/pull/345
## 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-22 22:30:16 +01:00
committed by GitHub
parent b486caa6f4
commit a14cf0edc1
14 changed files with 1979 additions and 272 deletions
+53 -3
View File
@@ -27,7 +27,22 @@
"click_to_copy": "Click to copy",
"enabled": "Enabled",
"disabled": "Disabled",
"map_default": "Map default"
"map_default": "Map default",
"month_jan": "January",
"month_feb": "February",
"month_mar": "March",
"month_apr": "April",
"month_may": "May",
"month_jun": "June",
"month_jul": "July",
"month_aug": "August",
"month_sep": "September",
"month_oct": "October",
"month_nov": "November",
"month_dec": "December",
"duration_hour_short": "h",
"duration_minute_short": "min",
"duration_second_short": "s"
},
"main": {
"title": "OpenFront (ALPHA)",
@@ -285,7 +300,6 @@
"no_pending_applications": "No pending applications.",
"applied": "Applied",
"cancel_request": "Cancel",
"statistics": "Statistics",
"stats_total": "Total",
"stats_ffa": "FFA",
"stats_team": "Teams",
@@ -340,7 +354,43 @@
"error_rate_limited_generic": "Please wait before joining another clan",
"error_network": "Network error",
"error_failed": "Action failed",
"error_loading": "Failed to load"
"error_loading": "Failed to load",
"tab_overview": "Overview",
"tab_members": "Members",
"tab_game_history": "Game History",
"members_visible_to_members": "Member list is visible to clan members only.",
"history_empty": "No games played yet.",
"history_unavailable": "Game history is unavailable.",
"history_members_only": "Game history is visible to clan members only.",
"history_watch_replay": "Watch Replay",
"history_result_victory": "Victory!",
"history_result_defeat": "Defeat",
"history_result_partial": "{wins} / {total} Won",
"history_clan_winners": "Clan Member Winners",
"history_clan_members": "Clan Members",
"history_filter_all": "All",
"history_filter_hvn": "HvN",
"history_filter_ranked": "Ranked",
"history_game_type": "Game Type",
"history_type_ffa": "FFA",
"history_type_team": "Team",
"history_type_hvn": "Humans vs Nations",
"history_type_duos": "Duos",
"history_type_trios": "Trios",
"history_type_quads": "Quads",
"history_type_n_teams": "{count, plural, one {# Team} other {# Teams}}",
"history_type_ranked": "Ranked {ranked}",
"history_map": "Map",
"history_clan_players": "Clan players",
"history_clan_players_value": "{clanCount} / {total} total",
"history_players": "Players",
"history_duration": "Duration",
"history_game_id": "Game ID",
"history_today_at": "Today at {time}",
"history_yesterday": "Yesterday",
"history_today": "Today",
"history_load_more_failed": "Couldn't load more games.",
"history_end_of_history": "End of history."
},
"account_modal": {
"title": "Account",
+38 -23
View File
@@ -3,6 +3,9 @@ import {
ClanBansResponseSchema,
type ClanBrowseResponse,
ClanBrowseResponseSchema,
type ClanGameFilter,
type ClanGamesResponse,
ClanGamesResponseSchema,
type ClanInfo,
ClanInfoSchema,
type ClanLeaderboardResponse,
@@ -11,8 +14,6 @@ import {
ClanMembersResponseSchema,
type ClanRequestsResponse,
ClanRequestsResponseSchema,
type ClanStats,
ClanStatsSchema,
JoinClanResponseSchema,
} from "../core/ClanApiSchemas";
import { getApiBase } from "./Api";
@@ -21,6 +22,11 @@ export type {
ClanBan,
ClanBansResponse,
ClanBrowseResponse,
ClanGame,
ClanGameFilter,
ClanGamePlayer,
ClanGameResult,
ClanGamesResponse,
ClanInfo,
ClanJoinRequest,
ClanMember,
@@ -28,7 +34,6 @@ export type {
ClanMemberStats,
ClanMemberWL,
ClanRequestsResponse,
ClanStats,
} from "../core/ClanApiSchemas";
async function clanFetch(
@@ -80,26 +85,6 @@ export async function fetchClanLeaderboard(): Promise<
}
}
export async function fetchClanStats(tag: string): Promise<ClanStats | false> {
try {
const res = await fetch(
`${getApiBase()}/public/clan/${encodeURIComponent(tag)}`,
{ headers: { Accept: "application/json" } },
);
if (!res.ok) return false;
const json = await res.json();
const parsed = ClanStatsSchema.safeParse(json?.clan);
if (!parsed.success) {
console.warn("fetchClanStats: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchClanStats: request failed", err);
return false;
}
}
export async function fetchClans(
search?: string,
page = 1,
@@ -468,6 +453,36 @@ export async function unbanClanMember(
}
}
export type ClanGamesFetchError = "forbidden" | "failed";
export async function fetchClanGames(
tag: string,
opts: { filter?: ClanGameFilter; cursor?: string } = {},
): Promise<ClanGamesResponse | { error: ClanGamesFetchError }> {
try {
const params = new URLSearchParams();
if (opts.filter) params.set("filter", opts.filter);
// `cursor` is an opaque continuation token issued by the previous
// response's `nextCursor`. Round-trip verbatim; never construct.
if (opts.cursor) params.set("cursor", opts.cursor);
const qs = params.toString();
const res = await clanFetch(
`/clans/${encodeURIComponent(tag)}/games${qs ? `?${qs}` : ""}`,
);
if (res.status === 403) return { error: "forbidden" };
if (!res.ok) return { error: "failed" };
const json = await res.json();
const parsed = ClanGamesResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchClanGames: Zod validation failed", parsed.error);
return { error: "failed" };
}
return parsed.data;
} catch {
return { error: "failed" };
}
}
export async function fetchClanBans(
tag: string,
page = 1,
+110 -14
View File
@@ -1,13 +1,15 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getUserMe, invalidateUserMe } from "./Api";
import { type ClanInfo, type ClanMember, type ClanStats } from "./ClanApi";
import { type ClanInfo, type ClanMember } from "./ClanApi";
import { BaseModal } from "./components/BaseModal";
import "./components/clan/ClanBansView";
import "./components/clan/ClanBrowseView";
import type { BrowseState } from "./components/clan/ClanBrowseView";
import "./components/clan/ClanCard";
import "./components/clan/ClanDetailView";
import "./components/clan/ClanGameHistoryView";
import type { ClanGameHistoryCache } from "./components/clan/ClanGameHistoryView";
import "./components/clan/ClanManageView";
import "./components/clan/ClanMyRequestsView";
import "./components/clan/ClanRequestsView";
@@ -27,6 +29,15 @@ type View =
| "bans"
| "my-requests";
// List tabs share BaseModal's `activeTab` slot with detail tabs ("overview" /
// "members" / "game-history"); which set is live depends on `view`.
const LIST_TABS = ["my-clans", "browse"] as const;
type ListTab = (typeof LIST_TABS)[number];
function isListTab(key: string): key is ListTab {
return (LIST_TABS as readonly string[]).includes(key);
}
@customElement("clan-modal")
export class ClanModal extends BaseModal {
protected routerName = "clan";
@@ -56,13 +67,23 @@ export class ClanModal extends BaseModal {
members: ClanMember[];
membersTotal: number;
pendingRequestCount: number;
stats: ClanStats | null;
} | null = null;
// Single-clan cache: switching clans within one modal session drops
// it (see `openDetail`), so a user who clan-hops loses their filter
// selection and accumulated scroll on the previous clan. Keyed-by-tag
// would persist across hops if that becomes desired.
private gameHistoryCache: ClanGameHistoryCache | null = null;
private previousListTab: ListTab = "my-clans";
private get onListView(): boolean {
return this.view === "list" && !this.selectedClanTag;
}
private get onDetailView(): boolean {
return this.view === "detail" && !!this.selectedClanTag;
}
protected modalConfig() {
return {
tabs: this.onListView
@@ -70,7 +91,22 @@ export class ClanModal extends BaseModal {
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
{ key: "browse", label: translateText("clan_modal.browse") },
]
: [],
: this.onDetailView
? [
{
key: "overview",
label: translateText("clan_modal.tab_overview"),
},
{
key: "members",
label: translateText("clan_modal.tab_members"),
},
{
key: "game-history",
label: translateText("clan_modal.tab_game_history"),
},
]
: [],
};
}
@@ -89,12 +125,19 @@ export class ClanModal extends BaseModal {
}
protected onTabEnter(tab: string): void {
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
if (tab === "my-clans") {
this.loadMyClans();
if (isListTab(tab)) {
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
this.detailCache = null;
this.gameHistoryCache = null;
if (tab === "my-clans") {
this.loadMyClans();
}
return;
}
// Detail tabs: BaseModal already updated activeTab; renderInner reads it.
// No additional side effects required here.
}
private tagPill(tag: string) {
@@ -152,6 +195,8 @@ export class ClanModal extends BaseModal {
this.selectedClanTag = "";
this.myRole = null;
this.detailCache = null;
this.gameHistoryCache = null;
this.setActiveTab(this.previousListTab);
},
ariaLabel,
rightContent: clan ? this.tagPill(clan.tag) : undefined,
@@ -164,12 +209,14 @@ export class ClanModal extends BaseModal {
protected onClose(): void {
this.activeTab = "my-clans";
this.previousListTab = "my-clans";
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
this.myRole = null;
this.browseCache = null;
this.detailCache = null;
this.gameHistoryCache = null;
}
private async loadMyClans() {
@@ -257,7 +304,7 @@ export class ClanModal extends BaseModal {
this.myRole = null;
this.detailCache = null;
this.view = "list";
this.loadMyClans();
this.setActiveTab(this.previousListTab);
}}
></clan-manage-view>`;
}
@@ -295,13 +342,26 @@ export class ClanModal extends BaseModal {
@navigate-back=${() => (this.view = "manage")}
></clan-bans-view>`;
}
// Default: detail view
// Default: detail view — dispatched by the active detail tab
if (this.activeTab === "game-history") {
return html`<clan-game-history-view
.clanTag=${this.selectedClanTag}
.cachedState=${this.gameHistoryCache?.tag === this.selectedClanTag
? this.gameHistoryCache
: null}
@history-updated=${(e: CustomEvent<ClanGameHistoryCache>) => {
this.gameHistoryCache = e.detail;
}}
@close-clan-modal=${() => this.close()}
></clan-game-history-view>`;
}
return html`<clan-detail-view
.clanTag=${this.selectedClanTag}
.cachedClan=${this.selectedClan}
.myPublicId=${this.myPublicId}
.myClanRoles=${this.myClanRoles}
.myPendingRequests=${this.myPendingRequests}
.detailTab=${this.activeTab === "members" ? "members" : "overview"}
.cachedDetail=${this.detailCache?.tag === this.selectedClanTag
? this.detailCache
: null}
@@ -311,6 +371,8 @@ export class ClanModal extends BaseModal {
this.selectedClanTag = "";
this.myRole = null;
this.detailCache = null;
this.gameHistoryCache = null;
this.setActiveTab(this.previousListTab);
}}
@detail-loaded=${(
e: CustomEvent<{
@@ -319,7 +381,6 @@ export class ClanModal extends BaseModal {
members: ClanMember[];
membersTotal: number;
pendingRequestCount: number;
stats: ClanStats | null;
}>,
) => {
this.selectedClan = e.detail.clan;
@@ -329,7 +390,25 @@ export class ClanModal extends BaseModal {
members: e.detail.members,
membersTotal: e.detail.membersTotal,
pendingRequestCount: e.detail.pendingRequestCount,
stats: e.detail.stats,
};
}}
@members-loaded=${(
e: CustomEvent<{
members: ClanMember[];
membersTotal: number;
pendingRequestCount: number;
}>,
) => {
if (
!this.detailCache ||
this.detailCache.tag !== this.selectedClanTag
)
return;
this.detailCache = {
...this.detailCache,
members: e.detail.members,
membersTotal: e.detail.membersTotal,
pendingRequestCount: e.detail.pendingRequestCount,
};
}}
@navigate-manage=${() => (this.view = "manage")}
@@ -339,6 +418,7 @@ export class ClanModal extends BaseModal {
...this.myClanRoles,
[e.detail.tag, "member" as ClanRole],
]);
this.detailCache = null;
this.openDetail(e.detail.tag);
}}
@clan-left=${(e: CustomEvent<{ tag: string }>) => {
@@ -350,7 +430,7 @@ export class ClanModal extends BaseModal {
this.myRole = null;
this.detailCache = null;
this.view = "list";
this.loadMyClans();
this.setActiveTab(this.previousListTab);
}}
@request-sent=${(e: CustomEvent<{ tag: string; name: string }>) => {
this.myPendingRequests = [
@@ -383,8 +463,24 @@ export class ClanModal extends BaseModal {
}
private openDetail(tag: string) {
if (this.selectedClanTag !== tag) {
// History cache is per-clan (see `gameHistoryCache` declaration),
// so it must be cleared on tag change. `detailCache` is left
// alone — its `tag` field is checked at render time and the
// detail view falls back to a fresh fetch when it doesn't match,
// so an explicit null here would be redundant.
this.gameHistoryCache = null;
}
// Remember which list tab the user was on so the back button can
// return them to it (browse vs my-clans).
if (isListTab(this.activeTab)) {
this.previousListTab = this.activeTab;
}
this.selectedClanTag = tag;
this.view = "detail";
// modalConfig() returns detail tabs; setActiveTab anchors activeTab to
// "overview" and syncs the URL router (routerName = "clan").
this.setActiveTab("overview");
}
private renderMyClans() {
@@ -398,7 +494,7 @@ export class ClanModal extends BaseModal {
${translateText("clan_modal.no_clans")}
</p>
<button
@click=${() => (this.activeTab = "browse")}
@click=${() => this.setActiveTab("browse")}
class="px-6 py-2 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-lg transition-all"
>
${translateText("clan_modal.browse")}
+18 -7
View File
@@ -231,13 +231,24 @@ export function getModifierLabels(
}
export function renderDuration(totalSeconds: number): string {
if (totalSeconds <= 0) return "0s";
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
let time = "";
if (minutes > 0) time += `${minutes}min `;
time += `${seconds}s`;
return time.trim();
// Floor once so fractional inputs don't leak through to the seconds
// component (e.g. `0.5` → `"0.5s"`).
const whole = Math.floor(totalSeconds);
if (whole <= 0) return `0${translateText("common.duration_second_short")}`;
const hours = Math.floor(whole / 3600);
const minutes = Math.floor((whole % 3600) / 60);
const seconds = whole % 60;
// Build largest-first, dropping trailing-zero components so 3600s reads
// as "1h" rather than "1h 0min 0s", and 60s as "1min" rather than
// "1min 0s". Sub-minute durations still surface seconds.
const parts: string[] = [];
if (hours > 0)
parts.push(`${hours}${translateText("common.duration_hour_short")}`);
if (minutes > 0)
parts.push(`${minutes}${translateText("common.duration_minute_short")}`);
if (seconds > 0 || parts.length === 0)
parts.push(`${seconds}${translateText("common.duration_second_short")}`);
return parts.join(" ");
}
export function renderTroops(troops: number): string {
+102 -44
View File
@@ -6,10 +6,8 @@ import {
type ClanMember,
type ClanMemberOrder,
type ClanMemberSort,
type ClanStats,
fetchClanDetail,
fetchClanMembers,
fetchClanStats,
joinClan,
leaveClan,
} from "../../ClanApi";
@@ -20,7 +18,6 @@ import {
type ClanRole,
defaultOrderForSort,
filterMembersBySearch,
renderClanWL,
renderLoadingSpinner,
renderMemberPagination,
renderMemberRow,
@@ -50,8 +47,8 @@ export class ClanDetailView extends LitElement {
members: ClanMember[];
membersTotal: number;
pendingRequestCount: number;
stats: ClanStats | null;
} | null = null;
@property() detailTab: "overview" | "members" = "overview";
@property({ type: Object }) cachedClan: ClanInfo | null = null;
@state() private selectedClan: ClanInfo | null = null;
@@ -63,10 +60,10 @@ export class ClanDetailView extends LitElement {
@state() private memberSort: ClanMemberSort = "default";
@state() private memberOrder: ClanMemberOrder = "asc";
@state() private pendingRequestCount = 0;
@state() private clanStats: ClanStats | null = null;
@state() private loading = false;
@state() private actionPending = false;
@state() private allStatsExpanded = false;
@state() private membersLoadInFlight = false;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
private asyncGeneration = 0;
@@ -85,7 +82,6 @@ export class ClanDetailView extends LitElement {
this.members = cache.members;
this.membersTotal = cache.membersTotal;
this.pendingRequestCount = cache.pendingRequestCount;
this.clanStats = cache.stats;
this.memberPage = 1;
const knownRole = this.myClanRoles.get(this.clanTag);
this.myRole = knownRole ?? null;
@@ -111,23 +107,22 @@ export class ClanDetailView extends LitElement {
this.pendingRequestCount = 0;
this.memberSearch = "";
const isMember = this.myClanRoles.has(this.clanTag);
const [detail, membersRes, stats] = await Promise.all([
fetchClanDetail(this.clanTag),
isMember
? fetchClanMembers(
this.clanTag,
1,
this.membersPerPage,
this.memberSort,
this.memberOrder,
)
: Promise.resolve(false as const),
fetchClanStats(this.clanTag),
]);
// When the user lands directly on the Members tab (deep link / cached
// activeTab), fire both fetches in parallel — otherwise sequencing
// adds a full members RTT to the visible loading time. The Overview
// tab waits for detail only; willUpdate kicks off members on tab
// switch later.
const goingToMembers =
this.detailTab === "members" && this.myClanRoles.has(this.clanTag);
const detailPromise = fetchClanDetail(this.clanTag);
if (goingToMembers) {
// Floating; loadInitialMembers's own asyncGeneration + tag guards
// cancel cleanly if the user navigates away mid-flight.
void this.loadInitialMembers();
}
const detail = await detailPromise;
if (gen !== this.asyncGeneration) return;
this.clanStats = stats || null;
this.loading = false;
if (!detail) {
@@ -140,25 +135,13 @@ export class ClanDetailView extends LitElement {
this.selectedClan = detail;
this.memberPage = 1;
if (membersRes) {
this.members = membersRes.results;
this.membersTotal = membersRes.total;
this.pendingRequestCount = membersRes.pendingRequests ?? 0;
const knownRole = this.myClanRoles.get(this.clanTag);
if (knownRole) {
this.myRole = knownRole;
} else {
const me = this.myPublicId
? membersRes.results.find((m) => m.publicId === this.myPublicId)
: null;
this.myRole = me ? me.role : null;
}
} else {
if (!goingToMembers) {
// Members tab will populate these via loadInitialMembers; the
// Overview tab doesn't need them.
this.members = [];
this.membersTotal = 0;
this.myRole = null;
}
this.myRole = this.myClanRoles.get(this.clanTag) ?? null;
this.dispatchEvent(
new CustomEvent("detail-loaded", {
@@ -168,7 +151,6 @@ export class ClanDetailView extends LitElement {
members: this.members,
membersTotal: this.membersTotal,
pendingRequestCount: this.pendingRequestCount,
stats: this.clanStats,
},
bubbles: true,
composed: true,
@@ -176,6 +158,61 @@ export class ClanDetailView extends LitElement {
);
}
private async loadInitialMembers() {
if (this.membersLoadInFlight) return;
if (!this.clanTag) return;
if (!this.myClanRoles.has(this.clanTag)) return;
if (this.members.length > 0) return;
// Don't share `asyncGeneration` with loadDetail — these two run
// concurrently when the user lands on the Members tab directly, and
// bumping the shared counter would cancel the parent. The
// `membersLoadInFlight` flag dedupes concurrent invocations and the
// `requestedTag` check handles tag navigation.
const requestedTag = this.clanTag;
this.membersLoadInFlight = true;
try {
const res = await fetchClanMembers(
requestedTag,
1,
this.membersPerPage,
this.memberSort,
this.memberOrder,
);
if (requestedTag !== this.clanTag) return;
if (!res) return;
this.members = res.results;
this.membersTotal = res.total;
this.pendingRequestCount = res.pendingRequests ?? 0;
this.memberPage = 1;
this.dispatchEvent(
new CustomEvent("members-loaded", {
detail: {
members: this.members,
membersTotal: this.membersTotal,
pendingRequestCount: this.pendingRequestCount,
},
bubbles: true,
composed: true,
}),
);
} finally {
this.membersLoadInFlight = false;
}
}
protected willUpdate(changed: Map<string, unknown>) {
if (
(changed.has("detailTab") || changed.has("selectedClan")) &&
this.detailTab === "members" &&
this.selectedClan &&
this.members.length === 0 &&
this.myClanRoles.has(this.clanTag)
) {
this.loadInitialMembers();
}
}
private async loadMemberPage(page: number) {
if (!this.selectedClan) return;
const res = await fetchClanMembers(
@@ -304,6 +341,33 @@ export class ClanDetailView extends LitElement {
(r) => r.tag === clan.tag,
);
if (this.detailTab === "members") {
if (!isMember) {
return html`
<div
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
>
<p class="text-white/40 text-sm">
${translateText("clan_modal.members_visible_to_members")}
</p>
</div>
`;
}
// Initial lazy-load: show a spinner instead of an empty members
// list + pagination so there's no flash of "no members".
if (this.membersLoadInFlight && this.members.length === 0) {
return renderLoadingSpinner();
}
return html`
<div class="space-y-6">
${canManageRequests && this.pendingRequestCount > 0
? this.renderRequestsButton()
: ""}
${this.renderMembersList()}
</div>
`;
}
return html`
<div class="space-y-6">
<div class="bg-white/5 rounded-xl border border-white/10 p-5">
@@ -325,12 +389,6 @@ export class ClanDetailView extends LitElement {
)}
</div>
${this.clanStats ? renderClanWL(this.clanStats) : ""}
${canManageRequests && this.pendingRequestCount > 0
? this.renderRequestsButton()
: ""}
${isMember ? this.renderMembersList() : ""}
<div class="flex flex-wrap gap-3">
${this.renderActionButtons(
isMember,
@@ -0,0 +1,716 @@
import { html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { GameMapType, GameMode } from "../../../core/game/Game";
import {
type ClanGame,
type ClanGameFilter,
fetchClanGames,
} from "../../ClanApi";
import { ClientEnv } from "../../ClientEnv";
import { terrainMapFileLoader } from "../../TerrainMapFileLoader";
import { getMapName, renderDuration, translateText } from "../../Utils";
import "../CopyButton";
import { renderLoadingSpinner, showToast } from "./ClanShared";
type FilterKey = ClanGameFilter | "all";
// "All" is filter-only; FFA and Team reuse the type-label keys (same
// English strings); HvN and Ranked have shorter filter labels than their
// type labels ("Humans vs Nations" / "Ranked 1v1") so keep those split.
const FILTER_TABS: { key: FilterKey; labelKey: string }[] = [
{ key: "all", labelKey: "clan_modal.history_filter_all" },
{ key: "ffa", labelKey: "clan_modal.history_type_ffa" },
{ key: "team", labelKey: "clan_modal.history_type_team" },
{ key: "hvn", labelKey: "clan_modal.history_filter_hvn" },
{ key: "ranked", labelKey: "clan_modal.history_filter_ranked" },
];
// Cache survives a tab switch within the modal: keep the full
// accumulated list plus the cursor state so re-entering the tab restores
// the scroll position the user had built up.
export type ClanGameHistoryCache = {
tag: string;
filter: FilterKey;
games: ClanGame[];
nextCursor: string | null;
};
@customElement("clan-game-history-view")
export class ClanGameHistoryView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@property({ type: Object }) cachedState: ClanGameHistoryCache | null = null;
@state() private games: ClanGame[] = [];
@state() private nextCursor: string | null = null;
@state() private loading = false;
// Distinct from `loading` because it controls the inline footer spinner
// rather than replacing the whole list with a centred spinner.
@state() private loadingMore = false;
@state() private loadState: "ok" | "failed" | "forbidden" = "ok";
@state() private appendFailed = false;
@state() private filter: FilterKey = "all";
private asyncGeneration = 0;
private sentinel: HTMLElement | null = null;
private observer: IntersectionObserver | null = null;
// Memoise grouping against the current `games` reference so re-renders
// triggered by unrelated state (e.g. `loadingMore` flipping) don't
// re-walk the accumulated list each time.
private groupedFor: ClanGame[] | null = null;
private grouped: DayGroup[] = [];
connectedCallback() {
super.connectedCallback();
if (this.cachedState && this.cachedState.tag === this.clanTag) {
this.games = this.cachedState.games;
this.nextCursor = this.cachedState.nextCursor;
this.filter = this.cachedState.filter;
} else if (this.clanTag) {
this.reload();
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.teardownObserver();
}
updated() {
// The IntersectionObserver target only exists when there's more to
// load AND we're not in the middle of a request — wire it up after
// each render so it tracks the current sentinel node.
this.ensureObserver();
}
// Hard reset on filter change — drop cached games and start fresh from
// the newest game.
private async reload() {
this.games = [];
this.nextCursor = null;
this.appendFailed = false;
await this.load({ append: false });
}
private setFilter(filter: FilterKey) {
if (filter === this.filter) return;
this.filter = filter;
this.reload();
}
private async load({ append }: { append: boolean }) {
if (!this.clanTag) return;
const gen = ++this.asyncGeneration;
if (append) {
this.loadingMore = true;
this.appendFailed = false;
} else {
this.loading = true;
this.loadState = "ok";
this.loadingMore = false;
}
const filterParam = this.filter === "all" ? undefined : this.filter;
// Append uses the saved cursor; a fresh load starts from the newest
// game (no cursor).
const cursor = append ? (this.nextCursor ?? undefined) : undefined;
const res = await fetchClanGames(this.clanTag, {
filter: filterParam,
cursor,
});
if (gen !== this.asyncGeneration) return;
if (append) this.loadingMore = false;
else this.loading = false;
if ("error" in res) {
if (append) {
// Keep the games we already have; just surface a retry footer.
this.appendFailed = true;
} else {
this.loadState = res.error;
this.games = [];
this.nextCursor = null;
}
return;
}
this.games = append ? [...this.games, ...res.results] : res.results;
this.nextCursor = res.nextCursor;
this.dispatchEvent(
new CustomEvent<ClanGameHistoryCache>("history-updated", {
detail: {
tag: this.clanTag,
filter: this.filter,
games: this.games,
nextCursor: this.nextCursor,
},
bubbles: true,
composed: true,
}),
);
}
private ensureObserver() {
const sentinel = this.querySelector<HTMLElement>("[data-scroll-sentinel]");
if (sentinel === this.sentinel) return;
this.teardownObserver();
this.sentinel = sentinel;
if (!sentinel) return;
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
if (this.loading || this.loadingMore) continue;
if (this.nextCursor === null) continue;
if (this.appendFailed) continue;
void this.load({ append: true });
}
});
this.observer.observe(sentinel);
}
private teardownObserver() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
this.sentinel = null;
}
private async watchReplay(gameId: string) {
try {
const encoded = encodeURIComponent(gameId);
const url = `/${ClientEnv.workerPath(gameId)}/game/${encoded}`;
history.pushState({ join: gameId }, "", url);
window.dispatchEvent(
new CustomEvent("join-changed", { detail: { gameId: encoded } }),
);
this.dispatchEvent(
new CustomEvent("close-clan-modal", { bubbles: true, composed: true }),
);
} catch {
showToast(translateText("clan_modal.error_failed"), "red");
}
}
render() {
if (this.loadState === "forbidden") {
return html`
<div
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
>
<p class="text-white/40 text-sm">
${translateText("clan_modal.history_members_only")}
</p>
</div>
`;
}
return html`<div class="space-y-3">
${this.renderFilters()}${this.renderBody()}
</div>`;
}
private renderFilters(): TemplateResult {
return html`
<div
role="tablist"
class="flex flex-wrap gap-1 p-1 bg-white/5 border border-white/10 rounded-xl"
>
${FILTER_TABS.map((tab) => {
const active = this.filter === tab.key;
// "All" gets a full row on mobile (basis-full) and normal sizing
// on sm+. The others use basis-20 so "Ranked" stays comfortable
// and flex-wrap drops them to a second row when needed.
const basis =
tab.key === "all" ? "basis-full sm:basis-20" : "basis-20";
return html`
<button
type="button"
role="tab"
aria-selected=${active}
@click=${() => this.setFilter(tab.key)}
class="grow ${basis} px-3 py-1.5 text-xs font-bold uppercase tracking-wider whitespace-nowrap rounded-lg transition-colors ${active
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30"
: "text-white/50 hover:text-white hover:bg-white/5 border border-transparent"}"
>
${translateText(tab.labelKey)}
</button>
`;
})}
</div>
`;
}
private renderBody(): TemplateResult {
if (this.loading && this.games.length === 0) {
return renderLoadingSpinner();
}
if (this.loadState === "failed") {
return html`
<div
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
>
<p class="text-white/40 text-sm mb-3">
${translateText("clan_modal.history_unavailable")}
</p>
<button
type="button"
@click=${() => this.reload()}
class="text-xs font-bold text-white/60 hover:text-white uppercase tracking-wider px-3 py-1.5 rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors"
>
${translateText("leaderboard_modal.try_again")}
</button>
</div>
`;
}
if (this.games.length === 0) {
return html`
<div
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
>
<p class="text-white/40 text-sm">
${translateText("clan_modal.history_empty")}
</p>
</div>
`;
}
// Group consecutive games by their start day so the user gets a sense
// of when each batch was played without us having to render N
// standalone date pills. Cached against the `games` reference; `load()`
// always assigns a fresh array, so identity comparison is safe.
if (this.groupedFor !== this.games) {
this.grouped = groupGamesByDay(this.games);
this.groupedFor = this.games;
}
const groups = this.grouped;
return html`
<div class="space-y-5">
${groups.map(
(group) => html`
<div class="space-y-3">
<div
class="sticky top-0 z-10 flex items-center gap-3 px-1 py-1.5"
>
<span class="h-px flex-1 bg-white/10"></span>
<h3
class="text-xs font-bold uppercase tracking-widest text-white/70 whitespace-nowrap"
>
${formatDayHeader(group.day)}
</h3>
<span class="h-px flex-1 bg-white/10"></span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
${group.games.map((game) => this.renderGameRow(game))}
</div>
</div>
`,
)}
${this.renderScrollFooter()}
</div>
`;
}
private renderScrollFooter(): TemplateResult {
if (this.nextCursor === null) {
return html`
<div class="text-center text-[11px] text-white/30 py-3 select-none">
${translateText("clan_modal.history_end_of_history")}
</div>
`;
}
if (this.appendFailed) {
return html`
<div class="text-center py-3">
<p class="text-white/40 text-xs mb-2">
${translateText("clan_modal.history_load_more_failed")}
</p>
<button
type="button"
@click=${() => this.load({ append: true })}
class="text-xs font-bold text-white/60 hover:text-white uppercase tracking-wider px-3 py-1.5 rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors"
>
${translateText("leaderboard_modal.try_again")}
</button>
</div>
`;
}
// Sentinel drives auto-load; the spinner is shown adjacent to it (not
// *as* it) so the sentinel node identity stays stable across pages —
// otherwise every fetch tears down and recreates the IntersectionObserver
// (the spinner replacing the sentinel changed the queried node).
return html`
<div class="py-3">
<div data-scroll-sentinel aria-hidden="true" class="h-px"></div>
${this.loadingMore ? renderLoadingSpinner() : ""}
</div>
`;
}
private renderGameRow(game: ClanGame): TemplateResult {
// getMapData() throws for unknown map values — guard so an unmapped
// server response doesn't tank the whole history view.
let mapWebpPath: string | null = null;
if (game.map) {
try {
mapWebpPath = terrainMapFileLoader.getMapData(
game.map as GameMapType,
).webpPath;
} catch {
mapWebpPath = null;
}
}
const mapDisplayName = game.map ? (getMapName(game.map) ?? game.map) : null;
// Partition once per row so renderResultBadge + renderPlayerLists
// don't each re-walk clanPlayers (matters in 50v50 lobbies).
const winners: ClanGame["clanPlayers"] = [];
const losers: ClanGame["clanPlayers"] = [];
for (const p of game.clanPlayers) {
(p.won ? winners : losers).push(p);
}
return html`
<div class="bg-white/5 border border-white/10 rounded-xl overflow-hidden">
${mapWebpPath
? html`<div
class="relative w-full aspect-[3/1] overflow-hidden bg-surface"
>
<img
src=${mapWebpPath}
alt=${mapDisplayName ?? ""}
draggable="false"
loading="lazy"
decoding="async"
class="w-full h-full object-cover"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"
></div>
${mapDisplayName
? html`<div
class="absolute bottom-2 left-3 text-xs font-bold text-white uppercase tracking-wider drop-shadow"
>
${mapDisplayName}
</div>`
: ""}
<div class="absolute top-2 right-2">
${this.renderResultBadge(game, winners)}
</div>
<div
class="absolute bottom-2 right-2 text-xs font-medium text-white bg-black/60 backdrop-blur-sm px-2 py-1 rounded-md whitespace-nowrap"
>
${formatAbsoluteTime(game.start)}
</div>
</div>`
: ""}
<div
class="flex items-center justify-between gap-3 px-4 py-3 border-b border-white/5"
>
<div class="flex items-center gap-2 min-w-0">
<span
class="text-[10px] font-bold uppercase tracking-wider text-white/40"
>${translateText("clan_modal.history_game_id")}:</span
>
<copy-button
compact
.copyText=${game.gameId}
.displayText=${game.gameId}
.showVisibilityToggle=${false}
></copy-button>
</div>
<button
type="button"
@click=${() => this.watchReplay(game.gameId)}
class="shrink-0 px-3 py-1.5 text-xs font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-lg transition-all"
>
${translateText("clan_modal.history_watch_replay")}
</button>
</div>
<div
class="px-4 py-3 grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 justify-items-center text-center"
>
${this.renderField(
translateText("clan_modal.history_game_type"),
this.formatGameType(game),
)}
${mapWebpPath
? ""
: this.renderField(
translateText("clan_modal.history_map"),
mapDisplayName ?? "—",
)}
${this.renderPlayersField(game)}
${this.renderField(
translateText("clan_modal.history_duration"),
renderDuration(game.durationSeconds),
)}
</div>
${this.renderPlayerLists(game, winners, losers)}
</div>
`;
}
private renderField(label: string, value: string): TemplateResult {
return html`
<div class="min-w-0">
<div
class="text-[10px] font-bold uppercase tracking-wider text-white/40 mb-0.5"
>
${label}
</div>
<div class="text-sm text-white truncate" title=${value}>${value}</div>
</div>
`;
}
// For FFA / Ranked 1v1 with multiple clan-mates in the same lobby,
// calling the whole game a "Victory" because one of 20 won is
// misleading — 19 lost. The server now stamps `won` per clan player
// so we count exactly. Team/HvN games still surface Victory/Defeat
// when the clan plays as a unit (everyone on the winning team won).
private renderResultBadge(
game: ClanGame,
winners: ClanGame["clanPlayers"],
): TemplateResult {
const result = game.result;
if (!result) return html``;
const clanCount = game.clanPlayers.length;
const winCount = winners.length;
const isIndividual =
isFfa(game) ||
(game.rankedType !== undefined && game.rankedType !== "unranked");
const isPartial =
isIndividual && clanCount > 1 && winCount > 0 && winCount < clanCount;
let label: string;
let tint: string;
if (isPartial) {
label = translateText("clan_modal.history_result_partial", {
wins: winCount,
total: clanCount,
});
tint = "text-white bg-amber-500 border-amber-400";
} else if (result === "victory") {
label = translateText("clan_modal.history_result_victory");
tint = "text-white bg-green-600 border-green-500";
} else {
label = translateText("clan_modal.history_result_defeat");
tint = "text-white bg-red-600 border-red-500";
}
return html`<span
class="text-xs font-bold uppercase tracking-wider px-2.5 py-1 rounded-full border shadow-lg ${tint}"
>${label}</span
>`;
}
// Split the clan roster into winners and non-winners so the user can
// tell at a glance which clan-mates actually won the match — a single
// mixed list with crowns was hard to scan, especially in 50v50 lobbies.
private renderPlayerLists(
game: ClanGame,
winners: ClanGame["clanPlayers"],
losers: ClanGame["clanPlayers"],
): TemplateResult | string {
if (game.clanPlayers.length === 0) return "";
return html`
${winners.length > 0
? this.renderPlayerSection(
translateText("clan_modal.history_clan_winners"),
winners,
"text-green-400",
)
: ""}
${losers.length > 0
? this.renderPlayerSection(
translateText("clan_modal.history_clan_members"),
losers,
"text-white/40",
)
: ""}
`;
}
private renderPlayerSection(
label: string,
players: ClanGame["clanPlayers"],
labelClass: string,
): TemplateResult {
return html`
<div
class="px-4 py-2 border-t border-white/5 text-xs text-white/60 flex flex-wrap items-center gap-x-1 gap-y-1"
>
<span
class="text-[10px] font-bold uppercase tracking-wider mr-1 ${labelClass}"
>${label}:</span
>
${players.map(
(p) => html`
<copy-button
compact
.copyText=${p.publicId}
.displayText=${p.username ?? p.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
`,
)}
</div>
`;
}
// Ranked games cap clan participation at a single player, so
// "1 / N total" is noise — just show the total. FFA can carry
// multiple clan members (renderResultBadge already handles partial
// wins via clanCount > 1), so it keeps the clan-vs-total breakdown.
// Team/HvN keep it too. Historical rows may carry a null
// totalPlayers (games.num_players is nullable on the schema); render
// "—" rather than "null".
private renderPlayersField(game: ClanGame): TemplateResult {
const isSingleClanSlot =
game.rankedType !== undefined && game.rankedType !== "unranked";
const total = game.totalPlayers ?? null;
if (isSingleClanSlot) {
return this.renderField(
translateText("clan_modal.history_players"),
total === null ? "—" : `${total}`,
);
}
return this.renderField(
translateText("clan_modal.history_clan_players"),
total === null
? `${game.clanPlayers.length}`
: translateText("clan_modal.history_clan_players_value", {
clanCount: game.clanPlayers.length,
total,
}),
);
}
// FFA / Duos / 7 Teams / Humans vs Nations / Ranked 1v1 — derived from
// the same fields the bucket filter uses, so the label always agrees
// with the active tab.
private formatGameType(game: ClanGame): string {
if (game.rankedType && game.rankedType !== "unranked") {
return translateText("clan_modal.history_type_ranked", {
ranked: game.rankedType,
});
}
if (isFfa(game)) {
return translateText("clan_modal.history_type_ffa");
}
const pt = game.playerTeams;
if (pt === "Humans Vs Nations") {
return translateText("clan_modal.history_type_hvn");
}
if (pt === "Duos" || pt === "Trios" || pt === "Quads") {
return translateText(`clan_modal.history_type_${pt.toLowerCase()}`);
}
if (pt && /^\d+$/.test(pt)) {
return translateText("clan_modal.history_type_n_teams", {
count: Number(pt),
});
}
return translateText("clan_modal.history_type_team");
}
}
// FFA is "no team grouping". Match the server's `GameMode.FFA` enum
// literal first, but fall back to absent `playerTeams` so a row that
// arrives without the mode field (older row, server bug) still labels
// as FFA instead of silently degrading to "Team" — which would
// disagree with the FFA filter bucket that already routed it here.
function isFfa(game: ClanGame): boolean {
if (game.mode === GameMode.FFA) return true;
if (
game.mode === undefined &&
(game.playerTeams === null || game.playerTeams === undefined)
) {
return true;
}
return false;
}
function formatAbsoluteTime(iso: string): string {
const date = new Date(iso);
if (!Number.isFinite(date.getTime())) return iso;
const now = new Date();
const time = date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (sameDay) {
return translateText("clan_modal.history_today_at", { time });
}
return `${date.toLocaleDateString()} ${time}`;
}
type DayGroup = { day: string; games: ClanGame[] };
// Groups games by local-day key while preserving server order. Server
// ordering is already newest-first, so within a group we just keep the
// arrival order.
function groupGamesByDay(games: ClanGame[]): DayGroup[] {
const groups: DayGroup[] = [];
for (const g of games) {
const day = dayKey(g.start);
const last = groups[groups.length - 1];
if (last && last.day === day) {
last.games.push(g);
} else {
groups.push({ day, games: [g] });
}
}
return groups;
}
function dayKey(iso: string): string {
const d = new Date(iso);
if (!Number.isFinite(d.getTime())) return iso;
// Use local-time YYYY-MM-DD so headers line up with the user's clock,
// not UTC midnight (which would split late-night games into a "next
// day" group for most timezones).
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
// Indexed by Date.getMonth() (011). Kept as a const list rather than
// a switch so the translation pipeline picks up every key from a single
// place.
const MONTH_KEYS = [
"common.month_jan",
"common.month_feb",
"common.month_mar",
"common.month_apr",
"common.month_may",
"common.month_jun",
"common.month_jul",
"common.month_aug",
"common.month_sep",
"common.month_oct",
"common.month_nov",
"common.month_dec",
] as const;
function formatDayHeader(day: string): string {
const d = new Date(`${day}T00:00:00`);
if (!Number.isFinite(d.getTime())) return day;
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round(
(today.getTime() - dayStart.getTime()) / (24 * 60 * 60 * 1000),
);
if (diffDays === 0) return translateText("clan_modal.history_today");
if (diffDays === 1) return translateText("clan_modal.history_yesterday");
// "17 May 2026" — weekday dropped (no translation coverage) and the
// month rendered through our own translation keys instead of
// toLocaleDateString so other locales can swap it cleanly.
const month = translateText(MONTH_KEYS[d.getMonth()]);
return `${d.getDate()} ${month} ${d.getFullYear()}`;
}
-13
View File
@@ -5,7 +5,6 @@ import type {
ClanMemberOrder,
ClanMemberSort,
ClanMemberStats,
ClanStats,
} from "../../ClanApi";
import { showToast, translateText } from "../../Utils";
import "./ClanStatsBreakdown";
@@ -82,18 +81,6 @@ export function renderStat(label: string, value: string): TemplateResult {
`;
}
export function renderClanWL(stats: ClanStats): TemplateResult | string {
if (stats.games === 0) return "";
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.statistics")}
</h3>
<clan-stats-breakdown .stats=${stats.stats}></clan-stats-breakdown>
</div>
`;
}
function renderPaginationButtons(
currentPage: number,
totalPages: number,
+44 -15
View File
@@ -137,19 +137,48 @@ export const JoinClanResponseSchema = z.object({
});
export type JoinClanResponse = z.infer<typeof JoinClanResponseSchema>;
export const ClanStatsSchema = z.object({
clanTag: RequiredClanTagSchema,
games: z.number(),
wins: z.number(),
losses: z.number(),
stats: ClanMemberStatsSchema,
teamTypeWL: z.record(
z.string(),
z.object({ wl: z.tuple([z.number(), z.number()]) }),
),
teamCountWL: z.record(
z.string(),
z.object({ wl: z.tuple([z.number(), z.number()]) }),
),
export const ClanGamePlayerSchema = z.object({
publicId: z.string(),
username: z.string(),
won: z.boolean(),
});
export type ClanStats = z.infer<typeof ClanStatsSchema>;
export type ClanGamePlayer = z.infer<typeof ClanGamePlayerSchema>;
// "incomplete" covers games with no recorded winner.
// The server stamps this when winnerType IS NULL,
// so we have to accept it on the wire even if the UI collapses it back
// into the defeat-styled badge.
export const ClanGameResultSchema = z.enum(["victory", "defeat", "incomplete"]);
export type ClanGameResult = z.infer<typeof ClanGameResultSchema>;
export const ClanGameFilters = ["ffa", "team", "hvn", "ranked"] as const;
export const ClanGameFilterSchema = z.enum(ClanGameFilters);
export type ClanGameFilter = z.infer<typeof ClanGameFilterSchema>;
export const ClanGameSchema = z.object({
gameId: z.string(),
start: z.iso.datetime(),
durationSeconds: z.number().int().nonnegative(),
map: z.string().optional(),
mode: z.string().optional(),
// playerTeams is `null` (not absent) for FFA / non-team games — use
// `.nullish()` so the wire `null` doesn't fail the parse.
playerTeams: z.string().nullish(),
rankedType: z.string().optional(),
result: ClanGameResultSchema.optional(),
// Mirrors games.num_players nullability — historical rows may not
// carry a value. Use `.nullish()` so wire `null` parses cleanly.
totalPlayers: z.number().int().nonnegative().nullish(),
clanPlayers: ClanGamePlayerSchema.array(),
});
export type ClanGame = z.infer<typeof ClanGameSchema>;
export const ClanGamesResponseSchema = z.object({
results: ClanGameSchema.array(),
// Opaque continuation token. Round-trip verbatim as the `cursor` query
// parameter to fetch the next page; never construct or parse it.
// `null` means the server has no more rows to serve. Page size is
// fixed server-side, so the client never sends a limit.
nextCursor: z.string().nullable(),
});
export type ClanGamesResponse = z.infer<typeof ClanGamesResponseSchema>;
+124 -56
View File
@@ -10,11 +10,11 @@ vi.mock("../../../src/client/Auth", () => ({
import {
fetchClanDetail,
fetchClanGames,
fetchClanLeaderboard,
fetchClanMembers,
fetchClanRequests,
fetchClans,
fetchClanStats,
} from "../../../src/client/ClanApi";
const okJson = (data: unknown, status = 200) => ({
@@ -72,61 +72,6 @@ describe("fetchClanLeaderboard", () => {
});
});
describe("fetchClanStats", () => {
const clanStats = {
clanTag: "TEST",
games: 20,
wins: 15,
losses: 5,
stats: {
total: { wins: 15, losses: 5 },
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 },
},
teamTypeWL: { ffa: { wl: [15, 5] } },
teamCountWL: { "2": { wl: [10, 3] } },
};
it("returns parsed data from json.clan on success", async () => {
mockFetch(() => okJson({ clan: clanStats }));
const result = await fetchClanStats("TEST");
expect(result).toEqual(clanStats);
});
it("returns false when json.clan is missing", async () => {
mockFetch(() => okJson({}));
const result = await fetchClanStats("TEST");
expect(result).toBe(false);
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(404));
const result = await fetchClanStats("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanStats("TEST");
expect(result).toBe(false);
});
});
describe("fetchClanDetail", () => {
const clanInfo = {
name: "Test Clan",
@@ -389,3 +334,126 @@ describe("fetchClanRequests", () => {
expect(headers.Authorization).toBe("Bearer test-token");
});
});
describe("fetchClanGames", () => {
const gamesResponse = {
results: [
{
gameId: "g1",
start: "2024-06-01T00:00:00.000Z",
durationSeconds: 1234,
map: "World",
mode: "Team",
playerTeams: "Duos",
result: "victory",
totalPlayers: 8,
clanPlayers: [{ publicId: "p1", username: "alice", won: true }],
},
],
nextCursor: "opaque-cursor-abc123",
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(gamesResponse));
const result = await fetchClanGames("TEST");
expect(result).toEqual(gamesResponse);
});
it("accepts a null nextCursor (no more pages)", async () => {
mockFetch(() => okJson({ ...gamesResponse, nextCursor: null }));
const result = await fetchClanGames("TEST");
expect("error" in result).toBe(false);
if (!("error" in result)) expect(result.nextCursor).toBeNull();
});
it("omits filter and cursor query params when not provided", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(gamesResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanGames("TEST");
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.has("filter")).toBe(false);
expect(url.searchParams.has("cursor")).toBe(false);
expect(url.pathname).toBe("/clans/TEST/games");
});
it("passes filter and cursor as query params", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(gamesResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanGames("TEST", {
filter: "team",
cursor: "opaque-cursor-abc123",
});
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("filter")).toBe("team");
expect(url.searchParams.get("cursor")).toBe("opaque-cursor-abc123");
});
it("URL-encodes the clan tag", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(gamesResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanGames("A/B");
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
// encodeURIComponent('/') === '%2F'
expect(calledUrl).toContain("/clans/A%2FB/games");
});
it("returns { error: 'forbidden' } on 403", async () => {
mockFetch(() => failRes(403));
const result = await fetchClanGames("TEST");
expect(result).toEqual({ error: "forbidden" });
});
it("returns { error: 'failed' } on other non-ok responses", async () => {
mockFetch(() => failRes(500));
const result = await fetchClanGames("TEST");
expect(result).toEqual({ error: "failed" });
});
it("returns { error: 'failed' } when Zod validation fails", async () => {
mockFetch(() => okJson({ results: "not-an-array", nextCursor: 42 }));
const result = await fetchClanGames("TEST");
expect(result).toEqual({ error: "failed" });
});
it("returns { error: 'failed' } on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanGames("TEST");
expect(result).toEqual({ error: "failed" });
});
it("sends Authorization header", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(gamesResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanGames("TEST");
const headers = fetchSpy.mock.calls[0]![1]?.headers as Record<
string,
string
>;
expect(headers.Authorization).toBe("Bearer test-token");
});
});
+162 -57
View File
@@ -1,10 +1,14 @@
import { describe, expect, it } from "vitest";
import {
ClanBanSchema,
ClanGameFilterSchema,
ClanGamePlayerSchema,
ClanGameResultSchema,
ClanGameSchema,
ClanGamesResponseSchema,
ClanInfoSchema,
ClanJoinRequestSchema,
ClanMemberSchema,
ClanStatsSchema,
} from "../../../src/core/ClanApiSchemas";
describe("ClanInfoSchema", () => {
@@ -153,62 +157,6 @@ describe("ClanJoinRequestSchema", () => {
});
});
describe("ClanStatsSchema", () => {
const validStats = {
clanTag: "ABcd1",
games: 10,
wins: 7,
losses: 3,
stats: {
total: { wins: 7, losses: 3 },
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 },
},
teamTypeWL: { ffa: { wl: [7, 3] } },
teamCountWL: { "2": { wl: [4, 1] } },
};
it("accepts a valid clan tag (2-5 alphanumeric chars)", () => {
for (const tag of ["AB", "abc12", "XYZAB"]) {
const result = ClanStatsSchema.safeParse({ ...validStats, clanTag: tag });
expect(result.success, `tag "${tag}" should be valid`).toBe(true);
}
});
it("rejects tags that are too short", () => {
const result = ClanStatsSchema.safeParse({ ...validStats, clanTag: "A" });
expect(result.success).toBe(false);
});
it("rejects tags that are too long", () => {
const result = ClanStatsSchema.safeParse({
...validStats,
clanTag: "TOOLNG",
});
expect(result.success).toBe(false);
});
it("rejects tags with non-alphanumeric characters", () => {
const result = ClanStatsSchema.safeParse({
...validStats,
clanTag: "AB-CD",
});
expect(result.success).toBe(false);
});
});
describe("ClanBanSchema", () => {
const validBan = {
publicId: "player-1",
@@ -252,3 +200,160 @@ describe("ClanBanSchema", () => {
expect(result.success).toBe(false);
});
});
describe("ClanGameResultSchema", () => {
it.each(["victory", "defeat", "incomplete"])("accepts %s", (value) => {
expect(ClanGameResultSchema.safeParse(value).success).toBe(true);
});
it("rejects an unknown result value", () => {
expect(ClanGameResultSchema.safeParse("win").success).toBe(false);
});
});
describe("ClanGameFilterSchema", () => {
it.each(["ffa", "team", "hvn", "ranked"])("accepts %s", (value) => {
expect(ClanGameFilterSchema.safeParse(value).success).toBe(true);
});
it("rejects an unknown filter value", () => {
expect(ClanGameFilterSchema.safeParse("all").success).toBe(false);
});
});
describe("ClanGamePlayerSchema", () => {
const validPlayer = {
publicId: "p1",
username: "alice",
won: true,
};
it("accepts a valid player", () => {
expect(ClanGamePlayerSchema.safeParse(validPlayer).success).toBe(true);
});
it("rejects when won is not a boolean", () => {
expect(
ClanGamePlayerSchema.safeParse({ ...validPlayer, won: "true" }).success,
).toBe(false);
});
it("rejects when required fields are missing", () => {
expect(ClanGamePlayerSchema.safeParse({ publicId: "p1" }).success).toBe(
false,
);
});
});
describe("ClanGameSchema", () => {
const validGame = {
gameId: "g1",
start: "2024-06-01T00:00:00.000Z",
durationSeconds: 1234,
map: "World",
mode: "Team",
playerTeams: "Duos",
rankedType: "1v1",
result: "victory" as const,
totalPlayers: 8,
clanPlayers: [{ publicId: "p1", username: "alice", won: true }],
};
it("accepts a fully-populated game", () => {
expect(ClanGameSchema.safeParse(validGame).success).toBe(true);
});
it("accepts playerTeams: null (FFA / non-team games)", () => {
const result = ClanGameSchema.safeParse({
...validGame,
playerTeams: null,
});
expect(result.success).toBe(true);
});
it("accepts totalPlayers: null (historical rows)", () => {
const result = ClanGameSchema.safeParse({
...validGame,
totalPlayers: null,
});
expect(result.success).toBe(true);
});
it("accepts a row with map/mode/rankedType/result omitted", () => {
const minimal = {
gameId: validGame.gameId,
start: validGame.start,
durationSeconds: validGame.durationSeconds,
playerTeams: validGame.playerTeams,
totalPlayers: validGame.totalPlayers,
clanPlayers: validGame.clanPlayers,
};
expect(ClanGameSchema.safeParse(minimal).success).toBe(true);
});
it("rejects a non-ISO start", () => {
expect(
ClanGameSchema.safeParse({ ...validGame, start: "June 1 2024" }).success,
).toBe(false);
});
it("rejects a negative durationSeconds", () => {
expect(
ClanGameSchema.safeParse({ ...validGame, durationSeconds: -1 }).success,
).toBe(false);
});
it("rejects a negative totalPlayers", () => {
expect(
ClanGameSchema.safeParse({ ...validGame, totalPlayers: -1 }).success,
).toBe(false);
});
it("rejects an unknown result value", () => {
expect(
ClanGameSchema.safeParse({ ...validGame, result: "win" }).success,
).toBe(false);
});
});
describe("ClanGamesResponseSchema", () => {
const validGame = {
gameId: "g1",
start: "2024-06-01T00:00:00.000Z",
durationSeconds: 1234,
clanPlayers: [{ publicId: "p1", username: "alice", won: true }],
};
it("accepts a non-empty page with an opaque cursor", () => {
// The cursor is contractually opaque (see ClanGamesResponseSchema
// comment) — use a non-date token to make that explicit.
const result = ClanGamesResponseSchema.safeParse({
results: [validGame],
nextCursor: "opaque-cursor-abc123",
});
expect(result.success).toBe(true);
if (result.success)
expect(result.data.nextCursor).toBe("opaque-cursor-abc123");
});
it("accepts an empty page with a null cursor", () => {
const result = ClanGamesResponseSchema.safeParse({
results: [],
nextCursor: null,
});
expect(result.success).toBe(true);
});
it("rejects when nextCursor is missing (must be string or null)", () => {
const result = ClanGamesResponseSchema.safeParse({ results: [] });
expect(result.success).toBe(false);
});
it("rejects when results is not an array", () => {
const result = ClanGamesResponseSchema.safeParse({
results: "not-an-array",
nextCursor: null,
});
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,605 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushAsync } from "./ClanModalTestUtils";
// ─── Mocks (defined before imports so vi.mock hoisting applies) ─────────────
vi.mock("../../../src/client/Utils", () => ({
// Echo the key so we can assert on translation slugs.
translateText: vi.fn((key: string) => key),
showToast: vi.fn(),
// Cheap stub so we don't pull in the real i18n module.
renderDuration: vi.fn((s: number) => `${s}s`),
getMapName: vi.fn((m: string | undefined) => m ?? null),
}));
vi.mock("../../../src/client/Auth", () => ({
getAuthHeader: vi.fn(async () => "Bearer test-token"),
userAuth: vi.fn(),
}));
vi.mock("../../../src/client/ClanApi", () => ({
fetchClanGames: vi.fn(async () => ({ results: [], nextCursor: null })),
}));
vi.mock("../../../src/client/TerrainMapFileLoader", () => ({
terrainMapFileLoader: {
getMapData: vi.fn(() => ({ webpPath: "/maps/test.webp" })),
},
}));
vi.mock("../../../src/client/ClientEnv", () => ({
ClientEnv: {
workerPath: vi.fn(() => "w0"),
},
}));
// ClanShared re-exports from BaseModal; stub it directly so we don't pull
// BaseModal's dependency graph into this unit test.
vi.mock("../../../src/client/components/clan/ClanShared", async () => {
const { html } = await import("lit");
return {
renderLoadingSpinner: vi.fn(() => html`<div data-testid="spinner"></div>`),
showToast: vi.fn(),
};
});
// CopyButton is a custom element; stub it so its dependency graph (Auth,
// API, etc.) doesn't get pulled in transitively.
vi.mock("../../../src/client/components/CopyButton", () => ({}));
// jsdom doesn't ship IntersectionObserver — provide the minimum surface
// the component touches (observe / disconnect). Tests below trigger the
// callback manually when needed.
class FakeIntersectionObserver {
callback: IntersectionObserverCallback;
static last: FakeIntersectionObserver | null = null;
observed: Element[] = [];
constructor(cb: IntersectionObserverCallback) {
this.callback = cb;
FakeIntersectionObserver.last = this;
}
observe(el: Element) {
this.observed.push(el);
}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
root = null;
rootMargin = "";
thresholds = [];
}
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
// ─── Imports under test ──────────────────────────────────────────────────────
import type { ClanGame, ClanGamesResponse } from "../../../src/client/ClanApi";
import { fetchClanGames } from "../../../src/client/ClanApi";
import {
ClanGameHistoryView,
type ClanGameHistoryCache,
} from "../../../src/client/components/clan/ClanGameHistoryView";
// ─── Helpers ────────────────────────────────────────────────────────────────
function makeGame(overrides: Partial<ClanGame> = {}): ClanGame {
return {
gameId: "g1",
start: "2024-06-01T12:00:00.000Z",
durationSeconds: 600,
map: "World",
mode: "Team",
playerTeams: "Duos",
rankedType: undefined,
result: "victory",
totalPlayers: 8,
clanPlayers: [{ publicId: "p1", username: "alice", won: true }],
...overrides,
};
}
async function mountView(props: Partial<ClanGameHistoryView> = {}) {
if (!customElements.get("clan-game-history-view")) {
customElements.define("clan-game-history-view", ClanGameHistoryView);
}
const el = document.createElement(
"clan-game-history-view",
) as ClanGameHistoryView;
// Apply props before mount so connectedCallback sees them.
Object.assign(el, { clanTag: "TST", ...props });
document.body.appendChild(el);
await el.updateComplete;
return el;
}
const mockFetch = (impl: () => Promise<unknown>) => {
(fetchClanGames as ReturnType<typeof vi.fn>).mockImplementationOnce(impl);
};
const okPage = (
games: ClanGame[],
nextCursor: string | null = null,
): ClanGamesResponse => ({ results: games, nextCursor });
// ─── Tests ──────────────────────────────────────────────────────────────────
describe("ClanGameHistoryView", () => {
beforeEach(() => {
vi.clearAllMocks();
(fetchClanGames as ReturnType<typeof vi.fn>).mockResolvedValue(okPage([]));
});
afterEach(() => {
document.querySelectorAll("clan-game-history-view").forEach((el) => {
el.remove();
});
});
describe("initial load + caching", () => {
it("fetches games on mount when no cache is provided", async () => {
mockFetch(() => Promise.resolve(okPage([makeGame()])));
const el = await mountView();
await flushAsync(el);
expect(fetchClanGames).toHaveBeenCalledWith("TST", {
filter: undefined,
cursor: undefined,
});
});
it("skips the fetch when a matching cache is supplied", async () => {
const cache: ClanGameHistoryCache = {
tag: "TST",
filter: "ffa",
games: [makeGame({ gameId: "cached" })],
nextCursor: "cursor-1",
};
const el = await mountView({ cachedState: cache });
await flushAsync(el);
expect(fetchClanGames).not.toHaveBeenCalled();
// Sentinel must be present so the observer can pick up where the
// previous session left off — non-null cursor means more pages.
expect(el.querySelector("[data-scroll-sentinel]")).not.toBeNull();
});
it("ignores the cache when the tag does not match and fetches instead", async () => {
mockFetch(() => Promise.resolve(okPage([makeGame()])));
const cache: ClanGameHistoryCache = {
tag: "OTHER",
filter: "all",
games: [makeGame()],
nextCursor: null,
};
const el = await mountView({ cachedState: cache });
await flushAsync(el);
expect(fetchClanGames).toHaveBeenCalledOnce();
});
});
describe("filter switching", () => {
it("hard-resets games and refetches with the chosen filter", async () => {
mockFetch(() =>
Promise.resolve(okPage([makeGame({ gameId: "first" })], "cursor-1")),
);
const el = await mountView();
await flushAsync(el);
mockFetch(() => Promise.resolve(okPage([makeGame({ gameId: "ffa" })])));
// Click the FFA filter tab. translateText echoes the key.
const ffaTab = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("clan_modal.history_type_ffa"),
)!;
ffaTab.click();
await flushAsync(el);
expect(fetchClanGames).toHaveBeenLastCalledWith("TST", {
filter: "ffa",
cursor: undefined,
});
});
});
describe("cursor pagination (append)", () => {
it("sends the saved cursor on the next page request and concatenates results", async () => {
mockFetch(() =>
Promise.resolve(okPage([makeGame({ gameId: "p1" })], "next-token")),
);
const el = await mountView();
await flushAsync(el);
mockFetch(() =>
Promise.resolve(okPage([makeGame({ gameId: "p2" })], null)),
);
// Drive the observer callback manually — sentinel becomes intersecting.
const observer = FakeIntersectionObserver.last;
expect(observer).not.toBeNull();
observer!.callback(
[
{
isIntersecting: true,
target: observer!.observed[0],
} as unknown as IntersectionObserverEntry,
],
observer as unknown as IntersectionObserver,
);
await flushAsync(el);
expect(fetchClanGames).toHaveBeenLastCalledWith("TST", {
filter: undefined,
cursor: "next-token",
});
});
it("preserves prior games and surfaces a retry footer when append fails", async () => {
mockFetch(() =>
Promise.resolve(okPage([makeGame({ gameId: "p1" })], "next-token")),
);
const el = await mountView();
await flushAsync(el);
mockFetch(() => Promise.resolve({ error: "failed" }));
const observer = FakeIntersectionObserver.last!;
observer.callback(
[
{
isIntersecting: true,
target: observer.observed[0],
} as unknown as IntersectionObserverEntry,
],
observer as unknown as IntersectionObserver,
);
await flushAsync(el);
// Retry footer is rendered (the key is `history_load_more_failed`)
expect(el.textContent).toContain("clan_modal.history_load_more_failed");
// The first-page game card is still in the DOM — checking for a
// per-game render artefact rather than gameId since CopyButton is
// stubbed out and does not surface its `displayText`.
expect(el.textContent).toContain("clan_modal.history_game_type");
// And the empty state must NOT have replaced the list.
expect(el.textContent).not.toContain("clan_modal.history_empty");
});
});
describe("error / forbidden / empty states", () => {
it("renders the members-only message on 403", async () => {
mockFetch(() => Promise.resolve({ error: "forbidden" }));
const el = await mountView();
await flushAsync(el);
expect(el.textContent).toContain("clan_modal.history_members_only");
});
it("renders the unavailable state with a try-again button on non-403 errors", async () => {
mockFetch(() => Promise.resolve({ error: "failed" }));
const el = await mountView();
await flushAsync(el);
expect(el.textContent).toContain("clan_modal.history_unavailable");
expect(el.textContent).toContain("leaderboard_modal.try_again");
});
it("renders the empty state when results is []", async () => {
mockFetch(() => Promise.resolve(okPage([])));
const el = await mountView();
await flushAsync(el);
expect(el.textContent).toContain("clan_modal.history_empty");
});
});
describe("renderResultBadge", () => {
// Drive a single render and read the badge text out of the DOM so
// we test the actual code path (including isFfa).
async function badgeTextFor(game: ClanGame): Promise<string> {
mockFetch(() => Promise.resolve(okPage([game])));
const el = await mountView();
await flushAsync(el);
return el.textContent ?? "";
}
it("shows partial-win badge for FFA when only some clan members won", async () => {
const text = await badgeTextFor(
makeGame({
mode: "Free For All",
playerTeams: null,
result: "victory",
clanPlayers: [
{ publicId: "a", username: "a", won: true },
{ publicId: "b", username: "b", won: false },
{ publicId: "c", username: "c", won: false },
],
}),
);
expect(text).toContain("clan_modal.history_result_partial");
});
it("shows victory for FFA when all clan members won", async () => {
const text = await badgeTextFor(
makeGame({
mode: "Free For All",
playerTeams: null,
result: "victory",
clanPlayers: [
{ publicId: "a", username: "a", won: true },
{ publicId: "b", username: "b", won: true },
],
}),
);
expect(text).toContain("clan_modal.history_result_victory");
expect(text).not.toContain("clan_modal.history_result_partial");
});
it("shows defeat for FFA when no clan members won", async () => {
const text = await badgeTextFor(
makeGame({
mode: "Free For All",
playerTeams: null,
result: "defeat",
clanPlayers: [
{ publicId: "a", username: "a", won: false },
{ publicId: "b", username: "b", won: false },
],
}),
);
expect(text).toContain("clan_modal.history_result_defeat");
});
it("does not partial-win team games (clan plays as a unit)", async () => {
const text = await badgeTextFor(
makeGame({
mode: "Team",
playerTeams: "Duos",
result: "victory",
clanPlayers: [
{ publicId: "a", username: "a", won: true },
{ publicId: "b", username: "b", won: false },
],
}),
);
// Team games surface plain victory/defeat — never partial.
expect(text).toContain("clan_modal.history_result_victory");
expect(text).not.toContain("clan_modal.history_result_partial");
});
it("omits the badge when result is absent", async () => {
const text = await badgeTextFor(
makeGame({
result: undefined,
}),
);
expect(text).not.toContain("clan_modal.history_result_victory");
expect(text).not.toContain("clan_modal.history_result_defeat");
expect(text).not.toContain("clan_modal.history_result_partial");
});
});
describe("formatGameType", () => {
async function typeLabelFor(game: ClanGame): Promise<string> {
mockFetch(() => Promise.resolve(okPage([game])));
const el = await mountView();
await flushAsync(el);
return el.textContent ?? "";
}
it("labels ranked games with the rankedType variable", async () => {
const text = await typeLabelFor(
makeGame({ rankedType: "1v1", mode: undefined, playerTeams: null }),
);
expect(text).toContain("clan_modal.history_type_ranked");
});
it("labels FFA via the GameMode.FFA enum literal", async () => {
const text = await typeLabelFor(
makeGame({
mode: "Free For All",
playerTeams: null,
rankedType: undefined,
}),
);
expect(text).toContain("clan_modal.history_type_ffa");
});
it("labels FFA when mode is absent and playerTeams is null (no team grouping)", async () => {
const text = await typeLabelFor(
makeGame({
mode: undefined,
playerTeams: null,
rankedType: undefined,
}),
);
expect(text).toContain("clan_modal.history_type_ffa");
});
it("labels Humans Vs Nations", async () => {
const text = await typeLabelFor(
makeGame({
mode: "Team",
playerTeams: "Humans Vs Nations",
rankedType: undefined,
}),
);
expect(text).toContain("clan_modal.history_type_hvn");
});
it("labels Duos / Trios / Quads via the lowercased key", async () => {
for (const team of ["Duos", "Trios", "Quads"] as const) {
const text = await typeLabelFor(
makeGame({
mode: "Team",
playerTeams: team,
rankedType: undefined,
}),
);
expect(
text,
`team "${team}" should map to its lowercase label`,
).toContain(`clan_modal.history_type_${team.toLowerCase()}`);
}
});
it("labels numeric playerTeams as N teams", async () => {
const text = await typeLabelFor(
makeGame({
mode: "Team",
playerTeams: "7",
rankedType: undefined,
}),
);
expect(text).toContain("clan_modal.history_type_n_teams");
});
it("falls back to generic Team when playerTeams is an unknown string", async () => {
const text = await typeLabelFor(
makeGame({
mode: "Team",
playerTeams: "WeirdMode",
rankedType: undefined,
}),
);
expect(text).toContain("clan_modal.history_type_team");
});
});
describe("renderPlayersField", () => {
async function bodyTextFor(game: ClanGame): Promise<string> {
mockFetch(() => Promise.resolve(okPage([game])));
const el = await mountView();
await flushAsync(el);
return el.textContent ?? "";
}
it("shows total only for ranked single-clan-slot games", async () => {
const text = await bodyTextFor(
makeGame({ rankedType: "1v1", totalPlayers: 2 }),
);
expect(text).toContain("clan_modal.history_players");
expect(text).not.toContain("clan_modal.history_clan_players_value");
});
it("shows clan/total breakdown for non-ranked games", async () => {
const text = await bodyTextFor(
makeGame({ rankedType: undefined, totalPlayers: 50 }),
);
expect(text).toContain("clan_modal.history_clan_players_value");
});
it('renders "—" when totalPlayers is null and game is ranked', async () => {
const text = await bodyTextFor(
makeGame({ rankedType: "1v1", totalPlayers: null }),
);
expect(text).toContain("—");
});
});
describe("day grouping headers", () => {
it("groups consecutive same-day games under one header (Today)", async () => {
const now = new Date();
const today = (h: number) =>
new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
h,
).toISOString();
mockFetch(() =>
Promise.resolve(
okPage([
makeGame({ gameId: "g1", start: today(10) }),
makeGame({ gameId: "g2", start: today(11) }),
]),
),
);
const el = await mountView();
await flushAsync(el);
const headers = el.querySelectorAll("h3");
expect(headers).toHaveLength(1);
expect(headers[0]?.textContent).toContain("clan_modal.history_today");
});
it('labels yesterday under the "yesterday" key', async () => {
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
mockFetch(() =>
Promise.resolve(okPage([makeGame({ start: yesterday.toISOString() })])),
);
const el = await mountView();
await flushAsync(el);
expect(el.textContent).toContain("clan_modal.history_yesterday");
});
});
describe("watchReplay", () => {
it("pushes a /game/:id URL and emits close-clan-modal + join-changed", async () => {
mockFetch(() =>
Promise.resolve(okPage([makeGame({ gameId: "abc/xyz" })])),
);
const el = await mountView();
await flushAsync(el);
const pushSpy = vi.spyOn(history, "pushState");
const winEvents: string[] = [];
const winHandler = () => winEvents.push("join-changed");
window.addEventListener("join-changed", winHandler);
const elEvents: string[] = [];
el.addEventListener("close-clan-modal", () =>
elEvents.push("close-clan-modal"),
);
const watchBtn = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("clan_modal.history_watch_replay"),
)!;
watchBtn.click();
await flushAsync(el);
expect(pushSpy).toHaveBeenCalledOnce();
const url = pushSpy.mock.calls[0][2] as string;
// gameId is URL-encoded into the path
expect(url).toContain(encodeURIComponent("abc/xyz"));
expect(winEvents).toContain("join-changed");
expect(elEvents).toContain("close-clan-modal");
window.removeEventListener("join-changed", winHandler);
pushSpy.mockRestore();
});
});
describe("history-updated event", () => {
it("emits with the freshly-loaded games and cursor so the parent can cache", async () => {
mockFetch(() =>
Promise.resolve(okPage([makeGame({ gameId: "g1" })], "next-1")),
);
const el = await mountView();
const events: ClanGameHistoryCache[] = [];
el.addEventListener("history-updated", (e) =>
events.push((e as CustomEvent<ClanGameHistoryCache>).detail),
);
// The first load was already issued in connectedCallback — wait for it.
await flushAsync(el);
// Re-trigger by switching filter to capture the event.
mockFetch(() =>
Promise.resolve(okPage([makeGame({ gameId: "g2" })], null)),
);
const ffaTab = Array.from(el.querySelectorAll("button")).find((b) =>
b.textContent?.includes("clan_modal.history_type_ffa"),
)!;
ffaTab.click();
await flushAsync(el);
expect(events.length).toBeGreaterThan(0);
const last = events[events.length - 1];
expect(last.tag).toBe("TST");
expect(last.filter).toBe("ffa");
expect(last.games).toHaveLength(1);
expect(last.nextCursor).toBeNull();
});
});
});
+2 -5
View File
@@ -589,12 +589,10 @@ describe("ClanModal — handlers", () => {
describe("handleJoin", () => {
beforeEach(async () => {
const { fetchClanDetail, fetchClanStats } =
await import("../../../src/client/ClanApi");
const { fetchClanDetail } = await import("../../../src/client/ClanApi");
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeClan({ isOpen: true, memberCount: 5 }),
);
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myClanRoles" as keyof ClanModal, new Map() as never);
@@ -652,7 +650,7 @@ describe("ClanModal — handlers", () => {
describe("handleLeave", () => {
beforeEach(async () => {
const { fetchClanDetail, fetchClanMembers, fetchClanStats } =
const { fetchClanDetail, fetchClanMembers } =
await import("../../../src/client/ClanApi");
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeClan(),
@@ -670,7 +668,6 @@ describe("ClanModal — handlers", () => {
limit: 10,
pendingRequests: 0,
});
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(
+1 -10
View File
@@ -237,19 +237,10 @@ describe("ClanModal — rendering", () => {
});
it("shows 0 in the stats row of the detail view when memberCount is undefined", async () => {
const { fetchClanDetail, fetchClanStats } =
await import("../../../src/client/ClanApi");
const { fetchClanDetail } = await import("../../../src/client/ClanApi");
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeClan({ memberCount: undefined }),
);
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
clanTag: "TST",
games: 0,
wins: 0,
losses: 0,
teamTypeWL: {},
teamCountWL: {},
});
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "detail" as never);
await waitForSubComponent(modal, "clan-detail-view");
+4 -25
View File
@@ -31,31 +31,6 @@ export function clanApiMockFactory() {
limit: 10,
pendingRequests: 0,
})),
fetchClanStats: vi.fn(async () => ({
clanTag: "TST",
games: 10,
wins: 7,
losses: 3,
stats: {
total: { wins: 7, losses: 3 },
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 },
},
teamTypeWL: {},
teamCountWL: {},
})),
fetchClans: vi.fn(async () => ({
results: [],
total: 0,
@@ -88,6 +63,10 @@ export function clanApiMockFactory() {
page: 1,
limit: 20,
})),
fetchClanGames: vi.fn(async () => ({
results: [],
nextCursor: null,
})),
};
}