feat: Merge economy and rounds pages with unified economy utilities

- Create economyUtils.ts with team-aware buy type classification
  (CT has higher thresholds due to M4 cost)
- Add Economy Overview toggle to rounds page with charts
- Resolve player names/avatars in round economy display
- Remove standalone Economy tab (merged into Rounds)
- Document missing backend API data (round winner, win reason)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 19:58:06 +01:00
parent e27e9e8821
commit 235ef65556
8 changed files with 1151 additions and 556 deletions

View File

@@ -20,11 +20,15 @@ export function transformRoundsResponse(
): MatchRoundsResponse {
const rounds: RoundDetail[] = [];
// Create player ID to team mapping for potential future use
const playerTeamMap = new Map<string, number>();
// Create player lookup map for name, team, and avatar resolution
const playerInfoMap = new Map<string, { name: string; team_id: number; avatar: string }>();
if (match?.players) {
for (const player of match.players) {
playerTeamMap.set(player.id, player.team_id);
playerInfoMap.set(player.id, {
name: player.name,
team_id: player.team_id,
avatar: player.avatar
});
}
}
@@ -39,17 +43,29 @@ export function transformRoundsResponse(
const players: RoundStats[] = [];
// Convert player data
// Convert player data with name resolution
for (const [playerId, [bank, equipment, spent]] of Object.entries(roundData)) {
const playerInfo = playerInfoMap.get(playerId);
players.push({
round: roundNum + 1, // API uses 0-indexed, we use 1-indexed
bank,
equipment,
spent,
player_id: Number(playerId)
player_id: Number(playerId),
player_name: playerInfo?.name,
team_id: playerInfo?.team_id,
avatar: playerInfo?.avatar
});
}
// Sort players by team (CT first, then T) for consistent display
players.sort((a, b) => {
if (a.team_id !== b.team_id) {
return (a.team_id ?? 0) - (b.team_id ?? 0);
}
return (a.player_name ?? '').localeCompare(b.player_name ?? '');
});
rounds.push({
round: roundNum + 1,
// Round winner data not available from backend API

View File

@@ -26,6 +26,15 @@ export interface RoundStats {
/** Player ID for this round data */
player_id?: number;
/** Player display name (resolved from match.players) */
player_name?: string;
/** Player's team ID (2 = T, 3 = CT) */
team_id?: number;
/** Player avatar URL */
avatar?: string;
}
/**

View File

@@ -0,0 +1,214 @@
/**
* CS2 Economy Utilities
*
* Unified economy classification and display utilities for consistent
* buy type detection across the application.
*
* Thresholds based on:
* - Leetify economy groupings
* - Steam community guides
* - Professional CS2 analysis standards
*/
export type BuyType = 'pistol' | 'eco' | 'force' | 'full';
export type TeamSide = 'T' | 'CT';
export type EconomyHealth = 'healthy' | 'tight' | 'broken';
export interface BuyTypeConfig {
label: string;
color: string;
bgColor: string;
borderColor: string;
}
export interface EconomyHealthConfig {
label: string;
color: string;
bgColor: string;
description: string;
}
/**
* Buy type thresholds based on average equipment value per player
* CT side has higher thresholds due to more expensive rifles (M4 vs AK)
*/
const BUY_THRESHOLDS = {
T: {
eco: 1500,
force: 3500,
full: 3500
},
CT: {
eco: 1500,
force: 4000,
full: 4000
}
} as const;
/**
* Economy health thresholds based on average bank per player
*/
const ECONOMY_HEALTH_THRESHOLDS = {
healthy: 4000, // Can full-buy next round
tight: 2000 // Force-buy possible but risky
// Below tight = broken
} as const;
/**
* Pistol round starting money
*/
export const PISTOL_ROUND_MONEY = 800;
/**
* Visual configuration for each buy type
*/
export const BUY_TYPE_CONFIG: Record<BuyType, BuyTypeConfig> = {
pistol: {
label: 'Pistol',
color: 'text-neon-purple',
bgColor: 'bg-neon-purple/20',
borderColor: 'border-neon-purple'
},
eco: {
label: 'Eco',
color: 'text-red-400',
bgColor: 'bg-red-500/20',
borderColor: 'border-red-500'
},
force: {
label: 'Force',
color: 'text-yellow-400',
bgColor: 'bg-yellow-500/20',
borderColor: 'border-yellow-500'
},
full: {
label: 'Full Buy',
color: 'text-green-400',
bgColor: 'bg-green-500/20',
borderColor: 'border-green-500'
}
};
/**
* Visual configuration for economy health status
*/
export const ECONOMY_HEALTH_CONFIG: Record<EconomyHealth, EconomyHealthConfig> = {
healthy: {
label: 'Healthy',
color: 'text-green-400',
bgColor: 'bg-green-500/20',
description: 'Can full-buy next round'
},
tight: {
label: 'Tight',
color: 'text-yellow-400',
bgColor: 'bg-yellow-500/20',
description: 'Force-buy possible, risky'
},
broken: {
label: 'Broken',
color: 'text-red-400',
bgColor: 'bg-red-500/20',
description: 'Must eco or half-buy'
}
};
/**
* Determine buy type based on average equipment value
*
* @param avgEquipment - Average equipment value per player
* @param teamSide - Team side ('T' or 'CT')
* @param isPistolRound - Whether this is a pistol round
* @returns Buy type classification
*/
export function getBuyType(
avgEquipment: number,
teamSide: TeamSide,
isPistolRound: boolean = false
): BuyType {
if (isPistolRound) {
return 'pistol';
}
const thresholds = BUY_THRESHOLDS[teamSide];
if (avgEquipment < thresholds.eco) {
return 'eco';
}
if (avgEquipment < thresholds.force) {
return 'force';
}
return 'full';
}
/**
* Get visual configuration for a buy type
*/
export function getBuyTypeConfig(buyType: BuyType): BuyTypeConfig {
return BUY_TYPE_CONFIG[buyType];
}
/**
* Determine economy health based on average bank per player
*
* @param avgBank - Average bank per player
* @returns Economy health status
*/
export function getEconomyHealth(avgBank: number): EconomyHealth {
if (avgBank >= ECONOMY_HEALTH_THRESHOLDS.healthy) {
return 'healthy';
}
if (avgBank >= ECONOMY_HEALTH_THRESHOLDS.tight) {
return 'tight';
}
return 'broken';
}
/**
* Get visual configuration for economy health
*/
export function getEconomyHealthConfig(health: EconomyHealth): EconomyHealthConfig {
return ECONOMY_HEALTH_CONFIG[health];
}
/**
* Check if a round is a pistol round
*
* @param roundNumber - Current round number (1-indexed)
* @param halftimeRound - The halftime round number (12 for MR12, 15 for MR15)
* @returns Whether this is a pistol round
*/
export function isPistolRound(roundNumber: number, halftimeRound: number): boolean {
return roundNumber === 1 || roundNumber === halftimeRound + 1;
}
/**
* Get the halftime round based on max rounds
*
* @param maxRounds - Maximum rounds in the match (24 for MR12, 30 for MR15)
* @returns Halftime round number
*/
export function getHalftimeRound(maxRounds: number): number {
return maxRounds === 30 ? 15 : 12;
}
/**
* Calculate total team economy (bank + equipment value)
*
* @param totalBank - Total team bank
* @param totalEquipment - Total team equipment value
* @returns Combined economy value
*/
export function calculateTeamEconomy(totalBank: number, totalEquipment: number): number {
return totalBank + totalEquipment;
}
/**
* Format money value for display
*
* @param value - Money value
* @returns Formatted string with $ prefix and comma separators
*/
export function formatMoney(value: number): string {
return `$${value.toLocaleString()}`;
}

View File

@@ -1,7 +1,15 @@
<script lang="ts">
import { Download, Calendar, Clock, ArrowLeft, Server, Users } from 'lucide-svelte';
import {
Download,
Calendar,
Clock,
ArrowLeft,
Server,
Users,
CheckCircle2,
Timer
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import Badge from '$lib/components/ui/Badge.svelte';
import Tabs from '$lib/components/ui/Tabs.svelte';
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
import type { LayoutData } from './$types';
@@ -17,7 +25,7 @@
const tabs = [
{ label: 'Overview', href: `/match/${match.match_id}` },
{ label: 'Economy', href: `/match/${match.match_id}/economy` },
{ label: 'Rounds', href: `/match/${match.match_id}/rounds` },
{ label: 'Details', href: `/match/${match.match_id}/details` },
{ label: 'Weapons', href: `/match/${match.match_id}/weapons` },
{ label: 'Flashes', href: `/match/${match.match_id}/flashes` },
@@ -150,37 +158,54 @@
</div>
</div>
<!-- Match Meta -->
<div class="flex flex-wrap items-center justify-center gap-3 text-sm text-white/70">
<div class="flex items-center gap-1.5">
<Calendar class="h-3.5 w-3.5 text-neon-blue" />
<span>{formattedDate}</span>
<!-- Match Meta Grid -->
<div class="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6">
<!-- Date -->
<div class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
<Calendar class="h-4 w-4 shrink-0 text-neon-blue" />
<span class="truncate text-xs text-white/70">{formattedDate}</span>
</div>
<span class="text-white/20"></span>
<div class="flex items-center gap-1.5">
<Clock class="h-3.5 w-3.5 text-neon-blue" />
<span>{duration}</span>
<!-- Duration -->
<div class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
<Timer class="h-4 w-4 shrink-0 text-neon-green" />
<span class="text-xs text-white/70">{duration}</span>
</div>
<span class="text-white/20"></span>
<span>MR12 ({match.max_rounds} rounds)</span>
<!-- Rounds -->
<div class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
<Clock class="h-4 w-4 shrink-0 text-neon-purple" />
<span class="text-xs text-white/70">{match.max_rounds} rounds</span>
</div>
<!-- Tick Rate -->
{#if match.tick_rate}
<span class="text-white/20"></span>
<div class="flex items-center gap-1.5">
<Server class="h-3.5 w-3.5 text-neon-purple" />
<span class="font-mono">{match.tick_rate} tick</span>
<div
class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2"
>
<Server class="h-4 w-4 shrink-0 text-neon-gold" />
<span class="font-mono text-xs text-white/70">{match.tick_rate} tick</span>
</div>
{/if}
<!-- Avg Rating -->
{#if match.avg_rank && match.avg_rank > 0}
<span class="text-white/20"></span>
<div class="flex items-center gap-1.5">
<Users class="h-3.5 w-3.5 text-neon-gold" />
<span>Avg Rating:</span>
<div
class="flex items-center gap-2 rounded-lg border border-white/5 bg-white/5 px-3 py-2"
>
<Users class="h-4 w-4 shrink-0 text-neon-blue" />
<PremierRatingBadge rating={match.avg_rank} size="sm" showTier={false} />
</div>
{/if}
<!-- Demo Status -->
{#if match.demo_parsed}
<span class="text-white/20"></span>
<Badge variant="success" size="sm">Demo Parsed</Badge>
<div
class="flex items-center gap-2 rounded-lg border border-neon-green/20 bg-neon-green/10 px-3 py-2"
>
<CheckCircle2 class="h-4 w-4 shrink-0 text-neon-green" />
<span class="text-xs font-medium text-neon-green">Parsed</span>
</div>
{/if}
</div>
</div>

View File

@@ -1,513 +0,0 @@
<script lang="ts">
import { TrendingUp, ShoppingCart, AlertCircle, Wallet, DollarSign } 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;
}
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;
// Calculate halftime round based on max_rounds
const halfPoint = match.max_rounds === 30 ? 15 : 12;
// 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';
};
const t_totalEconomy = t_bank + t_spent;
const ct_totalEconomy = ct_bank + ct_spent;
let economyAdvantage;
if (roundData.round <= halfPoint) {
economyAdvantage = t_totalEconomy - ct_totalEconomy;
} else {
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 with neon colors
equipmentChartData = {
labels: teamEconomy.map((r) => `R${r.round}`),
datasets: [
{
label: 'Terrorists Equipment',
data: teamEconomy.map((r) => r.teamA_equipment),
borderColor: '#d4a74a', // terrorist color
backgroundColor: 'rgba(212, 167, 74, 0.1)',
fill: true,
tension: 0.4
},
{
label: 'Counter-Terrorists Equipment',
data: teamEconomy.map((r) => r.teamB_equipment),
borderColor: '#5e98d9', // ct color
backgroundColor: 'rgba(94, 152, 217, 0.1)',
fill: true,
tension: 0.4
}
]
};
// Prepare economy advantage chart data
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: '#5e98d9',
backgroundColor: 'rgba(94, 152, 217, 0.6)',
// @ts-expect-error - Chart.js types incorrectly show fill as boolean, but 'origin' is valid
fill: 'origin',
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4
},
{
label: 'Disadvantage',
data: teamEconomy.map((r) => (r.economyAdvantage < 0 ? r.economyAdvantage : 0)),
borderColor: '#d4a74a',
backgroundColor: 'rgba(212, 167, 74, 0.6)',
// @ts-expect-error - Chart.js types incorrectly show fill as boolean, but 'origin' is valid
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;
}
// Buy type labels with puns
const buyTypeLabels: Record<string, string> = {
Eco: 'The Poverty Round',
'Semi-Eco': 'Broke but Hopeful',
Force: 'YOLO Buy',
'Full Buy': 'Loaded'
};
// 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 colorClass =
strValue === 'Full Buy'
? 'bg-neon-green/20 text-neon-green border-neon-green/30'
: strValue === 'Eco'
? 'bg-neon-red/20 text-neon-red border-neon-red/30'
: strValue === 'Force'
? 'bg-neon-gold/20 text-neon-gold border-neon-gold/30'
: 'bg-white/10 text-white/60 border-white/20';
return `<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border ${colorClass}">${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 colorClass =
strValue === 'Full Buy'
? 'bg-neon-green/20 text-neon-green border-neon-green/30'
: strValue === 'Eco'
? 'bg-neon-red/20 text-neon-red border-neon-red/30'
: strValue === 'Force'
? 'bg-neon-gold/20 text-neon-gold border-neon-gold/30'
: 'bg-white/10 text-white/60 border-white/20';
return `<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border ${colorClass}">${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="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-bold bg-terrorist/20 text-terrorist border border-terrorist/30">T</span>';
if (numValue === 3)
return '<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-bold bg-ct/20 text-ct border border-ct/30">CT</span>';
return '<span class="text-white/30">-</span>';
}
}
];
</script>
{#if !roundsData}
<Card padding="lg">
<div class="text-center">
<AlertCircle
class="mx-auto mb-4 h-16 w-16 text-neon-gold"
style="filter: drop-shadow(0 0 15px rgba(255, 215, 0, 0.4));"
/>
<h2 class="mb-2 text-2xl font-bold text-white">Match Not Parsed</h2>
<p class="mb-4 text-white/60">
This match hasn't been parsed yet, so detailed economy data is not available. The evidence
of everyone's financial decisions remains hidden.
</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 flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-green/20"
style="box-shadow: 0 0 15px rgba(0, 255, 136, 0.2);"
>
<DollarSign class="h-5 w-5 text-neon-green" />
</div>
<div>
<h2 class="text-2xl font-bold text-white">Economy Flow</h2>
<p class="text-sm text-white/50">
Net-worth differential (bank + spent) - The money story
</p>
</div>
</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(255, 255, 255, 0.3)';
}
return 'rgba(255, 255, 255, 0.05)';
},
lineWidth: (context) => {
return context.tick.value === 0 ? 2 : 1;
}
},
ticks: {
color: 'rgba(255, 255, 255, 0.5)'
}
},
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)'
},
ticks: {
color: 'rgba(255, 255, 255, 0.5)'
}
}
},
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
labels: {
color: 'rgba(255, 255, 255, 0.7)'
}
}
}
}}
/>
{#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-neon-blue/30"></div>
<div
class="absolute -top-1 left-1/2 -translate-x-1/2 rounded-md border border-neon-blue/30 bg-void-light px-2 py-1 text-xs font-medium text-neon-blue"
>
Half-Time
</div>
</div>
{/if}
</div>
{/if}
</Card>
<!-- Summary Cards -->
<div class="grid gap-6 md:grid-cols-3">
<Card padding="lg">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-blue/20"
style="box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);"
>
<ShoppingCart class="h-5 w-5 text-neon-blue" />
</div>
<div>
<div class="text-sm text-white/50">Total Rounds</div>
<div class="text-3xl font-bold text-white">{totalRounds}</div>
</div>
</div>
<div class="mt-2 text-xs text-white/40">
{match.score_team_a} - {match.score_team_b}
</div>
</Card>
<Card padding="lg" class="border-l-4 border-l-terrorist">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-terrorist/20"
style="box-shadow: 0 0 15px rgba(212, 167, 74, 0.2);"
>
<TrendingUp class="h-5 w-5 text-terrorist" />
</div>
<div>
<div class="text-sm text-white/50">T Full Buys</div>
<div class="text-3xl font-bold text-white">{teamA_fullBuys}</div>
</div>
</div>
<div class="mt-2 text-xs text-neon-red">{teamA_ecos} poverty rounds</div>
</Card>
<Card padding="lg" class="border-l-4 border-l-ct">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-ct/20"
style="box-shadow: 0 0 15px rgba(94, 152, 217, 0.2);"
>
<TrendingUp class="h-5 w-5 text-ct" />
</div>
<div>
<div class="text-sm text-white/50">CT Full Buys</div>
<div class="text-3xl font-bold text-white">{teamB_fullBuys}</div>
</div>
</div>
<div class="mt-2 text-xs text-neon-red">{teamB_ecos} poverty rounds</div>
</Card>
</div>
<!-- Equipment Value Chart -->
<Card padding="lg">
<div class="mb-4 flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-purple/20"
style="box-shadow: 0 0 15px rgba(139, 92, 246, 0.2);"
>
<Wallet class="h-5 w-5 text-neon-purple" />
</div>
<div>
<h2 class="text-2xl font-bold text-white">Equipment Value Over Time</h2>
<p class="text-sm text-white/50">Total equipment value for each team across all rounds</p>
</div>
</div>
{#if equipmentChartData}
<LineChart
data={equipmentChartData}
height={350}
options={{
scales: {
y: {
grid: {
color: 'rgba(255, 255, 255, 0.05)'
},
ticks: {
color: 'rgba(255, 255, 255, 0.5)'
}
},
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)'
},
ticks: {
color: 'rgba(255, 255, 255, 0.5)'
}
}
},
plugins: {
legend: {
labels: {
color: 'rgba(255, 255, 255, 0.7)'
}
}
}
}}
/>
{/if}
</Card>
<!-- Round-by-Round Table -->
<Card padding="none">
<div class="p-6">
<h2 class="text-2xl font-bold text-white">Round-by-Round Economy</h2>
<p class="mt-1 text-sm text-white/50">
Detailed breakdown of buy types and equipment values - Where did all the money go?
</p>
</div>
<DataTable data={teamEconomy} columns={tableColumns} striped hoverable />
</Card>
<!-- Buy Type Legend -->
<Card padding="lg" class="border-neon-blue/20">
<h3 class="mb-3 text-lg font-semibold text-white">
Buy Type Classification (A Financial Guide)
</h3>
<div class="flex flex-wrap gap-4 text-sm">
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-md border border-neon-red/30 bg-neon-red/20 px-2 py-0.5 text-xs font-medium text-neon-red"
>
Eco
</span>
<span class="text-white/50">&lt; $1,500 avg - "{buyTypeLabels['Eco']}"</span>
</div>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-md border border-white/20 bg-white/10 px-2 py-0.5 text-xs font-medium text-white/60"
>
Semi-Eco
</span>
<span class="text-white/50">$1,500 - $2,500 - "{buyTypeLabels['Semi-Eco']}"</span>
</div>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-md border border-neon-gold/30 bg-neon-gold/20 px-2 py-0.5 text-xs font-medium text-neon-gold"
>
Force
</span>
<span class="text-white/50">$2,500 - $3,500 - "{buyTypeLabels['Force']}"</span>
</div>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-md border border-neon-green/30 bg-neon-green/20 px-2 py-0.5 text-xs font-medium text-neon-green"
>
Full Buy
</span>
<span class="text-white/50">&gt; $3,500 - "{buyTypeLabels['Full Buy']}"</span>
</div>
</div>
</Card>
</div>
{/if}

