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()}`;
}