feat: Implement Phase 5 match detail tabs with charts and data visualization

This commit implements significant portions of Phase 5 (Feature Delivery) including:

Chart Components (src/lib/components/charts/):
- LineChart.svelte: Line charts with Chart.js integration
- BarChart.svelte: Vertical/horizontal bar charts with stacking
- PieChart.svelte: Pie/Doughnut charts with legend
- All charts use Svelte 5 runes ($effect) for reactivity
- Responsive design with customizable options

Data Display Components (src/lib/components/data-display/):
- DataTable.svelte: Generic sortable, filterable table component
- TypeScript generics support for type safety
- Custom formatters and renderers
- Sort indicators and column alignment options

Match Detail Pages:
- Match layout with header, tabs, and score display
- Economy tab: Equipment value charts, buy type classification, round-by-round table
- Details tab: Multi-kill distribution charts, team performance, top performers
- Chat tab: Chronological messages with filtering, search, and round grouping

Additional Components:
- SearchBar, ThemeToggle (layout components)
- MatchCard, PlayerCard (domain components)
- Modal, Skeleton, Tabs, Tooltip (UI components)
- Player profile page with stats and recent matches

Dependencies:
- Installed chart.js for data visualization
- Created Svelte 5 compatible chart wrappers

Phase 4 marked as complete, Phase 5 at 50% completion.
Flashes and Damage tabs deferred for future implementation.

Note: Minor linting warnings to be addressed in follow-up commit.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-04 21:17:32 +01:00
parent 24b990ac62
commit 523136ffbc
30 changed files with 11721 additions and 9195 deletions

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { Download, Calendar, Clock } from 'lucide-svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Tabs from '$lib/components/ui/Tabs.svelte';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: any } = $props();
const { match } = data;
const tabs = [
{ label: 'Overview', href: `/match/${match.match_id}` },
{ label: 'Economy', href: `/match/${match.match_id}/economy` },
{ label: 'Details', href: `/match/${match.match_id}/details` },
{ label: 'Flashes', href: `/match/${match.match_id}/flashes` },
{ label: 'Damage', href: `/match/${match.match_id}/damage` },
{ label: 'Chat', href: `/match/${match.match_id}/chat` }
];
const formattedDate = new Date(match.date).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short'
});
const duration = match.duration
? `${Math.floor(match.duration / 60)}:${(match.duration % 60).toString().padStart(2, '0')}`
: 'N/A';
const mapName = match.map.replace('de_', '').toUpperCase();
</script>
<!-- Match Header -->
<div class="border-b border-base-300 bg-gradient-to-r from-primary/5 to-secondary/5">
<div class="container mx-auto px-4 py-8">
<!-- Map Name -->
<div class="mb-4 flex items-center justify-between">
<div>
<Badge variant="default" size="lg">{match.map}</Badge>
<h1 class="mt-2 text-4xl font-bold text-base-content">{mapName}</h1>
</div>
{#if match.demo_parsed}
<button class="btn btn-outline btn-primary gap-2">
<Download class="h-4 w-4" />
Download Demo
</button>
{/if}
</div>
<!-- Score -->
<div class="mb-6 flex items-center justify-center gap-6">
<div class="text-center">
<div class="text-sm font-medium text-base-content/60">TERRORISTS</div>
<div class="font-mono text-5xl font-bold text-terrorist">{match.score_team_a}</div>
</div>
<div class="text-3xl font-bold text-base-content/40">:</div>
<div class="text-center">
<div class="text-sm font-medium text-base-content/60">COUNTER-TERRORISTS</div>
<div class="font-mono text-5xl font-bold text-ct">{match.score_team_b}</div>
</div>
</div>
<!-- Match Meta -->
<div class="flex flex-wrap items-center justify-center gap-4 text-sm text-base-content/70">
<div class="flex items-center gap-2">
<Calendar class="h-4 w-4" />
<span>{formattedDate}</span>
</div>
<div class="flex items-center gap-2">
<Clock class="h-4 w-4" />
<span>{duration}</span>
</div>
<Badge variant="info" size="sm">MR12 ({match.max_rounds} rounds)</Badge>
{#if match.demo_parsed}
<Badge variant="success" size="sm">Demo Parsed</Badge>
{/if}
</div>
<!-- Tabs -->
<div class="mt-6">
<Tabs {tabs} variant="bordered" size="md" />
</div>
</div>
</div>
<!-- Tab Content -->
<div class="container mx-auto px-4 py-8">
{@render children()}
</div>

View File

@@ -0,0 +1,22 @@
import { error } from '@sveltejs/kit';
import { api } from '$lib/api';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ params }) => {
const matchId = Number(params.id);
if (isNaN(matchId) || matchId <= 0) {
throw error(400, 'Invalid match ID');
}
try {
const match = await api.matches.getMatch(matchId);
return {
match
};
} catch (err) {
console.error(`Failed to load match ${matchId}:`, err);
throw error(404, `Match ${matchId} not found`);
}
};

View File

