diff --git a/resources/lang/en.json b/resources/lang/en.json index 9bc8fe3f6..d8456c6cd 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts index c531b383c..9b7f62000 100644 --- a/src/client/ClanApi.ts +++ b/src/client/ClanApi.ts @@ -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 { - 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 { + 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, diff --git a/src/client/ClanModal.ts b/src/client/ClanModal.ts index 975367c2b..b161e3c73 100644 --- a/src/client/ClanModal.ts +++ b/src/client/ClanModal.ts @@ -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); }} >`; } @@ -295,13 +342,26 @@ export class ClanModal extends BaseModal { @navigate-back=${() => (this.view = "manage")} >`; } - // Default: detail view + // Default: detail view — dispatched by the active detail tab + if (this.activeTab === "game-history") { + return html`) => { + this.gameHistoryCache = e.detail; + }} + @close-clan-modal=${() => this.close()} + >`; + } return html`, ) => { 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")}

+ `; + })} + + `; + } + + private renderBody(): TemplateResult { + if (this.loading && this.games.length === 0) { + return renderLoadingSpinner(); + } + if (this.loadState === "failed") { + return html` +
+

+ ${translateText("clan_modal.history_unavailable")} +

+ +
+ `; + } + if (this.games.length === 0) { + return html` +
+

+ ${translateText("clan_modal.history_empty")} +

+
+ `; + } + + // 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` +
+ ${groups.map( + (group) => html` +
+
+ +

+ ${formatDayHeader(group.day)} +

+ +
+
+ ${group.games.map((game) => this.renderGameRow(game))} +
+
+ `, + )} + ${this.renderScrollFooter()} +
+ `; + } + + private renderScrollFooter(): TemplateResult { + if (this.nextCursor === null) { + return html` +
+ ${translateText("clan_modal.history_end_of_history")} +
+ `; + } + if (this.appendFailed) { + return html` +
+

+ ${translateText("clan_modal.history_load_more_failed")} +

+ +
+ `; + } + // 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` +
+ + ${this.loadingMore ? renderLoadingSpinner() : ""} +
+ `; + } + + 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` +
+ ${mapWebpPath + ? html`
+ ${mapDisplayName +
+ ${mapDisplayName + ? html`
+ ${mapDisplayName} +
` + : ""} +
+ ${this.renderResultBadge(game, winners)} +
+
+ ${formatAbsoluteTime(game.start)} +
+
` + : ""} +
+
+ ${translateText("clan_modal.history_game_id")}: + +
+ +
+
+ ${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), + )} +
+ ${this.renderPlayerLists(game, winners, losers)} +
+ `; + } + + private renderField(label: string, value: string): TemplateResult { + return html` +
+
+ ${label} +
+
${value}
+
+ `; + } + + // 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`${label}`; + } + + // 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` +
+ ${label}: + ${players.map( + (p) => html` + + `, + )} +
+ `; + } + + // 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()}`; +} diff --git a/src/client/components/clan/ClanShared.ts b/src/client/components/clan/ClanShared.ts index 26c6a9365..cb79faef1 100644 --- a/src/client/components/clan/ClanShared.ts +++ b/src/client/components/clan/ClanShared.ts @@ -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` -
-

- ${translateText("clan_modal.statistics")} -

- -
- `; -} - function renderPaginationButtons( currentPage: number, totalPages: number, diff --git a/src/core/ClanApiSchemas.ts b/src/core/ClanApiSchemas.ts index 427f49768..eb8b01130 100644 --- a/src/core/ClanApiSchemas.ts +++ b/src/core/ClanApiSchemas.ts @@ -137,19 +137,48 @@ export const JoinClanResponseSchema = z.object({ }); export type JoinClanResponse = z.infer; -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; +export type ClanGamePlayer = z.infer; + +// "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; + +export const ClanGameFilters = ["ffa", "team", "hvn", "ranked"] as const; +export const ClanGameFilterSchema = z.enum(ClanGameFilters); +export type ClanGameFilter = z.infer; + +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; + +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; diff --git a/tests/client/clan/ClanApiQueries.test.ts b/tests/client/clan/ClanApiQueries.test.ts index b3f016c14..f55b3fb90 100644 --- a/tests/client/clan/ClanApiQueries.test.ts +++ b/tests/client/clan/ClanApiQueries.test.ts @@ -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"); + }); +}); diff --git a/tests/client/clan/ClanApiSchemas.test.ts b/tests/client/clan/ClanApiSchemas.test.ts index 7b99678e9..c94edc39f 100644 --- a/tests/client/clan/ClanApiSchemas.test.ts +++ b/tests/client/clan/ClanApiSchemas.test.ts @@ -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); + }); +}); diff --git a/tests/client/clan/ClanGameHistoryView.test.ts b/tests/client/clan/ClanGameHistoryView.test.ts new file mode 100644 index 000000000..fd2ac4a8a --- /dev/null +++ b/tests/client/clan/ClanGameHistoryView.test.ts @@ -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`
`), + 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 { + 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 = {}) { + 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) => { + (fetchClanGames as ReturnType).mockImplementationOnce(impl); +}; + +const okPage = ( + games: ClanGame[], + nextCursor: string | null = null, +): ClanGamesResponse => ({ results: games, nextCursor }); + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe("ClanGameHistoryView", () => { + beforeEach(() => { + vi.clearAllMocks(); + (fetchClanGames as ReturnType).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 { + 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 { + 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 { + 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).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(); + }); + }); +}); diff --git a/tests/client/clan/ClanModal.handlers.test.ts b/tests/client/clan/ClanModal.handlers.test.ts index d54bbebb8..2246c5b07 100644 --- a/tests/client/clan/ClanModal.handlers.test.ts +++ b/tests/client/clan/ClanModal.handlers.test.ts @@ -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).mockResolvedValueOnce( makeClan({ isOpen: true, memberCount: 5 }), ); - (fetchClanStats as ReturnType).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).mockResolvedValueOnce( makeClan(), @@ -670,7 +668,6 @@ describe("ClanModal — handlers", () => { limit: 10, pendingRequests: 0, }); - (fetchClanStats as ReturnType).mockResolvedValueOnce(false); setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never); setState( diff --git a/tests/client/clan/ClanModal.rendering.test.ts b/tests/client/clan/ClanModal.rendering.test.ts index 338f9a2a0..a0f65b768 100644 --- a/tests/client/clan/ClanModal.rendering.test.ts +++ b/tests/client/clan/ClanModal.rendering.test.ts @@ -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).mockResolvedValueOnce( makeClan({ memberCount: undefined }), ); - (fetchClanStats as ReturnType).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"); diff --git a/tests/client/clan/ClanModalTestUtils.ts b/tests/client/clan/ClanModalTestUtils.ts index 001fa4856..05c9f040e 100644 --- a/tests/client/clan/ClanModalTestUtils.ts +++ b/tests/client/clan/ClanModalTestUtils.ts @@ -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, + })), }; }