fix: Fix player profile loading with API transformer and improve UI layout

- Add LegacyPlayerProfile transformer to handle API response format mismatch
- Transform avatar hashes to full Steam CDN URLs
- Map team IDs correctly (API 1/2 -> Schema 2/3)
- Calculate aggregate stats (avg_kills, avg_deaths, win_rate) from matches
- Reduce featured matches on homepage from 6 to 3
- Show 4 recent matches on player profile instead of 10
- Display recent matches in 4-column grid on desktop (side-by-side)

Fixes "Player not found" error for all player profiles.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-05 00:43:50 +01:00
parent 62bfdc8090
commit a861b1c1b6
5 changed files with 152 additions and 10 deletions

View File

@@ -1,6 +1,7 @@
import { apiClient } from './client'; import { apiClient } from './client';
import { parsePlayer, parsePlayerMeta } from '$lib/schemas'; import { parsePlayer } from '$lib/schemas';
import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types'; import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types';
import { transformPlayerProfile, type LegacyPlayerProfile } from './transformers';
/** /**
* Player API endpoints * Player API endpoints
@@ -27,11 +28,54 @@ export const playersAPI = {
* @returns Player metadata * @returns Player metadata
*/ */
async getPlayerMeta(steamId: string, limit = 10): Promise<PlayerMeta> { async getPlayerMeta(steamId: string, limit = 10): Promise<PlayerMeta> {
const url = `/player/${steamId}/meta/${limit}`; // Use the /player/{id} endpoint which has the data we need
const data = await apiClient.get<PlayerMeta>(url); const url = `/player/${steamId}`;
const legacyData = await apiClient.get<LegacyPlayerProfile>(url);
// Validate with Zod schema // Transform legacy API format to our schema format
return parsePlayerMeta(data); const transformedData = transformPlayerProfile(legacyData);
// Validate the player data
const player = parsePlayer(transformedData);
// Calculate aggregated stats from matches
const matches = player.matches || [];
const recentMatches = matches.slice(0, limit);
const totalKills = recentMatches.reduce((sum, m) => sum + (m.stats?.kills || 0), 0);
const totalDeaths = recentMatches.reduce((sum, m) => sum + (m.stats?.deaths || 0), 0);
const totalKast = recentMatches.reduce((sum, _m) => {
// KAST is a percentage, we need to calculate it
// For now, we'll use a placeholder
return sum + 0;
}, 0);
const wins = recentMatches.filter((m) => {
// match_result 1 = win, 2 = loss
return m.match_result === 1;
}).length;
const avgKills = recentMatches.length > 0 ? totalKills / recentMatches.length : 0;
const avgDeaths = recentMatches.length > 0 ? totalDeaths / recentMatches.length : 0;
const winRate = recentMatches.length > 0 ? wins / recentMatches.length : 0;
// Find the most recent match date
const lastMatchDate = matches.length > 0 ? matches[0].date : new Date().toISOString();
// Transform to PlayerMeta format
const playerMeta: PlayerMeta = {
id: parseInt(player.id),
name: player.name,
avatar: player.avatar, // Already transformed by transformPlayerProfile
recent_matches: recentMatches.length,
last_match_date: lastMatchDate,
avg_kills: avgKills,
avg_deaths: avgDeaths,
avg_kast: totalKast / recentMatches.length || 0, // Placeholder KAST calculation
win_rate: winRate
};
return playerMeta;
}, },
/** /**

View File

@@ -161,3 +161,101 @@ export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
players: legacy.stats?.map(transformPlayerStats) players: legacy.stats?.map(transformPlayerStats)
}; };
} }
/**
* Legacy player profile format from API
*/
export interface LegacyPlayerProfile {
steamid64: string;
name: string;
avatar: string; // Hash, not full URL
vac: boolean;
vac_date: number; // Unix timestamp
game_ban: boolean;
game_ban_date: number; // Unix timestamp
tracked: boolean;
match_stats?: {
win: number;
loss: number;
};
matches?: Array<{
match_id: string;
map: string;
date: number;
score: [number, number];
duration: number;
match_result: number;
max_rounds: number;
parsed: boolean;
vac: boolean;
game_ban: boolean;
stats: {
team_id: number;
kills: number;
deaths: number;
assists: number;
headshot: number;
mvp: number;
score: number;
rank: Record<string, unknown>;
multi_kills?: Record<string, number>;
dmg?: Record<string, unknown>;
};
}>;
}
/**
* Transform legacy player profile to schema-compatible format
*/
export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
// Unix timestamp -62135596800 represents "no date" (year 0)
const hasVacDate = legacy.vac_date && legacy.vac_date > 0;
const hasGameBanDate = legacy.game_ban_date && legacy.game_ban_date > 0;
return {
id: legacy.steamid64,
name: legacy.name,
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
steam_updated: new Date().toISOString(), // Not provided by API
vac_count: legacy.vac ? 1 : 0,
vac_date: hasVacDate ? new Date(legacy.vac_date * 1000).toISOString() : null,
game_ban_count: legacy.game_ban ? 1 : 0,
game_ban_date: hasGameBanDate ? new Date(legacy.game_ban_date * 1000).toISOString() : null,
wins: legacy.match_stats?.win,
losses: legacy.match_stats?.loss,
matches: legacy.matches?.map((match) => ({
match_id: match.match_id,
map: match.map || 'unknown',
date: new Date(match.date * 1000).toISOString(),
score_team_a: match.score[0],
score_team_b: match.score[1],
duration: match.duration,
match_result: match.match_result,
max_rounds: match.max_rounds,
demo_parsed: match.parsed,
vac_present: match.vac,
gameban_present: match.game_ban,
tick_rate: 64, // Not provided by API
stats: {
id: legacy.steamid64,
name: legacy.name,
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
// Fix team_id: API returns 1/2, but schema expects min 2
// Map: 1 -> 2 (Terrorists), 2 -> 3 (Counter-Terrorists)
team_id:
match.stats.team_id === 1 ? 2 : match.stats.team_id === 2 ? 3 : match.stats.team_id,
kills: match.stats.kills,
deaths: match.stats.deaths,
assists: match.stats.assists,
headshot: match.stats.headshot,
mvp: match.stats.mvp,
score: match.stats.score,
kast: 0,
mk_2: match.stats.multi_kills?.duo,
mk_3: match.stats.multi_kills?.triple,
mk_4: match.stats.multi_kills?.quad,
mk_5: match.stats.multi_kills?.ace
}
}))
};
}

