feat: Integrate player meta stats, match metadata, and sitemap proxies

- Add player meta stats API endpoint with teammates, weapons, and map data
- Display avg_rank badge on match cards and match detail page
- Add tick rate and demo download improvements to match layout
- Create sitemap proxy routes to backend for SEO
- Document backend data limitations in transformers (rounds/weapons)
- Fix 400 error: backend limits meta stats to max 10 items

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 18:42:44 +01:00
parent 95b385c471
commit e27e9e8821
13 changed files with 480 additions and 64 deletions

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client'; import { apiClient } from './client';
import { parsePlayer } from '$lib/schemas'; import { parsePlayer } from '$lib/schemas';
import type { Player, PlayerMeta, TrackPlayerResponse } from '$lib/types'; import type { Player, PlayerMeta, PlayerMetaStats, TrackPlayerResponse } from '$lib/types';
import { transformPlayerProfile, type LegacyPlayerProfile } from './transformers'; import { transformPlayerProfile, type LegacyPlayerProfile } from './transformers';
/** /**
@@ -85,6 +85,18 @@ export const playersAPI = {
return playerMeta; return playerMeta;
}, },
/**
* Get player aggregated meta stats from backend
* Uses pre-computed stats (cached 30 days) including teammates, weapons, maps
* @param steamId - Steam ID (uint64 as string to preserve precision)
* @param limit - Number of items per category (max 10, default: 4)
* @returns Player meta stats with teammates, weapons, map performance
*/
async getPlayerMetaStats(steamId: string, limit = 4): Promise<PlayerMetaStats> {
const url = `/player/${steamId}/meta/${limit}`;
return apiClient.get<PlayerMetaStats>(url);
},
/** /**
* Add player to tracking system * Add player to tracking system
* @param steamId - Steam ID (uint64 as string to preserve precision) * @param steamId - Steam ID (uint64 as string to preserve precision)

View File

@@ -55,6 +55,9 @@ export interface LegacyMatchDetail {
vac: boolean; // NOT vac_present vac: boolean; // NOT vac_present
game_ban: boolean; // NOT gameban_present game_ban: boolean; // NOT gameban_present
stats?: LegacyPlayerStats[]; // Player stats array stats?: LegacyPlayerStats[]; // Player stats array
tick_rate?: number; // Server tick rate (64 or 128)
avg_rank?: number; // Average Premier rating (backend computed)
replay_url?: string; // Demo download URL (if < 30 days old)
} }
/** /**
@@ -219,6 +222,9 @@ export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
demo_parsed: legacy.parsed, demo_parsed: legacy.parsed,
vac_present: legacy.vac, vac_present: legacy.vac,
gameban_present: legacy.game_ban, gameban_present: legacy.game_ban,
tick_rate: legacy.tick_rate,
avg_rank: legacy.avg_rank,
replay_url: legacy.replay_url,
players: legacy.stats?.map(transformPlayerStats) players: legacy.stats?.map(transformPlayerStats)
}; };
} }

View File

@@ -3,10 +3,15 @@ import type { MatchRoundsResponse, RoundDetail, RoundStats, Match } from '$lib/t
/** /**
* Transform raw rounds API response into structured format * Transform raw rounds API response into structured format
* @param rawData - Raw API response *
* NOTE: The backend API only provides economy data (bank/equipment/spent) per round.
* Round winners and win reasons are not currently stored in the database.
* To add this data would require backend changes to the demo parser.
*
* @param rawData - Raw API response containing economy data per round
* @param matchId - Match ID * @param matchId - Match ID
* @param match - Match data with player information * @param match - Match data with player information and final score
* @returns Structured rounds data * @returns Structured rounds data with economy info (winner/win_reason unavailable)
*/ */
export function transformRoundsResponse( export function transformRoundsResponse(
rawData: RoundsAPIResponse, rawData: RoundsAPIResponse,
@@ -15,7 +20,7 @@ export function transformRoundsResponse(
): MatchRoundsResponse { ): MatchRoundsResponse {
const rounds: RoundDetail[] = []; const rounds: RoundDetail[] = [];
// Create player ID to team mapping // Create player ID to team mapping for potential future use
const playerTeamMap = new Map<string, number>(); const playerTeamMap = new Map<string, number>();
if (match?.players) { if (match?.players) {
for (const player of match.players) { for (const player of match.players) {
@@ -47,8 +52,10 @@ export function transformRoundsResponse(
rounds.push({ rounds.push({
round: roundNum + 1, round: roundNum + 1,
winner: 0, // TODO: Determine winner from data if available // Round winner data not available from backend API
win_reason: '', // TODO: Determine win reason if available // Would require demo parser changes to store RoundEnd event winners
winner: 0,
win_reason: '',
players players
}); });
} }

View File

@@ -3,10 +3,15 @@ import type { MatchWeaponsResponse, PlayerWeaponStats, WeaponStats, Match } from
/** /**
* Transform raw weapons API response into structured format * Transform raw weapons API response into structured format
* @param rawData - Raw API response *
* NOTE: The backend API provides hit/damage data per weapon but not kill counts.
* Kill tracking would require the demo parser to correlate damage events with
* player death events. Currently only aggregated damage and hit group data is available.
*
* @param rawData - Raw API response containing damage/hit data per weapon
* @param matchId - Match ID * @param matchId - Match ID
* @param match - Match data with player information * @param match - Match data with player information
* @returns Structured weapons data * @returns Structured weapons data with damage stats (kills unavailable)
*/ */
export function transformWeaponsResponse( export function transformWeaponsResponse(
rawData: WeaponsAPIResponse, rawData: WeaponsAPIResponse,
@@ -77,7 +82,9 @@ export function transformWeaponsResponse(
weapon_stats.push({ weapon_stats.push({
eq_type: eqType, eq_type: eqType,
weapon_name: rawData.equipment_map[String(eqType)] || `Weapon ${eqType}`, weapon_name: rawData.equipment_map[String(eqType)] || `Weapon ${eqType}`,
kills: 0, // TODO: Calculate kills if needed // Kill data not available - API only provides hit/damage events
// Would require backend changes to correlate damage with death events
kills: 0,
damage: stats.damage, damage: stats.damage,
hits: stats.hits, hits: stats.hits,
hit_groups: hitGroupCounts, hit_groups: hitGroupCounts,

View File

@@ -3,6 +3,7 @@
import type { MatchListItem } from '$lib/types'; import type { MatchListItem } from '$lib/types';
import { storeMatchesState, type FilterState } from '$lib/utils/navigation'; import { storeMatchesState, type FilterState } from '$lib/utils/navigation';
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets'; import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
interface Props { interface Props {
match: MatchListItem; match: MatchListItem;
@@ -87,6 +88,11 @@
</div> </div>
<!-- Status badges - horizontal layout --> <!-- Status badges - horizontal layout -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{#if match.avg_rank && match.avg_rank > 0}
<div class="backdrop-blur-sm" title="Average player rating">
<PremierRatingBadge rating={match.avg_rank} size="sm" showTier={false} />
</div>
{/if}
{#if match.player_count} {#if match.player_count}
<span <span
class="rounded-full border border-neon-blue/30 bg-neon-blue/20 px-1.5 py-0.5 text-[10px] text-neon-blue backdrop-blur-sm" class="rounded-full border border-neon-blue/30 bg-neon-blue/20 px-1.5 py-0.5 text-[10px] text-neon-blue backdrop-blur-sm"

View File

@@ -42,6 +42,12 @@ export interface Match {
/** Server tick rate (64 or 128) - optional, not always provided by API */ /** Server tick rate (64 or 128) - optional, not always provided by API */
tick_rate?: number; tick_rate?: number;
/** Average Premier rating of all players in the match - optional, backend computed */
avg_rank?: number;
/** Demo replay download URL (only available for matches < 30 days old) */
replay_url?: string;
/** /**
* Game mode: 'premier' | 'competitive' | 'wingman' * Game mode: 'premier' | 'competitive' | 'wingman'
* - Premier: Uses CS Rating (numerical ELO, 0-30,000+) * - Premier: Uses CS Rating (numerical ELO, 0-30,000+)
@@ -67,6 +73,8 @@ export interface MatchListItem {
duration: number; duration: number;
demo_parsed: boolean; demo_parsed: boolean;
player_count?: number; player_count?: number;
/** Average Premier rating of all players (backend computed, optional) */
avg_rank?: number;
} }
/** /**

View File

@@ -119,3 +119,79 @@ export interface PlayerProfile extends Player {
/** Peak CS2 Premier rating */ /** Peak CS2 Premier rating */
peak_rating?: number; peak_rating?: number;
} }
/**
* Teammate statistics from backend meta endpoint
*/
export interface TeammateStats {
/** Player profile */
player: {
steamid64: string;
name?: string;
avatar?: string;
vac?: boolean;
game_ban?: boolean;
};
/** Win rate when playing together (0-1) */
win_rate?: number;
/** Tie rate when playing together (0-1) */
tie_rate?: number;
/** Total matches played together */
total?: number;
}
/**
* Weapon damage statistics from backend meta endpoint
*/
export interface WeaponDamageStats {
/** Equipment type ID */
eq: number;
/** Total damage dealt with this weapon */
dmg: number;
}
/**
* Map statistics from backend meta endpoint
*/
export interface MapStats {
/** Map name (e.g., "de_inferno") */
map: string;
/** Win rate on this map (0-1) */
win_rate: number;
/** Tie rate on this map (0-1) */
tie_rate: number;
/** Total matches on this map */
total: number;
}
/**
* Full player meta statistics from /player/:id/meta endpoint
* Contains pre-aggregated stats from the backend (cached for 30 days)
*/
export interface PlayerMetaStats {
/** Basic player info */
player: {
steamid64: string;
name?: string;
avatar?: string;
vac?: boolean;
vac_date?: number;
game_ban?: boolean;
game_ban_date?: number;
tracked?: boolean;
};
/** Best teammates sorted by win rate */
best_mates?: TeammateStats[];
/** Most played teammates sorted by total games */
most_mates?: TeammateStats[];
/** Equipment ID to name mapping */
eq_map?: Record<number, string>;
/** Weapon damage stats sorted by damage */
weapon_dmg?: WeaponDamageStats[];
/** Win rate per map (map name -> rate 0-1) */
win_maps?: Record<string, number>;
/** Tie rate per map (map name -> rate 0-1) */
tie_maps?: Record<string, number>;
/** Total matches per map (map name -> count) */
total_maps?: Record<string, number>;
}

View File

@@ -11,7 +11,16 @@ export type { ChatAPIResponse } from './api/ChatAPIResponse';
export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match'; export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match';
// Player types // Player types
export type { Player, PlayerMatch, PlayerMeta, PlayerProfile } from './Player'; export type {
Player,
PlayerMatch,
PlayerMeta,
PlayerProfile,
PlayerMetaStats,
TeammateStats,
WeaponDamageStats,
MapStats
} from './Player';
// Round statistics types // Round statistics types
export type { export type {

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Download, Calendar, Clock, ArrowLeft } from 'lucide-svelte'; import { Download, Calendar, Clock, ArrowLeft, Server, Users } from 'lucide-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Badge from '$lib/components/ui/Badge.svelte'; import Badge from '$lib/components/ui/Badge.svelte';
import Tabs from '$lib/components/ui/Tabs.svelte'; import Tabs from '$lib/components/ui/Tabs.svelte';
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets'; import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
@@ -42,6 +43,12 @@
} }
function handleDownloadDemo() { function handleDownloadDemo() {
// Prefer direct replay_url if available (faster download)
if (match.replay_url) {
window.open(match.replay_url, '_blank');
return;
}
// Fall back to Steam share code
if (!match.share_code) { if (!match.share_code) {
alert('Share code not available for this match'); alert('Share code not available for this match');
return; return;
@@ -49,6 +56,9 @@
const downloadUrl = `steam://rungame/730/76561202255233023/+csgo_download_match%20${match.share_code}`; const downloadUrl = `steam://rungame/730/76561202255233023/+csgo_download_match%20${match.share_code}`;
window.location.href = downloadUrl; window.location.href = downloadUrl;
} }
// Check if demo download is available
const canDownloadDemo = match.replay_url || (match.demo_parsed && match.share_code);
</script> </script>
<!-- Match Header with Background --> <!-- Match Header with Background -->
@@ -95,11 +105,13 @@
{mapName} {mapName}
</h1> </h1>
</div> </div>
{#if match.demo_parsed && match.share_code} {#if canDownloadDemo}
<button <button
onclick={handleDownloadDemo} onclick={handleDownloadDemo}
class="inline-flex items-center gap-2 rounded-lg border border-neon-blue/30 bg-neon-blue/10 px-4 py-2 text-sm font-medium text-neon-blue backdrop-blur-md transition-all duration-200 hover:border-neon-blue/50 hover:bg-neon-blue/20 hover:shadow-[0_0_20px_rgba(0,212,255,0.2)]" class="inline-flex items-center gap-2 rounded-lg border border-neon-blue/30 bg-neon-blue/10 px-4 py-2 text-sm font-medium text-neon-blue backdrop-blur-md transition-all duration-200 hover:border-neon-blue/50 hover:bg-neon-blue/20 hover:shadow-[0_0_20px_rgba(0,212,255,0.2)]"
title="Download this match demo to your Steam client" title={match.replay_url
? 'Download demo file directly'
: 'Download this match demo to your Steam client'}
> >
<Download class="h-4 w-4" /> <Download class="h-4 w-4" />
<span class="hidden sm:inline">Download Demo</span> <span class="hidden sm:inline">Download Demo</span>
@@ -151,6 +163,21 @@
</div> </div>
<span class="text-white/20"></span> <span class="text-white/20"></span>
<span>MR12 ({match.max_rounds} rounds)</span> <span>MR12 ({match.max_rounds} rounds)</span>
{#if match.tick_rate}
<span class="text-white/20"></span>
<div class="flex items-center gap-1.5">
<Server class="h-3.5 w-3.5 text-neon-purple" />
<span class="font-mono">{match.tick_rate} tick</span>
</div>
{/if}
{#if match.avg_rank && match.avg_rank > 0}
<span class="text-white/20"></span>
<div class="flex items-center gap-1.5">
<Users class="h-3.5 w-3.5 text-neon-gold" />
<span>Avg Rating:</span>
<PremierRatingBadge rating={match.avg_rank} size="sm" showTier={false} />
</div>
{/if}
{#if match.demo_parsed} {#if match.demo_parsed}
<span class="text-white/20"></span> <span class="text-white/20"></span>
<Badge variant="success" size="sm">Demo Parsed</Badge> <Badge variant="success" size="sm">Demo Parsed</Badge>

View File

@@ -38,7 +38,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const { profile, recentMatches, playerStats } = data; const { profile, recentMatches, playerStats, metaStats } = data;
// Track this player visit // Track this player visit
onMount(() => { onMount(() => {
@@ -807,6 +807,203 @@
</div> </div>
{/if} {/if}
<!-- Teammates Section (from pre-aggregated meta stats) -->
{#if metaStats && (metaStats.best_mates?.length || metaStats.most_mates?.length)}
<div>
<div class="mb-4 flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-blue/20"
style="box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);"
>
<Users class="h-5 w-5 text-neon-blue" />
</div>
<div>
<h2 class="text-2xl font-bold text-white">Teammates</h2>
<p class="text-sm text-white/50">Who they play with</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- Best Teammates (by win rate) -->
{#if metaStats.best_mates && metaStats.best_mates.length > 0}
<Card padding="lg">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<Trophy class="h-5 w-5 text-neon-gold" />
Best Teammates
</h3>
<div class="space-y-3">
{#each metaStats.best_mates.slice(0, 5) as teammate}
{@const winRatePercent = (teammate.win_rate ?? 0) * 100}
<a
href="/player/{teammate.player.steamid64}"
class="group flex items-center gap-3 rounded-lg bg-white/5 p-3 transition-all hover:bg-white/10"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-void"
>
{#if teammate.player.avatar}
<img
src={teammate.player.avatar}
alt={teammate.player.name || 'Player'}
class="h-full w-full rounded-full"
/>
{:else}
<User class="h-5 w-5 text-white/50" />
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-white group-hover:text-neon-blue">
{teammate.player.name || `Player ${teammate.player.steamid64}`}
</span>
{#if teammate.player.vac || teammate.player.game_ban}
<AlertTriangle class="h-3 w-3 text-neon-red" />
{/if}
</div>
<div class="text-xs text-white/50">
{teammate.total || 0} matches together
</div>
</div>
<div class="text-right">
<div
class="font-mono text-lg font-bold {winRatePercent >= 55
? 'text-neon-green'
: winRatePercent >= 45
? 'text-neon-blue'
: 'text-neon-red'}"
>
{winRatePercent.toFixed(0)}%
</div>
<div class="text-xs text-white/50">win rate</div>
</div>
</a>
{/each}
</div>
</Card>
{/if}
<!-- Most Played Teammates -->
{#if metaStats.most_mates && metaStats.most_mates.length > 0}
<Card padding="lg">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<Users class="h-5 w-5 text-neon-purple" />
Frequent Teammates
</h3>
<div class="space-y-3">
{#each metaStats.most_mates.slice(0, 5) as teammate}
{@const winRatePercent = (teammate.win_rate ?? 0) * 100}
<a
href="/player/{teammate.player.steamid64}"
class="group flex items-center gap-3 rounded-lg bg-white/5 p-3 transition-all hover:bg-white/10"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-void"
>
{#if teammate.player.avatar}
<img
src={teammate.player.avatar}
alt={teammate.player.name || 'Player'}
class="h-full w-full rounded-full"
/>
{:else}
<User class="h-5 w-5 text-white/50" />
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-white group-hover:text-neon-purple">
{teammate.player.name || `Player ${teammate.player.steamid64}`}
</span>
{#if teammate.player.vac || teammate.player.game_ban}
<AlertTriangle class="h-3 w-3 text-neon-red" />
{/if}
</div>
<div class="flex items-center gap-2 text-xs text-white/50">
<span
class="font-mono {winRatePercent >= 55
? 'text-neon-green'
: winRatePercent >= 45
? 'text-neon-blue'
: 'text-neon-red'}"
>
{winRatePercent.toFixed(0)}% WR
</span>
</div>
</div>
<div class="text-right">
<div class="font-mono text-lg font-bold text-neon-purple">
{teammate.total || 0}
</div>
<div class="text-xs text-white/50">matches</div>
</div>
</a>
{/each}
</div>
</Card>
{/if}
</div>
</div>
{/if}
<!-- Weapon Preferences Section (from pre-aggregated meta stats) -->
{#if metaStats && metaStats.weapon_dmg && metaStats.weapon_dmg.length > 0}
{@const topWeapons = metaStats.weapon_dmg.slice(0, 8)}
{@const maxDamage = Math.max(...topWeapons.map((w) => w.dmg))}
<div>
<div class="mb-4 flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-gold/20"
style="box-shadow: 0 0 15px rgba(255, 215, 0, 0.2);"
>
<Crosshair class="h-5 w-5 text-neon-gold" />
</div>
<div>
<h2 class="text-2xl font-bold text-white">Weapon Preferences</h2>
<p class="text-sm text-white/50">Damage dealt by weapon type</p>
</div>
</div>
<Card padding="lg">
<div class="space-y-4">
{#each topWeapons as weapon, index}
{@const weaponName = metaStats.eq_map?.[weapon.eq] || `Weapon ${weapon.eq}`}
{@const barWidth = (weapon.dmg / maxDamage) * 100}
{@const isTopWeapon = index < 3}
<div class="group">
<div class="mb-1 flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="font-mono text-xs font-bold {isTopWeapon
? 'text-neon-gold'
: 'text-white/50'}"
>
#{index + 1}
</span>
<span class="font-medium capitalize text-white">
{weaponName.toLowerCase().replace(/_/g, ' ')}
</span>
</div>
<span class="font-mono text-sm text-white/70">
{weapon.dmg.toLocaleString()} dmg
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-white/10">
<div
class="h-full rounded-full transition-all duration-500 {isTopWeapon
? 'bg-gradient-to-r from-neon-gold/80 to-neon-gold'
: 'bg-gradient-to-r from-neon-blue/60 to-neon-blue/80'}"
style="width: {barWidth}%; box-shadow: 0 0 10px {isTopWeapon
? 'rgba(255, 215, 0, 0.3)'
: 'rgba(0, 212, 255, 0.2)'};"
></div>
</div>
</div>
{/each}
</div>
</Card>
</div>
{/if}
<!-- Empty State for Unparsed Matches --> <!-- Empty State for Unparsed Matches -->
{#if playerStats.length === 0 && recentMatches.length > 0} {#if playerStats.length === 0 && recentMatches.length > 0}
<Card padding="lg"> <Card padding="lg">
@@ -825,6 +1022,24 @@
</Card> </Card>
{/if} {/if}
<!-- Info when meta stats unavailable (teammates, weapons) -->
{#if !metaStats}
<Card padding="lg">
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-neon-blue/10">
<Users class="h-6 w-6 text-neon-blue/50" />
</div>
<div>
<h3 class="text-lg font-semibold text-white">Teammate & Weapon Stats Loading</h3>
<p class="text-sm text-white/50">
Teammate history and weapon preferences are being calculated. This data is cached and
may take a moment to generate on first visit.
</p>
</div>
</div>
</Card>
{/if}
<!-- Recent Matches --> <!-- Recent Matches -->
<div> <div>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">

View File

@@ -1,6 +1,7 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { api } from '$lib/api'; import { api } from '$lib/api';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import type { PlayerMetaStats } from '$lib/types';
export const load: PageLoad = async ({ params }) => { export const load: PageLoad = async ({ params }) => {
const playerId = params.id; // Keep as string to preserve uint64 precision const playerId = params.id; // Keep as string to preserve uint64 precision
@@ -10,10 +11,15 @@ export const load: PageLoad = async ({ params }) => {
} }
try { try {
// Fetch player profile and recent matches in parallel // Fetch player profile, recent matches, and pre-aggregated meta stats in parallel
const [profile, matchesData] = await Promise.all([ // Note: Backend limits meta stats to max 10 items per category
const [profile, matchesData, metaStats] = await Promise.all([
api.players.getPlayerMeta(playerId), api.players.getPlayerMeta(playerId),
api.matches.getMatches({ player_id: playerId, limit: 20 }) api.matches.getMatches({ player_id: playerId, limit: 20 }),
api.players.getPlayerMetaStats(playerId, 10).catch((err): PlayerMetaStats | null => {
console.error(`Failed to fetch player meta stats for ${playerId}:`, err);
return null;
})
]); ]);
// Fetch full match details with player stats for performance charts // Fetch full match details with player stats for performance charts
@@ -52,6 +58,7 @@ export const load: PageLoad = async ({ params }) => {
profile, profile,
recentMatches: matchesData.matches.slice(0, 4), // Show 4 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
metaStats, // Pre-aggregated stats from backend (teammates, weapons, maps)
meta: { meta: {
title: `${profile.name} - Player Profile | teamflash.rip`, title: `${profile.name} - Player Profile | teamflash.rip`,
description: `View ${profile.name}'s CS2 statistics, flash history, and how often they blind their own team.` description: `View ${profile.name}'s CS2 statistics, flash history, and how often they blind their own team.`

View File

@@ -1,65 +1,49 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { matchesAPI } from '$lib/api/matches';
const SITE_URL = 'https://teamflash.rip'; // Production URL // Get backend API URL from environment variable (same as API proxy route)
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
/** /**
* Generate XML sitemap for SEO * Proxy sitemap.xml requests to the backend API
* Includes static pages and dynamic match pages *
* The backend generates comprehensive sitemaps using gositemap library,
* including all matches and players. This proxies that for SEO.
*/ */
export const GET: RequestHandler = async () => { export const GET: RequestHandler = async () => {
try { try {
// Static pages const response = await fetch(`${API_BASE_URL}/sitemap.xml`, {
const staticPages = [ headers: {
{ url: '', priority: 1.0, changefreq: 'daily' }, // Home Accept: 'application/xml'
{ url: '/matches', priority: 0.9, changefreq: 'hourly' } // Matches listing }
]; });
// Fetch recent matches for dynamic URLs if (!response.ok) {
let matchUrls: { url: string; lastmod: string }[] = []; console.error(`Backend sitemap returned ${response.status}`);
try { return new Response('Sitemap not available', {
const matchesResponse = await matchesAPI.getMatches({ limit: 100 }); status: response.status,
matchUrls = matchesResponse.matches.map((match) => ({ headers: {
url: `/match/${match.match_id}`, 'Content-Type': 'text/plain'
lastmod: match.date || new Date().toISOString() }
})); });
} catch (error) {
console.error('Failed to fetch matches for sitemap:', error);
} }
// Build XML sitemap const xml = await response.text();
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${staticPages
.map(
(page) => ` <url>
<loc>${SITE_URL}${page.url}</loc>
<lastmod>${new Date().toISOString()}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>`
)
.join('\n')}
${matchUrls
.map(
(match) => ` <url>
<loc>${SITE_URL}${match.url}</loc>
<lastmod>${match.lastmod}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>`
)
.join('\n')}
</urlset>`.trim();
return new Response(sitemap, { return new Response(xml, {
status: 200,
headers: { headers: {
'Content-Type': 'application/xml', 'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour 'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
} }
}); });
} catch (error) { } catch (error) {
console.error('Error generating sitemap:', error); console.error('Failed to fetch sitemap from backend:', error);
return new Response('Error generating sitemap', { status: 500 }); return new Response('Sitemap temporarily unavailable', {
status: 503,
headers: {
'Content-Type': 'text/plain',
'Retry-After': '300' // Retry after 5 minutes
}
});
} }
}; };

View File

@@ -0,0 +1,52 @@
import type { RequestHandler } from './$types';
// Get backend API URL from environment variable (same as API proxy route)
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
/**
* Proxy individual sitemap requests to the backend API
*
* The backend uses a sitemap index pattern where /sitemap.xml points to
* individual sitemaps like /sitemap/0, /sitemap/1, etc.
* This proxies those individual sitemaps for SEO.
*/
export const GET: RequestHandler = async ({ params }) => {
const { id } = params;
try {
const response = await fetch(`${API_BASE_URL}/sitemap/${id}`, {
headers: {
Accept: 'application/xml'
}
});
if (!response.ok) {
console.error(`Backend sitemap/${id} returned ${response.status}`);
return new Response('Sitemap not found', {
status: response.status,
headers: {
'Content-Type': 'text/plain'
}
});
}
const xml = await response.text();
return new Response(xml, {
status: 200,
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
}
});
} catch (error) {
console.error(`Failed to fetch sitemap/${id} from backend:`, error);
return new Response('Sitemap temporarily unavailable', {
status: 503,
headers: {
'Content-Type': 'text/plain',
'Retry-After': '300' // Retry after 5 minutes
}
});
}
};