fix: Fix match page SSR, tab errors, and table consistency
- Enable SSR for match pages by detecting server vs client context in API client - Fix 500 errors on economy, chat, and details tabs by adding data loaders - Handle unparsed matches gracefully with "Match Not Parsed" messages - Fix dynamic team ID detection instead of hardcoding team IDs 2/3 - Fix DataTable component to properly render HTML in render functions - Add fixed column widths to tables for visual consistency - Add meta titles to all tab page loaders - Fix Svelte 5 $derived syntax errors - Fix ESLint errors (unused imports, any types, reactive state) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -6,25 +6,34 @@ import { APIException } from '$lib/types';
|
||||
* API Client Configuration
|
||||
*/
|
||||
const getAPIBaseURL = (): string => {
|
||||
// In production builds, use the configured URL directly
|
||||
if (import.meta.env.PROD) {
|
||||
return import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
const apiUrl = import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
|
||||
// Check if we're running on the server (SSR) or in production
|
||||
// On the server, we must use the actual API URL, not the proxy
|
||||
if (import.meta.env.SSR || import.meta.env.PROD) {
|
||||
return apiUrl;
|
||||
}
|
||||
|
||||
// In development mode, ALWAYS use the Vite proxy to avoid CORS issues
|
||||
// In development mode on the client, use the Vite proxy to avoid CORS issues
|
||||
// The proxy will forward /api requests to VITE_API_BASE_URL
|
||||
// This works regardless of whether the backend is local or remote
|
||||
return '/api';
|
||||
};
|
||||
|
||||
const API_BASE_URL = getAPIBaseURL();
|
||||
const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000;
|
||||
|
||||
// Log the API configuration in development
|
||||
// Log the API configuration
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[API Client] Development mode - using Vite proxy');
|
||||
console.log('[API Client] Frontend requests: /api/*');
|
||||
console.log('[API Client] Proxy target:', import.meta.env?.VITE_API_BASE_URL || 'http://localhost:8000');
|
||||
if (import.meta.env.SSR) {
|
||||
console.log('[API Client] SSR mode - using direct API URL:', API_BASE_URL);
|
||||
} else {
|
||||
console.log('[API Client] Browser mode - using Vite proxy');
|
||||
console.log('[API Client] Frontend requests: /api/*');
|
||||
console.log(
|
||||
'[API Client] Proxy target:',
|
||||
import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
render?: (value: T[keyof T], row: T) => unknown;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
class?: string;
|
||||
width?: string; // e.g., '200px', '30%', 'auto'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -19,6 +20,7 @@
|
||||
striped?: boolean;
|
||||
hoverable?: boolean;
|
||||
compact?: boolean;
|
||||
fixedLayout?: boolean; // Use table-layout: fixed for consistent column widths
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -27,7 +29,8 @@
|
||||
class: className = '',
|
||||
striped = false,
|
||||
hoverable = true,
|
||||
compact = false
|
||||
compact = false,
|
||||
fixedLayout = false
|
||||
}: Props = $props();
|
||||
|
||||
let sortKey = $state<keyof T | null>(null);
|
||||
@@ -68,7 +71,12 @@
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto {className}">
|
||||
<table class="table" class:table-zebra={striped} class:table-xs={compact}>
|
||||
<table
|
||||
class="table"
|
||||
class:table-zebra={striped}
|
||||
class:table-xs={compact}
|
||||
style={fixedLayout ? 'table-layout: fixed;' : ''}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
@@ -76,6 +84,7 @@
|
||||
class:cursor-pointer={column.sortable}
|
||||
class:hover:bg-base-200={column.sortable}
|
||||
class="text-{column.align || 'left'} {column.class || ''}"
|
||||
style={column.width ? `width: ${column.width}` : ''}
|
||||
onclick={() => handleSort(column)}
|
||||
>
|
||||
<div
|
||||
@@ -109,7 +118,7 @@
|
||||
{#each columns as column}
|
||||
<td class="text-{column.align || 'left'} {column.class || ''}">
|
||||
{#if column.render}
|
||||
{@render column.render(row[column.key], row)}
|
||||
{@html column.render(row[column.key], row)}
|
||||
{:else}
|
||||
{getValue(row, column)}
|
||||
{/if}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Trophy, Target, Crosshair } from 'lucide-svelte';
|
||||
import { Trophy } from 'lucide-svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import type { MatchPlayer } from '$lib/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) || [];
|
||||
// Group players by team - use dynamic team IDs from API
|
||||
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||
const firstTeamId = uniqueTeamIds[0] ?? 2;
|
||||
const secondTeamId = uniqueTeamIds[1] ?? 3;
|
||||
|
||||
const teamA = match.players?.filter((p) => p.team_id === firstTeamId) || [];
|
||||
const teamB = match.players?.filter((p) => p.team_id === secondTeamId) || [];
|
||||
|
||||
// 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);
|
||||
const sortedTeamA = teamA.sort((a, b) => b.kills - a.kills);
|
||||
const sortedTeamB = teamB.sort((a, b) => 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;
|
||||
const calcTeamStats = (players: MatchPlayer[]) => {
|
||||
const totalKills = players.reduce((sum, p) => sum + p.kills, 0);
|
||||
const totalDeaths = players.reduce((sum, p) => sum + p.deaths, 0);
|
||||
const totalADR = players.reduce((sum, p) => sum + (p.adr || 0), 0);
|
||||
const avgKAST = players.reduce((sum, p) => sum + (p.kast || 0), 0) / players.length;
|
||||
|
||||
return {
|
||||
kills: totalKills,
|
||||
@@ -41,7 +46,7 @@
|
||||
<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 class="font-mono text-3xl font-bold text-terrorist">{match.score_team_a}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -66,7 +71,7 @@
|
||||
<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 class="font-mono text-3xl font-bold text-ct">{match.score_team_b}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -101,16 +106,16 @@
|
||||
<h3 class="text-lg font-semibold text-terrorist">Terrorists</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table" style="table-layout: fixed;">
|
||||
<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>
|
||||
<th style="width: 200px;">Player</th>
|
||||
<th style="width: 80px;">K</th>
|
||||
<th style="width: 80px;">D</th>
|
||||
<th style="width: 80px;">A</th>
|
||||
<th style="width: 100px;">ADR</th>
|
||||
<th style="width: 100px;">HS%</th>
|
||||
<th style="width: 100px;">KAST%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -119,7 +124,7 @@
|
||||
<td>
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="font-medium hover:text-primary transition-colors"
|
||||
class="font-medium transition-colors hover:text-primary"
|
||||
>
|
||||
{player.name}
|
||||
</a>
|
||||
@@ -146,16 +151,16 @@
|
||||
<h3 class="text-lg font-semibold text-ct">Counter-Terrorists</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table" style="table-layout: fixed;">
|
||||
<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>
|
||||
<th style="width: 200px;">Player</th>
|
||||
<th style="width: 80px;">K</th>
|
||||
<th style="width: 80px;">D</th>
|
||||
<th style="width: 80px;">A</th>
|
||||
<th style="width: 100px;">ADR</th>
|
||||
<th style="width: 100px;">HS%</th>
|
||||
<th style="width: 100px;">KAST%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -164,7 +169,7 @@
|
||||
<td>
|
||||
<a
|
||||
href={`/player/${player.id}`}
|
||||
class="font-medium hover:text-primary transition-colors"
|
||||
class="font-medium transition-colors hover:text-primary"
|
||||
>
|
||||
{player.name}
|
||||
</a>
|
||||
@@ -191,8 +196,8 @@
|
||||
<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.
|
||||
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>
|
||||
|
||||
@@ -1,49 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { MessageSquare, Filter, Search } from 'lucide-svelte';
|
||||
import { MessageSquare, Filter, Search, AlertCircle } 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';
|
||||
|
||||
interface MessagePlayer {
|
||||
id: number;
|
||||
name: string;
|
||||
team_id: number;
|
||||
}
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { match, chatData } = data;
|
||||
|
||||
// State for filtering
|
||||
// Only process if chat data exists
|
||||
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;
|
||||
});
|
||||
})();
|
||||
let messagePlayers = $state<MessagePlayer[]>([]);
|
||||
let filteredMessages = $state<typeof chatData.messages>([]);
|
||||
let messagesByRound = $state<Record<number, typeof chatData.messages>>({});
|
||||
let rounds = $state<number[]>([]);
|
||||
let totalMessages = $state(0);
|
||||
let teamChatCount = $state(0);
|
||||
let allChatCount = $state(0);
|
||||
|
||||
// Get player info for a message
|
||||
const getPlayerInfo = (playerId: number) => {
|
||||
@@ -54,175 +36,217 @@
|
||||
};
|
||||
};
|
||||
|
||||
// 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);
|
||||
if (chatData) {
|
||||
// Get unique players who sent messages
|
||||
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 computeFilteredMessages = () => {
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
// Update filtered messages reactively
|
||||
$effect(() => {
|
||||
filteredMessages = computeFilteredMessages();
|
||||
|
||||
// Group messages by round
|
||||
messagesByRound = {};
|
||||
for (const msg of filteredMessages) {
|
||||
const round = msg.round || 0;
|
||||
if (!messagesByRound[round]) {
|
||||
messagesByRound[round] = [];
|
||||
}
|
||||
messagesByRound[round].push(msg);
|
||||
}
|
||||
|
||||
rounds = Object.keys(messagesByRound)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
});
|
||||
|
||||
// Stats
|
||||
totalMessages = chatData.messages.length;
|
||||
teamChatCount = chatData.messages.filter((m) => !m.all_chat).length;
|
||||
allChatCount = chatData.messages.filter((m) => m.all_chat).length;
|
||||
}
|
||||
|
||||
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 -->
|
||||
{#if !chatData}
|
||||
<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 class="text-center">
|
||||
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">Match Not Parsed</h2>
|
||||
<p class="mb-4 text-base-content/60">
|
||||
This match hasn't been parsed yet, so chat data is not available.
|
||||
</p>
|
||||
<Badge variant="warning" size="lg">Demo parsing required</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<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>
|
||||
|
||||
<!-- Messages -->
|
||||
{#if filteredMessages.length === 0}
|
||||
<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="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 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 min-w-[200px] flex-1">
|
||||
<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-sm input-bordered w-full pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 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}
|
||||
<!-- 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>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{: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 transition-colors hover:bg-base-200/50">
|
||||
<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="min-w-0 flex-1">
|
||||
<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 break-words text-base-content">{message.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { matchesAPI } from '$lib/api';
|
||||
import { api } 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();
|
||||
export const load: PageLoad = async ({ parent, params }) => {
|
||||
const { match } = await parent();
|
||||
|
||||
// Fetch chat messages
|
||||
const chatData = await matchesAPI.getMatchChat(params.id);
|
||||
// Only load chat data if match is parsed
|
||||
if (!match.demo_parsed) {
|
||||
return {
|
||||
match,
|
||||
chatData: null,
|
||||
meta: {
|
||||
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const chatData = await api.matches.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`
|
||||
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load chat data:', err);
|
||||
throw error(500, {
|
||||
message: 'Failed to load chat data'
|
||||
});
|
||||
console.error(`Failed to load chat data for match ${params.id}:`, err);
|
||||
// Return null instead of throwing error
|
||||
return {
|
||||
match,
|
||||
chatData: null,
|
||||
meta: {
|
||||
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Trophy, Target, Flame, Zap } from 'lucide-svelte';
|
||||
import { Trophy, Target, Flame, AlertCircle } 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';
|
||||
@@ -7,25 +7,40 @@
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { match, weaponsData } = data;
|
||||
const { match } = data;
|
||||
|
||||
// Check if we have player data to display
|
||||
const hasPlayerData = match.players && match.players.length > 0;
|
||||
|
||||
// Get unique team IDs dynamically
|
||||
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||
const firstTeamId = uniqueTeamIds[0] ?? 2;
|
||||
const secondTeamId = uniqueTeamIds[1] ?? 3;
|
||||
|
||||
// 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';
|
||||
const playersWithStats = hasPlayerData
|
||||
? (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)
|
||||
};
|
||||
});
|
||||
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);
|
||||
const sortedPlayers = hasPlayerData ? playersWithStats.sort((a, b) => b.kills - a.kills) : [];
|
||||
|
||||
// Prepare data table columns
|
||||
const detailsColumns = [
|
||||
@@ -33,18 +48,52 @@
|
||||
key: 'name',
|
||||
label: 'Player',
|
||||
sortable: true,
|
||||
render: (value: string, row: any) => {
|
||||
const teamClass = row.team_id === 2 ? 'text-terrorist' : 'text-ct';
|
||||
render: (value: string, row: (typeof playersWithStats)[0]) => {
|
||||
const teamClass = row.team_id === firstTeamId ? '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: '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: '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',
|
||||
@@ -86,148 +135,185 @@
|
||||
};
|
||||
|
||||
// Calculate team totals
|
||||
const teamAPlayers = playersWithStats.filter((p) => p.team_id === 2);
|
||||
const teamBPlayers = playersWithStats.filter((p) => p.team_id === 3);
|
||||
const teamAPlayers = hasPlayerData
|
||||
? playersWithStats.filter((p) => p.team_id === firstTeamId)
|
||||
: [];
|
||||
const teamBPlayers = hasPlayerData
|
||||
? playersWithStats.filter((p) => p.team_id === secondTeamId)
|
||||
: [];
|
||||
|
||||
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 teamAStats = hasPlayerData
|
||||
? {
|
||||
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.length > 0
|
||||
? (
|
||||
teamAPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamAPlayers.length
|
||||
).toFixed(1)
|
||||
: '0.0'
|
||||
}
|
||||
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
|
||||
|
||||
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)
|
||||
};
|
||||
const teamBStats = hasPlayerData
|
||||
? {
|
||||
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.length > 0
|
||||
? (
|
||||
teamBPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamBPlayers.length
|
||||
).toFixed(1)
|
||||
: '0.0'
|
||||
}
|
||||
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.meta.title}</title>
|
||||
<title>Match Details - CS2.WTF</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 -->
|
||||
{#if !hasPlayerData}
|
||||
<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
|
||||
<div class="text-center">
|
||||
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">No Player Data Available</h2>
|
||||
<p class="mb-4 text-base-content/60">
|
||||
Detailed player statistics are not available for this match.
|
||||
</p>
|
||||
<Badge variant="warning" size="lg">Player data unavailable</Badge>
|
||||
</div>
|
||||
<BarChart data={multiKillData} height={300} />
|
||||
</Card>
|
||||
{:else}
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<DataTable data={sortedPlayers} columns={detailsColumns} striped hoverable />
|
||||
</Card>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<DataTable data={sortedPlayers} columns={detailsColumns} striped hoverable />
|
||||
</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}
|
||||
<!-- 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 font-mono text-3xl 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 font-mono text-3xl 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 font-mono text-3xl 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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { matchesAPI } from '$lib/api';
|
||||
import { api } 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();
|
||||
export const load: PageLoad = async ({ parent, params }) => {
|
||||
const { match } = await parent();
|
||||
|
||||
// Fetch weapon statistics
|
||||
const weaponsData = await matchesAPI.getMatchWeapons(params.id);
|
||||
// Only load weapons data if match is parsed
|
||||
if (!match.demo_parsed) {
|
||||
return {
|
||||
match,
|
||||
weaponsData: null,
|
||||
meta: {
|
||||
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const weaponsData = await api.matches.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`
|
||||
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load match details:', err);
|
||||
throw error(500, {
|
||||
message: 'Failed to load match details'
|
||||
});
|
||||
console.error(`Failed to load weapons data for match ${params.id}:`, err);
|
||||
// Return null instead of throwing error
|
||||
return {
|
||||
match,
|
||||
weaponsData: null,
|
||||
meta: {
|
||||
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { DollarSign, TrendingUp, ShoppingCart } from 'lucide-svelte';
|
||||
import { TrendingUp, ShoppingCart, AlertCircle } 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';
|
||||
import type { ChartData } from 'chart.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { match, roundsData } = data;
|
||||
|
||||
// Aggregate team economy per round
|
||||
interface TeamEconomy {
|
||||
round: number;
|
||||
teamA_bank: number;
|
||||
@@ -23,84 +20,101 @@
|
||||
teamB_buyType: string;
|
||||
}
|
||||
|
||||
const teamEconomy: TeamEconomy[] = [];
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { match, roundsData } = data;
|
||||
|
||||
// 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;
|
||||
});
|
||||
// Get unique team IDs dynamically
|
||||
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||
const firstTeamId = uniqueTeamIds[0] ?? 2;
|
||||
const secondTeamId = uniqueTeamIds[1] ?? 3;
|
||||
|
||||
const teamBPlayers = roundData.players.filter((p) => {
|
||||
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
||||
return matchPlayer?.team_id === 3;
|
||||
});
|
||||
// Only process if rounds data exists
|
||||
let teamEconomy = $state<TeamEconomy[]>([]);
|
||||
let equipmentChartData = $state<ChartData<'line'> | null>(null);
|
||||
let totalRounds = $state(0);
|
||||
let teamA_fullBuys = $state(0);
|
||||
let teamB_fullBuys = $state(0);
|
||||
let teamA_ecos = $state(0);
|
||||
let teamB_ecos = $state(0);
|
||||
|
||||
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);
|
||||
if (roundsData) {
|
||||
// Process rounds data to calculate team totals
|
||||
for (const roundData of roundsData.rounds) {
|
||||
const teamAPlayers = roundData.players.filter((p) => {
|
||||
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
||||
return matchPlayer?.team_id === firstTeamId;
|
||||
});
|
||||
|
||||
// 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 teamBPlayers = roundData.players.filter((p) => {
|
||||
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
|
||||
return matchPlayer?.team_id === secondTeamId;
|
||||
});
|
||||
|
||||
const classifyBuyType = (avgEquipment: number): string => {
|
||||
if (avgEquipment < 1500) return 'Eco';
|
||||
if (avgEquipment < 2500) return 'Semi-Eco';
|
||||
if (avgEquipment < 3500) return 'Force';
|
||||
return 'Full Buy';
|
||||
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);
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
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)
|
||||
});
|
||||
// Calculate summary stats
|
||||
totalRounds = teamEconomy.length;
|
||||
teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
|
||||
teamB_fullBuys = teamEconomy.filter((r) => r.teamB_buyType === 'Full Buy').length;
|
||||
teamA_ecos = teamEconomy.filter((r) => r.teamA_buyType === 'Eco').length;
|
||||
teamB_ecos = teamEconomy.filter((r) => r.teamB_buyType === 'Eco').length;
|
||||
}
|
||||
|
||||
// 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
|
||||
// Table columns
|
||||
const tableColumns = [
|
||||
{ key: 'round', label: 'Round', sortable: true, align: 'center' as const },
|
||||
{
|
||||
@@ -124,7 +138,7 @@
|
||||
label: 'T Equipment',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
format: (value: number) => `$${value.toLocaleString()}`
|
||||
formatter: (value: number) => `$${value.toLocaleString()}`
|
||||
},
|
||||
{
|
||||
key: 'teamB_buyType',
|
||||
@@ -147,101 +161,114 @@
|
||||
label: 'CT Equipment',
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
format: (value: number) => `$${value.toLocaleString()}`
|
||||
formatter: (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>';
|
||||
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>
|
||||
{#if !roundsData}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
|
||||
<h2 class="mb-2 text-2xl font-bold text-base-content">Match Not Parsed</h2>
|
||||
<p class="mb-4 text-base-content/60">
|
||||
This match hasn't been parsed yet, so detailed economy data is not available.
|
||||
</p>
|
||||
<Badge variant="warning" size="lg">Demo parsing required</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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-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 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>
|
||||
{#if equipmentChartData}
|
||||
<LineChart data={equipmentChartData} height={350} />
|
||||
{/if}
|
||||
</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>
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<DataTable data={teamEconomy} columns={tableColumns} striped hoverable />
|
||||
</Card>
|
||||
|
||||
<!-- Buy Type Legend -->
|
||||
<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>
|
||||
<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">< $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">> $3,500 avg equipment</span>
|
||||
</div>
|
||||
</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">< $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">> $3,500 avg equipment</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { matchesAPI } from '$lib/api';
|
||||
import { api } 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();
|
||||
export const load: PageLoad = async ({ parent, params }) => {
|
||||
const { match } = await parent();
|
||||
|
||||
// Fetch round-by-round economy data
|
||||
const roundsData = await matchesAPI.getMatchRounds(params.id);
|
||||
// Only load rounds data if match is parsed
|
||||
if (!match.demo_parsed) {
|
||||
return {
|
||||
match,
|
||||
roundsData: null,
|
||||
meta: {
|
||||
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const roundsData = await api.matches.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`
|
||||
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load economy data:', err);
|
||||
throw error(500, {
|
||||
message: 'Failed to load economy data'
|
||||
});
|
||||
console.error(`Failed to load rounds data for match ${params.id}:`, err);
|
||||
// Return null instead of throwing error
|
||||
return {
|
||||
match,
|
||||
roundsData: null,
|
||||
meta: {
|
||||
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,9 +27,14 @@
|
||||
}))
|
||||
.sort((a, b) => b.enemies_blinded - a.enemies_blinded);
|
||||
|
||||
// Get unique team IDs dynamically
|
||||
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
|
||||
const firstTeamId = uniqueTeamIds[0] ?? 2;
|
||||
const secondTeamId = uniqueTeamIds[1] ?? 3;
|
||||
|
||||
// Separate by team
|
||||
const teamAFlashStats = flashStats.filter((p) => p.team_id === 2);
|
||||
const teamBFlashStats = flashStats.filter((p) => p.team_id === 3);
|
||||
const teamAFlashStats = flashStats.filter((p) => p.team_id === firstTeamId);
|
||||
const teamBFlashStats = flashStats.filter((p) => p.team_id === secondTeamId);
|
||||
|
||||
// Calculate team totals
|
||||
const calcTeamTotals = (players: typeof flashStats) => ({
|
||||
@@ -44,39 +49,44 @@
|
||||
const teamATotals = calcTeamTotals(teamAFlashStats);
|
||||
const teamBTotals = calcTeamTotals(teamBFlashStats);
|
||||
|
||||
// Table columns
|
||||
// Table columns with fixed widths for consistency across multiple tables
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Player', sortable: true },
|
||||
{ key: 'name', label: 'Player', sortable: true, width: '200px' },
|
||||
{
|
||||
key: 'enemies_blinded',
|
||||
label: 'Enemies Blinded',
|
||||
sortable: true,
|
||||
align: 'center' as const
|
||||
align: 'center' as const,
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
key: 'avg_blind_duration',
|
||||
label: 'Avg Duration (s)',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
formatter: (value: string) => `${value}s`
|
||||
formatter: (value: string) => `${value}s`,
|
||||
width: '150px'
|
||||
},
|
||||
{
|
||||
key: 'flash_assists',
|
||||
label: 'Flash Assists',
|
||||
sortable: true,
|
||||
align: 'center' as const
|
||||
align: 'center' as const,
|
||||
width: '130px'
|
||||
},
|
||||
{
|
||||
key: 'teammates_blinded',
|
||||
label: 'Team Flashed',
|
||||
sortable: true,
|
||||
align: 'center' as const
|
||||
align: 'center' as const,
|
||||
width: '130px'
|
||||
},
|
||||
{
|
||||
key: 'self_blinded',
|
||||
label: 'Self Flashed',
|
||||
sortable: true,
|
||||
align: 'center' as const
|
||||
align: 'center' as const,
|
||||
width: '130px'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
@@ -159,7 +169,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DataTable data={flashStats} {columns} striped hoverable />
|
||||
<DataTable data={flashStats} {columns} striped hoverable fixedLayout />
|
||||
</Card>
|
||||
|
||||
<!-- Team A Details -->
|
||||
@@ -167,7 +177,7 @@
|
||||
<div class="border-b border-base-300 bg-terrorist/5 p-6">
|
||||
<h3 class="text-xl font-bold text-terrorist">Terrorists - Flash Stats</h3>
|
||||
</div>
|
||||
<DataTable data={teamAFlashStats} {columns} striped hoverable />
|
||||
<DataTable data={teamAFlashStats} {columns} striped hoverable fixedLayout />
|
||||
</Card>
|
||||
|
||||
<!-- Team B Details -->
|
||||
@@ -175,7 +185,7 @@
|
||||
<div class="border-b border-base-300 bg-ct/5 p-6">
|
||||
<h3 class="text-xl font-bold text-ct">Counter-Terrorists - Flash Stats</h3>
|
||||
</div>
|
||||
<DataTable data={teamBFlashStats} {columns} striped hoverable />
|
||||
<DataTable data={teamBFlashStats} {columns} striped hoverable fixedLayout />
|
||||
</Card>
|
||||
|
||||
<!-- Info Box -->
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
});
|
||||
|
||||
// Computed filtered and sorted matches
|
||||
const displayMatches = $derived(() => {
|
||||
const displayMatches = $derived.by(() => {
|
||||
let filtered = [...matches];
|
||||
|
||||
// Apply result filter
|
||||
@@ -267,15 +267,15 @@
|
||||
{#if matches.length > 0 && resultFilter !== 'all'}
|
||||
<div class="mb-4">
|
||||
<Badge variant="info">
|
||||
Showing {displayMatches().length} of {matches.length} matches
|
||||
Showing {displayMatches.length} of {matches.length} matches
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Matches Grid -->
|
||||
{#if displayMatches().length > 0}
|
||||
{#if displayMatches.length > 0}
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each displayMatches() as match}
|
||||
{#each displayMatches as match}
|
||||
<MatchCard {match} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -299,12 +299,12 @@
|
||||
<div class="mt-8 text-center">
|
||||
<Badge variant="default">
|
||||
All matches loaded ({matches.length} total{resultFilter !== 'all'
|
||||
? `, ${displayMatches().length} shown`
|
||||
? `, ${displayMatches.length} shown`
|
||||
: ''})
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if matches.length > 0 && displayMatches().length === 0}
|
||||
{:else if matches.length > 0 && displayMatches.length === 0}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
<Calendar class="mx-auto mb-4 h-16 w-16 text-base-content/40" />
|
||||
|
||||
Reference in New Issue
Block a user