diff --git a/src/client/ClanModal.ts b/src/client/ClanModal.ts index 6ab325acc..3c479cc58 100644 --- a/src/client/ClanModal.ts +++ b/src/client/ClanModal.ts @@ -75,6 +75,12 @@ export class ClanModal extends BaseModal { // 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 gameHistoryScrollTop = 0; + // Moving to the dedicated stats page closes this inline modal. These + // one-shot flags keep that close/open pair from clearing or reloading the + // clan detail that the stats Back button returns to. + private preserveStateForGameStats = false; + private returningFromGameStats = false; private previousListTab: ListTab = "my-clans"; private get onListView(): boolean { @@ -206,6 +212,10 @@ export class ClanModal extends BaseModal { } protected onOpen(args?: Record): void { + if (this.returningFromGameStats) { + this.returningFromGameStats = false; + return; + } const targetTag = typeof args?.clan === "string" ? args.clan.trim() @@ -219,6 +229,7 @@ export class ClanModal extends BaseModal { } protected onClose(): void { + if (this.preserveStateForGameStats) return; this.activeTab = "my-clans"; this.previousListTab = "my-clans"; this.view = "list"; @@ -228,6 +239,8 @@ export class ClanModal extends BaseModal { this.browseCache = null; this.detailCache = null; this.gameHistoryCache = null; + this.gameHistoryScrollTop = 0; + this.returningFromGameStats = false; } private async loadMyClans(opts: { allowGuest?: boolean } = {}) { @@ -370,6 +383,8 @@ export class ClanModal extends BaseModal { @history-updated=${(e: CustomEvent) => { this.gameHistoryCache = e.detail; }} + @view-stats=${(e: CustomEvent<{ gameId: string }>) => + this.openGameStats(e.detail.gameId)} @close-clan-modal=${() => this.close()} >`; } @@ -502,6 +517,40 @@ export class ClanModal extends BaseModal { this.setActiveTab("overview"); } + private openGameStats(gameId: string): void { + const statsModal = document.querySelector< + HTMLElement & { openFromClan(gameId: string): void } + >("game-stats-modal"); + if (!statsModal) return; + + this.gameHistoryScrollTop = this.modalEl?.getScrollTop() ?? 0; + this.preserveStateForGameStats = true; + try { + statsModal.openFromClan(gameId); + } finally { + this.preserveStateForGameStats = false; + } + } + + public returnToGameHistory(): void { + const tag = this.selectedClanTag; + if (!tag) return; + + this.returningFromGameStats = true; + this.open({ clan: tag, tab: "game-history" }); + void this.restoreGameHistoryScroll(); + } + + private async restoreGameHistoryScroll(): Promise { + await this.updateComplete; + await this.modalEl?.updateComplete; + const historyView = this.querySelector< + HTMLElement & { updateComplete?: Promise } + >("clan-game-history-view"); + await historyView?.updateComplete; + this.modalEl?.setScrollTop(this.gameHistoryScrollTop); + } + private renderMyClans() { const hasClans = this.myClans.length > 0; const hasRequests = this.myPendingRequests.length > 0; diff --git a/src/client/GameStatsModal.ts b/src/client/GameStatsModal.ts index 1e88a83c6..f3bdaeb6e 100644 --- a/src/client/GameStatsModal.ts +++ b/src/client/GameStatsModal.ts @@ -10,7 +10,7 @@ export class GameStatsModal extends BaseModal { protected routerName = "stats"; @state() private gameId: string | null = null; - private openedFromAccount = false; + private openedFrom: "account" | "clan" | null = null; protected renderHeaderSlot() { return modalHeader({ @@ -39,21 +39,32 @@ export class GameStatsModal extends BaseModal { protected onClose(): void { this.gameId = null; - this.openedFromAccount = false; + this.openedFrom = null; } public openFromAccount(gameId: string): void { - this.openedFromAccount = true; + this.openedFrom = "account"; + this.open({ gameID: gameId }); + } + + public openFromClan(gameId: string): void { + this.openedFrom = "clan"; this.open({ gameID: gameId }); } private back(): void { - const returnToAccount = this.openedFromAccount; + const openedFrom = this.openedFrom; this.close(); - if (!returnToAccount) return; - - document - .querySelector("account-modal") - ?.returnToGames(); + if (openedFrom === "account") { + document + .querySelector("account-modal") + ?.returnToGames(); + } else if (openedFrom === "clan") { + document + .querySelector< + HTMLElement & { returnToGameHistory(): void } + >("clan-modal") + ?.returnToGameHistory(); + } } } diff --git a/src/client/components/clan/ClanGameHistoryView.ts b/src/client/components/clan/ClanGameHistoryView.ts index cc501f300..c39ebd8c6 100644 --- a/src/client/components/clan/ClanGameHistoryView.ts +++ b/src/client/components/clan/ClanGameHistoryView.ts @@ -197,6 +197,16 @@ export class ClanGameHistoryView extends LitElement { } } + private showStats(gameId: string) { + this.dispatchEvent( + new CustomEvent<{ gameId: string }>("view-stats", { + detail: { gameId }, + bubbles: true, + composed: true, + }), + ); + } + render() { if (this.loadState === "forbidden") { return html` @@ -423,13 +433,22 @@ export class ClanGameHistoryView extends LitElement { .showVisibilityToggle=${false} > - +
+ + +
apiMockFactory()); +vi.mock("../../src/client/ClanApi", () => clanApiMockFactory()); + +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`), + showToast: vi.fn(), + translateText: vi.fn((key: string) => key), +})); + +vi.mock("../../src/client/components/CopyButton", () => ({})); + +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 }; +}); + +class FakeIntersectionObserver { + constructor(_callback: IntersectionObserverCallback) {} + observe() {} + disconnect() {} + unobserve() {} + takeRecords() { + return []; + } + root = null; + rootMargin = ""; + thresholds = []; +} +vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); + +import { fetchClanGames } from "../../src/client/ClanApi"; +import { ClanModal } from "../../src/client/ClanModal"; +import { GameStatsModal } from "../../src/client/GameStatsModal"; +import { modalRouter } from "../../src/client/ModalRouter"; +import { initNavigation } from "../../src/client/Navigation"; + +type ModalShell = HTMLElement & { + getScrollTop(): number; + setScrollTop(value: number): void; + updateComplete: Promise; +}; + +async function waitForClanModal( + modal: ClanModal, + assertion: () => void, +): Promise { + await vi.waitFor(async () => { + await modal.updateComplete; + const shell = modal.querySelector("o-modal") as ModalShell | null; + await shell?.updateComplete; + const gameHistory = modal.querySelector("clan-game-history-view") as + | (HTMLElement & { updateComplete: Promise }) + | null; + await gameHistory?.updateComplete; + assertion(); + }); +} + +async function waitForStatsModal( + modal: GameStatsModal, + assertion: () => void, +): Promise { + await vi.waitFor(async () => { + await modal.updateComplete; + const shell = modal.querySelector("o-modal") as ModalShell | null; + await shell?.updateComplete; + assertion(); + }); +} + +describe("Clan Games stats navigation", () => { + let modal: ClanModal; + 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(); + (fetchClanGames as ReturnType).mockResolvedValue({ + results: [ + { + gameId: "clan-game-1", + start: "2026-07-01T12:00:00.000Z", + durationSeconds: 600, + map: "World", + mode: "Team", + playerTeams: "Duos", + rankedType: undefined, + result: "victory", + totalPlayers: 8, + clanPlayers: [ + { publicId: "player-1", username: "Player", won: true }, + ], + }, + ], + nextCursor: null, + }); + history.replaceState(null, "", "/"); + modalRouter.register("clan", { + tag: "clan-modal", + pageId: "page-clan", + }); + modalRouter.register("stats", { + tag: "game-stats-modal", + pageId: "page-stats", + }); + if (!customElements.get("clan-modal")) { + customElements.define("clan-modal", ClanModal); + } + if (!customElements.get("game-stats-modal")) { + customElements.define("game-stats-modal", GameStatsModal); + } + + modal = document.createElement("clan-modal") as ClanModal; + modal.id = "page-clan"; + 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; + window.showPage?.("page-clan"); + modal.open({ clan: "TST" }); + await waitForClanModal(modal, () => { + const shell = modal.querySelector("o-modal") as ModalShell; + expect(shell.shadowRoot?.textContent).toContain( + "clan_modal.tab_game_history", + ); + }); + modal.setActiveTab("game-history"); + await waitForClanModal(modal, () => { + expect(modal.querySelector("clan-game-history-view")).not.toBeNull(); + expect(modal.textContent).toContain("game_list.stats"); + }); + }); + + afterEach(() => { + window.showPage?.("page-play"); + modal.remove(); + statsModal.remove(); + history.replaceState(null, "", "/"); + }); + + it("drills into Stats and returns to the cached clan 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("clan-game-1"); + expect(window.location.hash).toBe("#modal=stats&gameID=clan-game-1"); + + const backButton = statsModal.querySelector( + '[slot="header"] button', + ) as HTMLButtonElement; + backButton.click(); + await waitForClanModal(modal, () => { + expect(modal.isOpen()).toBe(true); + expect(modal.querySelector("clan-game-history-view")).not.toBeNull(); + expect(shell.getScrollTop()).toBe(420); + }); + expect(window.location.hash).toBe("#modal=clan&clan=TST&tab=game-history"); + + expect(fetchClanGames).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/client/clan/ClanGameHistoryView.test.ts b/tests/client/clan/ClanGameHistoryView.test.ts index fd2ac4a8a..4e53c144a 100644 --- a/tests/client/clan/ClanGameHistoryView.test.ts +++ b/tests/client/clan/ClanGameHistoryView.test.ts @@ -536,6 +536,37 @@ describe("ClanGameHistoryView", () => { }); }); + describe("viewStats", () => { + it("emits a bubbling, composed view-stats event with the game id", async () => { + mockFetch(() => + Promise.resolve(okPage([makeGame({ gameId: "stats-game" })])), + ); + const el = await mountView(); + await flushAsync(el); + + const statsButton = Array.from(el.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "game_list.stats", + ); + expect(statsButton).toBeTruthy(); + + const events: CustomEvent<{ gameId: string }>[] = []; + const handleViewStats = (event: Event) => { + events.push(event as CustomEvent<{ gameId: string }>); + }; + document.body.addEventListener("view-stats", handleViewStats); + try { + statsButton!.click(); + } finally { + document.body.removeEventListener("view-stats", handleViewStats); + } + + expect(events).toHaveLength(1); + expect(events[0].detail).toEqual({ gameId: "stats-game" }); + expect(events[0].bubbles).toBe(true); + expect(events[0].composed).toBe(true); + }); + }); + describe("watchReplay", () => { it("pushes a /game/:id URL and emits close-clan-modal + join-changed", async () => { mockFetch(() =>