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:
2025-11-05 00:27:47 +01:00
parent 7d8e3a6de0
commit 62bfdc8090
11 changed files with 797 additions and 591 deletions

View File

@@ -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">&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>
<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>
{/if}

View File

@@ -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`
}
};
}
};