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:
@@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user