- 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>
401 lines
13 KiB
Svelte
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">< $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}
|