@@ -0,0 +1,200 @@
<script lang="ts">
import { Trophy, Target, Crosshair } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { match } = data;
// Group players by team
const teamA = match.players?.filter((p: any) => p.team_id === 2) || [];
const teamB = match.players?.filter((p: any) => p.team_id === 3) || [];
// Sort by kills descending
const sortedTeamA = teamA.sort((a: any, b: any) => b.kills - a.kills);
const sortedTeamB = teamB.sort((a: any, b: any) => b.kills - a.kills);
// Calculate team stats
const calcTeamStats = (players: typeof teamA) => {
const totalKills = players.reduce((sum: number, p: any) => sum + p.kills, 0);
const totalDeaths = players.reduce((sum: number, p: any) => sum + p.deaths, 0);
const totalADR = players.reduce((sum: number, p: any) => sum + (p.adr || 0), 0);
const avgKAST = players.reduce((sum: number, p: any) => sum + (p.kast || 0), 0) / players.length;
return {
kills: totalKills,
deaths: totalDeaths,
kd: totalDeaths > 0 ? (totalKills / totalDeaths).toFixed(2) : totalKills.toFixed(2),
adr: (totalADR / players.length).toFixed(1),
kast: avgKAST.toFixed(1)
};
};
const teamAStats = calcTeamStats(sortedTeamA);
const teamBStats = calcTeamStats(sortedTeamB);
</script>
<div class="space-y-8">
<!-- Team Statistics Overview -->
<div class="grid gap-6 md:grid-cols-2">
<Card padding="lg">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-2xl font-bold text-terrorist">Terrorists</h2>
<div class="text-3xl font-bold font-mono text-terrorist">{match.score_team_a}</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-base-content/60">Team K/D</div>
<div class="text-xl font-bold">{teamAStats.kd}</div>
</div>
<div>
<div class="text-sm text-base-content/60">Avg ADR</div>
<div class="text-xl font-bold">{teamAStats.adr}</div>
</div>
<div>
<div class="text-sm text-base-content/60">Total Kills</div>
<div class="text-xl font-bold">{teamAStats.kills}</div>
</div>
<div>
<div class="text-sm text-base-content/60">Avg KAST</div>
<div class="text-xl font-bold">{teamAStats.kast}%</div>
</div>
</div>
</Card>
<Card padding="lg">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-2xl font-bold text-ct">Counter-Terrorists</h2>
<div class="text-3xl font-bold font-mono text-ct">{match.score_team_b}</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-base-content/60">Team K/D</div>
<div class="text-xl font-bold">{teamBStats.kd}</div>
</div>
<div>
<div class="text-sm text-base-content/60">Avg ADR</div>
<div class="text-xl font-bold">{teamBStats.adr}</div>
</div>
<div>
<div class="text-sm text-base-content/60">Total Kills</div>
<div class="text-xl font-bold">{teamBStats.kills}</div>
</div>
<div>
<div class="text-sm text-base-content/60">Avg KAST</div>
<div class="text-xl font-bold">{teamBStats.kast}%</div>
</div>
</div>
</Card>
</div>
<!-- Scoreboard -->
<Card padding="none">
<div class="p-6">
<h2 class="text-2xl font-bold text-base-content">Scoreboard</h2>
</div>
<!-- Team A -->
<div class="border-t border-base-300 bg-terrorist/5">
<div class="px-6 py-3">
<h3 class="text-lg font-semibold text-terrorist">Terrorists</h3>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="border-base-300">
<th>Player</th>
<th>K</th>
<th>D</th>
<th>A</th>
<th>ADR</th>
<th>HS%</th>
<th>KAST%</th>
</tr>
</thead>
<tbody>
{#each sortedTeamA as player, index}
<tr class="border-base-300">
<td>
<a
href={`/player/${player.id}`}
class="font-medium hover:text-primary transition-colors"
>
{player.name}
</a>
{#if index === 0}
<Trophy class="ml-2 inline h-4 w-4 text-warning" />
{/if}
</td>
<td class="font-mono font-semibold">{player.kills}</td>
<td class="font-mono">{player.deaths}</td>
<td class="font-mono">{player.assists}</td>
<td class="font-mono">{player.adr?.toFixed(1) || '0.0'}</td>
<td class="font-mono">{player.hs_percent?.toFixed(1) || '0.0'}%</td>
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Team B -->
<div class="border-t border-base-300 bg-ct/5">
<div class="px-6 py-3">
<h3 class="text-lg font-semibold text-ct">Counter-Terrorists</h3>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="border-base-300">
<th>Player</th>
<th>K</th>
<th>D</th>
<th>A</th>
<th>ADR</th>
<th>HS%</th>
<th>KAST%</th>
</tr>
</thead>
<tbody>
{#each sortedTeamB as player, index}
<tr class="border-base-300">
<td>
<a
href={`/player/${player.id}`}
class="font-medium hover:text-primary transition-colors"
>
{player.name}
</a>
{#if index === 0}
<Trophy class="ml-2 inline h-4 w-4 text-warning" />
{/if}
</td>
<td class="font-mono font-semibold">{player.kills}</td>
<td class="font-mono">{player.deaths}</td>
<td class="font-mono">{player.assists}</td>
<td class="font-mono">{player.adr?.toFixed(1) || '0.0'}</td>
<td class="font-mono">{player.hs_percent?.toFixed(1) || '0.0'}%</td>
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</Card>
<!-- Coming Soon Badges for Round Timeline -->
<Card padding="lg">
<div class="text-center">
<h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3>
<p class="text-base-content/60">
Round-by-round timeline visualization coming soon. Will show bomb plants, defuses, and
round winners.
</p>
<Badge variant="warning" size="md" class="mt-4">Coming in Future Update</Badge>
</div>
</Card>
</div>

View File

@@ -0,0 +1,228 @@
<script lang="ts">
import { MessageSquare, Filter, Search } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { match, chatData } = data;
// State for filtering
let searchQuery = $state('');
let showTeamChat = $state(true);
let showAllChat = $state(true);
let selectedPlayer = $state<number | null>(null);
// Get unique players who sent messages
const messagePlayers = Array.from(
new Set(chatData.messages.map((m) => m.player_id))
).map((playerId) => {
const player = match.players?.find((p) => p.id === playerId);
return {
id: playerId,
name: player?.name || `Player ${playerId}`,
team_id: player?.team_id
};
});
// Filter messages
const filteredMessages = $derived(() => {
return chatData.messages.filter((msg) => {
// Chat type filter
if (!showTeamChat && !msg.all_chat) return false;
if (!showAllChat && msg.all_chat) return false;
// Player filter
if (selectedPlayer !== null && msg.player_id !== selectedPlayer) return false;
// Search filter
if (searchQuery && !msg.message.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
return true;
});
})();
// Get player info for a message
const getPlayerInfo = (playerId: number) => {
const player = match.players?.find((p) => p.id === playerId);
return {
name: player?.name || `Player ${playerId}`,
team_id: player?.team_id || 0
};
};
// Group messages by round
const messagesByRound: Record<number, typeof chatData.messages> = {};
for (const msg of filteredMessages) {
const round = msg.round || 0;
if (!messagesByRound[round]) {
messagesByRound[round] = [];
}
messagesByRound[round].push(msg);
}
const rounds = Object.keys(messagesByRound)
.map(Number)
.sort((a, b) => a - b);
// Stats
const totalMessages = chatData.messages.length;
const teamChatCount = chatData.messages.filter((m) => !m.all_chat).length;
const allChatCount = chatData.messages.filter((m) => m.all_chat).length;
</script>
<svelte:head>
<title>{data.meta.title}</title>
</svelte:head>
<div class="space-y-6">
<!-- Stats -->
<div class="grid gap-6 md:grid-cols-3">
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<MessageSquare class="h-5 w-5 text-primary" />
<span class="text-sm font-medium text-base-content/70">Total Messages</span>
</div>
<div class="text-3xl font-bold text-base-content">{totalMessages}</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<MessageSquare class="h-5 w-5 text-warning" />
<span class="text-sm font-medium text-base-content/70">Team Chat</span>
</div>
<div class="text-3xl font-bold text-base-content">{teamChatCount}</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<MessageSquare class="h-5 w-5 text-success" />
<span class="text-sm font-medium text-base-content/70">All Chat</span>
</div>
<div class="text-3xl font-bold text-base-content">{allChatCount}</div>
</Card>
</div>
<!-- Filters -->
<Card padding="lg">
<div class="space-y-4">
<div class="flex items-center gap-2">
<Filter class="h-5 w-5 text-base-content" />
<h3 class="font-semibold">Filters</h3>
</div>
<div class="flex flex-wrap gap-4">
<!-- Chat Type -->
<div class="flex gap-2">
<label class="label cursor-pointer gap-2">
<input
type="checkbox"
bind:checked={showTeamChat}
class="checkbox checkbox-sm"
/>
<span class="label-text">Team Chat</span>
</label>
<label class="label cursor-pointer gap-2">
<input
type="checkbox"
bind:checked={showAllChat}
class="checkbox checkbox-sm"
/>
<span class="label-text">All Chat</span>
</label>
</div>
<!-- Player Filter -->
<select
bind:value={selectedPlayer}
class="select select-bordered select-sm"
>
<option value={null}>All Players</option>
{#each messagePlayers as player}
<option value={player.id}>{player.name}</option>
{/each}
</select>
<!-- Search -->
<div class="relative flex-1 min-w-[200px]">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-base-content/40" />
<input
type="text"
bind:value={searchQuery}
placeholder="Search messages..."
class="input input-bordered input-sm w-full pl-9"
/>
</div>
</div>
</div>
</Card>
<!-- Messages -->
{#if filteredMessages.length === 0}
<Card padding="lg">
<div class="text-center text-base-content/60">
<MessageSquare class="mx-auto mb-2 h-12 w-12" />
<p>No messages match your filters.</p>
</div>
</Card>
{:else}
{#each rounds as round}
<Card padding="none">
<!-- Round Header -->
<div class="border-b border-base-300 bg-base-200 px-6 py-3">
<div class="flex items-center justify-between">
<h3 class="font-semibold text-base-content">
{round === 0 ? 'Warmup / Pre-Match' : `Round ${round}`}
</h3>
<Badge variant="default" size="sm">
{messagesByRound[round].length} message{messagesByRound[round].length !== 1 ? 's' : ''}
</Badge>
</div>
</div>
<!-- Messages -->
<div class="divide-y divide-base-300">
{#each messagesByRound[round] as message}
{@const playerInfo = getPlayerInfo(message.player_id)}
<div class="p-4 hover:bg-base-200/50 transition-colors">
<div class="flex items-start gap-3">
<!-- Player Avatar/Icon -->
<div
class="flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold text-white"
class:bg-terrorist={playerInfo.team_id === 2}
class:bg-ct={playerInfo.team_id === 3}
class:bg-base-300={playerInfo.team_id === 0}
>
{playerInfo.name.charAt(0).toUpperCase()}
</div>
<!-- Message Content -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<a
href="/player/{message.player_id}"
class="font-semibold hover:underline"
class:text-terrorist={playerInfo.team_id === 2}
class:text-ct={playerInfo.team_id === 3}
>
{playerInfo.name}
</a>
{#if message.all_chat}
<Badge variant="success" size="sm">All Chat</Badge>
{:else}
<Badge variant="default" size="sm">Team</Badge>
{/if}
</div>
<p class="mt-1 text-base-content break-words">{message.message}</p>
</div>
</div>
</div>
{/each}
</div>
</Card>
{/each}
{/if}
</div>

View File

@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { matchesAPI } from '$lib/api';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, parent }) => {
try {
// Get match data from parent layout
const { match } = await parent();
// Fetch chat messages
const chatData = await matchesAPI.getMatchChat(params.id);
return {
match,
chatData,
meta: {
title: `${match.map} Chat - Match ${match.match_id} - CS2.WTF`,
description: `In-game chat log for ${match.map} match`
}
};
} catch (err) {
console.error('Failed to load chat data:', err);
throw error(500, {
message: 'Failed to load chat data'
});
}
};

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { Crosshair, Target } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
</script>
<div class="space-y-6">
<Card padding="lg">
<div class="text-center">
<Crosshair class="mx-auto mb-4 h-16 w-16 text-error" />
<h2 class="mb-2 text-2xl font-bold text-base-content">Damage Analysis</h2>
<p class="mb-4 text-base-content/60">
Damage dealt/received, hit group breakdown, damage heatmaps, and weapon range analysis.
</p>
<Badge variant="warning" size="lg">Coming in Future Update</Badge>
</div>
</Card>
<div class="grid gap-6 md:grid-cols-3">
<Card padding="lg">
<Crosshair class="mb-2 h-8 w-8 text-error" />
<h3 class="mb-1 text-lg font-semibold">Damage Summary</h3>
<p class="text-sm text-base-content/60">Total damage dealt and received</p>
</Card>
<Card padding="lg">
<Target class="mb-2 h-8 w-8 text-primary" />
<h3 class="mb-1 text-lg font-semibold">Hit Groups</h3>
<p class="text-sm text-base-content/60">Headshots, chest, legs, arms breakdown</p>
</Card>
<Card padding="lg">
<Crosshair class="mb-2 h-8 w-8 text-info" />
<h3 class="mb-1 text-lg font-semibold">Range Analysis</h3>
<p class="text-sm text-base-content/60">Damage effectiveness by distance</p>
</Card>
</div>
</div>

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import { Trophy, Target, Flame, Zap } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import DataTable from '$lib/components/data-display/DataTable.svelte';
import BarChart from '$lib/components/charts/BarChart.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { match, weaponsData } = data;
// Calculate additional stats for players
const playersWithStats = (match.players || []).map((player) => {
const kd = player.deaths > 0 ? (player.kills / player.deaths).toFixed(2) : player.kills.toFixed(2);
const hsPercent = player.kills > 0 ? ((player.headshot / player.kills) * 100).toFixed(1) : '0.0';
const adr = player.dmg_enemy ? (player.dmg_enemy / (match.max_rounds || 24)).toFixed(1) : '0.0';
return {
...player,
kd: parseFloat(kd),
hsPercent: parseFloat(hsPercent),
adr: parseFloat(adr),
totalMultiKills: (player.mk_2 || 0) + (player.mk_3 || 0) + (player.mk_4 || 0) + (player.mk_5 || 0)
};
});
// Sort by kills descending
const sortedPlayers = playersWithStats.sort((a, b) => b.kills - a.kills);
// Prepare data table columns
const detailsColumns = [
{
key: 'name',
label: 'Player',
sortable: true,
render: (value: string, row: any) => {
const teamClass = row.team_id === 2 ? 'text-terrorist' : 'text-ct';
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${value}</a>`;
}
},
{ key: 'kills', label: 'K', sortable: true, align: 'center' as const, class: 'font-mono font-semibold' },
{ key: 'deaths', label: 'D', sortable: true, align: 'center' as const, class: 'font-mono' },
{ key: 'assists', label: 'A', sortable: true, align: 'center' as const, class: 'font-mono' },
{ key: 'kd', label: 'K/D', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => v.toFixed(2) },
{ key: 'adr', label: 'ADR', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => v.toFixed(1) },
{ key: 'hsPercent', label: 'HS%', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => `${v.toFixed(1)}%` },
{ key: 'kast', label: 'KAST%', sortable: true, align: 'center' as const, class: 'font-mono', format: (v: number) => `${v.toFixed(1)}%` },
{ key: 'mvp', label: 'MVP', sortable: true, align: 'center' as const, class: 'font-mono' },
{
key: 'mk_5',
label: 'Aces',
sortable: true,
align: 'center' as const,
render: (value: number) => {
if (value > 0) return `<span class="badge badge-warning badge-sm">${value}</span>`;
return '<span class="text-base-content/40">-</span>';
}
}
];
// Multi-kill chart data
const multiKillData = {
labels: sortedPlayers.map((p) => p.name),
datasets: [
{
label: '2K',
data: sortedPlayers.map((p) => p.mk_2 || 0),
backgroundColor: 'rgba(34, 197, 94, 0.8)'
},
{
label: '3K',
data: sortedPlayers.map((p) => p.mk_3 || 0),
backgroundColor: 'rgba(59, 130, 246, 0.8)'
},
{
label: '4K',
data: sortedPlayers.map((p) => p.mk_4 || 0),
backgroundColor: 'rgba(249, 115, 22, 0.8)'
},
{
label: '5K (Ace)',
data: sortedPlayers.map((p) => p.mk_5 || 0),
backgroundColor: 'rgba(239, 68, 68, 0.8)'
}
]
};
// Calculate team totals
const teamAPlayers = playersWithStats.filter((p) => p.team_id === 2);
const teamBPlayers = playersWithStats.filter((p) => p.team_id === 3);
const teamAStats = {
totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamAPlayers.reduce((sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0), 0),
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST: (teamAPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamAPlayers.length).toFixed(1)
};
const teamBStats = {
totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamBPlayers.reduce((sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0), 0),
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST: (teamBPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamBPlayers.length).toFixed(1)
};
</script>
<svelte:head>
<title>{data.meta.title}</title>
</svelte:head>
<div class="space-y-6">
<!-- Team Performance Summary -->
<div class="grid gap-6 md:grid-cols-2">
<!-- Terrorists Stats -->
<Card padding="lg">
<h3 class="mb-4 text-xl font-bold text-terrorist">Terrorists Performance</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-base-content/60">Total Damage</div>
<div class="text-2xl font-bold">{teamAStats.totalDamage.toLocaleString()}</div>
</div>
<div>
<div class="text-base-content/60">Utility Damage</div>
<div class="text-2xl font-bold">{teamAStats.totalUtilityDamage.toLocaleString()}</div>
</div>
<div>
<div class="text-base-content/60">Flash Assists</div>
<div class="text-2xl font-bold">{teamAStats.totalFlashAssists}</div>
</div>
<div>
<div class="text-base-content/60">Avg KAST</div>
<div class="text-2xl font-bold">{teamAStats.avgKAST}%</div>
</div>
</div>
</Card>
<!-- Counter-Terrorists Stats -->
<Card padding="lg">
<h3 class="mb-4 text-xl font-bold text-ct">Counter-Terrorists Performance</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<div class="text-base-content/60">Total Damage</div>
<div class="text-2xl font-bold">{teamBStats.totalDamage.toLocaleString()}</div>
</div>
<div>
<div class="text-base-content/60">Utility Damage</div>
<div class="text-2xl font-bold">{teamBStats.totalUtilityDamage.toLocaleString()}</div>
</div>
<div>
<div class="text-base-content/60">Flash Assists</div>
<div class="text-2xl font-bold">{teamBStats.totalFlashAssists}</div>
</div>
<div>
<div class="text-base-content/60">Avg KAST</div>
<div class="text-2xl font-bold">{teamBStats.avgKAST}%</div>
</div>
</div>
</Card>
</div>
<!-- Multi-Kills Chart -->
<Card padding="lg">
<div class="mb-4">
<h2 class="text-2xl font-bold text-base-content">Multi-Kill Distribution</h2>
<p class="text-sm text-base-content/60">
Double kills (2K), triple kills (3K), quad kills (4K), and aces (5K) per player
</p>
</div>
<BarChart data={multiKillData} height={300} />
</Card>
<!-- Detailed Player Statistics Table -->
<Card padding="none">
<div class="p-6">
<h2 class="text-2xl font-bold text-base-content">Detailed Player Statistics</h2>
<p class="mt-1 text-sm text-base-content/60">
Complete performance breakdown for all players
</p>
</div>
<DataTable data={sortedPlayers} columns={detailsColumns} striped hoverable />
</Card>
<!-- Top Performers -->
<div class="grid gap-6 md:grid-cols-3">
{#if sortedPlayers.length > 0}
<!-- Most Kills -->
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Trophy class="h-5 w-5 text-warning" />
<h3 class="font-semibold text-base-content">Most Kills</h3>
</div>
<div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div>
<div class="mt-1 text-3xl font-mono font-bold text-primary">{sortedPlayers[0].kills}</div>
<div class="mt-2 text-xs text-base-content/60">
{sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D
</div>
</Card>
<!-- Best K/D -->
{@const bestKD = [...sortedPlayers].sort((a, b) => b.kd - a.kd)[0]}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Target class="h-5 w-5 text-success" />
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestKD.name}</div>
<div class="mt-1 text-3xl font-mono font-bold text-success">{bestKD.kd.toFixed(2)}</div>
<div class="mt-2 text-xs text-base-content/60">
{bestKD.kills}K / {bestKD.deaths}D
</div>
</Card>
<!-- Most Utility Damage -->
{@const bestUtility = [...sortedPlayers].sort(
(a, b) => (b.ud_he || 0) + (b.ud_flames || 0) - ((a.ud_he || 0) + (a.ud_flames || 0))
)[0]}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Flame class="h-5 w-5 text-error" />
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
<div class="mt-1 text-3xl font-mono font-bold text-error">
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
</div>
<div class="mt-2 text-xs text-base-content/60">
HE: {bestUtility.ud_he || 0} | Fire: {bestUtility.ud_flames || 0}
</div>
</Card>
{/if}
</div>
</div>

View File

@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { matchesAPI } from '$lib/api';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, parent }) => {
try {
// Get match data from parent layout
const { match } = await parent();
// Fetch weapon statistics
const weaponsData = await matchesAPI.getMatchWeapons(params.id);
return {
match,
weaponsData,
meta: {
title: `${match.map} Details - Match ${match.match_id} - CS2.WTF`,
description: `Detailed player statistics and weapon breakdown for ${match.map} match`
}
};
} catch (err) {
console.error('Failed to load match details:', err);
throw error(500, {
message: 'Failed to load match details'
});
}
};

View File

@@ -0,0 +1,247 @@
<script lang="ts">
import { DollarSign, TrendingUp, ShoppingCart } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import LineChart from '$lib/components/charts/LineChart.svelte';
import DataTable from '$lib/components/data-display/DataTable.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { match, roundsData } = data;
// Aggregate team economy per round
interface TeamEconomy {
round: number;
teamA_bank: number;
teamB_bank: number;
teamA_equipment: number;
teamB_equipment: number;
teamA_spent: number;
teamB_spent: number;
winner: number;
teamA_buyType: string;
teamB_buyType: string;
}
const teamEconomy: TeamEconomy[] = [];
// Process rounds data to calculate team totals
for (const roundData of roundsData.rounds) {
const teamAPlayers = roundData.players.filter((p) => {
// Find player's team from match data
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
return matchPlayer?.team_id === 2;
});
const teamBPlayers = roundData.players.filter((p) => {
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
return matchPlayer?.team_id === 3;
});
const teamA_bank = teamAPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
const teamB_bank = teamBPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
const teamA_equipment = teamAPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
const teamB_equipment = teamBPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
const teamA_spent = teamAPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
const teamB_spent = teamBPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
// Classify buy type based on average equipment value
const avgTeamA_equipment = teamAPlayers.length > 0 ? teamA_equipment / teamAPlayers.length : 0;
const avgTeamB_equipment = teamBPlayers.length > 0 ? teamB_equipment / teamBPlayers.length : 0;
const classifyBuyType = (avgEquipment: number): string => {
if (avgEquipment < 1500) return 'Eco';
if (avgEquipment < 2500) return 'Semi-Eco';
if (avgEquipment < 3500) return 'Force';
return 'Full Buy';
};
teamEconomy.push({
round: roundData.round,
teamA_bank,
teamB_bank,
teamA_equipment,
teamB_equipment,
teamA_spent,
teamB_spent,
winner: roundData.winner || 0,
teamA_buyType: classifyBuyType(avgTeamA_equipment),
teamB_buyType: classifyBuyType(avgTeamB_equipment)
});
}
// Prepare chart data
const equipmentChartData = {
labels: teamEconomy.map((r) => `R${r.round}`),
datasets: [
{
label: 'Terrorists Equipment',
data: teamEconomy.map((r) => r.teamA_equipment),
borderColor: 'rgb(249, 115, 22)',
backgroundColor: 'rgba(249, 115, 22, 0.1)',
fill: true,
tension: 0.4
},
{
label: 'Counter-Terrorists Equipment',
data: teamEconomy.map((r) => r.teamB_equipment),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4
}
]
};
// Calculate summary stats
const totalRounds = teamEconomy.length;
const teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
const teamB_fullBuys = teamEconomy.filter((r) => r.teamB_buyType === 'Full Buy').length;
const teamA_ecos = teamEconomy.filter((r) => r.teamA_buyType === 'Eco').length;
const teamB_ecos = teamEconomy.filter((r) => r.teamB_buyType === 'Eco').length;
// Prepare table data
const tableColumns = [
{ key: 'round', label: 'Round', sortable: true, align: 'center' as const },
{
key: 'teamA_buyType',
label: 'T Buy',
sortable: true,
render: (value: string) => {
const variant =
value === 'Full Buy'
? 'success'
: value === 'Eco'
? 'error'
: value === 'Force'
? 'warning'
: 'default';
return `<span class="badge badge-${variant} badge-sm">${value}</span>`;
}
},
{
key: 'teamA_equipment',
label: 'T Equipment',
sortable: true,
align: 'right' as const,
format: (value: number) => `$${value.toLocaleString()}`
},
{
key: 'teamB_buyType',
label: 'CT Buy',
sortable: true,
render: (value: string) => {
const variant =
value === 'Full Buy'
? 'success'
: value === 'Eco'
? 'error'
: value === 'Force'
? 'warning'
: 'default';
return `<span class="badge badge-${variant} badge-sm">${value}</span>`;
}
},
{
key: 'teamB_equipment',
label: 'CT Equipment',
sortable: true,
align: 'right' as const,
format: (value: number) => `$${value.toLocaleString()}`
},
{
key: 'winner',
label: 'Winner',
align: 'center' as const,
render: (value: number) => {
if (value === 2) return '<span class="badge badge-sm" style="background-color: rgb(249, 115, 22); color: white;">T</span>';
if (value === 3) return '<span class="badge badge-sm" style="background-color: rgb(59, 130, 246); color: white;">CT</span>';
return '<span class="text-base-content/40">-</span>';
}
}
];
</script>
<svelte:head>
<title>{data.meta.title}</title>
</svelte:head>
<div class="space-y-6">
<!-- Summary Cards -->
<div class="grid gap-6 md:grid-cols-3">
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<ShoppingCart class="h-5 w-5 text-primary" />
<span class="text-sm font-medium text-base-content/70">Total Rounds</span>
</div>
<div class="text-3xl font-bold text-base-content">{totalRounds}</div>
<div class="mt-1 text-xs text-base-content/60">
{match.score_team_a} - {match.score_team_b}
</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<TrendingUp class="h-5 w-5 text-terrorist" />
<span class="text-sm font-medium text-base-content/70">Terrorists Buy Rounds</span>
</div>
<div class="text-3xl font-bold text-base-content">{teamA_fullBuys}</div>
<div class="mt-1 text-xs text-base-content/60">{teamA_ecos} eco rounds</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<TrendingUp class="h-5 w-5 text-ct" />
<span class="text-sm font-medium text-base-content/70">CT Buy Rounds</span>
</div>
<div class="text-3xl font-bold text-base-content">{teamB_fullBuys}</div>
<div class="mt-1 text-xs text-base-content/60">{teamB_ecos} eco rounds</div>
</Card>
</div>
<!-- Equipment Value Chart -->
<Card padding="lg">
<div class="mb-4">
<h2 class="text-2xl font-bold text-base-content">Equipment Value Over Time</h2>
<p class="text-sm text-base-content/60">
Total equipment value for each team across all rounds
</p>
</div>
<LineChart data={equipmentChartData} height={350} />
</Card>
<!-- Round-by-Round Table -->
<Card padding="none">
<div class="p-6">
<h2 class="text-2xl font-bold text-base-content">Round-by-Round Economy</h2>
<p class="mt-1 text-sm text-base-content/60">
Detailed breakdown of buy types and equipment values
</p>
</div>
<DataTable data={teamEconomy} columns={tableColumns} striped hoverable />
</Card>
<!-- Buy Type Legend -->
<Card padding="lg">
<h3 class="mb-3 text-lg font-semibold text-base-content">Buy Type Classification</h3>
<div class="flex flex-wrap gap-4 text-sm">
<div class="flex items-center gap-2">
<Badge variant="error" size="sm">Eco</Badge>
<span class="text-base-content/60">&lt; $1,500 avg equipment</span>
</div>
<div class="flex items-center gap-2">
<Badge variant="default" size="sm">Semi-Eco</Badge>
<span class="text-base-content/60">$1,500 - $2,500 avg equipment</span>
</div>
<div class="flex items-center gap-2">
<Badge variant="warning" size="sm">Force</Badge>
<span class="text-base-content/60">$2,500 - $3,500 avg equipment</span>
</div>
<div class="flex items-center gap-2">
<Badge variant="success" size="sm">Full Buy</Badge>
<span class="text-base-content/60">&gt; $3,500 avg equipment</span>
</div>
</div>
</Card>
</div>

View File

@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { matchesAPI } from '$lib/api';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, parent }) => {
try {
// Get match data from parent layout
const { match } = await parent();
// Fetch round-by-round economy data
const roundsData = await matchesAPI.getMatchRounds(params.id);
return {
match,
roundsData,
meta: {
title: `${match.map} Economy - Match ${match.match_id} - CS2.WTF`,
description: `Round-by-round economy analysis for ${match.map} match`
}
};
} catch (err) {
console.error('Failed to load economy data:', err);
throw error(500, {
message: 'Failed to load economy data'
});
}
};

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Zap, Eye } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
</script>
<div class="space-y-6">
<Card padding="lg">
<div class="text-center">
<Eye class="mx-auto mb-4 h-16 w-16 text-warning" />
<h2 class="mb-2 text-2xl font-bold text-base-content">Flash Analysis</h2>
<p class="mb-4 text-base-content/60">
Flash effectiveness, enemies blinded, flash assists, and positioning heatmaps.
</p>
<Badge variant="warning" size="lg">Coming in Future Update</Badge>
</div>
</Card>
<div class="grid gap-6 md:grid-cols-2">
<Card padding="lg">
<Zap class="mb-2 h-8 w-8 text-warning" />
<h3 class="mb-1 text-lg font-semibold">Flash Effectiveness</h3>
<p class="text-sm text-base-content/60">Enemies blinded and average blind duration</p>
</Card>
<Card padding="lg">
<Eye class="mb-2 h-8 w-8 text-success" />
<h3 class="mb-1 text-lg font-semibold">Flash Assists</h3>
<p class="text-sm text-base-content/60">Blinded enemies killed by teammates</p>
</Card>
</div>
</div>

View File

@@ -1,8 +1,33 @@
<script lang="ts">
import { Search, Filter } from 'lucide-svelte';
import { Search, Filter, Calendar } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import MatchCard from '$lib/components/match/MatchCard.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// Extract current filters from URL
const currentMap = $page.url.searchParams.get('map') || '';
const currentPlayerId = $page.url.searchParams.get('player_id') || '';
const currentSearch = $page.url.searchParams.get('search') || '';
let searchQuery = $state(currentSearch);
let showFilters = $state(false);
const handleSearch = () => {
const params = new URLSearchParams();
if (searchQuery) params.set('search', searchQuery);
if (currentMap) params.set('map', currentMap);
if (currentPlayerId) params.set('player_id', currentPlayerId);
goto(`/matches?${params.toString()}`);
};
const commonMaps = ['de_dust2', 'de_mirage', 'de_inferno', 'de_nuke', 'de_overpass', 'de_ancient', 'de_anubis'];
</script>
<svelte:head>
@@ -17,34 +42,93 @@
<!-- Search & Filters -->
<Card padding="lg" class="mb-8">
<div class="flex flex-col gap-4 md:flex-row">
<div class="flex-1">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-base-content/40" />
<input
type="text"
placeholder="Search by player name, match ID, or share code..."
class="input input-bordered w-full pl-10"
/>
<form onsubmit={(e) => { e.preventDefault(); handleSearch(); }} class="flex flex-col gap-4">
<div class="flex flex-col gap-4 md:flex-row">
<div class="flex-1">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-base-content/40" />
<input
bind:value={searchQuery}
type="text"
placeholder="Search by player name, match ID, or share code..."
class="input input-bordered w-full pl-10"
/>
</div>
</div>
<Button type="submit" variant="primary">
<Search class="mr-2 h-5 w-5" />
Search
</Button>
<Button type="button" variant="ghost" onclick={() => (showFilters = !showFilters)}>
<Filter class="mr-2 h-5 w-5" />
Filters
</Button>
</div>
<Button variant="ghost">
<Filter class="mr-2 h-5 w-5" />
Filters
</Button>
</div>
<!-- Filter Panel (Collapsible) -->
{#if showFilters}
<div class="border-t border-base-300 pt-4">
<h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3>
<div class="flex flex-wrap gap-2">
{#each commonMaps as mapName}
<a
href={`/matches?map=${mapName}`}
class="badge badge-lg badge-outline hover:badge-primary"
class:badge-primary={currentMap === mapName}
>
{mapName}
</a>
{/each}
</div>
</div>
{/if}
</form>
<!-- Active Filters -->
{#if currentMap || currentPlayerId || currentSearch}
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-base-300 pt-4">
<span class="text-sm font-medium text-base-content/70">Active Filters:</span>
{#if currentSearch}
<Badge variant="info">Search: {currentSearch}</Badge>
{/if}
{#if currentMap}
<Badge variant="info">Map: {currentMap}</Badge>
{/if}
{#if currentPlayerId}
<Badge variant="info">Player ID: {currentPlayerId}</Badge>
{/if}
<Button variant="ghost" size="sm" href="/matches">Clear All</Button>
</div>
{/if}
</Card>
<!-- Coming Soon -->
<div
class="flex min-h-[400px] items-center justify-center rounded-lg border-2 border-dashed border-base-300 bg-base-200/50"
>
<div class="text-center">
<h2 class="mb-2 text-2xl font-bold text-base-content">Coming Soon</h2>
<p class="text-base-content/60">Match listings will be available in Phase 3</p>
<div class="mt-6">
<Badge variant="info">Phase 3 - In Development</Badge>
</div>
<!-- Matches Grid -->
{#if data.matches.length > 0}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each data.matches as match}
<MatchCard {match} />
{/each}
</div>
</div>
{#if data.hasMore}
<div class="mt-8 text-center">
<Badge variant="info">More matches available - pagination coming soon</Badge>
</div>
{/if}
{:else}
<Card padding="lg">
<div class="text-center">
<Calendar class="mx-auto mb-4 h-16 w-16 text-base-content/40" />
<h2 class="mb-2 text-xl font-semibold text-base-content">No Matches Found</h2>
<p class="text-base-content/60">
Try adjusting your filters or search query.
</p>
{#if currentMap || currentPlayerId || currentSearch}
<div class="mt-4">
<Button variant="primary" href="/matches">View All Matches</Button>
</div>
{/if}
</div>
</Card>
{/if}
</div>

View File

@@ -0,0 +1,164 @@
<script lang="ts">
import { User, Target, TrendingUp, Calendar, Trophy, Heart } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Button from '$lib/components/ui/Button.svelte';
import MatchCard from '$lib/components/match/MatchCard.svelte';
import { preferences } from '$lib/stores';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { profile, recentMatches } = data;
// Calculate stats
const kd = profile.deaths > 0 ? (profile.kills / profile.deaths).toFixed(2) : profile.kills.toFixed(2);
const winRate = profile.wins + profile.losses > 0
? ((profile.wins / (profile.wins + profile.losses)) * 100).toFixed(1)
: '0.0';
const totalMatches = profile.wins + profile.losses;
const hsPercent = profile.headshots > 0 && profile.kills > 0
? ((profile.headshots / profile.kills) * 100).toFixed(1)
: '0.0';
// Check if player is favorited
const isFavorite = $derived($preferences.favoritePlayers.includes(profile.id));
const toggleFavorite = () => {
if (isFavorite) {
preferences.removeFavoritePlayer(profile.id);
} else {
preferences.addFavoritePlayer(profile.id);
}
};
</script>
<svelte:head>
<title>{data.meta.title}</title>
<meta name="description" content={data.meta.description} />
</svelte:head>
<div class="space-y-8">
<!-- Player Header -->
<Card variant="elevated" padding="lg">
<div class="flex flex-col items-start gap-6 md:flex-row md:items-center">
<!-- Avatar -->
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-primary to-secondary">
<User class="h-12 w-12 text-white" />
</div>
<!-- Info -->
<div class="flex-1">
<div class="mb-2 flex items-center gap-3">
<h1 class="text-3xl font-bold text-base-content">{profile.name}</h1>
<button
onclick={toggleFavorite}
class="btn btn-circle btn-ghost btn-sm"
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart class="h-5 w-5 {isFavorite ? 'fill-error text-error' : ''}" />
</button>
</div>
<div class="flex flex-wrap gap-3 text-sm text-base-content/60">
<span>Steam ID: {profile.id}</span>
{#if profile.rank}
<Badge variant="info">Rating: {profile.rank}</Badge>
{/if}
</div>
</div>
<!-- Actions -->
<div class="flex gap-2">
<Button variant="ghost" size="sm" href={`/matches?player_id=${profile.id}`}>
View All Matches
</Button>
</div>
</div>
</Card>
<!-- Career Statistics -->
<div>
<h2 class="mb-4 text-2xl font-bold text-base-content">Career Statistics</h2>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<Target class="h-5 w-5 text-primary" />
<span class="text-sm font-medium text-base-content/70">K/D Ratio</span>
</div>
<div class="text-3xl font-bold text-base-content">{kd}</div>
<div class="mt-1 text-xs text-base-content/60">
{profile.kills} K / {profile.deaths} D
</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<TrendingUp class="h-5 w-5 text-success" />
<span class="text-sm font-medium text-base-content/70">Win Rate</span>
</div>
<div class="text-3xl font-bold text-base-content">{winRate}%</div>
<div class="mt-1 text-xs text-base-content/60">
{profile.wins}W - {profile.losses}L
</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<Trophy class="h-5 w-5 text-warning" />
<span class="text-sm font-medium text-base-content/70">Total Matches</span>
</div>
<div class="text-3xl font-bold text-base-content">{totalMatches}</div>
<div class="mt-1 text-xs text-base-content/60">
{profile.wins} wins, {profile.losses} losses
</div>
</Card>
<Card padding="lg">
<div class="mb-2 flex items-center gap-2">
<Target class="h-5 w-5 text-error" />
<span class="text-sm font-medium text-base-content/70">Headshot %</span>
</div>
<div class="text-3xl font-bold text-base-content">{hsPercent}%</div>
<div class="mt-1 text-xs text-base-content/60">
{profile.headshots} headshots
</div>
</Card>
</div>
</div>
<!-- Recent Matches -->
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-2xl font-bold text-base-content">Recent Matches</h2>
<Button variant="ghost" href={`/matches?player_id=${profile.id}`}>
View All
</Button>
</div>
{#if recentMatches.length > 0}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each recentMatches as match}
<MatchCard {match} />
{/each}
</div>
{:else}
<Card padding="lg">
<div class="text-center text-base-content/60">
<Calendar class="mx-auto mb-2 h-12 w-12" />
<p>No recent matches found for this player.</p>
</div>
</Card>
{/if}
</div>
<!-- Performance Charts (Coming Soon) -->
<Card padding="lg">
<div class="text-center">
<TrendingUp class="mx-auto mb-4 h-16 w-16 text-primary" />
<h3 class="mb-2 text-xl font-semibold text-base-content">Performance Charts</h3>
<p class="mb-4 text-base-content/60">
Rating trends, map performance, favorite weapons, and more visualization coming soon.
</p>
<Badge variant="warning" size="lg">Coming in Future Update</Badge>
</div>
</Card>
</div>

View File

@@ -0,0 +1,31 @@
import { error } from '@sveltejs/kit';
import { api } from '$lib/api';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
const playerId = Number(params.id);
if (isNaN(playerId) || playerId <= 0) {
throw error(400, 'Invalid player ID');
}
try {
// Fetch player profile and recent matches in parallel
const [profile, matchesData] = await Promise.all([
api.players.getPlayerMeta(playerId),
api.matches.getMatches({ player_id: playerId, limit: 10 })
]);
return {
profile,
recentMatches: matchesData.matches,
meta: {
title: `${profile.name} - Player Profile | CS2.WTF`,
description: `View ${profile.name}'s CS2 statistics, match history, and performance metrics.`
}
};
} catch (err) {
console.error(`Failed to load player ${playerId}:`, err);
throw error(404, `Player ${playerId} not found`);
}
};