add game history to a profile (#4662)

## Description:

adds game tab to player profile:

<img width="1235" height="928" alt="image"
src="https://github.com/user-attachments/assets/660fba48-18d0-4538-a9d5-f33abc0e693d"
/>

also make ui a bit more uniform, both account + profile modal match, and
changed the filtering options to be the same as the games tab

<img width="972" height="581" alt="image"
src="https://github.com/user-attachments/assets/6f10f78e-e32b-42a6-8df4-7aa6f0cf5146"
/>
<img width="1006" height="623" alt="image"
src="https://github.com/user-attachments/assets/caffb742-1d64-4d21-aa13-d3a7191088ca"
/>

## 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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n
This commit is contained in:
Ryan
2026-07-21 13:56:28 -07:00
committed by GitHub
parent 3c5c96c141
commit 5ae2040d65
7 changed files with 609 additions and 156 deletions
-3
View File
@@ -38,15 +38,12 @@
"marketing_title": "Email updates",
"no_games": "No games played yet.",
"no_stats": "No stats available yet. Play some games to start tracking.",
"not_found": "Not Found",
"or": "OR",
"public_player_id": "Public ID:",
"reactivate_subscription": "Reactivate",
"recovery_email_sent": "Recovery email sent to {email}",
"reward_daily": "Daily subscription reward",
"reward_signup_bonus": "Subscription signup bonus",
"sign_in_desc": "Sign in to save your stats and progress",
"stats_overview": "Stats Overview",
"sub_price_monthly": "{price}/mo",
"sub_renews_on": "Renews {date}",
"sub_status_active": "Active",
+10 -36
View File
@@ -107,25 +107,19 @@ export class AccountModal extends BaseModal {
protected renderHeaderSlot() {
const isLoggedIn = !!this.userMeResponse?.user;
const publicId = this.userMeResponse?.player?.publicId ?? "";
const displayId = publicId || translateText("account_modal.not_found");
return modalHeader({
title: translateText("account_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
rightContent:
isLoggedIn && !this.isLoadingUser
isLoggedIn && !this.isLoadingUser && publicId
? html`
<div class="flex items-center gap-2">
<span
class="text-xs text-blue-400 font-bold uppercase tracking-wider"
>${translateText("account_modal.public_player_id")}</span
>
<copy-button
.lobbyId=${publicId}
.copyText=${publicId}
.displayText=${displayId}
></copy-button>
</div>
<copy-button
class="shrink-0"
.copyText=${playerProfileUrl(publicId)}
.displayText=${translateText("player_profile.share")}
.showVisibilityToggle=${false}
></copy-button>
`
: undefined,
});
@@ -412,30 +406,10 @@ export class AccountModal extends BaseModal {
translateText("account_modal.no_stats"),
);
}
const publicId = this.userMeResponse?.player?.publicId ?? "";
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<div class="flex items-center justify-between gap-2 mb-4">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<span class="text-blue-400">📊</span>
${translateText("account_modal.stats_overview")}
</h3>
${publicId
? html`
<copy-button
compact
class="shrink-0"
.copyText=${playerProfileUrl(publicId)}
.displayText=${translateText("player_profile.share")}
.showVisibilityToggle=${false}
></copy-button>
`
: ""}
</div>
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
</div>
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
`;
}
+12 -1
View File
@@ -11,7 +11,7 @@ export class GameStatsModal extends BaseModal {
protected routerName = "stats";
@state() private gameId: string | null = null;
private openedFrom: "account" | "clan" | null = null;
private openedFrom: "account" | "clan" | "profile" | null = null;
protected modalConfig() {
return { maxWidth: "960px" };
@@ -66,6 +66,11 @@ export class GameStatsModal extends BaseModal {
this.open({ gameID: gameId });
}
public openFromProfile(gameId: string): void {
this.openedFrom = "profile";
this.open({ gameID: gameId });
}
private back(): void {
const openedFrom = this.openedFrom;
this.close();
@@ -73,6 +78,12 @@ export class GameStatsModal extends BaseModal {
document
.querySelector<HTMLElement & { returnToGames(): void }>("account-modal")
?.returnToGames();
} else if (openedFrom === "profile") {
document
.querySelector<
HTMLElement & { returnToGames(): void }
>("player-profile-modal")
?.returnToGames();
} else if (openedFrom === "clan") {
document
.querySelector<
+132 -20
View File
@@ -1,7 +1,10 @@
import { html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { isVerifiedUsername, type PlayerStatsTree } from "../core/ApiSchemas";
import { fetchPublicPlayerProfile } from "./Api";
import "./components/baseComponents/stats/PlayerGameHistoryView";
import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView";
import "./components/baseComponents/stats/PlayerStatsTree";
import { BaseModal } from "./components/BaseModal";
import "./components/PlayerName";
@@ -23,9 +26,23 @@ export class PlayerProfileModal extends BaseModal {
@state() private statsTree: PlayerStatsTree | null = null;
@state() private loading = false;
private openedFrom: "clan" | "leaderboard" | "account" | null = null;
// Mirrors the account modal's Games tab: keep the accumulated history list +
// cursor across tab switches (and the game-stats detour) so re-entering Games
// restores the scroll position the viewer had built up.
private gameHistoryCache: PlayerGameHistoryCache | null = null;
private gamesScrollTop = 0;
private restoreGamesScrollAfterOpen = false;
// Bumped on every profile load so a superseded in-flight response is dropped.
private loadGeneration = 0;
protected modalConfig() {
return { maxWidth: "960px" };
return {
maxWidth: "960px",
tabs: [
{ key: "stats", label: translateText("account_modal.tab_stats") },
{ key: "games", label: translateText("account_modal.tab_games") },
],
};
}
protected renderHeaderSlot() {
@@ -58,14 +75,45 @@ export class PlayerProfileModal extends BaseModal {
});
}
protected renderBody() {
protected renderBody(tab: string) {
return html`
<div class="px-3 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-7">
${this.renderProfile()}
<div class="custom-scrollbar mr-1">
<div class="p-6">
${tab === "games" ? this.renderGames() : this.renderProfile()}
</div>
</div>
`;
}
private renderGames() {
const publicId = this.publicId;
if (!publicId) {
return html`
<div class="flex flex-col items-center justify-center p-12 text-center">
<span class="text-4xl mb-4">🎮</span>
<p class="text-white/40 text-sm">
${translateText("account_modal.no_games")}
</p>
</div>
`;
}
return html`
<player-game-history-view
.publicId=${publicId}
.cachedState=${this.gameHistoryCache?.publicId === publicId
? this.gameHistoryCache
: null}
@history-updated=${(e: CustomEvent<PlayerGameHistoryCache>) => {
this.gameHistoryCache = e.detail;
}}
@view-stats=${(e: CustomEvent<{ gameId: string }>) =>
this.openGameStats(e.detail.gameId)}
@view-game=${(e: CustomEvent<{ gameId: string }>) =>
this.viewGame(e.detail.gameId)}
></player-game-history-view>
`;
}
private renderProfile() {
if (this.loading) {
return this.renderLoadingSpinner(translateText("player_profile.loading"));
@@ -81,11 +129,9 @@ export class PlayerProfileModal extends BaseModal {
`;
}
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
</div>
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
`;
}
@@ -94,9 +140,33 @@ export class PlayerProfileModal extends BaseModal {
typeof args?.publicID === "string" && args.publicID.length > 0
? args.publicID
: null;
// Returning from the game-stats modal. The page router closed this modal
// underneath when the stats page showed, but onClose deliberately preserves
// state (like the account modal) — so restore the scroll and keep the
// game-history cache instead of refetching. Preserve even when the profile
// stats never loaded (still pending / failed): the Games tab loads
// independently, so its list should survive the detour regardless.
if (
this.restoreGamesScrollAfterOpen &&
publicId !== null &&
publicId === this.publicId
) {
this.restoreGamesScrollAfterOpen = false;
void this.restoreGamesScroll();
return;
}
// Fresh open (router/share link): clear any stale origin. The openFrom*
// helpers re-set it right after open() so back() routes home; the
// return-from-stats path above skips this and keeps the origin intact.
this.openedFrom = null;
this.publicId = publicId;
this.username = null;
this.statsTree = null;
this.gameHistoryCache = null;
this.gamesScrollTop = 0;
this.restoreGamesScrollAfterOpen = false;
this.loading = publicId !== null;
if (publicId !== null) {
void this.loadProfile(publicId);
@@ -104,35 +174,77 @@ export class PlayerProfileModal extends BaseModal {
}
private async loadProfile(publicId: string): Promise<void> {
const gen = ++this.loadGeneration;
const profile = await fetchPublicPlayerProfile(publicId);
// A late response must not clobber state after close or re-open.
if (this.publicId !== publicId) return;
// Drop a superseded response: a newer load started, or the modal moved to a
// different player. onClose no longer clears publicId, so the id check alone
// can't reject a stale same-player load started before an earlier close.
if (gen !== this.loadGeneration || this.publicId !== publicId) return;
this.loading = false;
this.statsTree = profile === false ? null : profile.stats;
this.username = profile === false ? null : (profile.username ?? null);
}
protected onClose(): void {
this.publicId = null;
this.username = null;
this.statsTree = null;
this.loading = false;
this.openedFrom = null;
// Intentionally preserves publicId/statsTree/history cache/scroll: the page
// router closes this modal when the game-stats page opens on top, and the
// return flow (returnToGames) needs that state intact. onOpen resets it for a
// genuinely new player. Mirrors the account modal.
protected onClose(): void {}
// Open the game-stats modal on top for a game in this player's history. Stash
// the scroll offset so returning restores it (see returnToGames()).
private openGameStats(gameId: string): void {
this.gamesScrollTop = this.modalEl?.getScrollTop() ?? 0;
const statsModal = document.querySelector<
HTMLElement & { openFromProfile(gameId: string): void }
>("game-stats-modal");
statsModal?.openFromProfile(gameId);
}
private viewGame(gameId: string): void {
this.close();
const encodedGameId = encodeURIComponent(gameId);
const newUrl = `/${ClientEnv.workerPath(gameId)}/game/${encodedGameId}`;
history.pushState({ join: gameId }, "", newUrl);
window.dispatchEvent(
new CustomEvent("join-changed", { detail: { gameId: encodedGameId } }),
);
}
// Called by the game-stats modal's back button when it was opened from here.
public returnToGames(): void {
this.restoreGamesScrollAfterOpen = true;
this.open({ publicID: this.publicId ?? undefined, tab: "games" });
}
private async restoreGamesScroll(): Promise<void> {
await this.updateComplete;
await this.modalEl?.updateComplete;
const historyView = this.querySelector<
HTMLElement & { updateComplete?: Promise<boolean> }
>("player-game-history-view");
await historyView?.updateComplete;
this.modalEl?.setScrollTop(this.gamesScrollTop);
}
// Origin is set after open() in every openFrom* helper: onOpen clears it, and
// back() only reads it later on the user's click, so assigning here survives
// to route the back button home (and survives the game-stats detour, which
// never re-runs onOpen's fresh path).
public openFromClan(publicId: string): void {
this.openedFrom = "clan";
this.open({ publicID: publicId });
this.openedFrom = "clan";
}
public openFromLeaderboard(publicId: string): void {
this.openedFrom = "leaderboard";
this.open({ publicID: publicId });
this.openedFrom = "leaderboard";
}
public openFromAccount(publicId: string): void {
this.openedFrom = "account";
this.open({ publicID: publicId });
this.openedFrom = "account";
}
private back(): void {
@@ -1,4 +1,9 @@
import { html, LitElement, type TemplateResult } from "lit";
import {
html,
LitElement,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
type PlayerGameModeFilter,
@@ -81,13 +86,25 @@ export class PlayerGameHistoryView extends LitElement {
connectedCallback() {
super.connectedCallback();
this.syncToPublicId();
}
// Hydrate from the cache when it matches the current player, otherwise load
// that player's history fresh.
private syncToPublicId() {
if (this.cachedState && this.cachedState.publicId === this.publicId) {
this.games = this.cachedState.games;
this.nextCursor = this.cachedState.nextCursor;
this.typeFilter = this.cachedState.typeFilter;
this.modeFilter = this.cachedState.modeFilter;
this.appendFailed = false;
this.loadState = "ok";
} else if (this.publicId) {
this.reload();
// Fresh player → show the default (All) view rather than inheriting the
// previous player's filters. No-op on first mount (already defaults).
this.typeFilter = "all";
this.modeFilter = "all";
void this.reload();
}
}
@@ -96,6 +113,21 @@ export class PlayerGameHistoryView extends LitElement {
this.teardownObserver();
}
willUpdate(changed: PropertyValues) {
// Reload when the host swaps in a different player after mount (e.g. a
// manual hash edit routes the profile modal to a new publicId). Lit reuses
// this element, so connectedCallback won't fire again. The undefined check
// skips the initial render, which connectedCallback already handled.
const previous = changed.get("publicId");
if (
changed.has("publicId") &&
previous !== undefined &&
previous !== this.publicId
) {
this.syncToPublicId();
}
}
updated() {
// The IntersectionObserver target only exists when there's more to load AND
// we're not mid-request — wire it up after each render so it tracks the
@@ -1,4 +1,4 @@
import { LitElement, PropertyValues, html } from "lit";
import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas";
import {
@@ -74,6 +74,51 @@ export class PlayerStatsTreeView extends LitElement {
: translateText("game_mode.teams");
}
private labelForType(t: GameType | "Ranked") {
return t === "Ranked"
? translateText("player_stats_tree.ranked")
: t === GameType.Public
? translateText("player_stats_tree.public")
: t === GameType.Private
? translateText("player_stats_tree.private")
: translateText("player_stats_tree.solo");
}
// A full-width filter row styled like the game-history tab: a pill container
// wrapping tab-style buttons that grow to fill the row.
private renderFilterRow(buttons: TemplateResult[]): TemplateResult {
return html`
<div
role="tablist"
class="flex flex-wrap gap-1 p-1 bg-white/5 border border-white/10 rounded-xl"
>
${buttons}
</div>
`;
}
private renderFilterTab(
label: string,
isActive: boolean,
onSelect: () => void,
title?: string,
): TemplateResult {
return html`
<button
type="button"
role="tab"
aria-selected=${isActive}
title=${title ?? nothing}
@click=${onSelect}
class="grow basis-20 px-3 py-1.5 text-xs font-bold uppercase tracking-wider whitespace-nowrap rounded-lg transition-colors ${isActive
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30"
: "text-white/50 hover:text-white hover:bg-white/5 border border-transparent"}"
>
${label}
</button>
`;
}
private labelForRankedType(r: RankedType) {
switch (r) {
case RankedType.OneVOne:
@@ -275,103 +320,58 @@ export class PlayerStatsTreeView extends LitElement {
return html`
<div class="flex flex-col gap-4">
<!-- Filters -->
<div
class="flex flex-wrap gap-2 items-center justify-between p-2 bg-black/20 rounded-lg border border-white/5"
>
<!-- Type selector -->
<div class="flex gap-1">
${types.map(
(t) => html`
<button
class="text-xs px-3 py-1.5 rounded-md border font-bold uppercase tracking-wider transition-all duration-200 ${this
.selectedType === t
? "bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/40"
: "bg-white/5 border-white/10 text-gray-400 hover:bg-white/10 hover:text-white"}"
@click=${() => this.setGameType(t)}
>
${t === "Ranked"
? translateText("player_stats_tree.ranked")
: t === GameType.Public
? translateText("player_stats_tree.public")
: t === GameType.Private
? translateText("player_stats_tree.private")
: translateText("player_stats_tree.solo")}
</button>
`,
)}
</div>
<div class="flex gap-2">
<!-- Ranked type selector -->
${this.selectedType === "Ranked" && rankedTypes.length
? html`<div
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
>
${rankedTypes.map(
(r) => html`
<button
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
.selectedRankedType === r
? "bg-white/20 text-white font-bold"
: "text-gray-400 hover:text-white"}"
@click=${() => this.setRankedType(r)}
>
${this.labelForRankedType(r)}
</button>
`,
)}
</div>`
: html``}
<!-- Mode selector -->
${modes.length
? html`<div
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
>
${modes.map(
(m) => html`
<button
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
.selectedMode === m
? "bg-white/20 text-white font-bold"
: "text-gray-400 hover:text-white"}"
@click=${() => this.setMode(m)}
title=${translateText("player_stats_tree.mode")}
>
${this.labelForMode(m)}
</button>
`,
)}
</div>`
: html``}
<!-- Difficulty selector -->
${!this.shouldMergeDifficulties && diffs.length
? html`<div
class="flex gap-1 bg-black/20 rounded-md p-1 border border-white/5"
>
${diffs.map(
(d) =>
html` <button
class="text-xs px-3 py-1 rounded-sm transition-colors ${this
.selectedDifficulty === d
? "bg-white/20 text-white font-bold"
: "text-gray-400 hover:text-white"}"
@click=${() => this.setDifficulty(d)}
title=${translateText("difficulty.difficulty")}
>
${translateText(`difficulty.${d.toLowerCase()}`)}
</button>`,
)}
</div>`
: html``}
</div>
<!-- Filters: a type row, then a context-dependent second row (ranked
type / mode / difficulty), styled like the game-history tab. -->
<div class="space-y-2">
${this.renderFilterRow(
types.map((t) =>
this.renderFilterTab(
this.labelForType(t),
this.selectedType === t,
() => this.setGameType(t),
),
),
)}
${this.selectedType === "Ranked" && rankedTypes.length
? this.renderFilterRow(
rankedTypes.map((r) =>
this.renderFilterTab(
this.labelForRankedType(r) ?? "",
this.selectedRankedType === r,
() => this.setRankedType(r),
),
),
)
: nothing}
${modes.length
? this.renderFilterRow(
modes.map((m) =>
this.renderFilterTab(
this.labelForMode(m),
this.selectedMode === m,
() => this.setMode(m),
translateText("player_stats_tree.mode"),
),
),
)
: nothing}
${!this.shouldMergeDifficulties && diffs.length
? this.renderFilterRow(
diffs.map((d) =>
this.renderFilterTab(
translateText(`difficulty.${d.toLowerCase()}`),
this.selectedDifficulty === d,
() => this.setDifficulty(d),
translateText("difficulty.difficulty"),
),
),
)
: nothing}
</div>
${leaf
? html`
<div class="space-y-6 mt-2">
<div class="space-y-6 border-t border-white/10 pt-3">
<player-stats-grid
.titles=${[
translateText("player_stats_tree.stats_wins"),
@@ -0,0 +1,327 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
const copyToClipboardMock = vi.hoisted(() =>
vi.fn(async (_text: string, onSuccess?: () => void) => onSuccess?.()),
);
vi.mock("../../src/client/Api", () => ({
fetchPublicPlayerProfile: vi.fn(async () => ({
createdAt: "2026-01-01T00:00:00.000Z",
stats: { Public: { "Free For All": {} } },
})),
fetchPublicPlayerGames: vi.fn(),
}));
vi.mock("src/client/ClientEnv", () => ({
ClientEnv: { workerPath: vi.fn(() => "w0") },
}));
vi.mock("../../src/client/TerrainMapFileLoader", () => ({
terrainMapFileLoader: {
getMapData: vi.fn(() => ({ webpPath: "/maps/world.webp" })),
},
}));
vi.mock("../../src/client/Utils", () => ({
getMapName: vi.fn((name: string) => name),
renderDuration: vi.fn((seconds: number) => `${seconds}s`),
translateText: vi.fn((key: string) => key),
copyToClipboard: copyToClipboardMock,
}));
vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => {
class FakeGameInfoView extends HTMLElement {}
if (!customElements.get("game-info-view")) {
customElements.define("game-info-view", FakeGameInfoView);
}
return { GameInfoView: FakeGameInfoView };
});
vi.mock(
"../../src/client/components/baseComponents/stats/PlayerStatsTree",
() => {
class FakePlayerStatsTreeView extends HTMLElement {}
if (!customElements.get("player-stats-tree-view")) {
customElements.define("player-stats-tree-view", FakePlayerStatsTreeView);
}
return { PlayerStatsTreeView: FakePlayerStatsTreeView };
},
);
class FakeIntersectionObserver {
constructor(_callback: IntersectionObserverCallback) {}
observe() {}
disconnect() {}
unobserve() {}
takeRecords() {
return [];
}
root = null;
rootMargin = "";
thresholds = [];
}
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
import { fetchPublicPlayerGames } from "../../src/client/Api";
import { GameStatsModal } from "../../src/client/GameStatsModal";
import { modalRouter } from "../../src/client/ModalRouter";
import { initNavigation } from "../../src/client/Navigation";
import { PlayerProfileModal } from "../../src/client/PlayerProfileModal";
import {
GameMapType,
GameMode,
GameType,
PlayerInfo,
PlayerType,
} from "../../src/core/game/Game";
import { setup } from "../util/Setup";
type ModalShell = HTMLElement & {
getScrollTop(): number;
setScrollTop(value: number): void;
updateComplete: Promise<boolean>;
};
async function waitForProfile(
modal: PlayerProfileModal,
assertion: () => void,
): Promise<void> {
await vi.waitFor(async () => {
await modal.updateComplete;
const shell = modal.querySelector("o-modal") as ModalShell | null;
await shell?.updateComplete;
const history = modal.querySelector("player-game-history-view") as
| (HTMLElement & { updateComplete: Promise<boolean> })
| null;
await history?.updateComplete;
assertion();
});
}
async function waitForStatsModal(
modal: GameStatsModal,
assertion: () => void,
): Promise<void> {
await vi.waitFor(async () => {
await modal.updateComplete;
const shell = modal.querySelector("o-modal") as ModalShell | null;
await shell?.updateComplete;
assertion();
});
}
describe("Profile Games stats navigation", () => {
let modal: PlayerProfileModal;
let statsModal: GameStatsModal;
let playPage: HTMLElement;
beforeAll(() => {
playPage = document.createElement("div");
playPage.id = "page-play";
document.body.appendChild(playPage);
initNavigation();
});
afterAll(() => {
playPage.remove();
});
beforeEach(async () => {
vi.clearAllMocks();
const game = await setup(
"world",
{
gameMap: GameMapType.World,
gameMode: GameMode.FFA,
gameType: GameType.Public,
},
[new PlayerInfo("Player", PlayerType.Human, null, "player-1")],
);
const player = game.player("player-1");
game.setWinner(player, game.stats().stats());
const gameConfig = game.config().gameConfig();
vi.mocked(fetchPublicPlayerGames).mockResolvedValue({
results: [
{
gameId: "game-1",
start: "2026-07-01T12:00:00.000Z",
durationSeconds: game.elapsedGameSeconds(),
map: gameConfig.gameMap,
mode: gameConfig.gameMode,
type: gameConfig.gameType.toLowerCase(),
playerTeams: gameConfig.playerTeams?.toString() ?? null,
rankedType: gameConfig.rankedType ?? "",
result: game.getWinner() === player ? "victory" : "defeat",
totalPlayers: game.players().length,
username: player.name(),
clanTag: player.info().clanTag,
},
],
nextCursor: null,
});
vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) });
history.replaceState(null, "", "/");
modalRouter.register("profile", {
tag: "player-profile-modal",
pageId: "page-profile",
});
modalRouter.register("stats", {
tag: "game-stats-modal",
pageId: "page-stats",
});
if (!customElements.get("player-profile-modal")) {
customElements.define("player-profile-modal", PlayerProfileModal);
}
if (!customElements.get("game-stats-modal")) {
customElements.define("game-stats-modal", GameStatsModal);
}
modal = document.createElement(
"player-profile-modal",
) as PlayerProfileModal;
modal.id = "page-profile";
modal.setAttribute("inline", "");
modal.className = "hidden page-content";
document.body.appendChild(modal);
statsModal = document.createElement("game-stats-modal") as GameStatsModal;
statsModal.id = "page-stats";
statsModal.setAttribute("inline", "");
statsModal.className = "hidden page-content";
document.body.appendChild(statsModal);
await modal.updateComplete;
await statsModal.updateComplete;
history.replaceState(null, "", "/#modal=profile&publicID=player-1");
expect(modalRouter.routeFromHash()).toBe(true);
await waitForProfile(modal, () => {
expect(modal.isOpen()).toBe(true);
});
modal.setActiveTab("games");
await waitForProfile(modal, () => {
expect(modal.querySelector("player-game-history-view")).not.toBeNull();
});
});
afterEach(() => {
window.showPage?.("page-play");
modal.remove();
statsModal.remove();
history.replaceState(null, "", "/");
vi.unstubAllGlobals();
});
it("drills into Stats and returns to the cached Games scroll position", async () => {
const shell = modal.querySelector("o-modal") as ModalShell;
shell.setScrollTop(420);
const statsButton = Array.from(modal.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "game_list.stats",
);
expect(statsButton).toBeTruthy();
statsButton!.click();
await waitForStatsModal(statsModal, () => {
expect(statsModal.isOpen()).toBe(true);
expect(statsModal.querySelector("game-info-view")).not.toBeNull();
});
expect(modal.isOpen()).toBe(false);
const statsView = statsModal.querySelector(
"game-info-view",
) as HTMLElement & { gameId: string };
expect(statsView.gameId).toBe("game-1");
expect(window.location.hash).toBe("#modal=stats&gameID=game-1");
const backButton = statsModal.querySelector(
'[slot="header"] button',
) as HTMLButtonElement;
backButton.click();
await waitForProfile(modal, () => {
expect(modal.querySelector("player-game-history-view")).not.toBeNull();
expect(shell.getScrollTop()).toBe(420);
});
expect(window.location.hash).toBe(
"#modal=profile&publicID=player-1&tab=games",
);
// History wasn't refetched on return — the cached list was reused.
expect(fetchPublicPlayerGames).toHaveBeenCalledOnce();
});
it("reloads the games history when the route changes to a different player", async () => {
// beforeEach opened player-1's history.
expect(fetchPublicPlayerGames).toHaveBeenCalledWith(
"player-1",
expect.anything(),
);
// Editing the hash to a different player re-routes the already-open modal.
// Lit reuses the mounted history view, so it must load the new player's
// games rather than keep showing player-1's.
history.replaceState(
null,
"",
"/#modal=profile&publicID=player-2&tab=games",
);
expect(modalRouter.routeFromHash()).toBe(true);
await waitForProfile(modal, () => {
expect(fetchPublicPlayerGames).toHaveBeenCalledWith(
"player-2",
expect.anything(),
);
});
});
it("resets filters to the default view when the route changes players", async () => {
// Pick a non-default type filter on player-1 (translateText is mocked to
// echo the key, so the Private tab's text is its label key).
const privateTab = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "account_modal.games_type_private",
);
expect(privateTab).toBeTruthy();
privateTab!.click();
await waitForProfile(modal, () => {
const call = vi
.mocked(fetchPublicPlayerGames)
.mock.calls.find(
(c) => c[0] === "player-1" && c[1]?.type === "private",
);
expect(call).toBeTruthy();
});
// Routing to a different player must fall back to the default (All) view
// rather than inheriting player-1's Private filter.
history.replaceState(
null,
"",
"/#modal=profile&publicID=player-2&tab=games",
);
expect(modalRouter.routeFromHash()).toBe(true);
await waitForProfile(modal, () => {
const player2Calls = vi
.mocked(fetchPublicPlayerGames)
.mock.calls.filter((c) => c[0] === "player-2");
// Every player-2 request (there should only be the one reset reload) must
// drop player-1's filters — asserting across all of them catches a stale
// first request even if a later one happens to be clean.
expect(player2Calls.length).toBeGreaterThan(0);
for (const [, opts] of player2Calls) {
expect(opts?.type).toBeUndefined();
expect(opts?.filter).toBeUndefined();
}
});
});
});