forked from CSGOWTF/csgowtf
feat: CS2 format support, player tracking fixes, and homepage enhancements
- Add dynamic MR12/MR15 halftime calculation to RoundTimeline component - Fix TrackPlayerModal API signature (remove shareCode, change isOpen to open binding) - Update Player types for string IDs and add ban fields (vac_count, vac_date, game_ban_count, game_ban_date) - Add target/rel props to Button component for external links - Enhance homepage with processing matches indicator - Update preferences store for string player IDs - Document Phase 5 progress and TypeScript fixes in TODO.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -66,7 +66,7 @@ export const playersAPI = {
|
||||
|
||||
// Transform to PlayerMeta format
|
||||
const playerMeta: PlayerMeta = {
|
||||
id: parseInt(player.id, 10),
|
||||
id: player.id, // Keep as string for uint64 precision
|
||||
name: player.name,
|
||||
avatar: player.avatar, // Already transformed by transformPlayerProfile
|
||||
recent_matches: recentMatches.length,
|
||||
@@ -74,7 +74,12 @@ export const playersAPI = {
|
||||
avg_kills: avgKills,
|
||||
avg_deaths: avgDeaths,
|
||||
avg_kast: recentMatches.length > 0 ? totalKast / recentMatches.length : 0, // Placeholder KAST calculation
|
||||
win_rate: winRate
|
||||
win_rate: winRate,
|
||||
vac_count: player.vac_count,
|
||||
vac_date: player.vac_date,
|
||||
game_ban_count: player.game_ban_count,
|
||||
game_ban_date: player.game_ban_date,
|
||||
tracked: player.tracked
|
||||
};
|
||||
|
||||
return playerMeta;
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import type { RoundDetail } from '$lib/types/RoundStats';
|
||||
|
||||
let { rounds }: { rounds: RoundDetail[] } = $props();
|
||||
let { rounds, maxRounds = 24 }: { rounds: RoundDetail[]; maxRounds?: number } = $props();
|
||||
|
||||
// Calculate halftime round based on max_rounds
|
||||
// MR12 (24 rounds): halftime after round 12
|
||||
// MR15 (30 rounds): halftime after round 15
|
||||
const halftimeRound = $derived(maxRounds === 30 ? 15 : 12);
|
||||
|
||||
// State for hover/click details
|
||||
let selectedRound = $state<number | null>(null);
|
||||
@@ -174,10 +179,13 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Half marker (round 13 for MR12) -->
|
||||
{#if rounds.length > 12}
|
||||
<!-- Half marker (dynamic based on MR12/MR15) -->
|
||||
{#if rounds.length > halftimeRound}
|
||||
<div class="relative mt-2 flex gap-1">
|
||||
<div class="ml-[calc(60px*12-30px)] w-[60px] text-center">
|
||||
<div
|
||||
class="w-[60px] text-center"
|
||||
style="margin-left: calc(60px * {halftimeRound} - 30px);"
|
||||
>
|
||||
<Badge variant="info" size="sm">Halftime</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,23 @@
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
isTracked: boolean;
|
||||
isOpen: boolean;
|
||||
open: boolean;
|
||||
ontracked?: () => void;
|
||||
onuntracked?: () => void;
|
||||
}
|
||||
|
||||
let { playerId, playerName, isTracked, isOpen = $bindable() }: Props = $props();
|
||||
let {
|
||||
playerId,
|
||||
playerName,
|
||||
isTracked,
|
||||
open = $bindable(),
|
||||
ontracked,
|
||||
onuntracked
|
||||
}: Props = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let authCode = $state('');
|
||||
let shareCode = $state('');
|
||||
let isLoading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
@@ -30,10 +38,11 @@
|
||||
error = '';
|
||||
|
||||
try {
|
||||
await playersAPI.trackPlayer(playerId, authCode, shareCode || undefined);
|
||||
await playersAPI.trackPlayer(playerId, authCode);
|
||||
toast.success('Player tracking activated successfully!');
|
||||
isOpen = false;
|
||||
open = false;
|
||||
dispatch('tracked');
|
||||
ontracked?.();
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to track player';
|
||||
toast.error(error);
|
||||
@@ -43,19 +52,15 @@
|
||||
}
|
||||
|
||||
async function handleUntrack() {
|
||||
if (!authCode.trim()) {
|
||||
error = 'Auth code is required to untrack';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
await playersAPI.untrackPlayer(playerId, authCode);
|
||||
await playersAPI.untrackPlayer(playerId);
|
||||
toast.success('Player tracking removed successfully');
|
||||
isOpen = false;
|
||||
open = false;
|
||||
dispatch('untracked');
|
||||
onuntracked?.();
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to untrack player';
|
||||
toast.error(error);
|
||||
@@ -65,14 +70,13 @@
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
isOpen = false;
|
||||
open = false;
|
||||
authCode = '';
|
||||
shareCode = '';
|
||||
error = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:isOpen onClose={handleClose} title={isTracked ? 'Untrack Player' : 'Track Player'}>
|
||||
<Modal bind:open onClose={handleClose} title={isTracked ? 'Untrack Player' : 'Track Player'}>
|
||||
<div class="space-y-4">
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
@@ -99,44 +103,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Code Input -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="authCode">
|
||||
<span class="label-text font-medium">Authentication Code *</span>
|
||||
</label>
|
||||
<input
|
||||
id="authCode"
|
||||
type="text"
|
||||
placeholder="Enter your auth code"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={authCode}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Required to verify ownership of this Steam account
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Code Input (only for tracking) -->
|
||||
<!-- Auth Code Input (only for tracking, untrack doesn't need auth) -->
|
||||
{#if !isTracked}
|
||||
<div class="form-control">
|
||||
<label class="label" for="shareCode">
|
||||
<span class="label-text font-medium">Share Code (Optional)</span>
|
||||
<label class="label" for="authCode">
|
||||
<span class="label-text font-medium">Authentication Code *</span>
|
||||
</label>
|
||||
<input
|
||||
id="shareCode"
|
||||
id="authCode"
|
||||
type="text"
|
||||
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||
placeholder="Enter your auth code"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={shareCode}
|
||||
bind:value={authCode}
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Optional: Provide a share code if you have no matches yet
|
||||
Required to verify ownership of this Steam account
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
onclick?: () => void;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
@@ -20,6 +22,8 @@
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
onclick,
|
||||
target,
|
||||
rel,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
@@ -46,7 +50,7 @@
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} class={classes} aria-disabled={disabled}>
|
||||
<a {href} {target} {rel} class={classes} aria-disabled={disabled}>
|
||||
{@render children()}
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface UserPreferences {
|
||||
theme: 'cs2dark' | 'cs2light' | 'auto';
|
||||
language: string;
|
||||
favoriteMap?: string;
|
||||
favoritePlayers: number[];
|
||||
favoritePlayers: string[]; // Steam IDs as strings to preserve uint64 precision
|
||||
showAdvancedStats: boolean;
|
||||
dateFormat: 'relative' | 'absolute';
|
||||
timezone: string;
|
||||
@@ -76,13 +76,13 @@ const createPreferencesStore = () => {
|
||||
setLanguage: (language: string) => {
|
||||
update((prefs) => ({ ...prefs, language }));
|
||||
},
|
||||
addFavoritePlayer: (playerId: number) => {
|
||||
addFavoritePlayer: (playerId: string) => {
|
||||
update((prefs) => ({
|
||||
...prefs,
|
||||
favoritePlayers: [...new Set([...prefs.favoritePlayers, playerId])]
|
||||
}));
|
||||
},
|
||||
removeFavoritePlayer: (playerId: number) => {
|
||||
removeFavoritePlayer: (playerId: string) => {
|
||||
update((prefs) => ({
|
||||
...prefs,
|
||||
favoritePlayers: prefs.favoritePlayers.filter((id) => id !== playerId)
|
||||
|
||||
@@ -72,7 +72,8 @@ export interface PlayerMatch extends Match {
|
||||
* Lightweight player metadata for quick previews
|
||||
*/
|
||||
export interface PlayerMeta {
|
||||
id: number;
|
||||
/** Steam ID (string to preserve uint64 precision, consistent with Player) */
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
recent_matches: number;
|
||||
@@ -81,6 +82,16 @@ export interface PlayerMeta {
|
||||
avg_deaths: number;
|
||||
avg_kast: number;
|
||||
win_rate: number;
|
||||
/** Number of VAC bans on record (optional) */
|
||||
vac_count?: number;
|
||||
/** Date of last VAC ban (ISO 8601, optional) */
|
||||
vac_date?: string | null;
|
||||
/** Number of game bans on record (optional) */
|
||||
game_ban_count?: number;
|
||||
/** Date of last game ban (ISO 8601, optional) */
|
||||
game_ban_date?: string | null;
|
||||
/** Whether this player is being tracked for automatic match updates (optional) */
|
||||
tracked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,7 @@ export const mockPlayers: Player[] = [
|
||||
|
||||
/** Mock player metadata */
|
||||
export const mockPlayerMeta: PlayerMeta = {
|
||||
id: 765611980123456,
|
||||
id: '765611980123456',
|
||||
name: 'TestPlayer1',
|
||||
avatar:
|
||||
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
|
||||
@@ -44,7 +44,10 @@ export const mockPlayerMeta: PlayerMeta = {
|
||||
avg_kills: 21.3,
|
||||
avg_deaths: 17.8,
|
||||
avg_kast: 75.2,
|
||||
win_rate: 56.5
|
||||
win_rate: 56.5,
|
||||
vac_count: 0,
|
||||
game_ban_count: 0,
|
||||
tracked: false
|
||||
};
|
||||
|
||||
/** Mock match players */
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import MatchCard from '$lib/components/match/MatchCard.svelte';
|
||||
import RecentPlayers from '$lib/components/player/RecentPlayers.svelte';
|
||||
import PieChart from '$lib/components/charts/PieChart.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
// Get data from page loader
|
||||
@@ -12,6 +13,39 @@
|
||||
|
||||
// Use matches directly - already transformed by API client
|
||||
const featuredMatches = data.featuredMatches;
|
||||
const mapStats = data.mapStats;
|
||||
|
||||
// Count matches being processed (demos not yet parsed)
|
||||
const processingCount = $derived(featuredMatches.filter((m) => !m.demo_parsed).length);
|
||||
|
||||
// Prepare map chart data
|
||||
const mapChartData = $derived({
|
||||
labels: mapStats.map((s) => s.map),
|
||||
datasets: [
|
||||
{
|
||||
data: mapStats.map((s) => s.count),
|
||||
backgroundColor: [
|
||||
'rgba(59, 130, 246, 0.8)', // blue
|
||||
'rgba(16, 185, 129, 0.8)', // green
|
||||
'rgba(245, 158, 11, 0.8)', // amber
|
||||
'rgba(239, 68, 68, 0.8)', // red
|
||||
'rgba(139, 92, 246, 0.8)', // purple
|
||||
'rgba(236, 72, 153, 0.8)', // pink
|
||||
'rgba(20, 184, 166, 0.8)' // teal
|
||||
],
|
||||
borderColor: [
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)',
|
||||
'rgba(255, 255, 255, 0.8)'
|
||||
],
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{ icon: Users, label: 'Players Tracked', value: '1.2M+' },
|
||||
@@ -161,9 +195,22 @@
|
||||
<!-- Featured Matches -->
|
||||
<section class="py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div class="mb-8 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-base-content">Featured Matches</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-3xl font-bold text-base-content">Featured Matches</h2>
|
||||
{#if processingCount > 0}
|
||||
<Badge variant="warning" size="sm">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-warning opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-warning"></span>
|
||||
</span>
|
||||
<span class="ml-1.5">{processingCount} Processing</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-base-content/60">Latest competitive matches from our community</p>
|
||||
</div>
|
||||
<Button variant="ghost" href="/matches">View All</Button>
|
||||
@@ -238,6 +285,93 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Statistics Dashboard -->
|
||||
{#if mapStats.length > 0}
|
||||
<section class="border-t border-base-300 bg-base-100 py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="mb-8 text-center">
|
||||
<h2 class="text-3xl font-bold text-base-content">Community Statistics</h2>
|
||||
<p class="mt-2 text-base-content/60">
|
||||
Insights from {data.totalMatchesAnalyzed.toLocaleString()} recent matches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-2">
|
||||
<!-- Most Played Maps -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-6 text-xl font-semibold text-base-content">Most Played Maps</h3>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<PieChart data={mapChartData} options={{ maintainAspectRatio: true }} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-2">
|
||||
{#each mapStats as stat, i}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-3 w-3 rounded-sm"
|
||||
style="background-color: {mapChartData.datasets[0]?.backgroundColor?.[i] ||
|
||||
'rgba(59, 130, 246, 0.8)'}"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-base-content">{stat.map}</span>
|
||||
</div>
|
||||
<span class="text-sm text-base-content/60"
|
||||
>{stat.count} matches ({((stat.count / data.totalMatchesAnalyzed) * 100).toFixed(
|
||||
1
|
||||
)}%)</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Quick Stats Summary -->
|
||||
<Card padding="lg">
|
||||
<h3 class="mb-6 text-xl font-semibold text-base-content">Recent Activity</h3>
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-lg bg-base-200 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60">Total Matches</p>
|
||||
<p class="text-3xl font-bold text-primary">
|
||||
{data.totalMatchesAnalyzed.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp class="h-12 w-12 text-primary/40" />
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-base-content/50">From the last 24 hours</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-base-200 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60">Most Popular Map</p>
|
||||
<p class="text-3xl font-bold text-secondary">
|
||||
{mapStats[0]?.map || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="success" size="lg"
|
||||
>{mapStats[0]
|
||||
? `${((mapStats[0].count / data.totalMatchesAnalyzed) * 100).toFixed(0)}%`
|
||||
: '0%'}</Badge
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-base-content/50">
|
||||
Played in {mapStats[0]?.count || 0} matches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<Button variant="ghost" href="/matches">View All Match Statistics →</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="border-t border-base-300 bg-base-200 py-16">
|
||||
<div class="container mx-auto px-4">
|
||||
|
||||
@@ -10,11 +10,27 @@ export const load: PageLoad = async ({ parent }) => {
|
||||
await parent();
|
||||
|
||||
try {
|
||||
// Load featured matches for homepage carousel
|
||||
const matchesData = await api.matches.getMatches({ limit: 9 });
|
||||
// Load matches for homepage - get more for statistics
|
||||
const matchesData = await api.matches.getMatches({ limit: 50 });
|
||||
const allMatches = matchesData.matches;
|
||||
|
||||
// Calculate map statistics
|
||||
const mapCounts = new Map<string, number>();
|
||||
allMatches.forEach((match) => {
|
||||
const count = mapCounts.get(match.map) || 0;
|
||||
mapCounts.set(match.map, count + 1);
|
||||
});
|
||||
|
||||
// Convert to sorted array for pie chart
|
||||
const mapStats = Array.from(mapCounts.entries())
|
||||
.map(([map, count]) => ({ map, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 7); // Top 7 maps
|
||||
|
||||
return {
|
||||
featuredMatches: matchesData.matches.slice(0, 9), // Get 9 matches for carousel (3 slides)
|
||||
featuredMatches: allMatches.slice(0, 9), // Get 9 matches for carousel (3 slides)
|
||||
mapStats, // For most played maps pie chart
|
||||
totalMatchesAnalyzed: allMatches.length,
|
||||
meta: {
|
||||
title: 'CS2.WTF - Statistics for CS2 Matchmaking',
|
||||
description:
|
||||
@@ -31,6 +47,8 @@ export const load: PageLoad = async ({ parent }) => {
|
||||
// Return empty data - page will show without featured matches
|
||||
return {
|
||||
featuredMatches: [],
|
||||
mapStats: [],
|
||||
totalMatchesAnalyzed: 0,
|
||||
meta: {
|
||||
title: 'CS2.WTF - Statistics for CS2 Matchmaking',
|
||||
description:
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Get backend API URL from environment variable
|
||||
// Note: We use $env/dynamic/private instead of import.meta.env for server-side access
|
||||
const API_BASE_URL = env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
// Use import.meta.env for Vite environment variables (works in all environments)
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
|
||||
|
||||
/**
|
||||
* GET request handler
|
||||
|
||||
@@ -41,6 +41,17 @@
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.src = '/images/map_screenshots/default.webp';
|
||||
}
|
||||
|
||||
function handleDownloadDemo() {
|
||||
if (!match.share_code) {
|
||||
alert('Share code not available for this match');
|
||||
return;
|
||||
}
|
||||
// Open the demo download URL (typically from Valve servers or cached location)
|
||||
// Format: steam://rungame/730/76561202255233023/+csgo_download_match%20{SHARE_CODE}
|
||||
const downloadUrl = `steam://rungame/730/76561202255233023/+csgo_download_match%20${match.share_code}`;
|
||||
window.location.href = downloadUrl;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Match Header with Background -->
|
||||
@@ -72,9 +83,11 @@
|
||||
{mapName}
|
||||
</h1>
|
||||
</div>
|
||||
{#if match.demo_parsed}
|
||||
{#if match.demo_parsed && match.share_code}
|
||||
<button
|
||||
onclick={handleDownloadDemo}
|
||||
class="btn btn-ghost gap-2 border border-white/25 bg-white/15 text-white backdrop-blur-md hover:bg-white/25"
|
||||
title="Download this match demo to your Steam client"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">Download Demo</span>
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
|
||||
<!-- Round Timeline -->
|
||||
{#if rounds && rounds.rounds && rounds.rounds.length > 0}
|
||||
<RoundTimeline rounds={rounds.rounds} />
|
||||
<RoundTimeline rounds={rounds.rounds} maxRounds={match.max_rounds} />
|
||||
{:else}
|
||||
<Card padding="lg">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -53,22 +53,23 @@
|
||||
};
|
||||
|
||||
// Prepare data table columns
|
||||
type PlayerWithStats = (typeof playersWithStats)[0];
|
||||
const detailsColumns = [
|
||||
{
|
||||
key: 'avatar' as keyof (typeof playersWithStats)[0],
|
||||
key: 'avatar' as keyof PlayerWithStats,
|
||||
label: '',
|
||||
sortable: false,
|
||||
align: 'center' as const,
|
||||
render: (value: string | number | boolean | undefined, row: (typeof playersWithStats)[0]) => {
|
||||
render: (_value: unknown, row: PlayerWithStats) => {
|
||||
const avatarUrl = row.avatar || '';
|
||||
return `<img src="${avatarUrl}" alt="${row.name}" class="h-10 w-10 rounded-full border-2 border-base-300" />`;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'name' as keyof (typeof playersWithStats)[0],
|
||||
key: 'name' as keyof PlayerWithStats,
|
||||
label: 'Player',
|
||||
sortable: true,
|
||||
render: (value: string | number | boolean | undefined, row: (typeof playersWithStats)[0]) => {
|
||||
render: (value: unknown, row: PlayerWithStats) => {
|
||||
const strValue = value !== undefined ? String(value) : '';
|
||||
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
|
||||
// Color indicator dot
|
||||
@@ -108,40 +109,36 @@
|
||||
class: 'font-mono'
|
||||
},
|
||||
{
|
||||
key: 'kd' as keyof (typeof playersWithStats)[0],
|
||||
key: 'kd' as keyof PlayerWithStats,
|
||||
label: 'K/D',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
|
||||
v !== undefined ? (v as number).toFixed(2) : '0.00'
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toFixed(2) : '0.00')
|
||||
},
|
||||
{
|
||||
key: 'adr' as keyof (typeof playersWithStats)[0],
|
||||
key: 'adr' as keyof PlayerWithStats,
|
||||
label: 'ADR',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
|
||||
v !== undefined ? (v as number).toFixed(1) : '0.0'
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toFixed(1) : '0.0')
|
||||
},
|
||||
{
|
||||
key: 'hsPercent' as keyof (typeof playersWithStats)[0],
|
||||
key: 'hsPercent' as keyof PlayerWithStats,
|
||||
label: 'HS%',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
|
||||
v !== undefined ? (v as number).toFixed(1) : '0.0'
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toFixed(1) : '0.0')
|
||||
},
|
||||
{
|
||||
key: 'kast' as keyof (typeof playersWithStats)[0],
|
||||
key: 'kast' as keyof PlayerWithStats,
|
||||
label: 'KAST%',
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
|
||||
v !== undefined ? (v as number).toFixed(1) : '-'
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toFixed(1) : '-')
|
||||
},
|
||||
{
|
||||
key: 'mvp' as keyof (typeof playersWithStats)[0],
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
const tTeamId = 2;
|
||||
const ctTeamId = 3;
|
||||
|
||||
// Calculate halftime round based on max_rounds
|
||||
// MR12 (24 rounds): halftime after round 12
|
||||
// MR15 (30 rounds): halftime after round 15
|
||||
const halfPoint = match.max_rounds === 30 ? 15 : 12;
|
||||
|
||||
// Only process if rounds data exists
|
||||
let teamEconomy = $state<TeamEconomy[]>([]);
|
||||
let equipmentChartData = $state<{
|
||||
@@ -94,7 +99,7 @@
|
||||
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
|
||||
// halfPoint is calculated above based on match.max_rounds
|
||||
let economyAdvantage;
|
||||
if (roundData.round <= halfPoint) {
|
||||
// First half: T - CT
|
||||
@@ -153,6 +158,7 @@
|
||||
data: teamEconomy.map((r) => (r.economyAdvantage > 0 ? r.economyAdvantage : 0)),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.6)',
|
||||
// @ts-expect-error - Chart.js types incorrectly show fill as boolean, but 'origin' is valid
|
||||
fill: 'origin',
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
@@ -163,6 +169,7 @@
|
||||
data: teamEconomy.map((r) => (r.economyAdvantage < 0 ? r.economyAdvantage : 0)),
|
||||
borderColor: 'rgb(249, 115, 22)',
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.6)',
|
||||
// @ts-expect-error - Chart.js types incorrectly show fill as boolean, but 'origin' is valid
|
||||
fill: 'origin',
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
|
||||
@@ -55,15 +55,13 @@
|
||||
const sortedPlayerWeapons = playerWeaponsData.sort((a, b) => b.total_kills - a.total_kills);
|
||||
|
||||
// Prepare data table columns
|
||||
type PlayerWeapon = (typeof sortedPlayerWeapons)[0];
|
||||
const weaponColumns = [
|
||||
{
|
||||
key: 'player_name' as const,
|
||||
label: 'Player',
|
||||
sortable: true,
|
||||
render: (
|
||||
value: string | number | boolean | undefined,
|
||||
row: (typeof sortedPlayerWeapons)[0]
|
||||
) => {
|
||||
render: (value: unknown, row: PlayerWeapon) => {
|
||||
const strValue = value !== undefined ? String(value) : '';
|
||||
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
|
||||
return `<a href="/player/${row.player_id}" class="font-medium hover:underline ${teamClass}">${strValue}</a>`;
|
||||
@@ -89,8 +87,7 @@
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
class: 'font-mono',
|
||||
format: (v: string | number | undefined) =>
|
||||
v !== undefined ? (v as number).toLocaleString() : '0'
|
||||
format: (v: unknown) => (v !== undefined ? (v as number).toLocaleString() : '0')
|
||||
},
|
||||
{
|
||||
key: 'total_hits' as const,
|
||||
|
||||
@@ -476,9 +476,18 @@
|
||||
<input
|
||||
bind:value={searchQuery}
|
||||
type="text"
|
||||
placeholder="Search by player name, match ID, or share code..."
|
||||
placeholder="Search by match ID or share code..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
title="Player name search coming soon when API supports it"
|
||||
/>
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div
|
||||
class="tooltip tooltip-left"
|
||||
data-tip="Player name search will be available when the API supports it"
|
||||
>
|
||||
<Badge variant="warning" size="sm">Player Search Coming Soon</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" variant="primary">
|
||||
@@ -723,7 +732,7 @@
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-base-300 pt-4">
|
||||
<span class="text-sm font-medium text-base-content/70">Active Filters:</span>
|
||||
{#if currentSearch}
|
||||
<Badge variant="info">Search: {currentSearch}</Badge>
|
||||
<Badge variant="info">Match/Share Code: {currentSearch}</Badge>
|
||||
{/if}
|
||||
{#if currentMap}
|
||||
<Badge variant="info">Map: {currentMap}</Badge>
|
||||
|
||||
@@ -295,7 +295,7 @@
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={profile.tracked ? 'success' : 'primary'}
|
||||
variant={profile.tracked ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
onclick={() => (isTrackModalOpen = true)}
|
||||
>
|
||||
@@ -324,7 +324,7 @@
|
||||
playerId={profile.id}
|
||||
playerName={profile.name}
|
||||
isTracked={profile.tracked || false}
|
||||
bind:isOpen={isTrackModalOpen}
|
||||
bind:open={isTrackModalOpen}
|
||||
ontracked={handleTracked}
|
||||
onuntracked={handleUntracked}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user