From 580460c9692aea2bdc1dce97eba1bbee378e270d Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:12:37 +0100 Subject: [PATCH] Account Modal - Games Tab (#4473) Continuation of https://github.com/openfrontio/infra/pull/386, adds play games sessions image - [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 regression is found: w.o.n --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/API.md | 52 ++ resources/lang/en.json | 18 +- src/client/AccountModal.ts | 48 +- src/client/Api.ts | 52 ++ .../baseComponents/stats/GameHistoryDates.ts | 101 ++++ .../baseComponents/stats/GameList.ts | 156 ----- .../baseComponents/stats/GameTypeLabels.ts | 57 ++ .../stats/PlayerGameHistoryView.ts | 541 ++++++++++++++++++ .../components/clan/ClanGameHistoryView.ts | 144 +---- src/core/ApiSchemas.ts | 65 ++- tests/ApiSchemas.test.ts | 176 ++++++ 11 files changed, 1077 insertions(+), 333 deletions(-) create mode 100644 src/client/components/baseComponents/stats/GameHistoryDates.ts delete mode 100644 src/client/components/baseComponents/stats/GameList.ts create mode 100644 src/client/components/baseComponents/stats/GameTypeLabels.ts create mode 100644 src/client/components/baseComponents/stats/PlayerGameHistoryView.ts create mode 100644 tests/ApiSchemas.test.ts diff --git a/docs/API.md b/docs/API.md index a3a07ce59..d37707957 100644 --- a/docs/API.md +++ b/docs/API.md @@ -120,6 +120,58 @@ GET https://api.openfront.io/public/player/:playerId/sessions curl "https://api.openfront.io/public/player/HabCsQYR/sessions" ``` +### Get Player Games + +Retrieve a player's personal game history, newest first. Uses keyset (cursor) +pagination rather than the `page`/`limit` scheme used elsewhere. + +**Endpoint:** + +``` +GET https://api.openfront.io/public/player/:playerId/games +``` + +**Query Parameters:** + +- `filter` (optional): Mode bucket, one of `[ffa, team, hvn, ranked]`. Omit for all modes. +- `type` (optional): Game type, one of `[public, private, singleplayer]`. Omit for all types. `filter` and `type` are orthogonal and may be combined. +- `cursor` (optional): Opaque continuation token. Pass the `nextCursor` value from the previous response verbatim to fetch the next page — do not construct or parse it. + +**Response:** + +```json +{ + "results": [ + { + "gameId": "abc123", + "start": "2026-05-17T21:04:00.000Z", + "durationSeconds": 1234, + "map": "World", + "mode": "Team", + "type": "Public", + "playerTeams": "Duos", + "rankedType": "unranked", + "result": "victory", + "totalPlayers": 8, + "username": "alice", + "clanTag": "ABC" + } + ], + "nextCursor": "opaque-token" +} +``` + +- `result` is one of `[victory, defeat, incomplete]` (`incomplete` = no recorded winner). +- `playerTeams`, `totalPlayers`, and `clanTag` may be `null`. +- `nextCursor` is `null` when there are no more games. +- `username`/`clanTag` reflect the identity the player used in that specific game. + +**Example:** + +```bash +curl "https://api.openfront.io/public/player/HabCsQYR/games?filter=team&type=public" +``` + ## Clans ### Clan Leaderboard diff --git a/resources/lang/en.json b/resources/lang/en.json index af7251ddd..93c5ceb33 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -11,6 +11,12 @@ "enter_email_address": "Please enter an email address", "failed_to_send_recovery_email": "Failed to send recovery email", "fetching_account": "Fetching account information...", + "games_clan_tag": "Clan Tag", + "games_result_incomplete": "Incomplete", + "games_type_private": "Private", + "games_type_public": "Public", + "games_type_singleplayer": "Singleplayer", + "games_username": "Username", "get_magic_link": "Get Magic Link", "link_discord": "Link Discord Account", "linked_account": "Logged in as {account_name}", @@ -187,6 +193,8 @@ "history_clan_players": "Clan players", "history_clan_players_value": "{clanCount} / {total} total", "history_clan_winners": "Clan Member Winners", + "history_date_at": "{date} {time}", + "history_date_full": "{day} {month} {year}", "history_duration": "Duration", "history_empty": "No games played yet.", "history_end_of_history": "End of history.", @@ -494,15 +502,7 @@ "war": "War" }, "game_list": { - "details": "Details", - "difficulty": "Difficulty", - "game_id": "Game ID", - "map": "Map", - "mode": "Mode", - "ranking": "Ranking", - "recent_games": "Recent Games", - "replay": "Replay", - "type": "Type" + "ranking": "Ranking" }, "game_mode": { "ffa": "Free for All", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index e1aa2f0d1..d757f5136 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -1,17 +1,14 @@ import { html, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; -import { - PlayerGame, - PlayerStatsTree, - UserMeResponse, -} from "../core/ApiSchemas"; +import { PlayerStatsTree, UserMeResponse } from "../core/ApiSchemas"; import { assetUrl } from "../core/AssetUrls"; import { Cosmetics } from "../core/CosmeticSchemas"; import { fetchPlayerById, getUserMe } from "./Api"; import { discordLogin, logOut, sendMagicLink } from "./Auth"; import "./components/baseComponents/stats/DiscordUserHeader"; -import "./components/baseComponents/stats/GameList"; +import "./components/baseComponents/stats/PlayerGameHistoryView"; +import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; import "./components/baseComponents/stats/PlayerStatsTable"; import "./components/baseComponents/stats/PlayerStatsTree"; import { BaseModal } from "./components/BaseModal"; @@ -34,7 +31,8 @@ export class AccountModal extends BaseModal { private userMeResponse: UserMeResponse | null = null; private cosmetics: Cosmetics | null = null; private statsTree: PlayerStatsTree | null = null; - private recentGames: PlayerGame[] = []; + // Preserves the Games tab's accumulated list + cursor across tab switches. + private gameHistoryCache: PlayerGameHistoryCache | null = null; constructor() { super(); @@ -42,14 +40,19 @@ export class AccountModal extends BaseModal { document.addEventListener("userMeResponse", (event: Event) => { const customEvent = event as CustomEvent; if (customEvent.detail) { + const previousPublicId = this.userMeResponse?.player?.publicId; this.userMeResponse = customEvent.detail as UserMeResponse; - if (this.userMeResponse?.player?.publicId === undefined) { + // Reset whenever the player identity changes (login, or switching to a + // different account) so stats/history from the previous player don't + // linger. + if (this.userMeResponse?.player?.publicId !== previousPublicId) { this.statsTree = null; - this.recentGames = []; + this.gameHistoryCache = null; + this.requestUpdate(); } } else { this.statsTree = null; - this.recentGames = []; + this.gameHistoryCache = null; this.requestUpdate(); } }); @@ -193,23 +196,25 @@ export class AccountModal extends BaseModal { } private renderGamesTab(): TemplateResult { - if (this.recentGames.length === 0) { + const publicId = this.userMeResponse?.player?.publicId ?? ""; + if (!publicId) { return this.renderEmptyState( "🎮", translateText("account_modal.no_games"), ); } return html` -
-

- 🎮 - ${translateText("game_list.recent_games")} -

- void this.viewGame(id)} - > -
+ ) => { + this.gameHistoryCache = e.detail; + }} + @view-game=${(e: CustomEvent<{ gameId: string }>) => + void this.viewGame(e.detail.gameId)} + > `; } @@ -467,7 +472,6 @@ export class AccountModal extends BaseModal { return; } - this.recentGames = data.games; this.statsTree = data.stats; this.requestUpdate(); diff --git a/src/client/Api.ts b/src/client/Api.ts index 8fc5f0143..b83531dcc 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -3,8 +3,12 @@ import { z } from "zod"; import type { NewsItem } from "../core/ApiSchemas"; import { NewsItemSchema, + PlayerGameModeFilter, + PlayerGameTypeFilter, PlayerProfile, PlayerProfileSchema, + PublicPlayerGamesResponse, + PublicPlayerGamesResponseSchema, RankedLeaderboardResponse, RankedLeaderboardResponseSchema, UserMeResponse, @@ -53,6 +57,54 @@ export async function fetchPlayerById( } } +// GET /public/player/:publicId/games — keyset-paginated personal game history. +// Public (no auth). `filter` (mode bucket) and `type` (game-type split) are +// orthogonal; `cursor` is the opaque token from the previous response's +// nextCursor — round-trip verbatim, never construct it. +export async function fetchPublicPlayerGames( + publicId: string, + opts: { + filter?: PlayerGameModeFilter; + type?: PlayerGameTypeFilter; + cursor?: string; + } = {}, +): Promise { + try { + const url = new URL( + `${getApiBase()}/public/player/${encodeURIComponent(publicId)}/games`, + ); + if (opts.filter) url.searchParams.set("filter", opts.filter); + if (opts.type) url.searchParams.set("type", opts.type); + if (opts.cursor) url.searchParams.set("cursor", opts.cursor); + + const res = await fetch(url.toString(), { + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + console.warn( + "fetchPublicPlayerGames: unexpected status", + res.status, + res.statusText, + ); + return { error: "failed" }; + } + + const json = await res.json(); + const parsed = PublicPlayerGamesResponseSchema.safeParse(json); + if (!parsed.success) { + console.warn( + "fetchPublicPlayerGames: Zod validation failed", + parsed.error, + ); + return { error: "failed" }; + } + return parsed.data; + } catch (err) { + console.warn("fetchPublicPlayerGames: request failed", err); + return { error: "failed" }; + } +} + let __userMe: Promise | null = null; export async function getUserMe(): Promise { if (__userMe !== null) { diff --git a/src/client/components/baseComponents/stats/GameHistoryDates.ts b/src/client/components/baseComponents/stats/GameHistoryDates.ts new file mode 100644 index 000000000..97766e6c0 --- /dev/null +++ b/src/client/components/baseComponents/stats/GameHistoryDates.ts @@ -0,0 +1,101 @@ +import { translateText } from "../../../Utils"; + +// Shared date helpers for paginated game-history lists (clan + player). Kept +// generic over `{ start }` so both the clan and player history views can group +// and label rows identically without duplicating the timezone/formatting logic. + +export type DayGroup = { day: string; items: T[] }; + +// Groups rows by their local-day key while preserving server order. Server +// ordering is already newest-first, so within a group we keep arrival order. +export function groupByDay( + items: T[], +): DayGroup[] { + const groups: DayGroup[] = []; + for (const item of items) { + const day = dayKey(item.start); + const last = groups[groups.length - 1]; + if (last && last.day === day) { + last.items.push(item); + } else { + groups.push({ day, items: [item] }); + } + } + return groups; +} + +function dayKey(iso: string): string { + const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return iso; + // 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; + +export 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). The month uses + // our own translation keys, and the whole day/month/year template goes + // through a key too so other locales can reorder it (e.g. "May 17, 2026"). + // day/year are passed as strings so ICU doesn't apply number grouping to the + // year (e.g. "2,026"). + const month = translateText(MONTH_KEYS[d.getMonth()]); + return translateText("clan_modal.history_date_full", { + day: String(d.getDate()), + month, + year: String(d.getFullYear()), + }); +} + +export 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 }); + } + // Join the localized date and time through a key so locales control the + // order/separator (parallels history_today_at). + return translateText("clan_modal.history_date_at", { + date: date.toLocaleDateString(), + time, + }); +} diff --git a/src/client/components/baseComponents/stats/GameList.ts b/src/client/components/baseComponents/stats/GameList.ts deleted file mode 100644 index 74f4b526b..000000000 --- a/src/client/components/baseComponents/stats/GameList.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { LitElement, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { PlayerGame } from "../../../../core/ApiSchemas"; -import { GameMode } from "../../../../core/game/Game"; -import { GameInfoModal } from "../../../GameInfoModal"; -import { translateText } from "../../../Utils"; -import "../../CopyButton"; - -@customElement("game-list") -export class GameList extends LitElement { - createRenderRoot() { - return this; - } - - @property({ type: Array }) games: PlayerGame[] = []; - @property({ attribute: false }) onViewGame?: (id: string) => void; - - @state() private expandedGameId: string | null = null; - - private toggle(gameId: string) { - this.expandedGameId = this.expandedGameId === gameId ? null : gameId; - } - - private showRanking(gameId: string) { - const gameInfoModal = document.querySelector( - "game-info-modal", - ) as GameInfoModal; - - if (!gameInfoModal) { - console.warn("Game info modal element not found"); - } else { - gameInfoModal.loadGame(gameId); - gameInfoModal.open(); - } - } - - render() { - return html`
-
- ${this.games.map( - (game) => html` -
-
-
- -
-
- ${new Date(game.start).toLocaleDateString()} -
-
- ${translateText("game_list.mode")}: - ${game.mode === GameMode.FFA - ? translateText("game_mode.ffa") - : html`${translateText("game_mode.teams")}`} -
-
-
- -
- - - -
-
- -
-
-
-
- ${translateText("game_list.game_id")} -
- -
-
-
- ${translateText("game_list.map")} -
-
${game.map}
-
-
-
- ${translateText("game_list.difficulty")} -
-
${game.difficulty}
-
-
-
- ${translateText("game_list.type")} -
-
${game.type}
-
-
-
-
- `, - )} -
-
`; - } -} diff --git a/src/client/components/baseComponents/stats/GameTypeLabels.ts b/src/client/components/baseComponents/stats/GameTypeLabels.ts new file mode 100644 index 000000000..97c604faf --- /dev/null +++ b/src/client/components/baseComponents/stats/GameTypeLabels.ts @@ -0,0 +1,57 @@ +import { GameMode } from "../../../../core/game/Game"; +import { translateText } from "../../../Utils"; + +// Shared game-type labelling for the paginated history lists (clan + player). +// Both ClanGame and PublicPlayerGame satisfy this structural shape, so the +// label logic lives in one place and can't drift between the two views. +export type GameTypeFields = { + mode?: string; + playerTeams?: string | null; + rankedType?: string; +}; + +// FFA is "no team grouping". Match the server's `GameMode.FFA` enum literal +// first, then fall back to an absent `playerTeams` ONLY when `mode` itself is +// missing (older rows / server bug). Crucially we do NOT treat `playerTeams +// === null` as FFA on its own: legacy Team games store `player_teams = NULL` +// (the server buckets those into "team"), so a null-with-a-mode row is still a +// Team game and must not be relabelled FFA. +export function isFfa(game: GameTypeFields): boolean { + if (game.mode === GameMode.FFA) return true; + if ( + game.mode === undefined && + (game.playerTeams === null || game.playerTeams === undefined) + ) { + return true; + } + return false; +} + +// 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. +export function formatGameType(game: GameTypeFields): string { + if (game.rankedType && game.rankedType !== "unranked") { + // `rankedType` (e.g. "1v1") is a server-authoritative token interpolated + // verbatim — there is only one value today and it reads identically in + // every locale. If more ranked variants appear, map them through keys. + 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"); +} diff --git a/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts b/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts new file mode 100644 index 000000000..26ba36d87 --- /dev/null +++ b/src/client/components/baseComponents/stats/PlayerGameHistoryView.ts @@ -0,0 +1,541 @@ +import { html, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { + type PlayerGameModeFilter, + type PlayerGameTypeFilter, + type PublicPlayerGame, +} from "../../../../core/ApiSchemas"; +import { GameMapType } from "../../../../core/game/Game"; +import { fetchPublicPlayerGames } from "../../../Api"; +import { GameInfoModal } from "../../../GameInfoModal"; +import { terrainMapFileLoader } from "../../../TerrainMapFileLoader"; +import { getMapName, renderDuration, translateText } from "../../../Utils"; +import { renderLoadingSpinner } from "../../BaseModal"; +import "../../CopyButton"; +import { + formatAbsoluteTime, + formatDayHeader, + groupByDay, +} from "./GameHistoryDates"; +import { formatGameType } from "./GameTypeLabels"; + +type TypeKey = PlayerGameTypeFilter | "all"; +type ModeKey = PlayerGameModeFilter | "all"; + +// Top row — game-type split (orthogonal to the mode row below). "All" reuses +// the clan filter label; the rest are account-modal-specific. +const TYPE_TABS: { key: TypeKey; labelKey: string }[] = [ + { key: "all", labelKey: "clan_modal.history_filter_all" }, + { key: "public", labelKey: "account_modal.games_type_public" }, + { key: "private", labelKey: "account_modal.games_type_private" }, + { key: "singleplayer", labelKey: "account_modal.games_type_singleplayer" }, +]; + +// Bottom row — mode buckets. Mirrors the clan history filter exactly (FFA and +// Team reuse the type-label keys; HvN/Ranked have shorter filter labels). +const MODE_TABS: { key: ModeKey; labelKey: string }[] = [ + { key: "all", labelKey: "clan_modal.history_filter_all" }, + { key: "ffa", labelKey: "clan_modal.history_type_ffa" }, + { key: "team", labelKey: "clan_modal.history_type_team" }, + { key: "hvn", labelKey: "clan_modal.history_filter_hvn" }, + { key: "ranked", labelKey: "clan_modal.history_filter_ranked" }, +]; + +// Cache survives a tab switch within the modal: keep the full accumulated list +// plus the cursor + both active filters so re-entering the Games tab restores +// the scroll position the user had built up. +export type PlayerGameHistoryCache = { + publicId: string; + typeFilter: TypeKey; + modeFilter: ModeKey; + games: PublicPlayerGame[]; + nextCursor: string | null; +}; + +@customElement("player-game-history-view") +export class PlayerGameHistoryView extends LitElement { + createRenderRoot() { + return this; + } + + @property() publicId = ""; + @property({ type: Object }) cachedState: PlayerGameHistoryCache | null = null; + + @state() private games: PublicPlayerGame[] = []; + @state() private nextCursor: string | null = null; + @state() private loading = false; + // Distinct from `loading` because it controls the inline footer spinner + // rather than replacing the whole list with a centred spinner. + @state() private loadingMore = false; + @state() private loadState: "ok" | "failed" = "ok"; + @state() private appendFailed = false; + @state() private typeFilter: TypeKey = "all"; + @state() private modeFilter: ModeKey = "all"; + private asyncGeneration = 0; + private sentinel: HTMLElement | null = null; + private observer: IntersectionObserver | null = null; + // Memoise grouping against the current `games` reference so re-renders + // triggered by unrelated state (e.g. `loadingMore` flipping) don't re-walk + // the accumulated list each time. + private groupedFor: PublicPlayerGame[] | null = null; + private grouped: ReturnType> = []; + + connectedCallback() { + super.connectedCallback(); + 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; + } else if (this.publicId) { + this.reload(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.teardownObserver(); + } + + 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 + // current sentinel node. + this.ensureObserver(); + } + + // Hard reset on filter change — drop cached games and start fresh from the + // newest game. + private async reload() { + this.games = []; + this.nextCursor = null; + this.appendFailed = false; + await this.load({ append: false }); + } + + private setTypeFilter(filter: TypeKey) { + if (filter === this.typeFilter) return; + this.typeFilter = filter; + this.reload(); + } + + private setModeFilter(filter: ModeKey) { + if (filter === this.modeFilter) return; + this.modeFilter = filter; + this.reload(); + } + + private async load({ append }: { append: boolean }) { + if (!this.publicId) return; + const gen = ++this.asyncGeneration; + if (append) { + this.loadingMore = true; + this.appendFailed = false; + } else { + this.loading = true; + this.loadState = "ok"; + this.loadingMore = false; + } + // Append uses the saved cursor; a fresh load starts from the newest game + // (no cursor). + const cursor = append ? (this.nextCursor ?? undefined) : undefined; + const res = await fetchPublicPlayerGames(this.publicId, { + filter: this.modeFilter === "all" ? undefined : this.modeFilter, + type: this.typeFilter === "all" ? undefined : this.typeFilter, + cursor, + }); + if (gen !== this.asyncGeneration) return; + if (append) this.loadingMore = false; + else this.loading = false; + if ("error" in res) { + if (append) { + // Keep the games we already have; just surface a retry footer. + this.appendFailed = true; + } else { + this.loadState = "failed"; + this.games = []; + this.nextCursor = null; + } + return; + } + this.games = append ? [...this.games, ...res.results] : res.results; + this.nextCursor = res.nextCursor; + this.dispatchEvent( + new CustomEvent("history-updated", { + detail: { + publicId: this.publicId, + typeFilter: this.typeFilter, + modeFilter: this.modeFilter, + games: this.games, + nextCursor: this.nextCursor, + }, + bubbles: true, + composed: true, + }), + ); + } + + private ensureObserver() { + const sentinel = this.querySelector("[data-scroll-sentinel]"); + if (sentinel === this.sentinel) return; + this.teardownObserver(); + this.sentinel = sentinel; + if (!sentinel) return; + this.observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + if (this.loading || this.loadingMore) continue; + if (this.nextCursor === null) continue; + if (this.appendFailed) continue; + void this.load({ append: true }); + } + }); + this.observer.observe(sentinel); + } + + private teardownObserver() { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + this.sentinel = null; + } + + private watchReplay(gameId: string) { + // Navigation + modal close live in the host modal; just hand it the id. + this.dispatchEvent( + new CustomEvent<{ gameId: string }>("view-game", { + detail: { gameId }, + bubbles: true, + composed: true, + }), + ); + } + + // Opens the game-info ranking overlay on top of the account modal. The modal + // is a global singleton in the document (queried the same way as Main.ts), + // so we don't close the account modal — the overlay layers above it. + private showRanking(gameId: string) { + const gameInfoModal = document.querySelector( + "game-info-modal", + ) as GameInfoModal | null; + if (!gameInfoModal) { + console.warn("Game info modal element not found"); + return; + } + void gameInfoModal.loadGame(gameId); + gameInfoModal.open(); + } + + render() { + return html`
+ ${this.renderFilters()}${this.renderBody()} +
`; + } + + private renderFilters(): TemplateResult { + return html` +
+ ${this.renderFilterRow(TYPE_TABS, this.typeFilter, (k) => + this.setTypeFilter(k as TypeKey), + )} + ${this.renderFilterRow(MODE_TABS, this.modeFilter, (k) => + this.setModeFilter(k as ModeKey), + )} +
+ `; + } + + private renderFilterRow( + tabs: { key: string; labelKey: string }[], + active: string, + onSelect: (key: string) => void, + ): TemplateResult { + return html` +
+ ${tabs.map((tab) => { + const isActive = active === tab.key; + // "All" gets a full row on mobile (basis-full) and normal sizing on + // sm+. The others use basis-20 so longer labels stay comfortable and + // flex-wrap drops them to a second line when needed. + const basis = + tab.key === "all" ? "basis-full sm:basis-20" : "basis-20"; + return html` + + `; + })} +
+ `; + } + + 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. Cached against the `games` + // reference; `load()` always assigns a fresh array, so identity comparison + // is safe. + if (this.groupedFor !== this.games) { + this.grouped = groupByDay(this.games); + this.groupedFor = this.games; + } + const groups = this.grouped; + return html` +
+ ${groups.map( + (group) => html` +
+
+ +

+ ${formatDayHeader(group.day)} +

+ +
+
+ ${group.items.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 sits adjacent to it (not *as* it) + // so the sentinel node identity stays stable across pages — otherwise every + // fetch tears down and recreates the IntersectionObserver. + return html` +
+ + ${this.loadingMore ? renderLoadingSpinner() : ""} +
+ `; + } + + private renderGameRow(game: PublicPlayerGame): 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; + + return html` +
+ ${mapWebpPath + ? html`
+ ${mapDisplayName +
+ ${mapDisplayName + ? html`
+ ${mapDisplayName} +
` + : ""} +
+ ${this.renderResultBadge(game)} +
+
+ ${formatAbsoluteTime(game.start)} +
+
` + : ""} +
+
+ ${translateText("clan_modal.history_game_id")}: + +
+
+ + +
+
+
+ ${this.renderField( + translateText("account_modal.games_clan_tag"), + game.clanTag ?? "—", + )} + ${this.renderField( + translateText("account_modal.games_username"), + game.username, + )} +
+
+ ${this.renderField( + translateText("clan_modal.history_game_type"), + formatGameType(game), + )} + ${mapWebpPath + ? "" + : this.renderField( + translateText("clan_modal.history_map"), + mapDisplayName ?? "—", + )} + ${this.renderField( + translateText("clan_modal.history_players"), + game.totalPlayers === null ? "—" : `${game.totalPlayers}`, + )} + ${this.renderField( + translateText("clan_modal.history_duration"), + renderDuration(game.durationSeconds), + )} +
+
+ `; + } + + private renderField(label: string, value: string): TemplateResult { + return html` +
+
+ ${label} +
+
${value}
+
+ `; + } + + // The player's own outcome. "incomplete" (no recorded winner) gets a neutral + // badge rather than collapsing into Defeat, so an unfinished game isn't + // mislabelled as a loss in a personal history. + private renderResultBadge(game: PublicPlayerGame): TemplateResult { + let label: string; + let tint: string; + if (game.result === "victory") { + label = translateText("clan_modal.history_result_victory"); + tint = "text-white bg-green-600 border-green-500"; + } else if (game.result === "defeat") { + label = translateText("clan_modal.history_result_defeat"); + tint = "text-white bg-red-600 border-red-500"; + } else { + label = translateText("account_modal.games_result_incomplete"); + tint = "text-white bg-gray-500 border-gray-400"; + } + return html`${label}`; + } +} diff --git a/src/client/components/clan/ClanGameHistoryView.ts b/src/client/components/clan/ClanGameHistoryView.ts index 657e0c826..cc501f300 100644 --- a/src/client/components/clan/ClanGameHistoryView.ts +++ b/src/client/components/clan/ClanGameHistoryView.ts @@ -1,6 +1,6 @@ import { html, LitElement, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { GameMapType, GameMode } from "../../../core/game/Game"; +import { GameMapType } from "../../../core/game/Game"; import { type ClanGame, type ClanGameFilter, @@ -10,6 +10,12 @@ import { ClientEnv } from "../../ClientEnv"; import { terrainMapFileLoader } from "../../TerrainMapFileLoader"; import { getMapName, renderDuration, translateText } from "../../Utils"; import "../CopyButton"; +import { + formatAbsoluteTime, + formatDayHeader, + groupByDay, +} from "../baseComponents/stats/GameHistoryDates"; +import { formatGameType, isFfa } from "../baseComponents/stats/GameTypeLabels"; import { renderLoadingSpinner, showToast } from "./ClanShared"; type FilterKey = ClanGameFilter | "all"; @@ -60,7 +66,7 @@ export class ClanGameHistoryView extends LitElement { // triggered by unrelated state (e.g. `loadingMore` flipping) don't // re-walk the accumulated list each time. private groupedFor: ClanGame[] | null = null; - private grouped: DayGroup[] = []; + private grouped: ReturnType> = []; connectedCallback() { super.connectedCallback(); @@ -279,7 +285,7 @@ export class ClanGameHistoryView extends LitElement { // 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.grouped = groupByDay(this.games); this.groupedFor = this.games; } const groups = this.grouped; @@ -300,7 +306,7 @@ export class ClanGameHistoryView extends LitElement {
- ${group.games.map((game) => this.renderGameRow(game))} + ${group.items.map((game) => this.renderGameRow(game))}
`, @@ -430,7 +436,7 @@ export class ClanGameHistoryView extends LitElement { > ${this.renderField( translateText("clan_modal.history_game_type"), - this.formatGameType(game), + formatGameType(game), )} ${mapWebpPath ? "" @@ -585,132 +591,4 @@ export class ClanGameHistoryView extends LitElement { }), ); } - - // 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/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 828306b2c..bb8dad117 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { base64urlToUuid } from "./Base64"; import { ClanTagSchema } from "./Schemas"; import { BigIntStringSchema, PlayerStatsSchema } from "./StatsSchemas"; -import { Difficulty, GameMode, GameType, RankedType } from "./game/Game"; +import { Difficulty, GameMode, RankedType } from "./game/Game"; function stripClanTagFromUsername(username: string): string { return username.replace(/^\s*\[[a-zA-Z0-9]{2,5}\]\s*/u, "").trim(); @@ -154,25 +154,64 @@ export const PlayerStatsTreeSchema = z.object({ }); export type PlayerStatsTree = z.infer; -export const PlayerGameSchema = z.object({ - gameId: z.string(), - start: z.iso.datetime(), - mode: z.enum(GameMode), - type: z.enum(GameType), - map: z.string(), - difficulty: z.enum(Difficulty), - clientId: z.string().optional(), -}); -export type PlayerGame = z.infer; - export const PlayerProfileSchema = z.object({ createdAt: z.iso.datetime(), user: DiscordUserSchema.optional(), - games: PlayerGameSchema.array(), stats: PlayerStatsTreeSchema, }); export type PlayerProfile = z.infer; +// Mode buckets for GET /public/player/:publicId/games — mirrors the clan +// game-history filter (see ClanGameFilter). Resolved server-side off the +// games join (mode / ranked_type / player_teams). +export const PlayerGameModeFilters = ["ffa", "team", "hvn", "ranked"] as const; +export const PlayerGameModeFilterSchema = z.enum(PlayerGameModeFilters); +export type PlayerGameModeFilter = z.infer; + +// Game-type split — orthogonal to the mode filter. Matches games.type. +export const PlayerGameTypeFilters = [ + "public", + "private", + "singleplayer", +] as const; +export const PlayerGameTypeFilterSchema = z.enum(PlayerGameTypeFilters); +export type PlayerGameTypeFilter = z.infer; + +// "incomplete" covers games with no recorded winner (winnerType IS NULL). +export const PlayerGameResultSchema = z.enum([ + "victory", + "defeat", + "incomplete", +]); +export type PlayerGameResult = z.infer; + +export const PublicPlayerGameSchema = z.object({ + gameId: z.string(), + start: z.iso.datetime(), + durationSeconds: z.number().int().nonnegative(), + map: z.string(), + mode: z.string(), + type: z.string(), + playerTeams: z.string().nullable(), + rankedType: z.string(), + result: PlayerGameResultSchema, + totalPlayers: z.number().int().nonnegative().nullable(), + username: z.string(), + clanTag: z.string().nullable(), +}); +export type PublicPlayerGame = z.infer; + +export const PublicPlayerGamesResponseSchema = z.object({ + results: PublicPlayerGameSchema.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. + nextCursor: z.string().nullable(), +}); +export type PublicPlayerGamesResponse = z.infer< + typeof PublicPlayerGamesResponseSchema +>; + export const PlayerLeaderboardEntrySchema = z.object({ rank: z.number(), playerId: z.string(), diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts new file mode 100644 index 000000000..19c04ce51 --- /dev/null +++ b/tests/ApiSchemas.test.ts @@ -0,0 +1,176 @@ +import { + PlayerGameModeFilterSchema, + PlayerGameResultSchema, + PlayerGameTypeFilterSchema, + PlayerProfileSchema, + PublicPlayerGameSchema, + PublicPlayerGamesResponseSchema, +} from "../src/core/ApiSchemas"; + +describe("PlayerProfileSchema", () => { + const base = { + createdAt: "2024-01-15T12:00:00.000Z", + stats: {}, + }; + + it("accepts a profile without a games array (moved to its own endpoint)", () => { + const result = PlayerProfileSchema.safeParse(base); + expect(result.success).toBe(true); + }); + + it("ignores a legacy games field rather than failing the parse", () => { + const result = PlayerProfileSchema.safeParse({ + ...base, + games: [{ gameId: "g1" }], + }); + // Zod strips unknown keys by default, so an old server response that still + // carries games[] parses cleanly without the field surfacing. + expect(result.success).toBe(true); + if (result.success) { + expect("games" in result.data).toBe(false); + } + }); + + it("rejects a non-ISO createdAt", () => { + expect( + PlayerProfileSchema.safeParse({ ...base, createdAt: "yesterday" }) + .success, + ).toBe(false); + }); +}); + +describe("PlayerGameModeFilterSchema", () => { + it.each(["ffa", "team", "hvn", "ranked"])("accepts %s", (value) => { + expect(PlayerGameModeFilterSchema.safeParse(value).success).toBe(true); + }); + + it("rejects 'all' (filter omission is encoded by absence, not a value)", () => { + expect(PlayerGameModeFilterSchema.safeParse("all").success).toBe(false); + }); +}); + +describe("PlayerGameTypeFilterSchema", () => { + it.each(["public", "private", "singleplayer"])("accepts %s", (value) => { + expect(PlayerGameTypeFilterSchema.safeParse(value).success).toBe(true); + }); + + it("rejects an unknown type value", () => { + expect(PlayerGameTypeFilterSchema.safeParse("ranked").success).toBe(false); + }); +}); + +describe("PlayerGameResultSchema", () => { + it.each(["victory", "defeat", "incomplete"])("accepts %s", (value) => { + expect(PlayerGameResultSchema.safeParse(value).success).toBe(true); + }); + + it("rejects an unknown result value", () => { + expect(PlayerGameResultSchema.safeParse("win").success).toBe(false); + }); +}); + +describe("PublicPlayerGameSchema", () => { + const validGame = { + gameId: "g1", + start: "2024-06-01T00:00:00.000Z", + durationSeconds: 1234, + map: "World", + mode: "Team", + type: "Public", + playerTeams: "Duos", + rankedType: "unranked", + result: "victory" as const, + totalPlayers: 8, + username: "alice", + clanTag: "ABC", + }; + + it("accepts a fully-populated game", () => { + expect(PublicPlayerGameSchema.safeParse(validGame).success).toBe(true); + }); + + it("accepts clanTag: null (not repping a clan)", () => { + expect( + PublicPlayerGameSchema.safeParse({ ...validGame, clanTag: null }).success, + ).toBe(true); + }); + + it("rejects a missing username", () => { + const withoutUsername: Record = { ...validGame }; + delete withoutUsername.username; + expect(PublicPlayerGameSchema.safeParse(withoutUsername).success).toBe( + false, + ); + }); + + it("accepts playerTeams: null (FFA / non-team games)", () => { + expect( + PublicPlayerGameSchema.safeParse({ ...validGame, playerTeams: null }) + .success, + ).toBe(true); + }); + + it("accepts totalPlayers: null (historical rows)", () => { + expect( + PublicPlayerGameSchema.safeParse({ ...validGame, totalPlayers: null }) + .success, + ).toBe(true); + }); + + it("rejects a negative durationSeconds", () => { + expect( + PublicPlayerGameSchema.safeParse({ ...validGame, durationSeconds: -1 }) + .success, + ).toBe(false); + }); + + it("rejects a non-ISO start", () => { + expect( + PublicPlayerGameSchema.safeParse({ ...validGame, start: "June 1 2024" }) + .success, + ).toBe(false); + }); +}); + +describe("PublicPlayerGamesResponseSchema", () => { + const validGame = { + gameId: "g1", + start: "2024-06-01T00:00:00.000Z", + durationSeconds: 1234, + map: "World", + mode: "Free For All", + type: "Public", + playerTeams: null, + rankedType: "unranked", + result: "defeat" as const, + totalPlayers: 20, + username: "bob", + clanTag: null, + }; + + it("accepts a non-empty page with an opaque cursor", () => { + const result = PublicPlayerGamesResponseSchema.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", () => { + expect( + PublicPlayerGamesResponseSchema.safeParse({ + results: [], + nextCursor: null, + }).success, + ).toBe(true); + }); + + it("rejects when nextCursor is missing (must be string or null)", () => { + expect( + PublicPlayerGamesResponseSchema.safeParse({ results: [] }).success, + ).toBe(false); + }); +});