mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:10:42 +00:00
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:
+53
-3
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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() (0–11). 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()}`;
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user