Files
csgowtf/src/routes/match/[id]/economy/+page.svelte
vikingowl 05a6c10458 fix: Fix match detail data loading and add API transformation layer
- Update Zod schemas to match raw API response formats
- Create transformation layer (rounds, weapons, chat) to convert raw API to structured format
- Add player name mapping in transformers for better UX
- Fix Svelte 5 reactivity issues in chat page (replace $effect with $derived)
- Fix Chart.js compatibility with Svelte 5 state proxies using JSON serialization
- Add economy advantage chart with halftime perspective flip (WIP)
- Remove stray comment from details page
- Update layout to load match data first, then pass to API methods

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 00:37:41 +01:00

401 lines
13 KiB
Svelte

<script lang="ts">
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';
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;
economyAdvantage: number; // Cumulative economy differential (teamA - teamB)
}
let { data }: { data: PageData } = $props();
const { match, roundsData } = data;
// Team IDs - Terrorists are always team_id 2, Counter-Terrorists are always team_id 3
const tTeamId = 2;
const ctTeamId = 3;
// Only process if rounds data exists
let teamEconomy = $state<TeamEconomy[]>([]);
let equipmentChartData = $state<{
labels: string[];
datasets: Array<{
label: string;
data: number[];
borderColor?: string;
backgroundColor?: string;
fill?: boolean;
tension?: number;
}>;
} | null>(null);
let economyAdvantageChartData = $state<{
labels: string[];
datasets: Array<{
label: string;
data: number[];
borderColor?: string;
backgroundColor?: string;
fill?: boolean;
tension?: number;
}>;
} | 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);
let halfRoundIndex = $state<number>(0);
if (roundsData) {
// Process rounds data to calculate team totals
for (const roundData of roundsData.rounds) {
const tPlayers = roundData.players.filter((p) => {
const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
return matchPlayer?.team_id === tTeamId;
});
const ctPlayers = roundData.players.filter((p) => {
const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
return matchPlayer?.team_id === ctTeamId;
});
const t_bank = tPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
const ct_bank = ctPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
const t_equipment = tPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
const ct_equipment = ctPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
const t_spent = tPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
const ct_spent = ctPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
const avgT_equipment = tPlayers.length > 0 ? t_equipment / tPlayers.length : 0;
const avgCT_equipment = ctPlayers.length > 0 ? ct_equipment / ctPlayers.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';
};
// Calculate per-round economy advantage using bank + spent (like old portal)
// Teams swap sides at halftime, so we need to account for perspective flip
const t_totalEconomy = t_bank + t_spent;
const ct_totalEconomy = ct_bank + ct_spent;
// Determine perspective based on round (teams swap at half)
const halfPoint = 12; // MR12 format: rounds 1-12 first half, 13-24 second half
let economyAdvantage;
if (roundData.round <= halfPoint) {
// First half: T - CT
economyAdvantage = t_totalEconomy - ct_totalEconomy;
} else {
// Second half: CT - T (teams swapped sides)
economyAdvantage = ct_totalEconomy - t_totalEconomy;
}
teamEconomy.push({
round: roundData.round,
teamA_bank: t_bank,
teamB_bank: ct_bank,
teamA_equipment: t_equipment,
teamB_equipment: ct_equipment,
teamA_spent: t_spent,
teamB_spent: ct_spent,
winner: roundData.winner || 0,
teamA_buyType: classifyBuyType(avgT_equipment),
teamB_buyType: classifyBuyType(avgCT_equipment),
economyAdvantage
});
}
// Prepare equipment value 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
}
]
};
// Prepare economy advantage chart data
// Positive = above 0, Negative = below 0
halfRoundIndex = Math.floor(teamEconomy.length / 2);
economyAdvantageChartData = {
labels: teamEconomy.map((r) => `${r.round}`),
datasets: [
{
label: 'Advantage',
data: teamEconomy.map((r) => (r.economyAdvantage > 0 ? r.economyAdvantage : 0)),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.6)',
fill: 'origin',
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4
},
{
label: 'Disadvantage',
data: teamEconomy.map((r) => (r.economyAdvantage < 0 ? r.economyAdvantage : 0)),
borderColor: 'rgb(249, 115, 22)',
backgroundColor: 'rgba(249, 115, 22, 0.6)',
fill: 'origin',
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4
}
]
};
// 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;
}
// Table columns
const tableColumns = [
{
key: 'round' as keyof TeamEconomy,
label: 'Round',
sortable: true,
align: 'center' as const
},
{
key: 'teamA_buyType' as keyof TeamEconomy,
label: 'T Buy',
sortable: true,
render: (value: string | number | boolean, _row: TeamEconomy) => {
const strValue = value as string;
const variant =
strValue === 'Full Buy'
? 'success'
: strValue === 'Eco'
? 'error'
: strValue === 'Force'
? 'warning'
: 'default';
return `<span class="badge badge-${variant} badge-sm">${strValue}</span>`;
}
},
{
key: 'teamA_equipment' as keyof TeamEconomy,
label: 'T Equipment',
sortable: true,
align: 'right' as const,
format: (value: string | number | boolean, _row: TeamEconomy) =>
`$${(value as number).toLocaleString()}`
},
{
key: 'teamB_buyType' as keyof TeamEconomy,
label: 'CT Buy',
sortable: true,
render: (value: string | number | boolean, _row: TeamEconomy) => {
const strValue = value as string;
const variant =
strValue === 'Full Buy'
? 'success'
: strValue === 'Eco'
? 'error'
: strValue === 'Force'
? 'warning'
: 'default';
return `<span class="badge badge-${variant} badge-sm">${strValue}</span>`;
}
},
{
key: 'teamB_equipment' as keyof TeamEconomy,
label: 'CT Equipment',
sortable: true,
align: 'right' as const,
format: (value: string | number | boolean, _row: TeamEconomy) =>
`$${(value as number).toLocaleString()}`
},
{
key: 'winner' as keyof TeamEconomy,
label: 'Winner',
align: 'center' as const,
render: (value: string | number | boolean, _row: TeamEconomy) => {
const numValue = value as number;
if (numValue === 2)
return '<span class="badge badge-sm" style="background-color: rgb(249, 115, 22); color: white;">T</span>';
if (numValue === 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>
{#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">
<!-- Economy Advantage Chart -->
<Card padding="lg">
<div class="mb-4">
<h2 class="text-2xl font-bold text-base-content">Economy</h2>
<p class="text-sm text-base-content/60">Net-worth differential (bank + spent)</p>
</div>
{#if economyAdvantageChartData}
<div class="relative">
<LineChart
data={economyAdvantageChartData}
height={400}
options={{
scales: {
y: {
beginAtZero: true,
grid: {
color: (context) => {
if (context.tick.value === 0) {
return 'rgba(156, 163, 175, 0.5)'; // Stronger line at 0
}
return 'rgba(156, 163, 175, 0.1)';
},
lineWidth: (context) => {
return context.tick.value === 0 ? 2 : 1;
}
}
}
},
interaction: {
mode: 'index',
intersect: false
}
}}
/>
{#if halfRoundIndex > 0}
<div
class="pointer-events-none absolute top-0 flex h-full items-center"
style="left: {(halfRoundIndex / (teamEconomy.length || 1)) * 100}%"
>
<div class="h-full w-px bg-base-content/20"></div>
<div
class="absolute -top-1 left-1/2 -translate-x-1/2 rounded bg-base-300 px-2 py-1 text-xs font-medium text-base-content/70"
>
Half-Point
</div>
</div>
{/if}
</div>
{/if}
</Card>
<!-- 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>
{#if equipmentChartData}
<LineChart data={equipmentChartData} height={350} />
{/if}
</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}