mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 16:38:33 +00:00
Account Modal - Games Tab (#4473)
Continuation of https://github.com/openfrontio/infra/pull/386, adds play games sessions <img width="971" height="771" alt="image" src="https://github.com/user-attachments/assets/42c6bcbb-d690-4cd1-b859-3299a03f4350" /> - [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) <noreply@anthropic.com>
This commit is contained in:
+52
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+26
-22
@@ -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`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<span class="text-blue-400">🎮</span>
|
||||
${translateText("game_list.recent_games")}
|
||||
</h3>
|
||||
<game-list
|
||||
.games=${this.recentGames}
|
||||
.onViewGame=${(id: string) => void this.viewGame(id)}
|
||||
></game-list>
|
||||
</div>
|
||||
<player-game-history-view
|
||||
.publicId=${publicId}
|
||||
.cachedState=${this.gameHistoryCache?.publicId === publicId
|
||||
? this.gameHistoryCache
|
||||
: null}
|
||||
@history-updated=${(e: CustomEvent<PlayerGameHistoryCache>) => {
|
||||
this.gameHistoryCache = e.detail;
|
||||
}}
|
||||
@view-game=${(e: CustomEvent<{ gameId: string }>) =>
|
||||
void this.viewGame(e.detail.gameId)}
|
||||
></player-game-history-view>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -467,7 +472,6 @@ export class AccountModal extends BaseModal {
|
||||
return;
|
||||
}
|
||||
|
||||
this.recentGames = data.games;
|
||||
this.statsTree = data.stats;
|
||||
|
||||
this.requestUpdate();
|
||||
|
||||
@@ -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<PublicPlayerGamesResponse | { error: "failed" }> {
|
||||
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<UserMeResponse | false> | null = null;
|
||||
export async function getUserMe(): Promise<UserMeResponse | false> {
|
||||
if (__userMe !== null) {
|
||||
|
||||
@@ -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<T> = { 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<T extends { start: string }>(
|
||||
items: T[],
|
||||
): DayGroup<T>[] {
|
||||
const groups: DayGroup<T>[] = [];
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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` <div class="w-full">
|
||||
<div class="flex flex-col gap-3">
|
||||
${this.games.map(
|
||||
(game) => html`
|
||||
<div
|
||||
class="bg-white/5 border border-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition-all duration-200"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between px-4 py-3 gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="p-2 bg-malibu-blue/20 rounded-lg text-aquarius"
|
||||
@click=${() => this.onViewGame?.(game.gameId)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polygon points="10 8 16 12 10 16 10 8"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<div class="text-sm font-bold text-white tracking-wide">
|
||||
${new Date(game.start).toLocaleDateString()}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs text-blue-200/60 font-semibold uppercase tracking-wider"
|
||||
>
|
||||
${translateText("game_list.mode")}:
|
||||
${game.mode === GameMode.FFA
|
||||
? translateText("game_mode.ffa")
|
||||
: html`${translateText("game_mode.teams")}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 self-end sm:self-auto">
|
||||
<button
|
||||
class="text-xs font-bold text-white bg-blue-600 hover:bg-blue-500 px-3 py-1.5 rounded-lg transition-colors shadow-lg shadow-blue-900/20"
|
||||
@click=${() => this.onViewGame?.(game.gameId)}
|
||||
>
|
||||
${translateText("game_list.replay")}
|
||||
</button>
|
||||
<button
|
||||
class="text-xs font-bold text-gray-300 bg-white/10 hover:bg-white/20 px-3 py-1.5 rounded-lg transition-colors border border-white/5"
|
||||
@click=${() => this.toggle(game.gameId)}
|
||||
>
|
||||
${translateText("game_list.details")}
|
||||
</button>
|
||||
<button
|
||||
class="text-xs font-bold text-gray-300 bg-white/10 hover:bg-white/20 px-3 py-1.5 rounded-lg transition-colors border border-white/5"
|
||||
@click=${() => this.showRanking(game.gameId)}
|
||||
>
|
||||
${translateText("game_list.ranking")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-black/20 border-t border-white/5 px-4 text-xs text-gray-400 transition-all duration-300 overflow-hidden"
|
||||
style="max-height:${this.expandedGameId === game.gameId
|
||||
? "200px"
|
||||
: "0"}; opacity:${this.expandedGameId === game.gameId
|
||||
? "1"
|
||||
: "0"}"
|
||||
>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 py-3">
|
||||
<div>
|
||||
<div
|
||||
class="font-bold text-white uppercase tracking-wider text-[10px] mb-1"
|
||||
>
|
||||
${translateText("game_list.game_id")}
|
||||
</div>
|
||||
<copy-button
|
||||
.copyText="${game.gameId}"
|
||||
compact
|
||||
></copy-button>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="font-bold text-white uppercase tracking-wider text-[10px] mb-1"
|
||||
>
|
||||
${translateText("game_list.map")}
|
||||
</div>
|
||||
<div class="text-white">${game.map}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="font-bold text-white uppercase tracking-wider text-[10px] mb-1"
|
||||
>
|
||||
${translateText("game_list.difficulty")}
|
||||
</div>
|
||||
<div class="text-white">${game.difficulty}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="font-bold text-white uppercase tracking-wider text-[10px] mb-1"
|
||||
>
|
||||
${translateText("game_list.type")}
|
||||
</div>
|
||||
<div class="text-white">${game.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<typeof groupByDay<PublicPlayerGame>> = [];
|
||||
|
||||
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<PlayerGameHistoryCache>("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<HTMLElement>("[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`<div class="space-y-3">
|
||||
${this.renderFilters()}${this.renderBody()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderFilters(): TemplateResult {
|
||||
return html`
|
||||
<div class="space-y-2">
|
||||
${this.renderFilterRow(TYPE_TABS, this.typeFilter, (k) =>
|
||||
this.setTypeFilter(k as TypeKey),
|
||||
)}
|
||||
${this.renderFilterRow(MODE_TABS, this.modeFilter, (k) =>
|
||||
this.setModeFilter(k as ModeKey),
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFilterRow(
|
||||
tabs: { key: string; labelKey: string }[],
|
||||
active: string,
|
||||
onSelect: (key: string) => void,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex flex-wrap gap-1 p-1 bg-white/5 border border-white/10 rounded-xl"
|
||||
>
|
||||
${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`
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=${isActive}
|
||||
@click=${() => 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)}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBody(): TemplateResult {
|
||||
if (this.loading && this.games.length === 0) {
|
||||
return renderLoadingSpinner();
|
||||
}
|
||||
if (this.loadState === "failed") {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm mb-3">
|
||||
${translateText("clan_modal.history_unavailable")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => 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")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (this.games.length === 0) {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 rounded-xl border border-white/10 p-8 text-center"
|
||||
>
|
||||
<p class="text-white/40 text-sm">
|
||||
${translateText("clan_modal.history_empty")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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`
|
||||
<div class="space-y-5">
|
||||
${groups.map(
|
||||
(group) => html`
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center gap-3 px-1 py-1.5"
|
||||
>
|
||||
<span class="h-px flex-1 bg-white/10"></span>
|
||||
<h3
|
||||
class="text-xs font-bold uppercase tracking-widest text-white/70 whitespace-nowrap"
|
||||
>
|
||||
${formatDayHeader(group.day)}
|
||||
</h3>
|
||||
<span class="h-px flex-1 bg-white/10"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
${group.items.map((game) => this.renderGameRow(game))}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
${this.renderScrollFooter()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderScrollFooter(): TemplateResult {
|
||||
if (this.nextCursor === null) {
|
||||
return html`
|
||||
<div class="text-center text-[11px] text-white/30 py-3 select-none">
|
||||
${translateText("clan_modal.history_end_of_history")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (this.appendFailed) {
|
||||
return html`
|
||||
<div class="text-center py-3">
|
||||
<p class="text-white/40 text-xs mb-2">
|
||||
${translateText("clan_modal.history_load_more_failed")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => 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")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
// 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`
|
||||
<div class="py-3">
|
||||
<div data-scroll-sentinel aria-hidden="true" class="h-px"></div>
|
||||
${this.loadingMore ? renderLoadingSpinner() : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="bg-white/5 border border-white/10 rounded-xl overflow-hidden">
|
||||
${mapWebpPath
|
||||
? html`<div
|
||||
class="relative w-full aspect-[3/1] overflow-hidden bg-surface"
|
||||
>
|
||||
<img
|
||||
src=${mapWebpPath}
|
||||
alt=${mapDisplayName ?? ""}
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"
|
||||
></div>
|
||||
${mapDisplayName
|
||||
? html`<div
|
||||
class="absolute bottom-2 left-3 text-xs font-bold text-white uppercase tracking-wider drop-shadow"
|
||||
>
|
||||
${mapDisplayName}
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="absolute top-2 right-2">
|
||||
${this.renderResultBadge(game)}
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-2 right-2 text-xs font-medium text-white bg-black/60 backdrop-blur-sm px-2 py-1 rounded-md whitespace-nowrap"
|
||||
>
|
||||
${formatAbsoluteTime(game.start)}
|
||||
</div>
|
||||
</div>`
|
||||
: ""}
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 px-4 py-3 border-b border-white/5"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-white/40"
|
||||
>${translateText("clan_modal.history_game_id")}:</span
|
||||
>
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${game.gameId}
|
||||
.displayText=${game.gameId}
|
||||
.showVisibilityToggle=${false}
|
||||
></copy-button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => 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")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => 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")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="px-4 py-3 grid grid-cols-2 gap-x-4 gap-y-2 justify-items-center text-center border-b border-white/5"
|
||||
>
|
||||
${this.renderField(
|
||||
translateText("account_modal.games_clan_tag"),
|
||||
game.clanTag ?? "—",
|
||||
)}
|
||||
${this.renderField(
|
||||
translateText("account_modal.games_username"),
|
||||
game.username,
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="px-4 py-3 grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 justify-items-center text-center"
|
||||
>
|
||||
${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),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderField(label: string, value: string): TemplateResult {
|
||||
return html`
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-white/40 mb-0.5"
|
||||
>
|
||||
${label}
|
||||
</div>
|
||||
<div class="text-sm text-white truncate" title=${value}>${value}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 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`<span
|
||||
class="text-xs font-bold uppercase tracking-wider px-2.5 py-1 rounded-full border shadow-lg ${tint}"
|
||||
>${label}</span
|
||||
>`;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof groupByDay<ClanGame>> = [];
|
||||
|
||||
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 {
|
||||
<span class="h-px flex-1 bg-white/10"></span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
${group.games.map((game) => this.renderGameRow(game))}
|
||||
${group.items.map((game) => this.renderGameRow(game))}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
+52
-13
@@ -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<typeof PlayerStatsTreeSchema>;
|
||||
|
||||
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<typeof PlayerGameSchema>;
|
||||
|
||||
export const PlayerProfileSchema = z.object({
|
||||
createdAt: z.iso.datetime(),
|
||||
user: DiscordUserSchema.optional(),
|
||||
games: PlayerGameSchema.array(),
|
||||
stats: PlayerStatsTreeSchema,
|
||||
});
|
||||
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
|
||||
|
||||
// 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<typeof PlayerGameModeFilterSchema>;
|
||||
|
||||
// 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<typeof PlayerGameTypeFilterSchema>;
|
||||
|
||||
// "incomplete" covers games with no recorded winner (winnerType IS NULL).
|
||||
export const PlayerGameResultSchema = z.enum([
|
||||
"victory",
|
||||
"defeat",
|
||||
"incomplete",
|
||||
]);
|
||||
export type PlayerGameResult = z.infer<typeof PlayerGameResultSchema>;
|
||||
|
||||
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<typeof PublicPlayerGameSchema>;
|
||||
|
||||
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(),
|
||||
|
||||
@@ -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<string, unknown> = { ...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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user