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`
-
-
-
-
this.onViewGame?.(game.gameId)}
- >
-
-
-
-
-
-
-
- ${new Date(game.start).toLocaleDateString()}
-
-
- ${translateText("game_list.mode")}:
- ${game.mode === GameMode.FFA
- ? translateText("game_mode.ffa")
- : html`${translateText("game_mode.teams")}`}
-
-
-
-
-
- this.onViewGame?.(game.gameId)}
- >
- ${translateText("game_list.replay")}
-
- this.toggle(game.gameId)}
- >
- ${translateText("game_list.details")}
-
- this.showRanking(game.gameId)}
- >
- ${translateText("game_list.ranking")}
-
-
-
-
-
-
-
-
- ${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`
+ onSelect(tab.key)}
+ class="grow ${basis} 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"}"
+ >
+ ${translateText(tab.labelKey)}
+
+ `;
+ })}
+
+ `;
+ }
+
+ private renderBody(): TemplateResult {
+ if (this.loading && this.games.length === 0) {
+ return renderLoadingSpinner();
+ }
+ if (this.loadState === "failed") {
+ return html`
+
+
+ ${translateText("clan_modal.history_unavailable")}
+
+
this.reload()}
+ class="text-xs font-bold text-white/60 hover:text-white uppercase tracking-wider px-3 py-1.5 rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors"
+ >
+ ${translateText("leaderboard_modal.try_again")}
+
+
+ `;
+ }
+ 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")}
+
+
this.load({ append: true })}
+ class="text-xs font-bold text-white/60 hover:text-white uppercase tracking-wider px-3 py-1.5 rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 transition-colors"
+ >
+ ${translateText("leaderboard_modal.try_again")}
+
+
+ `;
+ }
+ // 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
+ ? html`
+ ${mapDisplayName}
+
`
+ : ""}
+
+ ${this.renderResultBadge(game)}
+
+
+ ${formatAbsoluteTime(game.start)}
+
+
`
+ : ""}
+
+
+ ${translateText("clan_modal.history_game_id")}:
+
+
+
+ this.showRanking(game.gameId)}
+ class="px-3 py-1.5 text-xs font-bold text-white/80 uppercase tracking-wider bg-white/10 hover:bg-white/20 border border-white/10 rounded-lg transition-colors"
+ >
+ ${translateText("game_list.ranking")}
+
+ this.watchReplay(game.gameId)}
+ class="px-3 py-1.5 text-xs font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-lg transition-all"
+ >
+ ${translateText("clan_modal.history_watch_replay")}
+
+
+
+
+ ${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);
+ });
+});