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

107
MISSING_BACKEND_API.md Normal file
View File

@@ -0,0 +1,107 @@
# Missing Backend API Data
This document outlines data that the frontend is ready to display but is not currently provided by the backend API. These features would enhance the match analysis experience.
## Round Winner & Win Reason
**Endpoint**: `GET /match/{id}/rounds`
**Currently Returns**:
```json
{
"0": { "player_id": [bank, equipment, spent] },
"1": { "player_id": [bank, equipment, spent] }
}
```
**Missing Fields Needed**:
| Field | Type | Description |
| ------------ | -------- | -------------------------------------- |
| `winner` | `number` | Team ID that won the round (1=T, 2=CT) |
| `win_reason` | `string` | How the round ended |
**Win Reason Values**:
- `elimination` - All enemies killed
- `bomb_defused` - CT defused the bomb
- `bomb_exploded` - Bomb detonated successfully
- `time` - Round timer expired (CT win)
- `target_saved` - Hostage rescued (rare mode)
**Expected Response Format**:
```json
{
"0": {
"winner": 1,
"win_reason": "elimination",
"players": {
"player_id": [bank, equipment, spent]
}
}
}
```
**Implementation**: Demo parser needs to capture `RoundEnd` game events and extract:
- `winner` field from the event
- `reason` field from the event
---
## Per-Round Player Stats
**Missing Fields per Player per Round**:
| Field | Type | Description |
| ------------------ | -------- | -------------------------------------------- |
| `kills_in_round` | `number` | Kills this player got in this specific round |
| `damage_in_round` | `number` | Total damage dealt this round |
| `assists_in_round` | `number` | Assists this round |
**Implementation**: During demo parse, aggregate `player_death` and `player_hurt` events per round, grouped by attacking player.
---
## Loss Bonus Tracking
**Missing Fields per Team per Round**:
| Field | Type | Description |
| ------------- | -------- | ------------------------------ |
| `loss_streak` | `number` | Consecutive round losses (0-4) |
| `loss_bonus` | `number` | Current loss bonus amount |
**CS2 Loss Bonus Scale**:
| Consecutive Losses | Bonus |
|-------------------|-------|
| 0 | $1,400 |
| 1 | $1,900 |
| 2 | $2,400 |
| 3 | $2,900 |
| 4+ | $3,400 |
**Implementation**: Track `RoundEnd` events and maintain loss counter per team, resetting on win.
---
## Frontend Readiness
The frontend already has:
- Type definitions for all missing fields (`src/lib/types/RoundStats.ts`)
- Zod validation schemas (`src/lib/schemas/roundStats.schema.ts`)
- UI components ready to display win reasons with icons
- Mock handlers showing expected data structure (`src/mocks/handlers/matches.ts`)
Once the backend provides this data, the frontend will automatically display it.
---
## Priority
1. **High**: Round winner & win reason - enables win/loss correlation analysis
2. **Medium**: Per-round player stats - enables round-by-round performance tracking
3. **Low**: Loss bonus tracking - nice-to-have for economy analysis

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>