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 LegacyMatchListItem,
type LegacyMatchDetail type LegacyMatchDetail
} from './transformers'; } from './transformers';
import { transformRoundsResponse } from './transformers/roundsTransformer';
import { transformWeaponsResponse } from './transformers/weaponsTransformer';
import { transformChatResponse } from './transformers/chatTransformer';
import type { import type {
Match, Match,
MatchesListResponse, MatchesListResponse,
@@ -55,10 +58,11 @@ export const matchesAPI = {
/** /**
* Get match weapons statistics * Get match weapons statistics
* @param matchId - Match ID * @param matchId - Match ID
* @param match - Optional match data for player name mapping
* @returns Weapon statistics for all players * @returns Weapon statistics for all players
* @throws Error if data is invalid or demo not parsed yet * @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 url = `/match/${matchId}/weapons`;
const data = await apiClient.get<unknown>(url); 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'); 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 * Get match round-by-round statistics
* @param matchId - Match ID * @param matchId - Match ID
* @param match - Optional match data for player name mapping
* @returns Round statistics and economy data * @returns Round statistics and economy data
* @throws Error if data is invalid or demo not parsed yet * @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 url = `/match/${matchId}/rounds`;
const data = await apiClient.get<unknown>(url); 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'); 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 * Get match chat messages
* @param matchId - Match ID * @param matchId - Match ID
* @param match - Optional match data for player name mapping
* @returns Chat messages from the match * @returns Chat messages from the match
* @throws Error if data is invalid or demo not parsed yet * @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 url = `/match/${matchId}/chat`;
const data = await apiClient.get<unknown>(url); 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'); 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 canvas: HTMLCanvasElement;
let chart: Chart<'bar'> | null = null; 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'] = { const defaultOptions: ChartConfiguration<'bar'>['options'] = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -99,7 +103,7 @@
if (ctx) { if (ctx) {
chart = new Chart(ctx, { chart = new Chart(ctx, {
type: 'bar', type: 'bar',
data: data, data: plainData,
options: { ...defaultOptions, ...options } options: { ...defaultOptions, ...options }
}); });
} }
@@ -113,8 +117,8 @@
// Watch for data changes and update chart // Watch for data changes and update chart
$effect(() => { $effect(() => {
if (chart) { if (chart && plainData) {
chart.data = data; chart.data = plainData;
chart.options = { ...defaultOptions, ...options }; chart.options = { ...defaultOptions, ...options };
chart.update(); chart.update();
} }

View File

@@ -49,6 +49,10 @@
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
let chart: Chart<'line'> | null = null; 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'] = { const defaultOptions: ChartConfiguration<'line'>['options'] = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -108,7 +112,7 @@
if (ctx) { if (ctx) {
chart = new Chart(ctx, { chart = new Chart(ctx, {
type: 'line', type: 'line',
data: data, data: plainData,
options: { ...defaultOptions, ...options } options: { ...defaultOptions, ...options }
}); });
} }
@@ -122,8 +126,8 @@
// Watch for data changes and update chart // Watch for data changes and update chart
$effect(() => { $effect(() => {
if (chart) { if (chart && plainData) {
chart.data = data; chart.data = plainData;
chart.options = { ...defaultOptions, ...options }; chart.options = { ...defaultOptions, ...options };
chart.update(); chart.update();
} }

View File

@@ -41,6 +41,10 @@
let canvas: HTMLCanvasElement; let canvas: HTMLCanvasElement;
let chart: Chart<'doughnut'> | null = null; 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'] = { const defaultOptions: ChartConfiguration<'doughnut'>['options'] = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -74,7 +78,7 @@
if (ctx) { if (ctx) {
chart = new Chart(ctx, { chart = new Chart(ctx, {
type: 'doughnut', type: 'doughnut',
data: data, data: plainData,
options: { ...defaultOptions, ...options } options: { ...defaultOptions, ...options }
}); });
} }
@@ -88,8 +92,8 @@
// Watch for data changes and update chart // Watch for data changes and update chart
$effect(() => { $effect(() => {
if (chart) { if (chart && plainData) {
chart.data = data; chart.data = plainData;
chart.options = { ...defaultOptions, ...options }; chart.options = { ...defaultOptions, ...options };
chart.update(); chart.update();
} }

View File

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

View File

@@ -24,11 +24,19 @@ export const roundDetailSchema = z.object({
players: z.array(roundStatsSchema) players: z.array(roundStatsSchema)
}); });
/** MatchRoundsResponse schema */ /** MatchRoundsResponse schema - matches actual API format */
export const matchRoundsResponseSchema = z.object({ // API returns: { "0": { "player_id": [bank, equipment, spent] }, "1": {...}, ... }
match_id: z.number().positive(), export const matchRoundsResponseSchema = z.record(
rounds: z.array(roundDetailSchema) 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 */ /** TeamRoundStats schema */
export const teamRoundStatsSchema = z.object({ export const teamRoundStatsSchema = z.object({

View File

@@ -42,10 +42,25 @@ export const playerWeaponStatsSchema = z.object({
weapon_stats: z.array(weaponStatsSchema) 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({ export const matchWeaponsResponseSchema = z.object({
match_id: z.number().positive(), equipment_map: z.record(z.string(), z.string()), // eq_type ID -> weapon name
weapons: z.array(playerWeaponStatsSchema) 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 */ /** Parser functions */

View File

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

View File

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

View File

@@ -61,6 +61,7 @@ export interface WeaponStats {
*/ */
export interface PlayerWeaponStats { export interface PlayerWeaponStats {
player_id: number; player_id: number;
player_name?: string;
weapon_stats: WeaponStats[]; weapon_stats: WeaponStats[];
} }
@@ -68,7 +69,7 @@ export interface PlayerWeaponStats {
* Match weapons response * Match weapons response
*/ */
export interface MatchWeaponsResponse { export interface MatchWeaponsResponse {
match_id: number; match_id: string | number;
weapons: PlayerWeaponStats[]; 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 * 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 // Match types
export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match'; export type { Match, MatchListItem, MatchPlayer, MatchWithPlayers } from './Match';

View File

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

View File

@@ -1,39 +1,14 @@
import { api } from '$lib/api';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, params }) => { export const load: PageLoad = async ({ parent }) => {
const { match } = await 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 { return {
match, match,
chatData: null, chatData: chat,
meta: { meta: {
title: `${match.map || 'Match'} Chat - Match ${match.match_id} - CS2.WTF` 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> </Card>
<!-- Top Performers --> <!-- Top Performers -->
// Top Performers
<div class="grid gap-6 md:grid-cols-3"> <div class="grid gap-6 md:grid-cols-3">
{#if sortedPlayers.length > 0 && sortedPlayers[0]} {#if sortedPlayers.length > 0 && sortedPlayers[0]}
<!-- Most Kills --> <!-- Most Kills -->

View File

@@ -1,39 +1,14 @@
import { api } from '$lib/api';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, params }) => { export const load: PageLoad = async ({ parent }) => {
const { match } = await 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 { return {
match, match,
weaponsData: null, weaponsData: weapons,
meta: { meta: {
title: `${match.map || 'Match'} Details - Match ${match.match_id} - CS2.WTF` 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; winner: number;
teamA_buyType: string; teamA_buyType: string;
teamB_buyType: string; teamB_buyType: string;
economyAdvantage: number; // Cumulative economy differential (teamA - teamB)
} }
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const { match, roundsData } = data; const { match, roundsData } = data;
// Get unique team IDs dynamically // Team IDs - Terrorists are always team_id 2, Counter-Terrorists are always team_id 3
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : []; const tTeamId = 2;
const firstTeamId = uniqueTeamIds[0] ?? 2; const ctTeamId = 3;
const secondTeamId = uniqueTeamIds[1] ?? 3;
// Only process if rounds data exists // Only process if rounds data exists
let teamEconomy = $state<TeamEconomy[]>([]); let teamEconomy = $state<TeamEconomy[]>([]);
@@ -40,36 +40,46 @@
tension?: number; tension?: number;
}>; }>;
} | null>(null); } | 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 totalRounds = $state(0);
let teamA_fullBuys = $state(0); let teamA_fullBuys = $state(0);
let teamB_fullBuys = $state(0); let teamB_fullBuys = $state(0);
let teamA_ecos = $state(0); let teamA_ecos = $state(0);
let teamB_ecos = $state(0); let teamB_ecos = $state(0);
let halfRoundIndex = $state<number>(0);
if (roundsData) { if (roundsData) {
// Process rounds data to calculate team totals // Process rounds data to calculate team totals
for (const roundData of roundsData.rounds) { 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)); 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)); 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 t_bank = tPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
const teamB_bank = teamBPlayers.reduce((sum, p) => sum + (p.bank || 0), 0); const ct_bank = ctPlayers.reduce((sum, p) => sum + (p.bank || 0), 0);
const teamA_equipment = teamAPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0); const t_equipment = tPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
const teamB_equipment = teamBPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0); const ct_equipment = ctPlayers.reduce((sum, p) => sum + (p.equipment || 0), 0);
const teamA_spent = teamAPlayers.reduce((sum, p) => sum + (p.spent || 0), 0); const t_spent = tPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
const teamB_spent = teamBPlayers.reduce((sum, p) => sum + (p.spent || 0), 0); const ct_spent = ctPlayers.reduce((sum, p) => sum + (p.spent || 0), 0);
const avgTeamA_equipment = const avgT_equipment = tPlayers.length > 0 ? t_equipment / tPlayers.length : 0;
teamAPlayers.length > 0 ? teamA_equipment / teamAPlayers.length : 0; const avgCT_equipment = ctPlayers.length > 0 ? ct_equipment / ctPlayers.length : 0;
const avgTeamB_equipment =
teamBPlayers.length > 0 ? teamB_equipment / teamBPlayers.length : 0;
const classifyBuyType = (avgEquipment: number): string => { const classifyBuyType = (avgEquipment: number): string => {
if (avgEquipment < 1500) return 'Eco'; if (avgEquipment < 1500) return 'Eco';
@@ -78,21 +88,38 @@
return 'Full Buy'; 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({ teamEconomy.push({
round: roundData.round, round: roundData.round,
teamA_bank, teamA_bank: t_bank,
teamB_bank, teamB_bank: ct_bank,
teamA_equipment, teamA_equipment: t_equipment,
teamB_equipment, teamB_equipment: ct_equipment,
teamA_spent, teamA_spent: t_spent,
teamB_spent, teamB_spent: ct_spent,
winner: roundData.winner || 0, winner: roundData.winner || 0,
teamA_buyType: classifyBuyType(avgTeamA_equipment), teamA_buyType: classifyBuyType(avgT_equipment),
teamB_buyType: classifyBuyType(avgTeamB_equipment) teamB_buyType: classifyBuyType(avgCT_equipment),
economyAdvantage
}); });
} }
// Prepare chart data // Prepare equipment value chart data
equipmentChartData = { equipmentChartData = {
labels: teamEconomy.map((r) => `R${r.round}`), labels: teamEconomy.map((r) => `R${r.round}`),
datasets: [ 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 // Calculate summary stats
totalRounds = teamEconomy.length; totalRounds = teamEconomy.length;
teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length; teamA_fullBuys = teamEconomy.filter((r) => r.teamA_buyType === 'Full Buy').length;
@@ -210,6 +266,57 @@
</Card> </Card>
{:else} {:else}
<div class="space-y-6"> <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 --> <!-- Summary Cards -->
<div class="grid gap-6 md:grid-cols-3"> <div class="grid gap-6 md:grid-cols-3">
<Card padding="lg"> <Card padding="lg">

View File

@@ -1,39 +1,14 @@
import { api } from '$lib/api';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, params }) => { export const load: PageLoad = async ({ parent }) => {
const { match } = await 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 { return {
match, match,
roundsData: null, roundsData: rounds,
meta: { meta: {
title: `${match.map || 'Match'} Economy - Match ${match.match_id} - CS2.WTF` 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 type { PageLoad } from './$types';
import { matchesAPI } from '$lib/api/matches';
export const load: PageLoad = async ({ parent, params }) => { export const load: PageLoad = async ({ parent }) => {
const { match } = await parent(); // Get all data from parent layout (already loaded upfront)
const { match, weapons } = await parent();
try {
// Fetch weapons statistics for this match
const weapons = await matchesAPI.getMatchWeapons(params.id);
return { return {
match, 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: [] }
};
}
}; };