View File

@@ -1,14 +0,0 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
// Get all data from parent layout (already loaded upfront)
const { match, rounds } = await parent();
return {
match,
roundsData: rounds,
meta: {
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - teamflash.rip`
}
};
};

View File

@@ -0,0 +1,751 @@
<script lang="ts">
import {
DollarSign,
Info,
ChevronLeft,
ChevronRight,
TrendingUp,
Minus,
BarChart3,
Wallet
} from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import LineChart from '$lib/components/charts/LineChart.svelte';
import type { LayoutData } from '../$types';
import type { RoundDetail, RoundStats } from '$lib/types/RoundStats';
import {
getBuyType,
getBuyTypeConfig,
getEconomyHealth,
getEconomyHealthConfig,
isPistolRound as checkIsPistolRound,
getHalftimeRound,
formatMoney,
PISTOL_ROUND_MONEY,
BUY_TYPE_CONFIG,
type BuyType
} from '$lib/utils/economyUtils';
let { data }: { data: LayoutData } = $props();
const { match, rounds } = data;
// Calculate halftime round based on max_rounds
const halftimeRound = $derived(getHalftimeRound(match.max_rounds));
const totalRounds = $derived(rounds?.rounds?.length ?? 0);
// Current selected round
let selectedRound = $state(1);
// Get current round data
const currentRoundData = $derived(
rounds?.rounds?.find((r: RoundDetail) => r.round === selectedRound)
);
// Determine if we're in second half (teams have swapped sides)
const isSecondHalf = $derived(selectedRound > halftimeRound);
// In first half: team_id 1 = T, team_id 2 = CT
// In second half: team_id 1 = CT, team_id 2 = T (swapped!)
const terroristTeamId = $derived(isSecondHalf ? 2 : 1);
const ctTeamId = $derived(isSecondHalf ? 1 : 2);
// Pistol round detection using unified utility
const isPistolRound = $derived(checkIsPistolRound(selectedRound, halftimeRound));
// Helper to get player bank (override for pistol rounds)
const getPlayerBank = (player: RoundStats) => (isPistolRound ? PISTOL_ROUND_MONEY : player.bank);
// Group players by team for current round (accounting for side swap)
const teamAPlayers = $derived(
currentRoundData?.players?.filter((p: RoundStats) => p.team_id === terroristTeamId) ?? []
);
const teamBPlayers = $derived(
currentRoundData?.players?.filter((p: RoundStats) => p.team_id === ctTeamId) ?? []
);
// Calculate team economy totals (with pistol round override)
const calcTeamEconomy = (players: RoundStats[]) => {
const totalBank = isPistolRound
? players.length * PISTOL_ROUND_MONEY
: players.reduce((sum, p) => sum + p.bank, 0);
const totalEquipment = players.reduce((sum, p) => sum + p.equipment, 0);
const totalSpent = players.reduce((sum, p) => sum + p.spent, 0);
const avgBank = players.length > 0 ? totalBank / players.length : 0;
const avgEquipment = players.length > 0 ? totalEquipment / players.length : 0;
return { totalBank, totalEquipment, totalSpent, avgBank, avgEquipment };
};
const teamAEconomy = $derived(calcTeamEconomy(teamAPlayers));
const teamBEconomy = $derived(calcTeamEconomy(teamBPlayers));
// Get buy types using unified utilities with team-aware thresholds
const teamABuyType = $derived(getBuyType(teamAEconomy.avgEquipment, 'T', isPistolRound));
const teamBBuyType = $derived(getBuyType(teamBEconomy.avgEquipment, 'CT', isPistolRound));
const teamABuyConfig = $derived(getBuyTypeConfig(teamABuyType));
const teamBBuyConfig = $derived(getBuyTypeConfig(teamBBuyType));
// Get economy health using unified utilities
const teamAHealth = $derived(getEconomyHealth(teamAEconomy.avgBank));
const teamBHealth = $derived(getEconomyHealth(teamBEconomy.avgBank));
const teamAHealthConfig = $derived(getEconomyHealthConfig(teamAHealth));
const teamBHealthConfig = $derived(getEconomyHealthConfig(teamBHealth));
// Navigate rounds
const nextRound = () => {
if (selectedRound < totalRounds) selectedRound++;
};
const prevRound = () => {
if (selectedRound > 1) selectedRound--;
};
// Check if we have round data
const hasRoundData = $derived(rounds && rounds.rounds && rounds.rounds.length > 0);
// Economy advantage calculation
const economyAdvantage = $derived(
teamAEconomy.totalBank +
teamAEconomy.totalEquipment -
(teamBEconomy.totalBank + teamBEconomy.totalEquipment)
);
// Calculate economy data for all rounds (for charts)
interface RoundEconomyData {
round: number;
t_bank: number;
ct_bank: number;
t_equipment: number;
ct_equipment: number;
t_buyType: BuyType;
ct_buyType: BuyType;
economyAdvantage: number;
}
const allRoundsEconomy = $derived.by(() => {
if (!rounds?.rounds) return [];
return rounds.rounds.map((roundData: RoundDetail) => {
const roundNum = roundData.round;
const roundIsSecondHalf = roundNum > halftimeRound;
const tTeamId = roundIsSecondHalf ? 2 : 1;
const ctTeamIdForRound = roundIsSecondHalf ? 1 : 2;
const tPlayers = roundData.players?.filter((p: RoundStats) => p.team_id === tTeamId) ?? [];
const ctPlayers =
roundData.players?.filter((p: RoundStats) => p.team_id === ctTeamIdForRound) ?? [];
const roundIsPistol = checkIsPistolRound(roundNum, halftimeRound);
const t_bank = roundIsPistol
? tPlayers.length * PISTOL_ROUND_MONEY
: tPlayers.reduce((sum, p) => sum + p.bank, 0);
const ct_bank = roundIsPistol
? ctPlayers.length * PISTOL_ROUND_MONEY
: ctPlayers.reduce((sum, p) => sum + p.bank, 0);
const t_equipment = tPlayers.reduce((sum, p) => sum + p.equipment, 0);
const ct_equipment = ctPlayers.reduce((sum, p) => sum + p.equipment, 0);
const avgT_equipment = tPlayers.length > 0 ? t_equipment / tPlayers.length : 0;
const avgCT_equipment = ctPlayers.length > 0 ? ct_equipment / ctPlayers.length : 0;
// Calculate economy advantage (positive = T advantage in first half, CT advantage in second half)
const t_total = t_bank + t_equipment;
const ct_total = ct_bank + ct_equipment;
const economyAdv = roundIsSecondHalf ? ct_total - t_total : t_total - ct_total;
return {
round: roundNum,
t_bank,
ct_bank,
t_equipment,
ct_equipment,
t_buyType: getBuyType(avgT_equipment, 'T', roundIsPistol),
ct_buyType: getBuyType(avgCT_equipment, 'CT', roundIsPistol),
economyAdvantage: economyAdv
} as RoundEconomyData;
});
});
// Chart data for economy flow
const economyFlowChartData = $derived.by(() => {
if (!allRoundsEconomy.length) return null;
return {
labels: allRoundsEconomy.map((r) => `${r.round}`),
datasets: [
{
label: 'Advantage',
data: allRoundsEconomy.map((r) => (r.economyAdvantage > 0 ? r.economyAdvantage : 0)),
borderColor: '#5e98d9',
backgroundColor: 'rgba(94, 152, 217, 0.6)',
fill: true,
tension: 0.4
},
{
label: 'Disadvantage',
data: allRoundsEconomy.map((r) => (r.economyAdvantage < 0 ? r.economyAdvantage : 0)),
borderColor: '#d4a74a',
backgroundColor: 'rgba(212, 167, 74, 0.6)',
fill: true,
tension: 0.4
}
]
};
});
// Chart data for equipment value over time
const equipmentChartData = $derived.by(() => {
if (!allRoundsEconomy.length) return null;
return {
labels: allRoundsEconomy.map((r) => `R${r.round}`),
datasets: [
{
label: 'Terrorists',
data: allRoundsEconomy.map((r) => r.t_equipment),
borderColor: '#d4a74a',
backgroundColor: 'rgba(212, 167, 74, 0.1)',
fill: true,
tension: 0.4
},
{
label: 'Counter-Terrorists',
data: allRoundsEconomy.map((r) => r.ct_equipment),
borderColor: '#5e98d9',
backgroundColor: 'rgba(94, 152, 217, 0.1)',
fill: true,
tension: 0.4
}
]
};
});
// Summary stats
const fullBuyCount = $derived({
t: allRoundsEconomy.filter((r) => r.t_buyType === 'full').length,
ct: allRoundsEconomy.filter((r) => r.ct_buyType === 'full').length
});
const ecoCount = $derived({
t: allRoundsEconomy.filter((r) => r.t_buyType === 'eco').length,
ct: allRoundsEconomy.filter((r) => r.ct_buyType === 'eco').length
});
// Toggle for showing charts vs details
let showCharts = $state(false);
</script>
<div class="space-y-6">
{#if hasRoundData}
<!-- View Toggle -->
<div class="flex items-center justify-center">
<div class="inline-flex rounded-lg border border-white/10 bg-void-light p-1">
<button
onclick={() => (showCharts = false)}
class="flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all {!showCharts
? 'bg-neon-blue/20 text-neon-blue'
: 'text-white/60 hover:text-white'}"
>
<DollarSign class="h-4 w-4" />
Round Details
</button>
<button
onclick={() => (showCharts = true)}
class="flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-all {showCharts
? 'bg-neon-blue/20 text-neon-blue'
: 'text-white/60 hover:text-white'}"
>
<BarChart3 class="h-4 w-4" />
Economy Overview
</button>
</div>
</div>
{#if showCharts}
<!-- Economy Overview Charts -->
<div class="space-y-6">
<!-- Summary Stats -->
<div class="grid gap-4 md:grid-cols-3">
<Card padding="lg">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-blue/20">
<BarChart3 class="h-5 w-5 text-neon-blue" />
</div>
<div>
<div class="text-sm text-white/50">Total Rounds</div>
<div class="text-3xl font-bold text-white">{totalRounds}</div>
</div>
</div>
</Card>
<Card padding="lg" class="border-l-4 border-l-terrorist">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-terrorist/20">
<TrendingUp class="h-5 w-5 text-terrorist" />
</div>
<div>
<div class="text-sm text-white/50">T Full Buys</div>
<div class="text-3xl font-bold text-white">{fullBuyCount.t}</div>
</div>
</div>
<div class="mt-2 text-xs text-red-400">{ecoCount.t} eco rounds</div>
</Card>
<Card padding="lg" class="border-l-4 border-l-ct">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-ct/20">
<TrendingUp class="h-5 w-5 text-ct" />
</div>
<div>
<div class="text-sm text-white/50">CT Full Buys</div>
<div class="text-3xl font-bold text-white">{fullBuyCount.ct}</div>
</div>
</div>
<div class="mt-2 text-xs text-red-400">{ecoCount.ct} eco rounds</div>
</Card>
</div>
<!-- Economy Flow Chart -->
{#if economyFlowChartData}
<Card padding="lg">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-green/20">
<DollarSign class="h-5 w-5 text-neon-green" />
</div>
<div>
<h2 class="text-2xl font-bold text-white">Economy Flow</h2>
<p class="text-sm text-white/50">Net-worth differential over time</p>
</div>
</div>
<div class="relative">
<LineChart
data={economyFlowChartData}
height={350}
options={{
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
},
x: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
}
},
plugins: {
legend: { labels: { color: 'rgba(255, 255, 255, 0.7)' } }
}
}}
/>
{#if totalRounds > halftimeRound}
<div
class="pointer-events-none absolute top-0 flex h-full items-center"
style="left: {(halftimeRound / totalRounds) * 100}%"
>
<div class="h-full w-px bg-neon-purple/40"></div>
<div
class="absolute -top-1 left-1/2 -translate-x-1/2 rounded-md border border-neon-purple/30 bg-void-light px-2 py-1 text-xs font-medium text-neon-purple"
>
Half
</div>
</div>
{/if}
</div>
</Card>
{/if}
<!-- Equipment Value Chart -->
{#if equipmentChartData}
<Card padding="lg">
<div class="mb-4 flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-neon-purple/20">
<Wallet class="h-5 w-5 text-neon-purple" />
</div>
<div>
<h2 class="text-2xl font-bold text-white">Equipment Value</h2>
<p class="text-sm text-white/50">Total equipment value per team across rounds</p>
</div>
</div>
<LineChart
data={equipmentChartData}
height={300}
options={{
scales: {
y: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
},
x: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
}
},
plugins: {
legend: { labels: { color: 'rgba(255, 255, 255, 0.7)' } }
}
}}
/>
</Card>
{/if}
</div>
{:else}
<!-- Round Details View -->
<!-- Round Selector Header -->
<Card padding="lg">
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-white">Round Economy</h2>
<p class="mt-1 text-sm text-white/50">
Track team economy and buy patterns throughout the match
</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={prevRound}
disabled={selectedRound <= 1}
class="rounded-lg border border-white/10 bg-white/5 p-2 transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronLeft class="h-5 w-5 text-white/70" />
</button>
<div class="min-w-[100px] text-center">
<span class="font-mono text-xl font-bold text-white">Round {selectedRound}</span>
<span class="text-white/40"> / {totalRounds}</span>
</div>
<button
onclick={nextRound}
disabled={selectedRound >= totalRounds}
class="rounded-lg border border-white/10 bg-white/5 p-2 transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronRight class="h-5 w-5 text-white/70" />
</button>
</div>
</div>
<!-- Economy Advantage Indicator -->
<div class="flex items-center justify-center gap-4">
<div class="flex items-center gap-2">
{#if economyAdvantage > 1000}
<TrendingUp class="h-4 w-4 text-terrorist" />
<span class="text-sm font-medium text-terrorist"
>T Advantage: {formatMoney(Math.abs(economyAdvantage))}</span
>
{:else if economyAdvantage < -1000}
<TrendingUp class="h-4 w-4 text-ct" />
<span class="text-sm font-medium text-ct"
>CT Advantage: {formatMoney(Math.abs(economyAdvantage))}</span
>
{:else}
<Minus class="h-4 w-4 text-white/50" />
<span class="text-sm font-medium text-white/50">Economy Even</span>
{/if}
</div>
</div>
<!-- Round Timeline -->
<div class="flex flex-wrap items-center justify-center gap-1">
{#each { length: totalRounds } as _, index}
{@const roundNum = index + 1}
{@const isHalftime = roundNum === halftimeRound && totalRounds > halftimeRound}
{@const isSelected = roundNum === selectedRound}
{@const isFirstHalf = roundNum <= halftimeRound}
{@const roundIsPistol = checkIsPistolRound(roundNum, halftimeRound)}
<button
onclick={() => (selectedRound = roundNum)}
class="relative flex h-8 w-8 items-center justify-center rounded-md text-xs font-bold transition-all
{isSelected
? 'bg-neon-blue text-white ring-2 ring-neon-blue/50'
: roundIsPistol
? 'bg-neon-purple/30 text-neon-purple hover:bg-neon-purple/40'
: isFirstHalf
? 'bg-terrorist/20 text-terrorist hover:bg-terrorist/30'
: 'bg-ct/20 text-ct hover:bg-ct/30'}"
title="Round {roundNum}{roundIsPistol ? ' (Pistol)' : ''}"
>
{roundNum}
</button>
{#if isHalftime}
<div
class="mx-2 flex items-center gap-1 rounded-full bg-neon-purple/20 px-3 py-1 text-xs font-medium text-neon-purple"
>
<span>HT</span>
</div>
{/if}
{/each}
</div>
</div>
</Card>
<!-- Team Economy Cards -->
<div class="grid gap-6 lg:grid-cols-2">
<!-- Team A (Terrorists) -->
<Card padding="none" class="overflow-hidden border-l-4 border-l-terrorist">
<!-- Team Header -->
<div class="flex items-center justify-between bg-terrorist/10 px-6 py-4">
<div class="flex items-center gap-3">
<h3 class="text-xl font-bold text-terrorist">Terrorists</h3>
<span
class={`rounded-full px-3 py-1 text-xs font-semibold ${teamABuyConfig.bgColor} ${teamABuyConfig.color}`}
>
{teamABuyConfig.label}
</span>
</div>
<div class="text-right">
<div class="flex items-center justify-end gap-2">
<span class={`text-xs ${teamAHealthConfig.color}`}>{teamAHealthConfig.label}</span>
</div>
<div class="font-mono text-lg font-bold text-neon-green">
{formatMoney(teamAEconomy.totalBank)}
</div>
</div>
</div>
<!-- Team Stats Summary -->
<div class="grid grid-cols-3 gap-4 border-b border-white/10 bg-void/30 px-6 py-3">
<div class="text-center">
<div class="text-xs text-white/40">Total Bank</div>
<div class="font-mono text-sm font-semibold text-neon-green">
{formatMoney(teamAEconomy.totalBank)}
</div>
</div>
<div class="text-center">
<div class="text-xs text-white/40">Equipment</div>
<div class="font-mono text-sm font-semibold text-white">
{formatMoney(teamAEconomy.totalEquipment)}
</div>
</div>
<div class="text-center">
<div class="text-xs text-white/40">Spent</div>
<div class="font-mono text-sm font-semibold text-neon-red">
{formatMoney(teamAEconomy.totalSpent)}
</div>
</div>
</div>
<!-- Player List -->
<div class="divide-y divide-white/5">
{#each teamAPlayers as player}
<a
href="/player/{player.player_id}"
class="flex items-center gap-4 px-6 py-3 transition-colors hover:bg-white/5"
>
<!-- Avatar -->
{#if player.avatar}
<img
src={player.avatar}
alt={player.player_name || 'Player'}
class="h-10 w-10 rounded-full border border-terrorist/30"
/>
{:else}
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-terrorist/20 text-terrorist"
>
<DollarSign class="h-5 w-5" />
</div>
{/if}
<!-- Player Info -->
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-white">
{player.player_name || `Player ${player.player_id}`}
</div>
<div class="flex items-center gap-3 text-xs text-white/50">
<span
>Bank: <span class="text-neon-green"
>{formatMoney(getPlayerBank(player))}</span
></span
>
<span
>Equip: <span class="text-white/70">{formatMoney(player.equipment)}</span
></span
>
</div>
</div>
<!-- Spent -->
<div class="text-right">
<div class="text-xs text-white/40">Spent</div>
<div
class="font-mono text-sm font-semibold {player.spent > 0
? 'text-neon-red'
: 'text-white/30'}"
>
{player.spent > 0 ? `-${formatMoney(player.spent)}` : '$0'}
</div>
</div>
</a>
{/each}
{#if teamAPlayers.length === 0}
<div class="px-6 py-8 text-center text-white/40">No player data available</div>
{/if}
</div>
</Card>
<!-- Team B (Counter-Terrorists) -->
<Card padding="none" class="overflow-hidden border-l-4 border-l-ct">
<!-- Team Header -->
<div class="flex items-center justify-between bg-ct/10 px-6 py-4">
<div class="flex items-center gap-3">
<h3 class="text-xl font-bold text-ct">Counter-Terrorists</h3>
<span
class={`rounded-full px-3 py-1 text-xs font-semibold ${teamBBuyConfig.bgColor} ${teamBBuyConfig.color}`}
>
{teamBBuyConfig.label}
</span>
</div>
<div class="text-right">
<div class="flex items-center justify-end gap-2">
<span class={`text-xs ${teamBHealthConfig.color}`}>{teamBHealthConfig.label}</span>
</div>
<div class="font-mono text-lg font-bold text-neon-green">
{formatMoney(teamBEconomy.totalBank)}
</div>
</div>
</div>
<!-- Team Stats Summary -->
<div class="grid grid-cols-3 gap-4 border-b border-white/10 bg-void/30 px-6 py-3">
<div class="text-center">
<div class="text-xs text-white/40">Total Bank</div>
<div class="font-mono text-sm font-semibold text-neon-green">
{formatMoney(teamBEconomy.totalBank)}
</div>
</div>
<div class="text-center">
<div class="text-xs text-white/40">Equipment</div>
<div class="font-mono text-sm font-semibold text-white">
{formatMoney(teamBEconomy.totalEquipment)}
</div>
</div>
<div class="text-center">
<div class="text-xs text-white/40">Spent</div>
<div class="font-mono text-sm font-semibold text-neon-red">
{formatMoney(teamBEconomy.totalSpent)}
</div>
</div>
</div>
<!-- Player List -->
<div class="divide-y divide-white/5">
{#each teamBPlayers as player}
<a
href="/player/{player.player_id}"
class="flex items-center gap-4 px-6 py-3 transition-colors hover:bg-white/5"
>
<!-- Avatar -->
{#if player.avatar}
<img
src={player.avatar}
alt={player.player_name || 'Player'}
class="h-10 w-10 rounded-full border border-ct/30"
/>
{:else}
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-ct/20 text-ct"
>
<DollarSign class="h-5 w-5" />
</div>
{/if}
<!-- Player Info -->
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-white">
{player.player_name || `Player ${player.player_id}`}
</div>
<div class="flex items-center gap-3 text-xs text-white/50">
<span
>Bank: <span class="text-neon-green"
>{formatMoney(getPlayerBank(player))}</span
></span
>
<span
>Equip: <span class="text-white/70">{formatMoney(player.equipment)}</span
></span
>
</div>
</div>
<!-- Spent -->
<div class="text-right">
<div class="text-xs text-white/40">Spent</div>
<div
class="font-mono text-sm font-semibold {player.spent > 0
? 'text-neon-red'
: 'text-white/30'}"
>
{player.spent > 0 ? `-${formatMoney(player.spent)}` : '$0'}
</div>
</div>
</a>
{/each}
{#if teamBPlayers.length === 0}
<div class="px-6 py-8 text-center text-white/40">No player data available</div>
{/if}
</div>
</Card>
</div>
<!-- Economy Legend -->
<Card padding="md">
<div class="flex flex-col gap-4">
<div class="text-center text-xs text-white/40">
Buy Type Classification (based on avg equipment per player)
</div>
<div class="flex flex-wrap items-center justify-center gap-6 text-sm">
<div class="flex items-center gap-2">
<span
class={`rounded-full px-2 py-0.5 text-xs font-semibold ${BUY_TYPE_CONFIG.pistol.bgColor} ${BUY_TYPE_CONFIG.pistol.color}`}
>Pistol</span
>
<span class="text-white/50">Round 1 & {halftimeRound + 1}</span>
</div>
<div class="flex items-center gap-2">
<span
class={`rounded-full px-2 py-0.5 text-xs font-semibold ${BUY_TYPE_CONFIG.eco.bgColor} ${BUY_TYPE_CONFIG.eco.color}`}
>Eco</span
>
<span class="text-white/50">&lt; $1,500</span>
</div>
<div class="flex items-center gap-2">
<span
class={`rounded-full px-2 py-0.5 text-xs font-semibold ${BUY_TYPE_CONFIG.force.bgColor} ${BUY_TYPE_CONFIG.force.color}`}
>Force</span
>
<span class="text-white/50">$1,500 - $3,500 (T) / $4,000 (CT)</span>
</div>
<div class="flex items-center gap-2">
<span
class={`rounded-full px-2 py-0.5 text-xs font-semibold ${BUY_TYPE_CONFIG.full.bgColor} ${BUY_TYPE_CONFIG.full.color}`}
>Full Buy</span
>
<span class="text-white/50">&gt; $3,500 (T) / $4,000 (CT)</span>
</div>
</div>
</div>
</Card>
{/if}
{:else}
<!-- No Data State -->
<Card padding="lg">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-4 rounded-full bg-white/5 p-4">
<Info class="h-12 w-12 text-white/30" />
</div>
<h3 class="mb-2 text-xl font-semibold text-white">Round Data Not Available</h3>
<p class="max-w-md text-white/60">
{#if !match.demo_parsed}
The demo for this match is still being processed. Round-by-round economy data will be
available once parsing is complete.
{:else}
Round economy data is not available for this match.
{/if}
</p>
</div>
</Card>
{/if}
</div>