View File

@@ -10,11 +10,11 @@ export const load: PageLoad = async ({ parent }) => {
await parent(); await parent();
try { try {
// Load featured matches (limit to 6 for homepage) // Load featured matches (limit to 3 for homepage)
const matchesData = await api.matches.getMatches({ limit: 6 }); const matchesData = await api.matches.getMatches({ limit: 3 });
return { return {
featuredMatches: matchesData.matches.slice(0, 6), // Ensure max 6 matches featuredMatches: matchesData.matches.slice(0, 3), // Ensure max 3 matches
meta: { meta: {
title: 'CS2.WTF - Statistics for CS2 Matchmaking', title: 'CS2.WTF - Statistics for CS2 Matchmaking',
description: description:

View File

@@ -247,7 +247,7 @@
</div> </div>
{#if recentMatches.length > 0} {#if recentMatches.length > 0}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
{#each recentMatches as match} {#each recentMatches as match}
<MatchCard {match} /> <MatchCard {match} />
{/each} {/each}

View File

@@ -45,7 +45,7 @@ export const load: PageLoad = async ({ params }) => {
return { return {
profile, profile,
recentMatches: matchesData.matches.slice(0, 10), // Show 10 in recent matches section recentMatches: matchesData.matches.slice(0, 4), // Show 4 in recent matches section
playerStats, // Full stats for charts playerStats, // Full stats for charts
meta: { meta: {
title: `${profile.name} - Player Profile | CS2.WTF`, title: `${profile.name} - Player Profile | CS2.WTF`,