fix: Fix match detail data loading and add API transformation layer

- Update Zod schemas to match raw API response formats
- Create transformation layer (rounds, weapons, chat) to convert raw API to structured format
- Add player name mapping in transformers for better UX
- Fix Svelte 5 reactivity issues in chat page (replace $effect with $derived)
- Fix Chart.js compatibility with Svelte 5 state proxies using JSON serialization
- Add economy advantage chart with halftime perspective flip (WIP)
- Remove stray comment from details page
- Update layout to load match data first, then pass to API methods

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-13 00:37:41 +01:00
parent 12115198b7
commit 05a6c10458
26 changed files with 575 additions and 279 deletions

View File

@@ -11,6 +11,9 @@ import {
type LegacyMatchListItem,
type LegacyMatchDetail
} from './transformers';
import { transformRoundsResponse } from './transformers/roundsTransformer';
import { transformWeaponsResponse } from './transformers/weaponsTransformer';
import { transformChatResponse } from './transformers/chatTransformer';
import type {
Match,
MatchesListResponse,
@@ -55,10 +58,11 @@ export const matchesAPI = {
/**
* Get match weapons statistics
* @param matchId - Match ID
* @param match - Optional match data for player name mapping
* @returns Weapon statistics for all players
* @throws Error if data is invalid or demo not parsed yet
*/
async getMatchWeapons(matchId: string | number): Promise<MatchWeaponsResponse> {
async getMatchWeapons(matchId: string | number, match?: Match): Promise<MatchWeaponsResponse> {
const url = `/match/${matchId}/weapons`;
const data = await apiClient.get<unknown>(url);
@@ -71,16 +75,18 @@ export const matchesAPI = {
throw new Error('Demo not parsed yet or invalid response format');
}
return result.data;
// Transform raw API response to structured format
return transformWeaponsResponse(result.data, String(matchId), match);
},
/**
* Get match round-by-round statistics
* @param matchId - Match ID
* @param match - Optional match data for player name mapping
* @returns Round statistics and economy data
* @throws Error if data is invalid or demo not parsed yet
*/
async getMatchRounds(matchId: string | number): Promise<MatchRoundsResponse> {
async getMatchRounds(matchId: string | number, match?: Match): Promise<MatchRoundsResponse> {
const url = `/match/${matchId}/rounds`;
const data = await apiClient.get<unknown>(url);
@@ -93,16 +99,18 @@ export const matchesAPI = {
throw new Error('Demo not parsed yet or invalid response format');
}
return result.data;
// Transform raw API response to structured format
return transformRoundsResponse(result.data, String(matchId), match);
},
/**
* Get match chat messages
* @param matchId - Match ID
* @param match - Optional match data for player name mapping
* @returns Chat messages from the match
* @throws Error if data is invalid or demo not parsed yet
*/
async getMatchChat(matchId: string | number): Promise<MatchChatResponse> {
async getMatchChat(matchId: string | number, match?: Match): Promise<MatchChatResponse> {
const url = `/match/${matchId}/chat`;
const data = await apiClient.get<unknown>(url);
@@ -115,7 +123,8 @@ export const matchesAPI = {
throw new Error('Demo not parsed yet or invalid response format');
}
return result.data;
// Transform raw API response to structured format
return transformChatResponse(result.data, String(matchId), match);
},
/**

View File

@@ -0,0 +1,46 @@
import type { ChatAPIResponse } from '$lib/types/api/ChatAPIResponse';
import type { MatchChatResponse, Message, Match } from '$lib/types';
/**
* Transform raw chat API response into structured format
* @param rawData - Raw API response
* @param matchId - Match ID
* @param match - Match data with player information
* @returns Structured chat data
*/
export function transformChatResponse(
rawData: ChatAPIResponse,
matchId: string,
match?: Match
): MatchChatResponse {
const messages: Message[] = [];
// Create player ID to name mapping
const playerMap = new Map<string, string>();
if (match?.players) {
for (const player of match.players) {
playerMap.set(player.id, player.name);
}
}
// Flatten all player messages into a single array
for (const [playerId, playerMessages] of Object.entries(rawData)) {
const playerName = playerMap.get(playerId) || `Player ${playerId}`;
for (const message of playerMessages) {
messages.push({
...message,
player_id: Number(playerId),
player_name: playerName
});
}
}
// Sort by tick
messages.sort((a, b) => a.tick - b.tick);
return {
match_id: matchId,
messages
};
}

View File

@@ -0,0 +1,60 @@
import type { RoundsAPIResponse } from '$lib/types/api/RoundsAPIResponse';
import type { MatchRoundsResponse, RoundDetail, RoundStats, Match } from '$lib/types';
/**
* Transform raw rounds API response into structured format
* @param rawData - Raw API response
* @param matchId - Match ID
* @param match - Match data with player information
* @returns Structured rounds data
*/
export function transformRoundsResponse(
rawData: RoundsAPIResponse,
matchId: string,
match?: Match
): MatchRoundsResponse {
const rounds: RoundDetail[] = [];
// Create player ID to team mapping
const playerTeamMap = new Map<string, number>();
if (match?.players) {
for (const player of match.players) {
playerTeamMap.set(player.id, player.team_id);
}
}
// Convert object keys to sorted round numbers
const roundNumbers = Object.keys(rawData)
.map(Number)
.sort((a, b) => a - b);
for (const roundNum of roundNumbers) {
const roundData = rawData[String(roundNum)];
if (!roundData) continue;
const players: RoundStats[] = [];
// Convert player data
for (const [playerId, [bank, equipment, spent]] of Object.entries(roundData)) {
players.push({
round: roundNum + 1, // API uses 0-indexed, we use 1-indexed
bank,
equipment,
spent,
player_id: Number(playerId)
});
}
rounds.push({
round: roundNum + 1,
winner: 0, // TODO: Determine winner from data if available
win_reason: '', // TODO: Determine win reason if available
players
});
}
return {
match_id: matchId,
rounds
};
}

View File

@@ -0,0 +1,99 @@
import type { WeaponsAPIResponse } from '$lib/types/api/WeaponsAPIResponse';
import type { MatchWeaponsResponse, PlayerWeaponStats, WeaponStats, Match } from '$lib/types';
/**
* Transform raw weapons API response into structured format
* @param rawData - Raw API response
* @param matchId - Match ID
* @param match - Match data with player information
* @returns Structured weapons data
*/
export function transformWeaponsResponse(
rawData: WeaponsAPIResponse,
matchId: string,
match?: Match
): MatchWeaponsResponse {
const playerWeaponsMap = new Map<
string,
Map<number, { damage: number; hits: number; hitGroups: number[] }>
>();
// Create player ID to name mapping
const playerMap = new Map<string, string>();
if (match?.players) {
for (const player of match.players) {
playerMap.set(player.id, player.name);
}
}
// Process all stats
for (const roundStats of rawData.stats) {
for (const [attackerId, victims] of Object.entries(roundStats)) {
if (!playerWeaponsMap.has(attackerId)) {
playerWeaponsMap.set(attackerId, new Map());
}
const weaponsMap = playerWeaponsMap.get(attackerId)!;
for (const [_, hits] of Object.entries(victims)) {
for (const [eqType, hitGroup, damage] of hits) {
if (!weaponsMap.has(eqType)) {
weaponsMap.set(eqType, { damage: 0, hits: 0, hitGroups: [] });
}
const weaponStats = weaponsMap.get(eqType)!;
weaponStats.damage += damage;
weaponStats.hits++;
weaponStats.hitGroups.push(hitGroup);
}
}
}
}
// Convert to output format
const weapons: PlayerWeaponStats[] = [];
for (const [playerId, weaponsMap] of playerWeaponsMap.entries()) {
const playerName = playerMap.get(playerId) || `Player ${playerId}`;
const weapon_stats: WeaponStats[] = [];
for (const [eqType, stats] of weaponsMap.entries()) {
const hitGroupCounts = {
head: 0,
chest: 0,
stomach: 0,
left_arm: 0,
right_arm: 0,
left_leg: 0,
right_leg: 0
};
for (const hitGroup of stats.hitGroups) {
if (hitGroup === 1) hitGroupCounts.head++;
else if (hitGroup === 2) hitGroupCounts.chest++;
else if (hitGroup === 3) hitGroupCounts.stomach++;
else if (hitGroup === 4) hitGroupCounts.left_arm++;
else if (hitGroup === 5) hitGroupCounts.right_arm++;
else if (hitGroup === 6) hitGroupCounts.left_leg++;
else if (hitGroup === 7) hitGroupCounts.right_leg++;
}
weapon_stats.push({
eq_type: eqType,
weapon_name: rawData.equipment_map[String(eqType)] || `Weapon ${eqType}`,
kills: 0, // TODO: Calculate kills if needed
damage: stats.damage,
hits: stats.hits,
hit_groups: hitGroupCounts,
headshot_pct: hitGroupCounts.head > 0 ? (hitGroupCounts.head / stats.hits) * 100 : 0
});
}
weapons.push({
player_id: Number(playerId),
player_name: playerName,
weapon_stats
});
}
return {
match_id: matchId,
weapons
};
}

View File

@@ -43,6 +43,10 @@
let canvas: HTMLCanvasElement;
let chart: Chart<'bar'> | null = null;
// Convert Svelte 5 $state proxy to plain object for Chart.js compatibility
// Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle
const plainData = $derived(JSON.parse(JSON.stringify(data)));
const defaultOptions: ChartConfiguration<'bar'>['options'] = {
responsive: true,
maintainAspectRatio: false,
@@ -99,7 +103,7 @@
if (ctx) {
chart = new Chart(ctx, {
type: 'bar',
data: data,
data: plainData,
options: { ...defaultOptions, ...options }
});
}
@@ -113,8 +117,8 @@
// Watch for data changes and update chart
$effect(() => {
if (chart) {
chart.data = data;
if (chart && plainData) {
chart.data = plainData;
chart.options = { ...defaultOptions, ...options };
chart.update();
}

View File

@@ -49,6 +49,10 @@
let canvas: HTMLCanvasElement;
let chart: Chart<'line'> | null = null;
// Convert Svelte 5 $state proxy to plain object for Chart.js compatibility
// Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle
const plainData = $derived(JSON.parse(JSON.stringify(data)));
const defaultOptions: ChartConfiguration<'line'>['options'] = {
responsive: true,
maintainAspectRatio: false,
@@ -108,7 +112,7 @@
if (ctx) {
chart = new Chart(ctx, {
type: 'line',
data: data,
data: plainData,
options: { ...defaultOptions, ...options }
});
}
@@ -122,8 +126,8 @@
// Watch for data changes and update chart
$effect(() => {
if (chart) {
chart.data = data;
if (chart && plainData) {
chart.data = plainData;
chart.options = { ...defaultOptions, ...options };
chart.update();
}

View File

@@ -41,6 +41,10 @@
let canvas: HTMLCanvasElement;
let chart: Chart<'doughnut'> | null = null;
// Convert Svelte 5 $state proxy to plain object for Chart.js compatibility
// Using JSON parse/stringify to handle Svelte proxies that structuredClone can't handle
const plainData = $derived(JSON.parse(JSON.stringify(data)));
const defaultOptions: ChartConfiguration<'doughnut'>['options'] = {
responsive: true,
maintainAspectRatio: false,
@@ -74,7 +78,7 @@
if (ctx) {
chart = new Chart(ctx, {
type: 'doughnut',
data: data,
data: plainData,
options: { ...defaultOptions, ...options }
});
}
@@ -88,8 +92,8 @@
// Watch for data changes and update chart
$effect(() => {
if (chart) {
chart.data = data;
if (chart && plainData) {
chart.data = plainData;
chart.options = { ...defaultOptions, ...options };
chart.update();
}

View File

@@ -16,11 +16,12 @@ export const messageSchema = z.object({
timestamp: z.string().datetime().optional()
});
/** MatchChatResponse schema */
export const matchChatResponseSchema = z.object({
match_id: z.number().positive(),
messages: z.array(messageSchema)
});
/** MatchChatResponse schema - matches actual API format */
// API returns: { "player_id": [{ message, all_chat, tick }, ...], ... }
export const matchChatResponseSchema = z.record(
z.string(), // player Steam ID as string key
z.array(messageSchema)
);
/** EnrichedMessage schema (with player data) */
export const enrichedMessageSchema = messageSchema.extend({

View File

@@ -24,11 +24,19 @@ export const roundDetailSchema = z.object({
players: z.array(roundStatsSchema)
});
/** MatchRoundsResponse schema */
export const matchRoundsResponseSchema = z.object({
match_id: z.number().positive(),
rounds: z.array(roundDetailSchema)
});
/** MatchRoundsResponse schema - matches actual API format */
// API returns: { "0": { "player_id": [bank, equipment, spent] }, "1": {...}, ... }
export const matchRoundsResponseSchema = z.record(
z.string(), // round number as string key
z.record(
z.string(), // player Steam ID as string key
z.tuple([
z.number().int().nonnegative(), // bank
z.number().int().nonnegative(), // equipment value
z.number().int().nonnegative() // spent
])
)
);
/** TeamRoundStats schema */
export const teamRoundStatsSchema = z.object({

View File

@@ -42,10 +42,25 @@ export const playerWeaponStatsSchema = z.object({
weapon_stats: z.array(weaponStatsSchema)
});
/** MatchWeaponsResponse schema */
/** MatchWeaponsResponse schema - matches actual API format */
// API returns: { equipment_map: { "1": "P2000", ... }, stats: [...] }
export const matchWeaponsResponseSchema = z.object({
match_id: z.number().positive(),
weapons: z.array(playerWeaponStatsSchema)
equipment_map: z.record(z.string(), z.string()), // eq_type ID -> weapon name
stats: z.array(
z.record(
z.string(), // attacker Steam ID
z.record(
z.string(), // victim Steam ID
z.array(
z.tuple([
z.number().int().nonnegative(), // eq_type
z.number().int().min(0).max(7), // hit_group
z.number().int().nonnegative() // damage
])
)
)
)
)
});
/** Parser functions */

View File

@@ -32,7 +32,7 @@ export interface Message {
* Match chat response
*/
export interface MatchChatResponse {
match_id: number;
match_id: string | number;
messages: Message[];
}

View File

@@ -80,6 +80,6 @@ export interface RoundDetail {
* Complete match rounds response
*/
export interface MatchRoundsResponse {
match_id: number;
match_id: string | number;
rounds: RoundDetail[];
}

View File

@@ -61,6 +61,7 @@ export interface WeaponStats {
*/
export interface PlayerWeaponStats {
player_id: number;
player_name?: string;
weapon_stats: WeaponStats[];
}
@@ -68,7 +69,7 @@ export interface PlayerWeaponStats {
* Match weapons response
*/
export interface MatchWeaponsResponse {
match_id: number;
match_id: string | number;
weapons: PlayerWeaponStats[];
}

View File

@@ -0,0 +1,9 @@
import type { Message } from '../Message';
/**
* Raw API response format for match chat endpoint
* API returns: { "player_id": [{ message, all_chat, tick }, ...], ... }
*/
export interface ChatAPIResponse {
[playerId: string]: Message[];
}

View File

@@ -0,0 +1,9 @@
/**
* Raw API response format for match rounds endpoint
* API returns: { "0": { "player_id": [bank, equipment, spent] }, "1": {...}, ... }
*/
export interface RoundsAPIResponse {
[roundNumber: string]: {
[playerId: string]: [bank: number, equipment: number, spent: number];
};
}

View File

@@ -0,0 +1,12 @@
/**
* Raw API response format for match weapons endpoint
* API returns: { equipment_map: { "1": "P2000", ... }, stats: [...] }
*/
export interface WeaponsAPIResponse {
equipment_map: Record<string, string>; // eq_type ID -> weapon name
stats: Array<{
[attackerId: string]: {
[victimId: string]: Array<[eqType: number, hitGroup: number, damage: number]>;
};
}>;
}

View File

@@ -2,6 +2,11 @@
* Central export for all CS2.WTF type definitions
*/
// Raw API response types (internal format from backend)
export type { RoundsAPIResponse } from './api/RoundsAPIResponse';
export type { WeaponsAPIResponse } from './api/WeaponsAPIResponse';
export type { ChatAPIResponse } from './api/ChatAPIResponse';
// Match types
export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match';

View File

@@ -10,10 +10,26 @@ export const load: LayoutLoad = async ({ params }) => {
}
try {
// First load match data (required)
const match = await api.matches.getMatch(matchId);
// Then load optional data in parallel, passing match for player name mapping
const [roundsResult, weaponsResult, chatResult] = await Promise.allSettled([
api.matches.getMatchRounds(matchId, match),
api.matches.getMatchWeapons(matchId, match),
api.matches.getMatchChat(matchId, match)
]);
// Optional data - return null if not available (demo not parsed yet)
const rounds = roundsResult.status === 'fulfilled' ? roundsResult.value : null;
const weapons = weaponsResult.status === 'fulfilled' ? weaponsResult.value : null;
const chat = chatResult.status === 'fulfilled' ? chatResult.value : null;
return {
match
match,
rounds,
weapons,
chat
};
} catch (err) {
console.error(`Failed to load match ${matchId}:`, err);

View File

@@ -1,21 +1,10 @@
import { api } from '$lib/api';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
const matchId = params.id;
try {
// Fetch rounds data for the timeline visualization
const rounds = await api.matches.getMatchRounds(matchId);
export const load: PageLoad = async ({ parent }) => {
// Get all data from parent layout (already loaded upfront)
const { rounds } = await parent();
return {
rounds
};
} catch (err) {
console.error(`Failed to load rounds for match ${matchId}:`, err);
// Return empty rounds if the endpoint fails (demo might not be parsed yet)
return {
rounds: null
};
}
};

View File

@@ -4,12 +4,6 @@
import Badge from '$lib/components/ui/Badge.svelte';
import type { PageData } from './$types';
interface MessagePlayer {
id: number;
name: string;
team_id: number;
}
let { data }: { data: PageData } = $props();
const { match, chatData } = data;
@@ -19,23 +13,6 @@
let showAllChat = $state(true);
let selectedPlayer = $state<number | null>(null);
let messagePlayers = $state<MessagePlayer[]>([]);
let filteredMessages = $state<NonNullable<PageData['chatData']>['messages']>([]);
let messagesByRound = $state<Record<number, NonNullable<PageData['chatData']>['messages']>>({});
let rounds = $state<number[]>([]);
let totalMessages = $state(0);
let teamChatCount = $state(0);
let allChatCount = $state(0);
// Get player info for a message
const getPlayerInfo = (playerId: number) => {
const player = match.players?.find((p) => p.id === String(playerId));
return {
name: player?.name || `Player ${playerId}`,
team_id: player?.team_id || 0
};
};
// Check if text likely needs translation (contains non-ASCII or Cyrillic characters)
const mightNeedTranslation = (text: string): boolean => {
// Check for Cyrillic, Chinese, Japanese, Korean, Arabic, etc.
@@ -52,9 +29,10 @@
window.open(translateUrl, '_blank', 'width=800,height=600,noopener,noreferrer');
};
if (chatData) {
// Get unique players who sent messages
messagePlayers = Array.from(new Set(chatData.messages.map((m) => m.player_id)))
// Get unique players who sent messages - use $derived for computed values
const messagePlayers = $derived(
chatData
? Array.from(new Set(chatData.messages.map((m) => m.player_id)))
.filter((playerId): playerId is number => playerId !== undefined)
.map((playerId) => {
const player = match.players?.find((p) => p.id === String(playerId));
@@ -63,11 +41,14 @@
name: player?.name || `Player ${playerId}`,
team_id: player?.team_id || 0
};
});
})
: []
);
// Filter messages
const computeFilteredMessages = () => {
return chatData.messages.filter((msg) => {
// Filter messages using $derived
const filteredMessages = $derived(
chatData
? chatData.messages.filter((msg) => {
// Chat type filter
if (!showTeamChat && !msg.all_chat) return false;
if (!showAllChat && msg.all_chat) return false;
@@ -81,33 +62,35 @@
}
return true;
});
};
})
: []
);
// Update filtered messages reactively
$effect(() => {
filteredMessages = computeFilteredMessages();
// Group messages by round
messagesByRound = {};
for (const msg of filteredMessages) {
// Group messages by round using $derived
const messagesByRound = $derived(
filteredMessages.reduce(
(acc, msg) => {
const round = msg.round || 0;
if (!messagesByRound[round]) {
messagesByRound[round] = [];
}
messagesByRound[round].push(msg);
if (!acc[round]) {
acc[round] = [];
}
acc[round].push(msg);
return acc;
},
{} as Record<number, typeof filteredMessages>
)
);
rounds = Object.keys(messagesByRound)
const rounds = $derived(
Object.keys(messagesByRound)
.map(Number)
.sort((a, b) => a - b);
});
.sort((a, b) => a - b)
);
// Stats
totalMessages = chatData.messages.length;
teamChatCount = chatData.messages.filter((m) => !m.all_chat).length;
allChatCount = chatData.messages.filter((m) => m.all_chat).length;
}
// Stats using $derived
const totalMessages = $derived(chatData?.messages.length || 0);
const teamChatCount = $derived(chatData?.messages.filter((m) => !m.all_chat).length || 0);
const allChatCount = $derived(chatData?.messages.filter((m) => m.all_chat).length || 0);
</script>
<svelte:head>
@@ -229,17 +212,20 @@
<!-- Messages -->
<div class="divide-y divide-base-300">
{#each messagesByRound[round] as message}
{@const playerInfo = getPlayerInfo(message.player_id || 0)}
{@const player = match.players?.find((p) => p.id === String(message.player_id))}
{@const playerName =
message.player_name || player?.name || `Player ${message.player_id}`}
{@const teamId = player?.team_id || 0}
<div class="p-4 transition-colors hover:bg-base-200/50">
<div class="flex items-start gap-3">
<!-- Player Avatar/Icon -->
<div
class="flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold text-white"
class:bg-terrorist={playerInfo.team_id === 2}
class:bg-ct={playerInfo.team_id === 3}
class:bg-base-300={playerInfo.team_id === 0}
class:bg-terrorist={teamId === 2}
class:bg-ct={teamId === 3}
class:bg-base-300={teamId === 0}
>
{playerInfo.name.charAt(0).toUpperCase()}
{playerName.charAt(0).toUpperCase()}
</div>
<!-- Message Content -->
@@ -248,10 +234,10 @@
<a
href={`/player/${message.player_id || 0}`}
class="font-semibold hover:underline"
class:text-terrorist={playerInfo.team_id === 2}
class:text-ct={playerInfo.team_id === 3}
class:text-terrorist={teamId === 2}
class:text-ct={teamId === 3}
>
{playerInfo.name}
{playerName}
</a>
{#if message.all_chat}
<Badge variant="success" size="sm">All Chat</Badge>

View File

@@ -1,39 +1,14 @@
import { api } from '$lib/api';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, params }) => {
const { match } = await parent();
export const load: PageLoad = async ({ parent }) => {
// Get all data from parent layout (already loaded upfront)
const { match, chat } = await parent();
// Only load chat data if match is parsed
if (!match.demo_parsed) {
return {
match,
chatData: null,
chatData: chat,
meta: {
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
}
};
}
try {
const chatData = await api.matches.getMatchChat(params.id);
return {
match,
chatData,
meta: {
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
}
};
} catch (err) {
console.error(`Failed to load chat data for match ${params.id}:`, err);
// Return null instead of throwing error
return {
match,
chatData: null,
meta: {
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF`
}
};
}
};

View File

@@ -350,7 +350,6 @@
</Card>
<!-- Top Performers -->
// Top Performers
<div class="grid gap-6 md:grid-cols-3">
{#if sortedPlayers.length > 0 && sortedPlayers[0]}
<!-- Most Kills -->

View File

@@ -1,39 +1,14 @@
import { api } from '$lib/api';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, params }) => {
const { match } = await parent();
export const load: PageLoad = async ({ parent }) => {
// Get all data from parent layout (already loaded upfront)
const { match, weapons } = await parent();
// Only load weapons data if match is parsed
if (!match.demo_parsed) {
return {
match,
weaponsData: null,
weaponsData: weapons,
meta: {
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
}
};
}
try {
const weaponsData = await api.matches.getMatchWeapons(params.id);
return {
match,
weaponsData,
meta: {
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
}
};
} catch (err) {
console.error(`Failed to load weapons data for match ${params.id}:`, err);
// Return null instead of throwing error
return {
match,
weaponsData: null,
meta: {
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF`
}
};
}
};

View File

@@ -17,15 +17,15 @@
winner: number;
teamA_buyType: string;
teamB_buyType: string;
economyAdvantage: number; // Cumulative economy differential (teamA - teamB)
}
let { data }: { data: PageData } = $props();
const { match, roundsData } = data;
// Get unique team IDs dynamically
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
const firstTeamId = uniqueTeamIds[0] ?? 2;
const secondTeamId = uniqueTeamIds[1] ?? 3;
// Team IDs - Terrorists are always team_id 2, Counter-Terrorists are always team_id 3
const tTeamId = 2;
const ctTeamId = 3;
// Only process if rounds data exists
let teamEconomy = $state<TeamEconomy[]>([]);
@@ -40,36 +40,46 @@
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 teamAPlayers = roundData.players.filter((p) => {
const tPlayers = roundData.players.filter((p) => {
const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
return matchPlayer?.team_id === firstTeamId;
return matchPlayer?.team_id === tTeamId;
});
const teamBPlayers = roundData.players.filter((p) => {
const ctPlayers = roundData.players.filter((p) => {
const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
return matchPlayer?.team_id === secondTeamId;
return matchPlayer?.team_id === ctTeamId;
});
const teamA_bank = teamAPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
const teamB_bank = teamBPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
const teamA_equipment = teamAPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
const teamB_equipment = teamBPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
const teamA_spent = teamAPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
const teamB_spent = teamBPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
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 avgTeamA_equipment =
teamAPlayers.length > 0 ? teamA_equipment / teamAPlayers.length : 0;
const avgTeamB_equipment =
teamBPlayers.length > 0 ? teamB_equipment / teamBPlayers.length : 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';
@@ -78,21 +88,38 @@
return 'Full Buy';
};
// Calculate per-round economy advantage using bank + spent (like old portal)
// Teams swap sides at halftime, so we need to account for perspective flip
const t_totalEconomy = t_bank + t_spent;
const ct_totalEconomy = ct_bank + ct_spent;
// Determine perspective based on round (teams swap at half)
const halfPoint = 12; // MR12 format: rounds 1-12 first half, 13-24 second half
let economyAdvantage;
if (roundData.round <= halfPoint) {
// First half: T - CT
economyAdvantage = t_totalEconomy - ct_totalEconomy;
} else {
// Second half: CT - T (teams swapped sides)
economyAdvantage = ct_totalEconomy - t_totalEconomy;
}
teamEconomy.push({
round: roundData.round,
teamA_bank,
teamB_bank,
teamA_equipment,
teamB_equipment,
teamA_spent,
teamB_spent,
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(avgTeamA_equipment),
teamB_buyType: classifyBuyType(avgTeamB_equipment)
teamA_buyType: classifyBuyType(avgT_equipment),
teamB_buyType: classifyBuyType(avgCT_equipment),
economyAdvantage
});
}
// Prepare chart data
// Prepare equipment value chart data
equipmentChartData = {
labels: teamEconomy.map((r) => `R${r.round}`),
datasets: [
@@ -115,6 +142,35 @@
]
};
// Prepare economy advantage chart data
// Positive = above 0, Negative = below 0
halfRoundIndex = Math.floor(teamEconomy.length / 2);
economyAdvantageChartData = {
labels: teamEconomy.map((r) => `${r.round}`),
datasets: [
{
label: 'Advantage',
data: teamEconomy.map((r) => (r.economyAdvantage > 0 ? r.economyAdvantage : 0)),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.6)',
fill: 'origin',
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4
},
{
label: 'Disadvantage',
data: teamEconomy.map((r) => (r.economyAdvantage < 0 ? r.economyAdvantage : 0)),
borderColor: 'rgb(249, 115, 22)',
backgroundColor: 'rgba(249, 115, 22, 0.6)',
fill: 'origin',
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4
}
]
};
// Calculate summary stats
totalRounds = teamEconomy.length;
teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
@@ -210,6 +266,57 @@
</Card>
{:else}
<div class="space-y-6">
<!-- Economy Advantage Chart -->
<Card padding="lg">
<div class="mb-4">
<h2 class="text-2xl font-bold text-base-content">Economy</h2>
<p class="text-sm text-base-content/60">Net-worth differential (bank + spent)</p>
</div>
{#if economyAdvantageChartData}
<div class="relative">
<LineChart
data={economyAdvantageChartData}
height={400}
options={{
scales: {
y: {
beginAtZero: true,
grid: {
color: (context) => {
if (context.tick.value === 0) {
return 'rgba(156, 163, 175, 0.5)'; // Stronger line at 0
}
return 'rgba(156, 163, 175, 0.1)';
},
lineWidth: (context) => {
return context.tick.value === 0 ? 2 : 1;
}
}
}
},
interaction: {
mode: 'index',
intersect: false
}
}}
/>
{#if halfRoundIndex > 0}
<div
class="pointer-events-none absolute top-0 flex h-full items-center"
style="left: {(halfRoundIndex / (teamEconomy.length || 1)) * 100}%"
>
<div class="h-full w-px bg-base-content/20"></div>
<div
class="absolute -top-1 left-1/2 -translate-x-1/2 rounded bg-base-300 px-2 py-1 text-xs font-medium text-base-content/70"
>
Half-Point
</div>
</div>
{/if}
</div>
{/if}
</Card>
<!-- Summary Cards -->
<div class="grid gap-6 md:grid-cols-3">
<Card padding="lg">

View File

@@ -1,39 +1,14 @@
import { api } from '$lib/api';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, params }) => {
const { match } = await parent();
export const load: PageLoad = async ({ parent }) => {
// Get all data from parent layout (already loaded upfront)
const { match, rounds } = await parent();
// Only load rounds data if match is parsed
if (!match.demo_parsed) {
return {
match,
roundsData: null,
roundsData: rounds,
meta: {
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
}
};
}
try {
const roundsData = await api.matches.getMatchRounds(params.id);
return {
match,
roundsData,
meta: {
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
}
};
} catch (err) {
console.error(`Failed to load rounds data for match ${params.id}:`, err);
// Return null instead of throwing error
return {
match,
roundsData: null,
meta: {
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF`
}
};
}
};

View File

@@ -1,23 +1,11 @@
import type { PageLoad } from './$types';
import { matchesAPI } from '$lib/api/matches';
export const load: PageLoad = async ({ parent, params }) => {
const { match } = await parent();
try {
// Fetch weapons statistics for this match
const weapons = await matchesAPI.getMatchWeapons(params.id);
export const load: PageLoad = async ({ parent }) => {
// Get all data from parent layout (already loaded upfront)
const { match, weapons } = await parent();
return {
match,
weapons
weapons: weapons || { match_id: match.match_id, weapons: [] }
};
} catch (error) {
console.error('Failed to load weapons data:', error);
// Return match without weapons data on error
return {
match,
weapons: { match_id: parseInt(params.id), weapons: [] }
};
}
};