feat: Implement Phase 1 critical features and fix API integration

This commit completes the first phase of feature parity implementation and
resolves all API integration issues to match the backend API format.

## API Integration Fixes

- Remove all hardcoded default values from transformers (tick_rate, kast, player_count, steam_updated)
- Update TypeScript types to make fields optional where backend doesn't guarantee them
- Update Zod schemas to validate optional fields correctly
- Fix mock data to match real API response format (plain arrays, not wrapped objects)
- Update UI components to handle undefined values with proper fallbacks
- Add comprehensive API documentation for Match and Player endpoints

## Phase 1 Features Implemented (3/6)

### 1. Player Tracking System 
- Created TrackPlayerModal.svelte with auth code input
- Integrated track/untrack player API endpoints
- Added UI for providing optional share code
- Displays tracked status on player profiles
- Full validation and error handling

### 2. Share Code Parsing 
- Created ShareCodeInput.svelte component
- Added to matches page for easy match submission
- Real-time validation of share code format
- Parse status feedback with loading states
- Auto-redirect to match page on success

### 3. VAC/Game Ban Status 
- Added VAC and game ban count/date fields to Player type
- Display status badges on player profile pages
- Show ban count and date when available
- Visual indicators using DaisyUI badge components

## Component Improvements

- Modal.svelte: Added Svelte 5 Snippet types, actions slot support
- ThemeToggle.svelte: Removed deprecated svelte:component usage
- Tooltip.svelte: Fixed type safety with Snippet type
- All new components follow Svelte 5 runes pattern ($state, $derived, $bindable)

## Type Safety & Linting

- Fixed all ESLint errors (any types → proper types)
- Fixed form label accessibility issues
- Replaced error: any with error: unknown + proper type guards
- Added Snippet type imports where needed
- Updated all catch blocks to use instanceof Error checks

## Static Assets

- Migrated all files from public/ to static/ directory per SvelteKit best practices
- Moved 200+ map icons, screenshots, and other assets
- Updated all import paths to use /images/ (served from static/)

## Documentation

- Created IMPLEMENTATION_STATUS.md tracking all 15 missing features
- Updated API.md with optional field annotations
- Created MATCHES_API.md with comprehensive endpoint documentation
- Added inline comments marking optional vs required fields

## Testing

- Updated mock fixtures to remove default values
- Fixed mock handlers to return plain arrays like real API
- Ensured all components handle undefined gracefully

## Remaining Phase 1 Tasks

- [ ] Add VAC status column to match scoreboard
- [ ] Create weapons statistics tab for matches
- [ ] Implement recently visited players on home page

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-12 19:31:18 +01:00
parent a861b1c1b6
commit 8f3b652740
422 changed files with 106174 additions and 102193 deletions

View File

@@ -2,6 +2,17 @@
@tailwind components;
@tailwind utilities;
/* CS2 Custom Font */
@font-face {
font-family: 'CS Regular';
src:
url('/fonts/cs_regular.woff2') format('woff2'),
url('/fonts/cs_regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@layer base {
:root {
/* Default to dark theme */
@@ -10,10 +21,34 @@
body {
@apply bg-base-100 text-base-content;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
font-feature-settings:
'rlig' 1,
'calt' 1;
}
/* CS2 Font for headlines only */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family:
'CS Regular',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
}
@layer components {

View File

@@ -4,38 +4,32 @@ import { APIException } from '$lib/types';
/**
* API Client Configuration
*
* Uses SvelteKit server routes (/api/[...path]/+server.ts) to proxy requests to the backend.
* This approach:
* - Works in all environments (dev, preview, production)
* - No CORS issues
* - Single code path for consistency
* - Can add caching, rate limiting, auth in the future
*
* Backend selection is controlled by VITE_API_BASE_URL environment variable:
* - Local development: VITE_API_BASE_URL=http://localhost:8000
* - Production: VITE_API_BASE_URL=https://api.csgow.tf
*
* Note: During SSR, we call the backend directly since relative URLs don't work server-side.
*/
const getAPIBaseURL = (): string => {
const apiUrl = import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf';
// Check if we're running on the server (SSR) or in production
// On the server, we must use the actual API URL, not the proxy
if (import.meta.env.SSR || import.meta.env.PROD) {
return apiUrl;
function getAPIBaseURL(): string {
// During SSR, call backend API directly (relative URLs don't work server-side)
if (import.meta.env.SSR) {
return import.meta.env.VITE_API_BASE_URL || 'https://api.csgow.tf';
}
// In development mode on the client, use the Vite proxy to avoid CORS issues
// The proxy will forward /api requests to VITE_API_BASE_URL
// In browser, use SvelteKit route
return '/api';
};
}
const API_BASE_URL = getAPIBaseURL();
const API_TIMEOUT = Number(import.meta.env?.VITE_API_TIMEOUT) || 10000;
// Log the API configuration
if (import.meta.env.DEV) {
if (import.meta.env.SSR) {
console.log('[API Client] SSR mode - using direct API URL:', API_BASE_URL);
} else {
console.log('[API Client] Browser mode - using Vite proxy');
console.log('[API Client] Frontend requests: /api/*');
console.log(
'[API Client] Proxy target:',
import.meta.env?.VITE_API_BASE_URL || 'https://api.csgow.tf'
);
}
}
/**
* Base API Client
* Provides centralized HTTP communication with error handling

View File

@@ -93,23 +93,54 @@ export const matchesAPI = {
/**
* Get paginated list of matches
*
* IMPORTANT: The API returns a plain array, not an object with properties.
* We must manually implement pagination by:
* 1. Requesting limit + 1 matches
* 2. Checking if we got more than limit (means there are more pages)
* 3. Extracting timestamp from last match for next page
*
* Pagination flow:
* - First call: GET /matches?limit=20 → returns array of up to 20 matches
* - Next call: GET /matches/next/{timestamp}?limit=20 → returns next 20 matches
* - Continue until response.length < limit (reached the end)
*
* @param params - Query parameters (filters, pagination)
* @returns List of matches with pagination
* @param params.limit - Number of matches to return (default: 50)
* @param params.before_time - Unix timestamp for pagination (get matches before this time)
* @param params.map - Filter by map name (e.g., "de_inferno")
* @param params.player_id - Filter by player Steam ID
* @returns List of matches with pagination metadata
*/
async getMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
const limit = params?.limit || 50;
// API returns a plain array, not a wrapped object
// CRITICAL: API returns a plain array, not a wrapped object
// We request limit + 1 to detect if there are more pages
const data = await apiClient.get<LegacyMatchListItem[]>(url, {
params: {
limit: params?.limit,
limit: limit + 1, // Request one extra to check if there are more
map: params?.map,
player_id: params?.player_id
}
});
// Check if there are more matches (if we got the extra one)
const hasMore = data.length > limit;
// Remove the extra match if we have more
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
// If there are more matches, use the timestamp of the last match for pagination
// This timestamp is used in the next request: /matches/next/{timestamp}
const lastMatch =
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
const nextPageTime =
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
// Transform legacy API response to new format
return transformMatchesListResponse(data);
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
},
/**
@@ -118,19 +149,32 @@ export const matchesAPI = {
* @returns List of matching matches
*/
async searchMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
const url = '/matches';
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
const limit = params?.limit || 20;
// API returns a plain array, not a wrapped object
const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', {
params: {
limit: params?.limit || 20,
limit: limit + 1, // Request one extra to check if there are more
map: params?.map,
player_id: params?.player_id,
before_time: params?.before_time
player_id: params?.player_id
}
});
// Check if there are more matches (if we got the extra one)
const hasMore = data.length > limit;
// Remove the extra match if we have more
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
// If there are more matches, use the timestamp of the last match for pagination
const lastMatch =
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
const nextPageTime =
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
// Transform legacy API response to new format
return transformMatchesListResponse(data);
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
},
/**

View File

@@ -36,6 +36,7 @@ export const playersAPI = {
const transformedData = transformPlayerProfile(legacyData);
// Validate the player data
// parsePlayer throws on validation failure, so player is always defined if we reach this point
const player = parsePlayer(transformedData);
// Calculate aggregated stats from matches
@@ -60,18 +61,19 @@ export const playersAPI = {
const winRate = recentMatches.length > 0 ? wins / recentMatches.length : 0;
// Find the most recent match date
const lastMatchDate = matches.length > 0 ? matches[0].date : new Date().toISOString();
const lastMatchDate =
matches.length > 0 && matches[0] ? matches[0].date : new Date().toISOString();
// Transform to PlayerMeta format
const playerMeta: PlayerMeta = {
id: parseInt(player.id),
id: parseInt(player.id, 10),
name: player.name,
avatar: player.avatar, // Already transformed by transformPlayerProfile
recent_matches: recentMatches.length,
last_match_date: lastMatchDate,
avg_kills: avgKills,
avg_deaths: avgDeaths,
avg_kast: totalKast / recentMatches.length || 0, // Placeholder KAST calculation
avg_kast: recentMatches.length > 0 ? totalKast / recentMatches.length : 0, // Placeholder KAST calculation
win_rate: winRate
};

View File

@@ -1,28 +1,46 @@
/**
* API Response Transformers
* Converts legacy CSGO:WTF API responses to the new CS2.WTF format
*
* IMPORTANT: The backend API returns data in a legacy format that differs from our TypeScript schemas.
* These transformers bridge that gap by:
* 1. Converting Unix timestamps to ISO 8601 strings
* 2. Splitting score arrays [team_a, team_b] into separate fields
* 3. Renaming fields (parsed → demo_parsed, vac → vac_present, etc.)
* 4. Constructing full avatar URLs from hashes
* 5. Normalizing team IDs (1/2 → 2/3)
*
* Always use these transformers before passing API data to Zod schemas or TypeScript types.
*/
import type { MatchListItem, MatchesListResponse, Match, MatchPlayer } from '$lib/types';
/**
* Legacy API match format (from api.csgow.tf)
* Legacy API match list item format (from api.csgow.tf)
*
* VERIFIED: This interface matches the actual API response from GET /matches
* Tested: 2025-11-12 via curl https://api.csgow.tf/matches?limit=2
*/
export interface LegacyMatchListItem {
match_id: string;
map: string;
date: number; // Unix timestamp
score: [number, number]; // [team_a, team_b]
duration: number;
match_result: number;
max_rounds: number;
parsed: boolean;
vac: boolean;
game_ban: boolean;
match_id: string; // uint64 as string
map: string; // Can be empty string if not parsed
date: number; // Unix timestamp (seconds since epoch)
score: [number, number]; // [team_a_score, team_b_score]
duration: number; // Match duration in seconds
match_result: number; // 0 = tie, 1 = team_a win, 2 = team_b win
max_rounds: number; // 24 for MR12, 30 for MR15
parsed: boolean; // Whether demo has been parsed (NOT demo_parsed)
vac: boolean; // Whether any player has VAC ban (NOT vac_present)
game_ban: boolean; // Whether any player has game ban (NOT gameban_present)
}
/**
* Legacy API match detail format
* Legacy API match detail format (from GET /match/:id)
*
* VERIFIED: This interface matches the actual API response
* Tested: 2025-11-12 via curl https://api.csgow.tf/match/3589487716842078322
*
* Note: Uses 'stats' array, not 'players' array
*/
export interface LegacyMatchDetail {
match_id: string;
@@ -33,14 +51,21 @@ export interface LegacyMatchDetail {
duration: number;
match_result: number;
max_rounds: number;
parsed: boolean;
vac: boolean;
game_ban: boolean;
stats?: LegacyPlayerStats[];
parsed: boolean; // NOT demo_parsed
vac: boolean; // NOT vac_present
game_ban: boolean; // NOT gameban_present
stats?: LegacyPlayerStats[]; // Player stats array
}
/**
* Legacy player stats format
* Legacy player stats format (nested within match detail)
*
* VERIFIED: Matches actual API response structure
* - Player info nested under 'player' object
* - Rank as object with 'old' and 'new' properties
* - Multi-kills as object with 'duo', 'triple', 'quad', 'ace'
* - Damage as object with 'enemy' and 'team'
* - Flash stats with nested 'duration' and 'total' objects
*/
export interface LegacyPlayerStats {
team_id: number;
@@ -82,6 +107,16 @@ export interface LegacyPlayerStats {
/**
* Transform legacy match list item to new format
*
* Converts a single match from the API's legacy format to our schema format.
*
* Key transformations:
* - date: Unix timestamp → ISO 8601 string
* - score: [a, b] array → score_team_a, score_team_b fields
* - parsed → demo_parsed (rename)
*
* @param legacy - Match data from API in legacy format
* @returns Match data in schema-compatible format
*/
export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListItem {
return {
@@ -91,21 +126,36 @@ export function transformMatchListItem(legacy: LegacyMatchListItem): MatchListIt
score_team_a: legacy.score[0],
score_team_b: legacy.score[1],
duration: legacy.duration,
demo_parsed: legacy.parsed,
player_count: 10 // Default to 10 players (5v5)
demo_parsed: legacy.parsed // Rename: parsed → demo_parsed
};
}
/**
* Transform legacy matches list response to new format
*
* IMPORTANT: The API returns a plain array, NOT an object with properties.
* This function wraps the array and adds pagination metadata that we calculate ourselves.
*
* How pagination works:
* 1. API returns plain array: [match1, match2, ...]
* 2. We request limit + 1 to check if there are more matches
* 3. If we get > limit matches, hasMore = true
* 4. We extract timestamp from last match for next page: matches[length-1].date
*
* @param legacyMatches - Array of matches from API (already requested limit + 1)
* @param hasMore - Whether there are more matches available (calculated by caller)
* @param nextPageTime - Unix timestamp for next page (extracted from last match by caller)
* @returns Wrapped response with pagination metadata
*/
export function transformMatchesListResponse(
legacyMatches: LegacyMatchListItem[]
legacyMatches: LegacyMatchListItem[],
hasMore: boolean = false,
nextPageTime?: number
): MatchesListResponse {
return {
matches: legacyMatches.map(transformMatchListItem),
has_more: false, // Legacy API doesn't provide pagination info
next_page_time: undefined
has_more: hasMore,
next_page_time: nextPageTime
};
}
@@ -113,6 +163,13 @@ export function transformMatchesListResponse(
* Transform legacy player stats to new format
*/
export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
// Extract Premier rating from rank object
// API provides rank as { old: number, new: number }
const rankOld =
legacy.rank && typeof legacy.rank.old === 'number' ? (legacy.rank.old as number) : undefined;
const rankNew =
legacy.rank && typeof legacy.rank.new === 'number' ? (legacy.rank.new as number) : undefined;
return {
id: legacy.player.steamid64,
name: legacy.player.name,
@@ -124,7 +181,9 @@ export function transformPlayerStats(legacy: LegacyPlayerStats): MatchPlayer {
headshot: legacy.headshot,
mvp: legacy.mvp,
score: legacy.score,
kast: 0, // Not provided by legacy API
// Premier rating (CS2: 0-30000)
rank_old: rankOld,
rank_new: rankNew,
// Multi-kills: map legacy names to new format
mk_2: legacy.multi_kills?.duo,
mk_3: legacy.multi_kills?.triple,
@@ -157,7 +216,6 @@ export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
demo_parsed: legacy.parsed,
vac_present: legacy.vac,
gameban_present: legacy.game_ban,
tick_rate: 64, // Default to 64, not provided by API
players: legacy.stats?.map(transformPlayerStats)
};
}
@@ -216,46 +274,59 @@ export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
id: legacy.steamid64,
name: legacy.name,
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
steam_updated: new Date().toISOString(), // Not provided by API
vac_count: legacy.vac ? 1 : 0,
vac_date: hasVacDate ? new Date(legacy.vac_date * 1000).toISOString() : null,
game_ban_count: legacy.game_ban ? 1 : 0,
game_ban_date: hasGameBanDate ? new Date(legacy.game_ban_date * 1000).toISOString() : null,
tracked: legacy.tracked,
wins: legacy.match_stats?.win,
losses: legacy.match_stats?.loss,
matches: legacy.matches?.map((match) => ({
match_id: match.match_id,
map: match.map || 'unknown',
date: new Date(match.date * 1000).toISOString(),
score_team_a: match.score[0],
score_team_b: match.score[1],
duration: match.duration,
match_result: match.match_result,
max_rounds: match.max_rounds,
demo_parsed: match.parsed,
vac_present: match.vac,
gameban_present: match.game_ban,
tick_rate: 64, // Not provided by API
stats: {
id: legacy.steamid64,
name: legacy.name,
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
// Fix team_id: API returns 1/2, but schema expects min 2
// Map: 1 -> 2 (Terrorists), 2 -> 3 (Counter-Terrorists)
team_id:
match.stats.team_id === 1 ? 2 : match.stats.team_id === 2 ? 3 : match.stats.team_id,
kills: match.stats.kills,
deaths: match.stats.deaths,
assists: match.stats.assists,
headshot: match.stats.headshot,
mvp: match.stats.mvp,
score: match.stats.score,
kast: 0,
mk_2: match.stats.multi_kills?.duo,
mk_3: match.stats.multi_kills?.triple,
mk_4: match.stats.multi_kills?.quad,
mk_5: match.stats.multi_kills?.ace
}
}))
matches: legacy.matches?.map((match) => {
// Extract Premier rating from rank object
const rankOld =
match.stats.rank && typeof match.stats.rank.old === 'number'
? (match.stats.rank.old as number)
: undefined;
const rankNew =
match.stats.rank && typeof match.stats.rank.new === 'number'
? (match.stats.rank.new as number)
: undefined;
return {
match_id: match.match_id,
map: match.map || 'unknown',
date: new Date(match.date * 1000).toISOString(),
score_team_a: match.score[0],
score_team_b: match.score[1],
duration: match.duration,
match_result: match.match_result,
max_rounds: match.max_rounds,
demo_parsed: match.parsed,
vac_present: match.vac,
gameban_present: match.game_ban,
stats: {
id: legacy.steamid64,
name: legacy.name,
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
// Fix team_id: API returns 1/2, but schema expects min 2
// Map: 1 -> 2 (Terrorists), 2 -> 3 (Counter-Terrorists)
team_id:
match.stats.team_id === 1 ? 2 : match.stats.team_id === 2 ? 3 : match.stats.team_id,
kills: match.stats.kills,
deaths: match.stats.deaths,
assists: match.stats.assists,
headshot: match.stats.headshot,
mvp: match.stats.mvp,
score: match.stats.score,
// Premier rating (CS2: 0-30000)
rank_old: rankOld,
rank_new: rankNew,
mk_2: match.stats.multi_kills?.duo,
mk_3: match.stats.multi_kills?.triple,
mk_4: match.stats.multi_kills?.quad,
mk_5: match.stats.multi_kills?.ace
}
};
})
};
}

View File

@@ -0,0 +1,268 @@
<script lang="ts">
import { Bomb, Shield, Clock, Target, Skull } from 'lucide-svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Card from '$lib/components/ui/Card.svelte';
import type { RoundDetail } from '$lib/types/RoundStats';
let { rounds }: { rounds: RoundDetail[] } = $props();
// State for hover/click details
let selectedRound = $state<number | null>(null);
// Helper to get win reason icon
const getWinReasonIcon = (reason: string) => {
const reasonLower = reason.toLowerCase();
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return Bomb;
if (reasonLower.includes('defused')) return Shield;
if (reasonLower.includes('elimination')) return Skull;
if (reasonLower.includes('time')) return Clock;
if (reasonLower.includes('target')) return Target;
return null;
};
// Helper to get win reason display text
const getWinReasonText = (reason: string) => {
const reasonLower = reason.toLowerCase();
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return 'Bomb Exploded';
if (reasonLower.includes('defused')) return 'Bomb Defused';
if (reasonLower.includes('elimination')) return 'Elimination';
if (reasonLower.includes('time')) return 'Time Expired';
if (reasonLower.includes('target')) return 'Target Saved';
return reason;
};
// Helper to format win reason for badge
const formatWinReason = (reason: string): string => {
const reasonLower = reason.toLowerCase();
if (reasonLower.includes('bomb') && reasonLower.includes('exploded')) return 'BOOM';
if (reasonLower.includes('defused')) return 'DEF';
if (reasonLower.includes('elimination')) return 'ELIM';
if (reasonLower.includes('time')) return 'TIME';
if (reasonLower.includes('target')) return 'SAVE';
return 'WIN';
};
// Toggle round selection
const toggleRound = (roundNum: number) => {
selectedRound = selectedRound === roundNum ? null : roundNum;
};
// Calculate team scores up to a given round
const getScoreAtRound = (roundNumber: number): { teamA: number; teamB: number } => {
let teamA = 0;
let teamB = 0;
for (let i = 0; i < roundNumber && i < rounds.length; i++) {
const round = rounds[i];
if (round && round.winner === 2) teamA++;
else if (round && round.winner === 3) teamB++;
}
return { teamA, teamB };
};
// Get selected round details
const selectedRoundData = $derived(
selectedRound ? rounds.find((r) => r.round === selectedRound) : null
);
</script>
<Card padding="lg">
<div class="mb-6">
<h2 class="text-2xl font-bold text-base-content">Round Timeline</h2>
<p class="mt-2 text-sm text-base-content/60">
Click on a round to see detailed information. T = Terrorists, CT = Counter-Terrorists
</p>
</div>
<!-- Timeline -->
<div class="relative">
<!-- Horizontal scroll container for mobile -->
<div class="overflow-x-auto pb-4">
<div class="min-w-max">
<!-- Round markers -->
<div class="flex gap-1">
{#each rounds as round (round.round)}
{@const isWinner2 = round.winner === 2}
{@const isWinner3 = round.winner === 3}
{@const isSelected = selectedRound === round.round}
{@const Icon = getWinReasonIcon(round.win_reason)}
{@const scoreAtRound = getScoreAtRound(round.round)}
<button
class="group relative flex flex-col items-center transition-all hover:scale-110"
style="width: 60px;"
onclick={() => toggleRound(round.round)}
aria-label={`Round ${round.round}`}
>
<!-- Round number -->
<div
class="mb-2 text-xs font-semibold transition-colors"
class:text-primary={isSelected}
class:opacity-60={!isSelected}
>
{round.round}
</div>
<!-- Round indicator circle -->
<div
class="relative flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all"
class:border-terrorist={isWinner2}
class:bg-terrorist={isWinner2}
class:bg-opacity-20={isWinner2 || isWinner3}
class:border-ct={isWinner3}
class:bg-ct={isWinner3}
class:ring-4={isSelected}
class:ring-primary={isSelected}
class:ring-opacity-30={isSelected}
class:scale-110={isSelected}
>
<!-- Win reason icon or T/CT badge -->
{#if Icon}
<Icon class={`h-5 w-5 ${isWinner2 ? 'text-terrorist' : 'text-ct'}`} />
{:else}
<span
class="text-sm font-bold"
class:text-terrorist={isWinner2}
class:text-ct={isWinner3}
>
{isWinner2 ? 'T' : 'CT'}
</span>
{/if}
<!-- Small win reason badge on bottom -->
<div
class="absolute -bottom-1 rounded px-1 py-0.5 text-[9px] font-bold leading-none"
class:bg-terrorist={isWinner2}
class:bg-ct={isWinner3}
class:text-white={true}
>
{formatWinReason(round.win_reason)}
</div>
</div>
<!-- Connecting line to next round -->
{#if round.round < rounds.length}
<div
class="absolute left-[60px] top-[34px] h-0.5 w-[calc(100%-60px)] bg-base-300"
></div>
{/if}
<!-- Hover tooltip -->
<div
class="pointer-events-none absolute top-full z-10 mt-2 hidden w-48 rounded-lg bg-base-100 p-3 text-left shadow-xl ring-1 ring-base-300 group-hover:block"
>
<div class="text-xs font-semibold text-base-content">
Round {round.round}
</div>
<div class="mt-1 text-xs text-base-content/80">
Winner:
<span
class="font-bold"
class:text-terrorist={isWinner2}
class:text-ct={isWinner3}
>
{isWinner2 ? 'Terrorists' : 'Counter-Terrorists'}
</span>
</div>
<div class="mt-1 text-xs text-base-content/60">
{getWinReasonText(round.win_reason)}
</div>
<div class="mt-2 text-xs text-base-content/60">
Score: {scoreAtRound.teamA} - {scoreAtRound.teamB}
</div>
</div>
</button>
{/each}
</div>
<!-- Half marker (round 13 for MR12) -->
{#if rounds.length > 12}
<div class="relative mt-2 flex gap-1">
<div class="ml-[calc(60px*12-30px)] w-[60px] text-center">
<Badge variant="info" size="sm">Halftime</Badge>
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Selected Round Details -->
{#if selectedRoundData}
<div class="mt-6 border-t border-base-300 pt-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-bold text-base-content">
Round {selectedRoundData.round} Details
</h3>
<button
class="btn btn-ghost btn-sm"
onclick={() => (selectedRound = null)}
aria-label="Close details"
>
Close
</button>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<div class="text-sm text-base-content/60">Winner</div>
<div
class="text-lg font-bold"
class:text-terrorist={selectedRoundData.winner === 2}
class:text-ct={selectedRoundData.winner === 3}
>
{selectedRoundData.winner === 2 ? 'Terrorists' : 'Counter-Terrorists'}
</div>
</div>
<div>
<div class="text-sm text-base-content/60">Win Reason</div>
<div class="text-lg font-semibold text-base-content">
{getWinReasonText(selectedRoundData.win_reason)}
</div>
</div>
</div>
<!-- Player stats for the round if available -->
{#if selectedRoundData.players && selectedRoundData.players.length > 0}
<div class="mt-4">
<h4 class="mb-2 text-sm font-semibold text-base-content">Round Economy</h4>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="border-base-300">
<th>Player</th>
<th>Bank</th>
<th>Equipment</th>
<th>Spent</th>
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
<th>Kills</th>
{/if}
{#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)}
<th>Damage</th>
{/if}
</tr>
</thead>
<tbody>
{#each selectedRoundData.players as player}
<tr class="border-base-300">
<td class="font-medium"
>Player {player.player_id || player.match_player_id || '?'}</td
>
<td class="font-mono text-success">${player.bank.toLocaleString()}</td>
<td class="font-mono">${player.equipment.toLocaleString()}</td>
<td class="font-mono text-error">${player.spent.toLocaleString()}</td>
{#if selectedRoundData.players.some((p) => p.kills_in_round !== undefined)}
<td class="font-mono">{player.kills_in_round || 0}</td>
{/if}
{#if selectedRoundData.players.some((p) => p.damage_in_round !== undefined)}
<td class="font-mono">{player.damage_in_round || 0}</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
{/if}
</Card>

View File

@@ -44,12 +44,7 @@
class?: string;
}
let {
data,
options = {},
height = 300,
class: className = ''
}: Props = $props();
let { data, options = {}, height = 300, class: className = '' }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart<'line'> | null = null;

View File

@@ -17,11 +17,10 @@
<div class="container mx-auto px-4">
<div class="flex h-16 items-center justify-between">
<!-- Logo -->
<a
href="/"
class="flex items-center gap-2 text-2xl font-bold transition-transform hover:scale-105"
>
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
<a href="/" class="transition-transform hover:scale-105" aria-label="CS2.WTF Home">
<h1 class="text-2xl font-bold">
<span class="text-primary">CS2</span><span class="text-secondary">.WTF</span>
</h1>
</a>
<!-- Desktop Navigation -->

View File

@@ -92,7 +92,7 @@
<div class="flex flex-wrap gap-2">
{#each $search.recentSearches as recent}
<button
class="badge badge-lg badge-outline gap-2 hover:badge-primary"
class="badge badge-outline badge-lg gap-2 hover:badge-primary"
onclick={() => handleRecentClick(recent)}
>
<Search class="h-3 w-3" />

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Sun, Moon, Monitor } from 'lucide-svelte';
import { Moon, Sun, Monitor } from 'lucide-svelte';
import { preferences } from '$lib/stores';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
@@ -10,9 +10,8 @@
{ value: 'auto', label: 'Auto', icon: Monitor }
] as const;
const currentIcon = $derived(
themes.find((t) => t.value === $preferences.theme)?.icon || Monitor
);
// Get current theme data
const currentTheme = $derived(themes.find((t) => t.value === $preferences.theme) || themes[2]);
const applyTheme = (theme: 'cs2light' | 'cs2dark' | 'auto') => {
if (!browser) return;
@@ -50,19 +49,19 @@
<!-- Theme Toggle Dropdown -->
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-circle" aria-label="Theme">
<svelte:component this={currentIcon} class="h-5 w-5" />
<button tabindex="0" class="btn btn-circle btn-ghost" aria-label="Theme">
<currentTheme.icon class="h-5 w-5" />
</button>
<ul class="menu dropdown-content z-[1] mt-3 w-52 rounded-box bg-base-100 p-2 shadow-lg">
{#each themes as { value, label, icon }}
{#each themes as theme}
<li>
<button
class:active={$preferences.theme === value}
onclick={() => handleThemeChange(value)}
class:active={$preferences.theme === theme.value}
onclick={() => handleThemeChange(theme.value)}
>
<svelte:component this={icon} class="h-4 w-4" />
{label}
{#if value === 'auto'}
<theme.icon class="h-4 w-4" />
{theme.label}
{#if theme.value === 'auto'}
<span class="text-xs text-base-content/60">(System)</span>
{/if}
</button>

View File

@@ -1,12 +1,15 @@
<script lang="ts">
import Badge from '$lib/components/ui/Badge.svelte';
import type { MatchListItem } from '$lib/types';
import { storeMatchesState } from '$lib/utils/navigation';
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
interface Props {
match: MatchListItem;
loadedCount?: number;
}
let { match }: Props = $props();
let { match, loadedCount = 0 }: Props = $props();
const formattedDate = new Date(match.date).toLocaleString('en-US', {
month: 'short',
@@ -15,26 +18,53 @@
minute: '2-digit'
});
const mapName = match.map.replace('de_', '').toUpperCase();
const mapName = formatMapName(match.map);
const mapBg = getMapBackground(match.map);
function handleClick() {
// Store navigation state before navigating
storeMatchesState(match.match_id, loadedCount);
}
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.src = '/images/map_screenshots/default.webp';
}
</script>
<a href={`/match/${match.match_id}`} class="block transition-transform hover:scale-[1.02]">
<a
href={`/match/${match.match_id}`}
class="block transition-transform hover:scale-[1.02]"
data-match-id={match.match_id}
onclick={handleClick}
>
<div
class="overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-md transition-shadow hover:shadow-xl"
>
<!-- Map Header -->
<div class="relative h-32 bg-gradient-to-br from-base-300 to-base-200">
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-5xl font-bold text-base-content/10">{mapName}</span>
</div>
<div class="absolute bottom-3 left-3">
<Badge variant="default">{match.map}</Badge>
</div>
{#if match.demo_parsed}
<div class="absolute right-3 top-3">
<Badge variant="success" size="sm">Parsed</Badge>
<!-- Map Header with Background Image -->
<div class="relative h-32 overflow-hidden">
<!-- Background Image -->
<img
src={mapBg}
alt={mapName}
class="absolute inset-0 h-full w-full object-cover"
loading="lazy"
onerror={handleImageError}
/>
<!-- Overlay for better text contrast -->
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-black/20"></div>
<!-- Content -->
<div class="relative flex h-full items-end justify-between p-3">
<div class="flex flex-col gap-1">
{#if match.map}
<Badge variant="default">{match.map}</Badge>
{/if}
<span class="text-lg font-bold text-white drop-shadow-lg">{mapName}</span>
</div>
{/if}
{#if match.demo_parsed}
<Badge variant="success" size="sm">Parsed</Badge>
{/if}
</div>
</div>
<!-- Match Info -->

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { Upload, Check, AlertCircle, Loader2 } from 'lucide-svelte';
import { matchesAPI } from '$lib/api/matches';
import { showToast } from '$lib/stores/toast';
import { goto } from '$app/navigation';
let shareCode = $state('');
let isLoading = $state(false);
let parseStatus: 'idle' | 'parsing' | 'success' | 'error' = $state('idle');
let statusMessage = $state('');
let parsedMatchId = $state('');
// Validate share code format
function isValidShareCode(code: string): boolean {
// Format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX
const pattern = /^CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$/;
return pattern.test(code.toUpperCase());
}
async function handleSubmit() {
const trimmedCode = shareCode.trim().toUpperCase();
if (!trimmedCode) {
showToast('Please enter a share code', 'error');
return;
}
if (!isValidShareCode(trimmedCode)) {
showToast('Invalid share code format', 'error');
parseStatus = 'error';
statusMessage = 'Share code must be in format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX';
return;
}
isLoading = true;
parseStatus = 'parsing';
statusMessage = 'Submitting share code for parsing...';
try {
const response = await matchesAPI.parseMatch(trimmedCode);
if (response.match_id) {
parsedMatchId = response.match_id;
parseStatus = 'success';
statusMessage =
response.message ||
'Match submitted successfully! Parsing may take a few minutes. You can view the match once parsing is complete.';
showToast('Match submitted for parsing!', 'success');
// Wait a moment then redirect to the match page
setTimeout(() => {
goto(`/match/${response.match_id}`);
}, 2000);
} else {
parseStatus = 'error';
statusMessage = response.message || 'Failed to parse share code';
showToast(statusMessage, 'error');
}
} catch (error: unknown) {
parseStatus = 'error';
statusMessage = error instanceof Error ? error.message : 'Failed to parse share code';
showToast(statusMessage, 'error');
} finally {
isLoading = false;
}
}
function resetForm() {
shareCode = '';
parseStatus = 'idle';
statusMessage = '';
parsedMatchId = '';
}
</script>
<div class="space-y-4">
<!-- Input Section -->
<div class="form-control">
<label class="label" for="shareCode">
<span class="label-text font-medium">Submit Match Share Code</span>
</label>
<div class="flex gap-2">
<input
id="shareCode"
type="text"
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
class="input input-bordered flex-1"
bind:value={shareCode}
disabled={isLoading}
onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
/>
<button
class="btn btn-primary"
onclick={handleSubmit}
disabled={isLoading || !shareCode.trim()}
>
{#if isLoading}
<Loader2 class="h-5 w-5 animate-spin" />
{:else}
<Upload class="h-5 w-5" />
{/if}
Parse
</button>
</div>
<div class="label">
<span class="label-text-alt text-base-content/60">
Submit a CS2 match share code to add it to the database
</span>
</div>
</div>
<!-- Status Messages -->
{#if parseStatus !== 'idle'}
<div
class="alert {parseStatus === 'success'
? 'alert-success'
: parseStatus === 'error'
? 'alert-error'
: 'alert-info'}"
>
{#if parseStatus === 'parsing'}
<Loader2 class="h-6 w-6 shrink-0 animate-spin stroke-current" />
{:else if parseStatus === 'success'}
<Check class="h-6 w-6 shrink-0 stroke-current" />
{:else}
<AlertCircle class="h-6 w-6 shrink-0 stroke-current" />
{/if}
<div class="flex-1">
<p>{statusMessage}</p>
{#if parseStatus === 'success' && parsedMatchId}
<p class="mt-1 text-sm">Redirecting to match page...</p>
{/if}
</div>
{#if parseStatus !== 'parsing'}
<button class="btn btn-ghost btn-sm" onclick={resetForm}>Dismiss</button>
{/if}
</div>
{/if}
<!-- Help Text -->
<div class="text-sm text-base-content/70">
<p class="mb-2 font-medium">How to get your match share code:</p>
<ol class="list-inside list-decimal space-y-1">
<li>Open CS2 and navigate to your Matches tab</li>
<li>Click on a match you want to analyze</li>
<li>Click the "Copy Share Link" button</li>
<li>Paste the share code here</li>
</ol>
<p class="mt-2 text-xs">
Note: Demo parsing can take 1-5 minutes depending on match length. You'll be able to view
basic match info immediately, but detailed statistics will be available after parsing
completes.
</p>
</div>
</div>

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import { playersAPI } from '$lib/api/players';
import { showToast } from '$lib/stores/toast';
interface Props {
playerId: string;
playerName: string;
isTracked: boolean;
isOpen: boolean;
}
let { playerId, playerName, isTracked, isOpen = $bindable() }: Props = $props();
const dispatch = createEventDispatcher();
let authCode = $state('');
let shareCode = $state('');
let isLoading = $state(false);
let error = $state('');
async function handleTrack() {
if (!authCode.trim()) {
error = 'Auth code is required';
return;
}
isLoading = true;
error = '';
try {
await playersAPI.trackPlayer(playerId, authCode, shareCode || undefined);
showToast('Player tracking activated successfully!', 'success');
isOpen = false;
dispatch('tracked');
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to track player';
showToast(error, 'error');
} finally {
isLoading = false;
}
}
async function handleUntrack() {
if (!authCode.trim()) {
error = 'Auth code is required to untrack';
return;
}
isLoading = true;
error = '';
try {
await playersAPI.untrackPlayer(playerId, authCode);
showToast('Player tracking removed successfully', 'success');
isOpen = false;
dispatch('untracked');
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to untrack player';
showToast(error, 'error');
} finally {
isLoading = false;
}
}
function handleClose() {
isOpen = false;
authCode = '';
shareCode = '';
error = '';
}
</script>
<Modal bind:isOpen onClose={handleClose} title={isTracked ? 'Untrack Player' : 'Track Player'}>
<div class="space-y-4">
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div class="text-sm">
{#if isTracked}
<p>Remove <strong>{playerName}</strong> from automatic match tracking.</p>
{:else}
<p>
Add <strong>{playerName}</strong> to the tracking system to automatically fetch new matches.
</p>
{/if}
</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) -->
{#if !isTracked}
<div class="form-control">
<label class="label" for="shareCode">
<span class="label-text font-medium">Share Code (Optional)</span>
</label>
<input
id="shareCode"
type="text"
placeholder="CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
class="input input-bordered w-full"
bind:value={shareCode}
disabled={isLoading}
/>
<div class="label">
<span class="label-text-alt text-base-content/60">
Optional: Provide a share code if you have no matches yet
</span>
</div>
</div>
{/if}
<!-- Error Message -->
{#if error}
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
<!-- Help Text -->
<div class="text-sm text-base-content/70">
<p class="mb-2 font-medium">How to get your authentication code:</p>
<ol class="list-inside list-decimal space-y-1">
<li>Open CS2 and go to Settings → Game</li>
<li>Enable the Developer Console</li>
<li>Press <kbd class="kbd kbd-sm">~</kbd> to open the console</li>
<li>Type: <code class="rounded bg-base-300 px-1">status</code></li>
<li>Copy the code shown next to "Account:"</li>
</ol>
</div>
</div>
{#snippet actions()}
<button class="btn" onclick={handleClose} disabled={isLoading}>Cancel</button>
{#if isTracked}
<button class="btn btn-error" onclick={handleUntrack} disabled={isLoading}>
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Untrack Player
</button>
{:else}
<button class="btn btn-primary" onclick={handleTrack} disabled={isLoading}>
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Track Player
</button>
{/if}
{/snippet}
</Modal>

View File

@@ -1,16 +1,18 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { fly, fade } from 'svelte/transition';
import type { Snippet } from 'svelte';
interface Props {
open?: boolean;
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
onClose?: () => void;
children?: any;
children?: Snippet;
actions?: Snippet;
}
let { open = $bindable(false), title, size = 'md', onClose, children }: Props = $props();
let { open = $bindable(false), title, size = 'md', onClose, children, actions }: Props = $props();
const sizeClasses = {
sm: 'max-w-md',
@@ -44,9 +46,15 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4"
transition:fade={{ duration: 200 }}
onclick={handleBackdropClick}
onkeydown={(e) => {
if (e.key === 'Escape') {
handleClose();
}
}}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
tabindex="-1"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
@@ -82,6 +90,13 @@
<div class="p-6">
{@render children?.()}
</div>
<!-- Actions -->
{#if actions}
<div class="flex justify-end gap-2 border-t border-base-300 p-6">
{@render actions()}
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { formatPremierRating, getPremierRatingChange } from '$lib/utils/formatters';
import { Trophy, TrendingUp, TrendingDown } from 'lucide-svelte';
interface Props {
rating: number | undefined | null;
oldRating?: number | undefined | null;
size?: 'sm' | 'md' | 'lg';
showTier?: boolean;
showChange?: boolean;
showIcon?: boolean;
class?: string;
}
let {
rating,
oldRating,
size = 'md',
showTier = false,
showChange = false,
showIcon = true,
class: className = ''
}: Props = $props();
const tierInfo = $derived(formatPremierRating(rating));
const changeInfo = $derived(showChange ? getPremierRatingChange(oldRating, rating) : null);
const baseClasses = 'inline-flex items-center gap-1.5 border rounded-lg font-medium';
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
lg: 'px-4 py-2 text-base'
};
const iconSizes = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5'
};
const classes = $derived(
`${baseClasses} ${tierInfo.cssClasses} ${sizeClasses[size]} ${className}`
);
</script>
<div class={classes}>
{#if showIcon}
<Trophy class={iconSizes[size]} />
{/if}
<span>{tierInfo.formatted}</span>
{#if showTier}
<span class="opacity-75">({tierInfo.tier})</span>
{/if}
{#if showChange && changeInfo}
<span class="ml-1 flex items-center gap-0.5 {changeInfo.cssClasses}">
{#if changeInfo.isPositive}
<TrendingUp class={iconSizes[size]} />
{:else if changeInfo.change < 0}
<TrendingDown class={iconSizes[size]} />
{/if}
{changeInfo.display}
</span>
{/if}
</div>

View File

@@ -43,8 +43,10 @@
}
};
const variantClass = variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : '';
const sizeClass = size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : '';
const variantClass =
variant === 'boxed' ? 'tabs-boxed' : variant === 'lifted' ? 'tabs-lifted' : '';
const sizeClass =
size === 'xs' ? 'tabs-xs' : size === 'sm' ? 'tabs-sm' : size === 'lg' ? 'tabs-lg' : '';
</script>
<div role="tablist" class="tabs {variantClass} {sizeClass} {className}">

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
text: string;
position?: 'top' | 'bottom' | 'left' | 'right';
children?: any;
children?: Snippet;
}
let { text, position = 'top', children }: Props = $props();

View File

@@ -19,7 +19,7 @@ export const matchPlayerSchema = z.object({
headshot: z.number().int().nonnegative(),
mvp: z.number().int().nonnegative(),
score: z.number().int().nonnegative(),
kast: z.number().int().min(0).max(100),
kast: z.number().int().min(0).max(100).optional(),
// Rank (CS2 Premier rating: 0-30000)
rank_old: z.number().int().min(0).max(30000).optional(),
@@ -74,7 +74,7 @@ export const matchSchema = z.object({
demo_parsed: z.boolean(),
vac_present: z.boolean(),
gameban_present: z.boolean(),
tick_rate: z.number().positive(),
tick_rate: z.number().positive().optional(),
players: z.array(matchPlayerSchema).optional()
});
@@ -87,7 +87,7 @@ export const matchListItemSchema = z.object({
score_team_b: z.number().int().nonnegative(),
duration: z.number().int().positive(),
demo_parsed: z.boolean(),
player_count: z.number().int().min(2).max(10)
player_count: z.number().int().min(2).max(10).optional()
});
/** Parser functions for safe data validation */

View File

@@ -12,7 +12,7 @@ export const playerSchema = z.object({
avatar: z.string().url(),
vanity_url: z.string().optional(),
vanity_url_real: z.string().optional(),
steam_updated: z.string().datetime(),
steam_updated: z.string().datetime().optional(),
profile_created: z.string().datetime().optional(),
wins: z.number().int().nonnegative().optional(),
losses: z.number().int().nonnegative().optional(),
@@ -24,6 +24,7 @@ export const playerSchema = z.object({
game_ban_count: z.number().int().nonnegative().optional(),
game_ban_date: z.string().datetime().nullable().optional(),
oldest_sharecode_seen: z.string().optional(),
tracked: z.boolean().optional(),
matches: z
.array(
matchSchema.extend({

View File

@@ -39,8 +39,8 @@ export interface Match {
/** Whether any player has a game ban */
gameban_present: boolean;
/** Server tick rate (64 or 128) */
tick_rate: number;
/** Server tick rate (64 or 128) - optional, not always provided by API */
tick_rate?: number;
/** Array of player statistics (optional, included in detailed match view) */
players?: MatchPlayer[];
@@ -57,7 +57,7 @@ export interface MatchListItem {
score_team_b: number;
duration: number;
demo_parsed: boolean;
player_count: number;
player_count?: number;
}
/**
@@ -91,8 +91,14 @@ export interface MatchPlayer {
/** In-game score */
score: number;
/** KAST percentage (0-100): Kill/Assist/Survive/Trade */
kast: number;
/** KAST percentage (0-100): Kill/Assist/Survive/Trade - optional, not always provided by API */
kast?: number;
/** Average Damage per Round */
adr?: number;
/** Headshot percentage */
hs_percent?: number;
// Rank tracking (CS2 Premier rating: 0-30000)
rank_old?: number;

View File

@@ -20,8 +20,8 @@ export interface Player {
/** Actual vanity URL (may differ from vanity_url) */
vanity_url_real?: string;
/** Last time Steam profile was updated (ISO 8601) */
steam_updated: string;
/** Last time Steam profile was updated (ISO 8601) - optional, not always provided by API */
steam_updated?: string;
/** Steam account creation date (ISO 8601) */
profile_created?: string;
@@ -53,6 +53,9 @@ export interface Player {
/** Oldest match share code seen for this player */
oldest_sharecode_seen?: string;
/** Whether this player is being tracked for automatic match updates */
tracked?: boolean;
/** Recent matches with player statistics */
matches?: PlayerMatch[];
}

154
src/lib/utils/export.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* Export utilities for match data
* Provides CSV and JSON export functionality for match listings
*/
import type { MatchListItem } from '$lib/types';
import { formatDuration } from './formatters';
/**
* Format date to readable string (YYYY-MM-DD HH:MM)
* @param dateString - ISO date string
* @returns Formatted date string
*/
function formatDateForExport(dateString: string): string {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
/**
* Convert matches array to CSV format
* @param matches - Array of match items to export
* @returns CSV string
*/
function matchesToCSV(matches: MatchListItem[]): string {
// CSV Headers
const headers = [
'Match ID',
'Date',
'Map',
'Score Team A',
'Score Team B',
'Duration',
'Demo Parsed',
'Player Count'
];
// CSV rows
const rows = matches.map((match) => {
return [
match.match_id,
formatDateForExport(match.date),
match.map,
match.score_team_a.toString(),
match.score_team_b.toString(),
formatDuration(match.duration),
match.demo_parsed ? 'Yes' : 'No',
match.player_count?.toString() || '-'
];
});
// Combine headers and rows
const csvContent = [
headers.join(','),
...rows.map((row) =>
row
.map((cell) => {
// Escape cells containing commas or quotes
if (cell.includes(',') || cell.includes('"')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
})
.join(',')
)
].join('\n');
return csvContent;
}
/**
* Convert matches array to formatted JSON
* @param matches - Array of match items to export
* @returns Formatted JSON string
*/
function matchesToJSON(matches: MatchListItem[]): string {
// Create clean export format
const exportData = {
export_date: new Date().toISOString(),
total_matches: matches.length,
matches: matches.map((match) => ({
match_id: match.match_id,
date: formatDateForExport(match.date),
map: match.map,
score: `${match.score_team_a} - ${match.score_team_b}`,
score_team_a: match.score_team_a,
score_team_b: match.score_team_b,
duration: formatDuration(match.duration),
duration_seconds: match.duration,
demo_parsed: match.demo_parsed,
player_count: match.player_count
}))
};
return JSON.stringify(exportData, null, 2);
}
/**
* Trigger browser download for a file
* @param content - File content
* @param filename - Name of file to download
* @param mimeType - MIME type of file
*/
function triggerDownload(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Export matches to CSV file
* Generates and downloads a CSV file with match data
* @param matches - Array of match items to export
* @throws Error if matches array is empty
*/
export function exportMatchesToCSV(matches: MatchListItem[]): void {
if (!matches || matches.length === 0) {
throw new Error('No matches to export');
}
const csvContent = matchesToCSV(matches);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `cs2wtf-matches-${timestamp}.csv`;
triggerDownload(csvContent, filename, 'text/csv;charset=utf-8;');
}
/**
* Export matches to JSON file
* Generates and downloads a JSON file with match data
* @param matches - Array of match items to export
* @throws Error if matches array is empty
*/
export function exportMatchesToJSON(matches: MatchListItem[]): void {
if (!matches || matches.length === 0) {
throw new Error('No matches to export');
}
const jsonContent = matchesToJSON(matches);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `cs2wtf-matches-${timestamp}.json`;
triggerDownload(jsonContent, filename, 'application/json;charset=utf-8;');
}

196
src/lib/utils/formatters.ts Normal file
View File

@@ -0,0 +1,196 @@
/**
* Formatting utilities for CS2 data
*/
/**
* Premier rating tier information
*/
export interface PremierRatingTier {
/** Formatted rating with comma separator (e.g., "15,000") */
formatted: string;
/** Hex color for this tier */
color: string;
/** Tier name */
tier: string;
/** Tailwind CSS classes for styling */
cssClasses: string;
}
/**
* Format Premier rating and return tier information
* CS2 Premier rating range: 0-30000
* Color tiers: <5000 (gray), 5000-9999 (blue), 10000-14999 (purple),
* 15000-19999 (pink), 20000-24999 (red), 25000+ (gold)
*
* @param rating - Premier rating (0-30000)
* @returns Tier information with formatted rating and colors
*/
export function formatPremierRating(rating: number | undefined | null): PremierRatingTier {
// Default for unranked/unknown
if (rating === undefined || rating === null || rating === 0) {
return {
formatted: 'Unranked',
color: '#9CA3AF',
tier: 'Unranked',
cssClasses: 'bg-base-300/50 border-base-content/20 text-base-content/60'
};
}
// Ensure rating is within valid range
const validRating = Math.max(0, Math.min(30000, rating));
const formatted = validRating.toLocaleString('en-US');
// Determine tier based on rating
if (validRating >= 25000) {
return {
formatted,
color: '#EAB308',
tier: 'Legendary',
cssClasses:
'bg-gradient-to-br from-yellow-500/20 to-amber-600/20 border-yellow-500/40 text-yellow-400 font-bold shadow-lg shadow-yellow-500/20'
};
} else if (validRating >= 20000) {
return {
formatted,
color: '#EF4444',
tier: 'Elite',
cssClasses:
'bg-gradient-to-br from-red-500/20 to-rose-600/20 border-red-500/40 text-red-400 font-semibold shadow-md shadow-red-500/10'
};
} else if (validRating >= 15000) {
return {
formatted,
color: '#EC4899',
tier: 'Expert',
cssClasses:
'bg-gradient-to-br from-pink-500/20 to-fuchsia-500/20 border-pink-500/40 text-pink-400 font-semibold shadow-md shadow-pink-500/10'
};
} else if (validRating >= 10000) {
return {
formatted,
color: '#A855F7',
tier: 'Advanced',
cssClasses:
'bg-gradient-to-br from-purple-500/20 to-violet-600/20 border-purple-500/40 text-purple-400 font-medium'
};
} else if (validRating >= 5000) {
return {
formatted,
color: '#3B82F6',
tier: 'Intermediate',
cssClasses:
'bg-gradient-to-br from-blue-500/20 to-indigo-500/20 border-blue-500/40 text-blue-400'
};
} else {
return {
formatted,
color: '#9CA3AF',
tier: 'Beginner',
cssClasses: 'bg-gray-500/10 border-gray-500/30 text-gray-400'
};
}
}
/**
* Get Tailwind CSS classes for Premier rating badge
* @param rating - Premier rating (0-30000)
* @returns Tailwind CSS class string
*/
export function getPremierRatingClass(rating: number | undefined | null): string {
return formatPremierRating(rating).cssClasses;
}
/**
* Calculate rating change display
* @param oldRating - Previous rating
* @param newRating - New rating
* @returns Object with change amount and display string
*/
export function getPremierRatingChange(
oldRating: number | undefined | null,
newRating: number | undefined | null
): {
change: number;
display: string;
isPositive: boolean;
cssClasses: string;
} | null {
if (
oldRating === undefined ||
oldRating === null ||
newRating === undefined ||
newRating === null
) {
return null;
}
const change = newRating - oldRating;
if (change === 0) {
return {
change: 0,
display: '±0',
isPositive: false,
cssClasses: 'text-base-content/60'
};
}
const isPositive = change > 0;
const display = isPositive ? `+${change}` : change.toString();
return {
change,
display,
isPositive,
cssClasses: isPositive ? 'text-success font-semibold' : 'text-error font-semibold'
};
}
/**
* Format K/D ratio
* @param kills - Number of kills
* @param deaths - Number of deaths
* @returns Formatted K/D ratio
*/
export function formatKD(kills: number, deaths: number): string {
if (deaths === 0) {
return kills.toFixed(2);
}
return (kills / deaths).toFixed(2);
}
/**
* Format percentage
* @param value - Percentage value (0-100)
* @param decimals - Number of decimal places (default: 1)
* @returns Formatted percentage string
*/
export function formatPercent(value: number | undefined | null, decimals = 1): string {
if (value === undefined || value === null) {
return '0.0%';
}
return `${value.toFixed(decimals)}%`;
}
/**
* Format duration in seconds to MM:SS
* @param seconds - Duration in seconds
* @returns Formatted duration string
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
/**
* Format large numbers with comma separators
* @param value - Number to format
* @returns Formatted number string
*/
export function formatNumber(value: number | undefined | null): string {
if (value === undefined || value === null) {
return '0';
}
return value.toLocaleString('en-US');
}

View File

@@ -0,0 +1,75 @@
/**
* Utility functions for accessing CS2 map assets (icons, backgrounds, screenshots)
*/
/**
* Get the background image URL for a map
* @param mapName - The map name (e.g., "de_dust2")
* @returns URL to the map screenshot/background
*/
export function getMapBackground(mapName: string | null | undefined): string {
// If no map name provided, use default
if (!mapName || mapName.trim() === '') {
return getDefaultMapBackground();
}
// For "unknown" maps, use default background directly
if (mapName.toLowerCase() === 'unknown') {
return getDefaultMapBackground();
}
// Try WebP first (better compression), fallback to PNG
return `/images/map_screenshots/${mapName}.webp`;
}
/**
* Get the icon SVG URL for a map
* @param mapName - The map name (e.g., "de_dust2")
* @returns URL to the map icon SVG
*/
export function getMapIcon(mapName: string | null | undefined): string {
if (!mapName || mapName.trim() === '') {
return `/images/map_icons/map_icon_lobby_mapveto.svg`; // Generic map icon
}
return `/images/map_icons/map_icon_${mapName}.svg`;
}
/**
* Get fallback default map background if specific map is not found
*/
export function getDefaultMapBackground(): string {
return '/images/map_screenshots/default.webp';
}
/**
* Format map name for display (remove de_ prefix, capitalize)
* @param mapName - The map name (e.g., "de_dust2")
* @returns Formatted name (e.g., "Dust 2")
*/
export function formatMapName(mapName: string | null | undefined): string {
if (!mapName || mapName.trim() === '') {
return 'Unknown Map';
}
return mapName
.replace(/^(de|cs|ar|dz|gd|coop)_/, '')
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Get team logo URL
* @param team - "t" or "ct"
* @param variant - "logo" (color) or "logo_1c" (monochrome)
* @returns URL to the team logo SVG
*/
export function getTeamLogo(team: 't' | 'ct', variant: 'logo' | 'logo_1c' = 'logo'): string {
return `/images/icons/${team}_${variant}.svg`;
}
/**
* Get team character background
* @param team - "t" or "ct"
* @returns URL to the team character background SVG
*/
export function getTeamBackground(team: 't' | 'ct'): string {
return `/images/icons/${team}_char_bg.svg`;
}

102
src/lib/utils/navigation.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Navigation utility for preserving scroll state and match position
* when navigating between matches and the matches listing page.
*/
const STORAGE_KEY = 'matches-navigation-state';
interface NavigationState {
matchId: string;
scrollY: number;
timestamp: number;
loadedCount: number; // Number of matches loaded (for pagination)
}
/**
* Store navigation state when leaving the matches page
*/
export function storeMatchesState(matchId: string, loadedCount: number): void {
if (typeof window === 'undefined') return;
const state: NavigationState = {
matchId,
scrollY: window.scrollY,
timestamp: Date.now(),
loadedCount
};
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Failed to store navigation state:', e);
}
}
/**
* Retrieve stored navigation state
*/
export function getMatchesState(): NavigationState | null {
if (typeof window === 'undefined') return null;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const state: NavigationState = JSON.parse(stored);
// Clear state if older than 5 minutes (likely stale)
if (Date.now() - state.timestamp > 5 * 60 * 1000) {
clearMatchesState();
return null;
}
return state;
} catch (e) {
console.warn('Failed to retrieve navigation state:', e);
return null;
}
}
/**
* Clear stored navigation state
*/
export function clearMatchesState(): void {
if (typeof window === 'undefined') return;
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn('Failed to clear navigation state:', e);
}
}
/**
* Scroll to a specific match card element by ID
*/
export function scrollToMatch(matchId: string, fallbackScrollY?: number): void {
if (typeof window === 'undefined') return;
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
// Try to find the match card element
const matchElement = document.querySelector(`[data-match-id="${matchId}"]`);
if (matchElement) {
// Found the element, scroll to it with some offset for the header
const offset = 100; // Header height + some padding
const elementPosition = matchElement.getBoundingClientRect().top + window.scrollY;
const offsetPosition = elementPosition - offset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
} else if (fallbackScrollY !== undefined) {
// Element not found (might be new matches), use stored scroll position
window.scrollTo({
top: fallbackScrollY,
behavior: 'instant'
});
}
});
}

View File

@@ -7,7 +7,7 @@ import type { Player, Match, MatchPlayer, MatchListItem, PlayerMeta } from '$lib
/** Mock players */
export const mockPlayers: Player[] = [
{
id: 765611980123456, // Smaller mock Steam ID (safe integer)
id: '765611980123456', // Smaller mock Steam ID (safe integer)
name: 'TestPlayer1',
avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
@@ -21,7 +21,7 @@ export const mockPlayers: Player[] = [
game_ban_count: 0
},
{
id: 765611980876543, // Smaller mock Steam ID (safe integer)
id: '765611980876543', // Smaller mock Steam ID (safe integer)
name: 'TestPlayer2',
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
steam_updated: '2024-11-04T11:15:00Z',
@@ -50,7 +50,7 @@ export const mockPlayerMeta: PlayerMeta = {
/** Mock match players */
export const mockMatchPlayers: MatchPlayer[] = [
{
id: 765611980123456,
id: '765611980123456',
name: 'Player1',
avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
@@ -77,7 +77,7 @@ export const mockMatchPlayers: MatchPlayer[] = [
color: 'yellow'
},
{
id: 765611980876543,
id: '765611980876543',
name: 'Player2',
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
team_id: 2,
@@ -96,7 +96,7 @@ export const mockMatchPlayers: MatchPlayer[] = [
color: 'blue'
},
{
id: 765611980111111,
id: '765611980111111',
name: 'Player3',
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/cd/cde456.jpg',
team_id: 3,
@@ -119,7 +119,7 @@ export const mockMatchPlayers: MatchPlayer[] = [
/** Mock matches */
export const mockMatches: Match[] = [
{
match_id: 358948771684207, // Smaller mock match ID (safe integer)
match_id: '358948771684207', // Smaller mock match ID (safe integer)
share_code: 'CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX',
map: 'de_inferno',
date: '2024-11-01T18:45:00Z',
@@ -131,11 +131,11 @@ export const mockMatches: Match[] = [
demo_parsed: true,
vac_present: false,
gameban_present: false,
tick_rate: 64.0,
// Note: tick_rate is not provided by the API
players: mockMatchPlayers
},
{
match_id: 358948771684208,
match_id: '358948771684208',
share_code: 'CSGO-YYYYY-YYYYY-YYYYY-YYYYY-YYYYY',
map: 'de_mirage',
date: '2024-11-02T20:15:00Z',
@@ -146,11 +146,11 @@ export const mockMatches: Match[] = [
max_rounds: 24,
demo_parsed: true,
vac_present: false,
gameban_present: false,
tick_rate: 64.0
gameban_present: false
// Note: tick_rate is not provided by the API
},
{
match_id: 358948771684209,
match_id: '358948771684209',
share_code: 'CSGO-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ',
map: 'de_dust2',
date: '2024-11-03T15:30:00Z',
@@ -161,8 +161,8 @@ export const mockMatches: Match[] = [
max_rounds: 24,
demo_parsed: true,
vac_present: false,
gameban_present: false,
tick_rate: 64.0
gameban_present: false
// Note: tick_rate is not provided by the API
}
];
@@ -174,8 +174,8 @@ export const mockMatchListItems: MatchListItem[] = mockMatches.map((match) => ({
score_team_a: match.score_team_a,
score_team_b: match.score_team_b,
duration: match.duration,
demo_parsed: match.demo_parsed,
player_count: 10
demo_parsed: match.demo_parsed
// Note: player_count is not provided by the API, so it's omitted from mocks
}));
/** Helper: Generate random Steam ID (safe integer) */
@@ -184,11 +184,11 @@ export const generateSteamId = (): number => {
};
/** Helper: Get mock player by ID */
export const getMockPlayer = (id: number): Player | undefined => {
export const getMockPlayer = (id: string): Player | undefined => {
return mockPlayers.find((p) => p.id === id);
};
/** Helper: Get mock match by ID */
export const getMockMatch = (id: number): Match | undefined => {
export const getMockMatch = (id: string): Match | undefined => {
return mockMatches.find((m) => m.match_id === id);
};

View File

@@ -2,7 +2,6 @@ import { http, HttpResponse, delay } from 'msw';
import { mockMatches, mockMatchListItems, getMockMatch } from '../fixtures';
import type {
MatchParseResponse,
MatchesListResponse,
MatchRoundsResponse,
MatchWeaponsResponse,
MatchChatResponse
@@ -21,7 +20,7 @@ export const matchesHandlers = [
await delay(500);
const response: MatchParseResponse = {
match_id: 358948771684207,
match_id: '358948771684207',
status: 'parsing',
message: 'Demo download and parsing initiated',
estimated_time: 120
@@ -33,7 +32,7 @@ export const matchesHandlers = [
// GET /match/:id
http.get(`${API_BASE_URL}/match/:id`, ({ params }) => {
const { id } = params;
const matchId = Number(id);
const matchId = String(id);
const match = getMockMatch(matchId) || mockMatches[0];
@@ -165,14 +164,11 @@ export const matchesHandlers = [
matches = matches.slice(0, Math.ceil(matches.length / 2));
}
const response: MatchesListResponse = {
matches: matches.slice(0, limit),
next_page_time: Date.now() / 1000 - 86400,
has_more: matches.length > limit,
total_count: matches.length
};
// NOTE: The real API returns a plain array, not a MatchesListResponse object
// The transformation to MatchesListResponse happens in the API client
const matchArray = matches.slice(0, limit);
return HttpResponse.json(response);
return HttpResponse.json(matchArray);
}),
// GET /matches/next/:time
@@ -181,12 +177,9 @@ export const matchesHandlers = [
const limit = Number(url.searchParams.get('limit')) || 50;
// Return older matches for pagination
const response: MatchesListResponse = {
matches: mockMatchListItems.slice(limit, limit * 2),
next_page_time: Date.now() / 1000 - 172800,
has_more: mockMatchListItems.length > limit * 2
};
// NOTE: The real API returns a plain array, not a MatchesListResponse object
const matchArray = mockMatchListItems.slice(limit, limit * 2);
return HttpResponse.json(response);
return HttpResponse.json(matchArray);
})
];

View File

@@ -12,7 +12,7 @@ export const playersHandlers = [
// GET /player/:id
http.get(`${API_BASE_URL}/player/:id`, ({ params }) => {
const { id } = params;
const playerId = Number(id);
const playerId = String(id);
const player = getMockPlayer(playerId);
if (!player) {
@@ -25,7 +25,7 @@ export const playersHandlers = [
// GET /player/:id/next/:time
http.get(`${API_BASE_URL}/player/:id/next/:time`, ({ params }) => {
const { id } = params;
const playerId = Number(id);
const playerId = String(id);
const player = getMockPlayer(playerId) ?? mockPlayers[0];

View File

@@ -1,29 +1,105 @@
<script lang="ts">
import { Search, TrendingUp, Users, Zap } from 'lucide-svelte';
import { Search, TrendingUp, Users, Zap, ChevronLeft, ChevronRight } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import MatchCard from '$lib/components/match/MatchCard.svelte';
import type { PageData } from './$types';
// Get data from page loader
let { data }: { data: PageData } = $props();
// Transform API matches to display format
const featuredMatches = data.featuredMatches.map((match) => ({
id: match.match_id.toString(),
map: match.map || 'unknown',
mapDisplay: match.map ? match.map.replace('de_', '').toUpperCase() : 'UNKNOWN',
scoreT: match.score_team_a,
scoreCT: match.score_team_b,
date: new Date(match.date).toLocaleString(),
live: false // TODO: Implement live match detection
}));
// Use matches directly - already transformed by API client
const featuredMatches = data.featuredMatches;
const stats = [
{ icon: Users, label: 'Players Tracked', value: '1.2M+' },
{ icon: TrendingUp, label: 'Matches Analyzed', value: '500K+' },
{ icon: Zap, label: 'Demos Parsed', value: '2M+' }
];
// Carousel state
let currentSlide = $state(0);
let isPaused = $state(false);
let autoRotateInterval: ReturnType<typeof setInterval> | null = null;
let manualNavigationTimeout: ReturnType<typeof setTimeout> | null = null;
let windowWidth = $state(1024); // Default to desktop
// Track window width for responsive slides
$effect(() => {
if (typeof window !== 'undefined') {
windowWidth = window.innerWidth;
const handleResize = () => {
windowWidth = window.innerWidth;
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}
// Return empty cleanup function for server-side rendering path
return () => {};
});
// Determine matches per slide based on screen width
const matchesPerSlide = $derived(windowWidth < 768 ? 1 : windowWidth < 1024 ? 2 : 3);
const totalSlides = $derived(Math.ceil(featuredMatches.length / matchesPerSlide));
// Get visible matches for current slide
const visibleMatches = $derived.by(() => {
const start = currentSlide * matchesPerSlide;
return featuredMatches.slice(start, start + matchesPerSlide);
});
function nextSlide() {
currentSlide = (currentSlide + 1) % totalSlides;
}
function prevSlide() {
currentSlide = (currentSlide - 1 + totalSlides) % totalSlides;
}
function goToSlide(index: number) {
currentSlide = index;
pauseAutoRotateTemporarily();
}
function pauseAutoRotateTemporarily() {
isPaused = true;
if (manualNavigationTimeout) clearTimeout(manualNavigationTimeout);
manualNavigationTimeout = setTimeout(() => {
isPaused = false;
}, 10000); // Resume after 10 seconds
}
function handleManualNavigation(direction: 'prev' | 'next') {
if (direction === 'prev') {
prevSlide();
} else {
nextSlide();
}
pauseAutoRotateTemporarily();
}
// Auto-rotation effect
$effect(() => {
if (autoRotateInterval) clearInterval(autoRotateInterval);
autoRotateInterval = setInterval(() => {
if (!isPaused) {
nextSlide();
}
}, 5000);
return () => {
if (autoRotateInterval) clearInterval(autoRotateInterval);
if (manualNavigationTimeout) clearTimeout(manualNavigationTimeout);
};
});
</script>
<svelte:head>
@@ -85,46 +161,72 @@
<Button variant="ghost" href="/matches">View All</Button>
</div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each featuredMatches as match}
<Card variant="interactive" padding="none">
<a href={`/match/${match.id}`} class="block">
<div
class="relative h-48 overflow-hidden rounded-t-md bg-gradient-to-br from-base-300 to-base-100"
>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-6xl font-bold text-base-content/10">{match.mapDisplay}</span>
</div>
<div class="absolute bottom-4 left-4">
<Badge variant="default">{match.map}</Badge>
</div>
{#if match.live}
<div class="absolute right-4 top-4">
<Badge variant="error" size="sm">
<span class="animate-pulse">● LIVE</span>
</Badge>
</div>
{/if}
</div>
{#if featuredMatches.length > 0}
<!-- Carousel Container -->
<div
class="relative"
onmouseenter={() => (isPaused = true)}
onmouseleave={() => (isPaused = false)}
role="region"
aria-label="Featured matches carousel"
>
<!-- Matches Grid with Fade Transition -->
<div class="transition-opacity duration-500" class:opacity-100={true}>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each visibleMatches as match (match.match_id)}
<MatchCard {match} />
{/each}
</div>
</div>
<div class="p-4">
<div class="mb-3 flex items-center justify-center gap-4">
<span class="font-mono text-2xl font-bold text-terrorist">{match.scoreT}</span>
<span class="text-base-content/40">-</span>
<span class="font-mono text-2xl font-bold text-ct">{match.scoreCT}</span>
</div>
<!-- Navigation Arrows - Only show if there are multiple slides -->
{#if totalSlides > 1}
<!-- Previous Button -->
<button
onclick={() => handleManualNavigation('prev')}
class="group absolute left-0 top-1/2 z-10 -translate-x-4 -translate-y-1/2 rounded-md border border-base-content/10 bg-base-100/95 p-2 shadow-[0_8px_30px_rgb(0,0,0,0.12)] backdrop-blur-md transition-all duration-200 hover:-translate-x-5 hover:border-primary/30 hover:bg-base-100 hover:shadow-[0_12px_40px_rgb(0,0,0,0.15)] focus:outline-none focus:ring-2 focus:ring-primary/50 md:-translate-x-6 md:hover:-translate-x-7"
aria-label="Previous slide"
>
<ChevronLeft
class="h-6 w-6 text-base-content/70 transition-colors duration-200 group-hover:text-primary"
/>
</button>
<div class="flex items-center justify-between text-sm text-base-content/60">
<span>{match.date}</span>
{#if !match.live}
<Badge variant="default" size="sm">Completed</Badge>
{/if}
</div>
</div>
</a>
</Card>
{/each}
</div>
<!-- Next Button -->
<button
onclick={() => handleManualNavigation('next')}
class="group absolute right-0 top-1/2 z-10 -translate-y-1/2 translate-x-4 rounded-md border border-base-content/10 bg-base-100/95 p-2 shadow-[0_8px_30px_rgb(0,0,0,0.12)] backdrop-blur-md transition-all duration-200 hover:translate-x-5 hover:border-primary/30 hover:bg-base-100 hover:shadow-[0_12px_40px_rgb(0,0,0,0.15)] focus:outline-none focus:ring-2 focus:ring-primary/50 md:translate-x-6 md:hover:translate-x-7"
aria-label="Next slide"
>
<ChevronRight
class="h-6 w-6 text-base-content/70 transition-colors duration-200 group-hover:text-primary"
/>
</button>
{/if}
</div>
<!-- Dot Indicators - Only show if there are multiple slides -->
{#if totalSlides > 1}
<div class="mt-8 flex justify-center gap-2">
{#each Array(totalSlides) as _, i}
<button
onclick={() => goToSlide(i)}
class="h-2 w-2 rounded-full transition-all duration-300 hover:scale-125 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
class:bg-primary={i === currentSlide}
class:w-8={i === currentSlide}
class:bg-base-300={i !== currentSlide}
aria-label={`Go to slide ${i + 1}`}
></button>
{/each}
</div>
{/if}
{:else}
<!-- No Matches Found -->
<div class="rounded-lg border border-base-300 bg-base-100 p-12 text-center">
<p class="text-lg text-base-content/60">No featured matches available at the moment.</p>
<p class="mt-2 text-sm text-base-content/40">Check back soon for the latest matches!</p>
</div>
{/if}
</div>
</section>

View File

@@ -10,11 +10,11 @@ export const load: PageLoad = async ({ parent }) => {
await parent();
try {
// Load featured matches (limit to 3 for homepage)
const matchesData = await api.matches.getMatches({ limit: 3 });
// Load featured matches for homepage carousel
const matchesData = await api.matches.getMatches({ limit: 9 });
return {
featuredMatches: matchesData.matches.slice(0, 3), // Ensure max 3 matches
featuredMatches: matchesData.matches.slice(0, 9), // Get 9 matches for carousel (3 slides)
meta: {
title: 'CS2.WTF - Statistics for CS2 Matchmaking',
description:

View File

@@ -0,0 +1,163 @@
/**
* SvelteKit API Route Handler
*
* This catch-all route proxies requests to the backend API.
* Benefits over Vite proxy:
* - Works in development, preview, and production
* - Single code path for all environments
* - Can add caching, rate limiting, auth in the future
* - No CORS issues
*
* Backend selection:
* - Set VITE_API_BASE_URL=http://localhost:8000 for local development
* - Set VITE_API_BASE_URL=https://api.csgow.tf for production API
*/
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';
/**
* GET request handler
* Forwards GET requests to the backend API
*/
export const GET: RequestHandler = async ({ params, url, request }) => {
const path = params.path;
const queryString = url.search;
// Construct full backend URL
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
try {
// Forward request to backend
const response = await fetch(backendUrl, {
method: 'GET',
headers: {
// Forward relevant headers
Accept: request.headers.get('Accept') || 'application/json',
'User-Agent': 'CS2.WTF Frontend'
}
});
// Check if request was successful
if (!response.ok) {
throw error(response.status, `Backend API returned ${response.status}`);
}
// Get response data
const data = await response.json();
// Return JSON response
return json(data);
} catch (err) {
// Log error for debugging
console.error(`[API Route] Error fetching ${backendUrl}:`, err);
// Handle fetch errors
if (err instanceof Error && err.message.includes('fetch')) {
throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`);
}
// Re-throw SvelteKit errors
throw err;
}
};
/**
* POST request handler
* Forwards POST requests to the backend API
*/
export const POST: RequestHandler = async ({ params, url, request }) => {
const path = params.path;
const queryString = url.search;
// Construct full backend URL
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
try {
// Get request body
const body = await request.text();
// Forward request to backend
const response = await fetch(backendUrl, {
method: 'POST',
headers: {
'Content-Type': request.headers.get('Content-Type') || 'application/json',
Accept: request.headers.get('Accept') || 'application/json',
'User-Agent': 'CS2.WTF Frontend'
},
body
});
// Check if request was successful
if (!response.ok) {
throw error(response.status, `Backend API returned ${response.status}`);
}
// Get response data
const data = await response.json();
// Return JSON response
return json(data);
} catch (err) {
// Log error for debugging
console.error(`[API Route] Error fetching ${backendUrl}:`, err);
// Handle fetch errors
if (err instanceof Error && err.message.includes('fetch')) {
throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`);
}
// Re-throw SvelteKit errors
throw err;
}
};
/**
* DELETE request handler
* Forwards DELETE requests to the backend API
*/
export const DELETE: RequestHandler = async ({ params, url, request }) => {
const path = params.path;
const queryString = url.search;
// Construct full backend URL
const backendUrl = `${API_BASE_URL}/${path}${queryString}`;
try {
// Forward request to backend
const response = await fetch(backendUrl, {
method: 'DELETE',
headers: {
Accept: request.headers.get('Accept') || 'application/json',
'User-Agent': 'CS2.WTF Frontend'
}
});
// Check if request was successful
if (!response.ok) {
throw error(response.status, `Backend API returned ${response.status}`);
}
// Get response data
const data = await response.json();
// Return JSON response
return json(data);
} catch (err) {
// Log error for debugging
console.error(`[API Route] Error fetching ${backendUrl}:`, err);
// Handle fetch errors
if (err instanceof Error && err.message.includes('fetch')) {
throw error(503, `Unable to connect to backend API at ${API_BASE_URL}`);
}
// Re-throw SvelteKit errors
throw err;
}
};

View File

@@ -1,13 +1,20 @@
<script lang="ts">
import { Download, Calendar, Clock } from 'lucide-svelte';
import { Download, Calendar, Clock, ArrowLeft } from 'lucide-svelte';
import { goto } from '$app/navigation';
import Badge from '$lib/components/ui/Badge.svelte';
import Tabs from '$lib/components/ui/Tabs.svelte';
import type { LayoutData } from './$types';
import { getMapBackground, formatMapName } from '$lib/utils/mapAssets';
let { data, children }: { data: LayoutData; children: any } = $props();
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props();
const { match } = data;
function handleBack() {
// Navigate back to matches page
goto('/matches');
}
const tabs = [
{ label: 'Overview', href: `/match/${match.match_id}` },
{ label: 'Economy', href: `/match/${match.match_id}/economy` },
@@ -26,17 +33,42 @@
? `${Math.floor(match.duration / 60)}:${(match.duration % 60).toString().padStart(2, '0')}`
: 'N/A';
const mapName = match.map.replace('de_', '').toUpperCase();
const mapName = formatMapName(match.map);
const mapBg = getMapBackground(match.map);
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.src = '/images/map_screenshots/default.webp';
}
</script>
<!-- Match Header -->
<div class="border-b border-base-300 bg-gradient-to-r from-primary/5 to-secondary/5">
<div class="container mx-auto px-4 py-8">
<!-- Match Header with Background -->
<div class="relative overflow-hidden border-b border-base-300">
<!-- Background Image -->
<div class="absolute inset-0">
<img src={mapBg} alt={mapName} class="h-full w-full object-cover" onerror={handleImageError} />
<div class="absolute inset-0 bg-gradient-to-r from-black/90 via-black/70 to-black/50"></div>
</div>
<div class="container relative mx-auto px-4 py-8">
<!-- Back Button -->
<div class="mb-4">
<button
onclick={handleBack}
class="btn btn-ghost btn-sm gap-2 text-white/80 hover:text-white"
>
<ArrowLeft class="h-4 w-4" />
<span>Back to Matches</span>
</button>
</div>
<!-- Map Name -->
<div class="mb-4 flex items-center justify-between">
<div>
<Badge variant="default" size="lg">{match.map}</Badge>
<h1 class="mt-2 text-4xl font-bold text-base-content">{mapName}</h1>
{#if match.map}
<Badge variant="default" size="lg">{match.map}</Badge>
{/if}
<h1 class="mt-2 text-4xl font-bold text-white drop-shadow-lg">{mapName}</h1>
</div>
{#if match.demo_parsed}
<button class="btn btn-outline btn-primary gap-2">
@@ -49,18 +81,20 @@
<!-- Score -->
<div class="mb-6 flex items-center justify-center gap-6">
<div class="text-center">
<div class="text-sm font-medium text-base-content/60">TERRORISTS</div>
<div class="font-mono text-5xl font-bold text-terrorist">{match.score_team_a}</div>
<div class="text-sm font-medium text-white/70">TERRORISTS</div>
<div class="font-mono text-5xl font-bold text-terrorist drop-shadow-lg">
{match.score_team_a}
</div>
</div>
<div class="text-3xl font-bold text-base-content/40">:</div>
<div class="text-3xl font-bold text-white/40">:</div>
<div class="text-center">
<div class="text-sm font-medium text-base-content/60">COUNTER-TERRORISTS</div>
<div class="font-mono text-5xl font-bold text-ct">{match.score_team_b}</div>
<div class="text-sm font-medium text-white/70">COUNTER-TERRORISTS</div>
<div class="font-mono text-5xl font-bold text-ct drop-shadow-lg">{match.score_team_b}</div>
</div>
</div>
<!-- Match Meta -->
<div class="flex flex-wrap items-center justify-center gap-4 text-sm text-base-content/70">
<div class="flex flex-wrap items-center justify-center gap-4 text-sm text-white/80">
<div class="flex items-center gap-2">
<Calendar class="h-4 w-4" />
<span>{formattedDate}</span>
@@ -76,7 +110,7 @@
</div>
<!-- Tabs -->
<div class="mt-6">
<div class="mt-6 rounded-lg bg-black/30 p-4 backdrop-blur-sm">
<Tabs {tabs} variant="bordered" size="md" />
</div>
</div>

View File

@@ -2,11 +2,13 @@
import { Trophy } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
import RoundTimeline from '$lib/components/RoundTimeline.svelte';
import type { PageData } from './$types';
import type { MatchPlayer } from '$lib/types';
let { data }: { data: PageData } = $props();
const { match } = data;
const { match, rounds } = data;
// Group players by team - use dynamic team IDs from API
const uniqueTeamIds = match.players ? [...new Set(match.players.map((p) => p.team_id))] : [];
@@ -25,7 +27,8 @@
const totalKills = players.reduce((sum, p) => sum + p.kills, 0);
const totalDeaths = players.reduce((sum, p) => sum + p.deaths, 0);
const totalADR = players.reduce((sum, p) => sum + (p.adr || 0), 0);
const avgKAST = players.reduce((sum, p) => sum + (p.kast || 0), 0) / players.length;
const avgKAST =
players.length > 0 ? players.reduce((sum, p) => sum + (p.kast || 0), 0) / players.length : 0;
return {
kills: totalKills,
@@ -116,6 +119,7 @@
<th style="width: 100px;">ADR</th>
<th style="width: 100px;">HS%</th>
<th style="width: 100px;">KAST%</th>
<th style="width: 180px;">Rating</th>
</tr>
</thead>
<tbody>
@@ -135,9 +139,18 @@
<td class="font-mono font-semibold">{player.kills}</td>
<td class="font-mono">{player.deaths}</td>
<td class="font-mono">{player.assists}</td>
<td class="font-mono">{player.adr?.toFixed(1) || '0.0'}</td>
<td class="font-mono">{player.hs_percent?.toFixed(1) || '0.0'}%</td>
<td class="font-mono">{(player.adr || 0).toFixed(1)}</td>
<td class="font-mono">{(player.hs_percent || 0).toFixed(1)}%</td>
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</td>
<td>
<PremierRatingBadge
rating={player.rank_new}
oldRating={player.rank_old}
size="sm"
showChange={true}
showIcon={false}
/>
</td>
</tr>
{/each}
</tbody>
@@ -161,6 +174,7 @@
<th style="width: 100px;">ADR</th>
<th style="width: 100px;">HS%</th>
<th style="width: 100px;">KAST%</th>
<th style="width: 180px;">Rating</th>
</tr>
</thead>
<tbody>
@@ -180,9 +194,18 @@
<td class="font-mono font-semibold">{player.kills}</td>
<td class="font-mono">{player.deaths}</td>
<td class="font-mono">{player.assists}</td>
<td class="font-mono">{player.adr?.toFixed(1) || '0.0'}</td>
<td class="font-mono">{player.hs_percent?.toFixed(1) || '0.0'}%</td>
<td class="font-mono">{(player.adr || 0).toFixed(1)}</td>
<td class="font-mono">{(player.hs_percent || 0).toFixed(1)}%</td>
<td class="font-mono">{player.kast?.toFixed(1) || '0.0'}%</td>
<td>
<PremierRatingBadge
rating={player.rank_new}
oldRating={player.rank_old}
size="sm"
showChange={true}
showIcon={false}
/>
</td>
</tr>
{/each}
</tbody>
@@ -191,15 +214,23 @@
</div>
</Card>
<!-- Coming Soon Badges for Round Timeline -->
<Card padding="lg">
<div class="text-center">
<h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3>
<p class="text-base-content/60">
Round-by-round timeline visualization coming soon. Will show bomb plants, defuses, and round
winners.
</p>
<Badge variant="warning" size="md" class="mt-4">Coming in Future Update</Badge>
</div>
</Card>
<!-- Round Timeline -->
{#if rounds && rounds.rounds && rounds.rounds.length > 0}
<RoundTimeline rounds={rounds.rounds} />
{:else}
<Card padding="lg">
<div class="text-center">
<h3 class="mb-2 text-xl font-semibold text-base-content">Round Timeline</h3>
<p class="text-base-content/60">
Round-by-round timeline data is not available for this match. This requires the demo to be
fully parsed.
</p>
{#if !match.demo_parsed}
<Badge variant="warning" size="md" class="mt-4">Demo Not Yet Parsed</Badge>
{:else}
<Badge variant="info" size="md" class="mt-4">Round Data Not Available</Badge>
{/if}
</div>
</Card>
{/if}
</div>

View File

@@ -0,0 +1,21 @@
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);
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

@@ -20,8 +20,8 @@
let selectedPlayer = $state<number | null>(null);
let messagePlayers = $state<MessagePlayer[]>([]);
let filteredMessages = $state<typeof chatData.messages>([]);
let messagesByRound = $state<Record<number, typeof chatData.messages>>({});
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);
@@ -29,7 +29,7 @@
// Get player info for a message
const getPlayerInfo = (playerId: number) => {
const player = match.players?.find((p) => p.id === playerId);
const player = match.players?.find((p) => p.id === String(playerId));
return {
name: player?.name || `Player ${playerId}`,
team_id: player?.team_id || 0
@@ -38,16 +38,16 @@
if (chatData) {
// Get unique players who sent messages
messagePlayers = Array.from(new Set(chatData.messages.map((m) => m.player_id))).map(
(playerId) => {
const player = match.players?.find((p) => p.id === playerId);
messagePlayers = 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));
return {
id: playerId,
name: player?.name || `Player ${playerId}`,
team_id: player?.team_id
team_id: player?.team_id || 0
};
}
);
});
// Filter messages
const computeFilteredMessages = () => {
@@ -199,7 +199,11 @@
{round === 0 ? 'Warmup / Pre-Match' : `Round ${round}`}
</h3>
<Badge variant="default" size="sm">
{messagesByRound[round].length} message{messagesByRound[round].length !== 1
{messagesByRound[round] ? messagesByRound[round].length : 0} message{(messagesByRound[
round
]
? messagesByRound[round].length
: 0) !== 1
? 's'
: ''}
</Badge>
@@ -209,7 +213,7 @@
<!-- Messages -->
<div class="divide-y divide-base-300">
{#each messagesByRound[round] as message}
{@const playerInfo = getPlayerInfo(message.player_id)}
{@const playerInfo = getPlayerInfo(message.player_id || 0)}
<div class="p-4 transition-colors hover:bg-base-200/50">
<div class="flex items-start gap-3">
<!-- Player Avatar/Icon -->
@@ -226,7 +230,7 @@
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2">
<a
href="/player/{message.player_id}"
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}

View File

@@ -1,38 +1,291 @@
<script lang="ts">
import { Crosshair, Target } from 'lucide-svelte';
import { Target, Crosshair, AlertCircle } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import DataTable from '$lib/components/data-display/DataTable.svelte';
import PieChart from '$lib/components/charts/PieChart.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { match } = data;
// Check if we have player data to display
const hasPlayerData = match.players && match.players.length > 0;
// 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;
// Calculate player stats with damage metrics
const playersWithDamageStats = hasPlayerData
? (match.players || []).map((player) => {
const damage = player.dmg_enemy || 0;
const avgDamagePerRound = match.max_rounds > 0 ? damage / match.max_rounds : 0;
// Note: Hit group breakdown would require weapon stats data
// For now, using total damage metrics
return {
...player,
damage,
avgDamagePerRound
};
})
: [];
// Sort by damage descending
const sortedByDamage = hasPlayerData
? [...playersWithDamageStats].sort((a, b) => b.damage - a.damage)
: [];
// Team damage stats
const teamAPlayers = hasPlayerData
? playersWithDamageStats.filter((p) => p.team_id === firstTeamId)
: [];
const teamBPlayers = hasPlayerData
? playersWithDamageStats.filter((p) => p.team_id === secondTeamId)
: [];
const teamAStats = hasPlayerData
? {
totalDamage: teamAPlayers.reduce((sum, p) => sum + p.damage, 0),
avgDamagePerPlayer:
teamAPlayers.length > 0
? teamAPlayers.reduce((sum, p) => sum + p.damage, 0) / teamAPlayers.length
: 0
}
: { totalDamage: 0, avgDamagePerPlayer: 0 };
const teamBStats = hasPlayerData
? {
totalDamage: teamBPlayers.reduce((sum, p) => sum + p.damage, 0),
avgDamagePerPlayer:
teamBPlayers.length > 0
? teamBPlayers.reduce((sum, p) => sum + p.damage, 0) / teamBPlayers.length
: 0
}
: { totalDamage: 0, avgDamagePerPlayer: 0 };
// Top damage dealers (top 3)
const topDamageDealers = sortedByDamage.slice(0, 3);
// Damage table columns
const damageColumns = [
{
key: 'name' as const,
label: 'Player',
sortable: true,
render: (value: unknown, row: (typeof playersWithDamageStats)[0]) => {
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${value}</a>`;
}
},
{
key: 'damage' as const,
label: 'Damage Dealt',
sortable: true,
align: 'right' as const,
class: 'font-mono font-semibold',
format: (value: unknown) => (typeof value === 'number' ? value.toLocaleString() : '0')
},
{
key: 'avgDamagePerRound' as const,
label: 'Avg Damage/Round',
sortable: true,
align: 'right' as const,
class: 'font-mono',
format: (value: unknown) => (typeof value === 'number' ? value.toFixed(1) : '0.0')
},
{
key: 'headshot' as const,
label: 'Headshots',
sortable: true,
align: 'center' as const,
class: 'font-mono'
},
{
key: 'kills' as const,
label: 'Kills',
sortable: true,
align: 'center' as const,
class: 'font-mono'
},
{
key: 'dmg_team' as const,
label: 'Team Damage',
sortable: true,
align: 'right' as const,
class: 'font-mono',
render: (value: unknown) => {
const dmg = typeof value === 'number' ? value : 0;
if (!dmg || dmg === 0) return '<span class="text-base-content/40">-</span>';
return `<span class="text-error">${dmg.toLocaleString()}</span>`;
}
}
];
// Hit group distribution data (placeholder - would need weapon stats data)
// For now, showing utility damage breakdown instead
const utilityDamageData = hasPlayerData
? {
labels: ['HE Grenades', 'Fire (Molotov/Inc)'],
datasets: [
{
label: 'Utility Damage',
data: [
playersWithDamageStats.reduce((sum, p) => sum + (p.ud_he || 0), 0),
playersWithDamageStats.reduce((sum, p) => sum + (p.ud_flames || 0), 0)
],
backgroundColor: [
'rgba(34, 197, 94, 0.8)', // Green for HE
'rgba(239, 68, 68, 0.8)' // Red for Fire
],
borderColor: ['rgba(34, 197, 94, 1)', 'rgba(239, 68, 68, 1)'],
borderWidth: 2
}
]
}
: {
labels: [],
datasets: []
};
</script>
<div class="space-y-6">
<svelte:head>
<title>Damage Analysis - CS2.WTF</title>
</svelte:head>
{#if !hasPlayerData}
<Card padding="lg">
<div class="text-center">
<Crosshair class="mx-auto mb-4 h-16 w-16 text-error" />
<h2 class="mb-2 text-2xl font-bold text-base-content">Damage Analysis</h2>
<AlertCircle class="mx-auto mb-4 h-16 w-16 text-warning" />
<h2 class="mb-2 text-2xl font-bold text-base-content">No Player Data Available</h2>
<p class="mb-4 text-base-content/60">
Damage dealt/received, hit group breakdown, damage heatmaps, and weapon range analysis.
Detailed damage statistics are not available for this match.
</p>
<Badge variant="warning" size="lg">Coming in Future Update</Badge>
<Badge variant="warning" size="lg">Player data unavailable</Badge>
</div>
</Card>
{:else}
<div class="space-y-6">
<!-- Team Damage Summary Cards -->
<div class="grid gap-6 md:grid-cols-2">
<!-- Terrorists Damage Stats -->
<Card padding="lg">
<h3 class="mb-4 text-xl font-bold text-terrorist">Terrorists Damage</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-base-content/60">Total Damage</div>
<div class="text-3xl font-bold text-base-content">
{teamAStats.totalDamage.toLocaleString()}
</div>
</div>
<div>
<div class="text-sm text-base-content/60">Avg per Player</div>
<div class="text-3xl font-bold text-base-content">
{Math.round(teamAStats.avgDamagePerPlayer).toLocaleString()}
</div>
</div>
</div>
</Card>
<div class="grid gap-6 md:grid-cols-3">
<!-- Counter-Terrorists Damage Stats -->
<Card padding="lg">
<h3 class="mb-4 text-xl font-bold text-ct">Counter-Terrorists Damage</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-base-content/60">Total Damage</div>
<div class="text-3xl font-bold text-base-content">
{teamBStats.totalDamage.toLocaleString()}
</div>
</div>
<div>
<div class="text-sm text-base-content/60">Avg per Player</div>
<div class="text-3xl font-bold text-base-content">
{Math.round(teamBStats.avgDamagePerPlayer).toLocaleString()}
</div>
</div>
</div>
</Card>
</div>
<!-- Top Damage Dealers -->
<div class="grid gap-6 md:grid-cols-3">
{#each topDamageDealers as player, index}
<Card padding="lg">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<Target
class="h-5 w-5 {index === 0
? 'text-warning'
: index === 1
? 'text-base-content/70'
: 'text-base-content/50'}"
/>
<h3 class="font-semibold text-base-content">
#{index + 1} Damage Dealer
</h3>
</div>
</div>
<div
class="text-2xl font-bold {player.team_id === firstTeamId
? 'text-terrorist'
: 'text-ct'}"
>
{player.name}
</div>
<div class="mt-1 font-mono text-3xl font-bold text-primary">
{player.damage.toLocaleString()}
</div>
<div class="mt-2 text-xs text-base-content/60">
{player.avgDamagePerRound.toFixed(1)} ADR
</div>
</Card>
{/each}
</div>
<!-- Utility Damage Distribution -->
<Card padding="lg">
<Crosshair class="mb-2 h-8 w-8 text-error" />
<h3 class="mb-1 text-lg font-semibold">Damage Summary</h3>
<p class="text-sm text-base-content/60">Total damage dealt and received</p>
<div class="mb-4">
<h2 class="text-2xl font-bold text-base-content">Utility Damage Distribution</h2>
<p class="text-sm text-base-content/60">
Breakdown of damage dealt by grenades and fire across all players
</p>
</div>
{#if utilityDamageData.datasets.length > 0 && utilityDamageData.datasets[0]?.data.some((v) => v > 0)}
<PieChart data={utilityDamageData} height={300} />
{:else}
<div class="py-12 text-center text-base-content/40">
<Crosshair class="mx-auto mb-2 h-12 w-12" />
<p>No utility damage recorded for this match</p>
</div>
{/if}
</Card>
<Card padding="lg">
<Target class="mb-2 h-8 w-8 text-primary" />
<h3 class="mb-1 text-lg font-semibold">Hit Groups</h3>
<p class="text-sm text-base-content/60">Headshots, chest, legs, arms breakdown</p>
<!-- Player Damage Table -->
<Card padding="none">
<div class="p-6">
<h2 class="text-2xl font-bold text-base-content">Player Damage Statistics</h2>
<p class="mt-1 text-sm text-base-content/60">Detailed damage breakdown for all players</p>
</div>
<DataTable data={sortedByDamage} columns={damageColumns} striped hoverable />
</Card>
<!-- Additional Info Note -->
<Card padding="lg">
<Crosshair class="mb-2 h-8 w-8 text-info" />
<h3 class="mb-1 text-lg font-semibold">Range Analysis</h3>
<p class="text-sm text-base-content/60">Damage effectiveness by distance</p>
<div class="flex items-start gap-3">
<AlertCircle class="h-5 w-5 flex-shrink-0 text-info" />
<div class="text-sm">
<h3 class="mb-1 font-semibold text-base-content">About Damage Statistics</h3>
<p class="text-base-content/70">
Damage statistics show total damage dealt to enemies throughout the match. Average
damage per round (ADR) is calculated by dividing total damage by the number of rounds
played. Hit group breakdown (head, chest, legs, etc.) is available in weapon-specific
statistics.
</p>
</div>
</div>
</Card>
</div>
</div>
{/if}

View File

@@ -45,63 +45,90 @@
// Prepare data table columns
const detailsColumns = [
{
key: 'name',
key: 'name' as keyof (typeof playersWithStats)[0],
label: 'Player',
sortable: true,
render: (value: string, row: (typeof playersWithStats)[0]) => {
render: (value: string | number | boolean | undefined, row: (typeof playersWithStats)[0]) => {
const strValue = value !== undefined ? String(value) : '';
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${value}</a>`;
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${strValue}</a>`;
}
},
{
key: 'kills',
key: 'kills' as keyof (typeof playersWithStats)[0],
label: 'K',
sortable: true,
align: 'center' as const,
class: 'font-mono font-semibold'
},
{ key: 'deaths', label: 'D', sortable: true, align: 'center' as const, class: 'font-mono' },
{ key: 'assists', label: 'A', sortable: true, align: 'center' as const, class: 'font-mono' },
{
key: 'kd',
key: 'deaths' as keyof (typeof playersWithStats)[0],
label: 'D',
sortable: true,
align: 'center' as const,
class: 'font-mono'
},
{
key: 'assists' as keyof (typeof playersWithStats)[0],
label: 'A',
sortable: true,
align: 'center' as const,
class: 'font-mono'
},
{
key: 'kd' as keyof (typeof playersWithStats)[0],
label: 'K/D',
sortable: true,
align: 'center' as const,
class: 'font-mono',
format: (v: number) => v.toFixed(2)
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
v !== undefined ? (v as number).toFixed(2) : '0.00'
},
{
key: 'adr',
key: 'adr' as keyof (typeof playersWithStats)[0],
label: 'ADR',
sortable: true,
align: 'center' as const,
class: 'font-mono',
format: (v: number) => v.toFixed(1)
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
v !== undefined ? (v as number).toFixed(1) : '0.0'
},
{
key: 'hsPercent',
key: 'hsPercent' as keyof (typeof playersWithStats)[0],
label: 'HS%',
sortable: true,
align: 'center' as const,
class: 'font-mono',
format: (v: number) => `${v.toFixed(1)}%`
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
v !== undefined ? (v as number).toFixed(1) : '0.0'
},
{
key: 'kast',
key: 'kast' as keyof (typeof playersWithStats)[0],
label: 'KAST%',
sortable: true,
align: 'center' as const,
class: 'font-mono',
format: (v: number) => `${v.toFixed(1)}%`
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
v !== undefined ? (v as number).toFixed(1) : '-'
},
{ key: 'mvp', label: 'MVP', sortable: true, align: 'center' as const, class: 'font-mono' },
{
key: 'mk_5',
key: 'mvp' as keyof (typeof playersWithStats)[0],
label: 'MVP',
sortable: true,
align: 'center' as const,
class: 'font-mono'
},
{
key: 'mk_5' as keyof (typeof playersWithStats)[0],
label: 'Aces',
sortable: true,
align: 'center' as const,
render: (value: number) => {
if (value > 0) return `<span class="badge badge-warning badge-sm">${value}</span>`;
render: (
value: string | number | boolean | undefined,
_row: (typeof playersWithStats)[0]
) => {
const numValue = value !== undefined ? (value as number) : 0;
if (numValue > 0) return `<span class="badge badge-warning badge-sm">${numValue}</span>`;
return '<span class="text-base-content/40">-</span>';
}
}
@@ -142,39 +169,41 @@
? playersWithStats.filter((p) => p.team_id === secondTeamId)
: [];
const teamAStats = hasPlayerData
? {
totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamAPlayers.reduce(
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
0
),
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST:
teamAPlayers.length > 0
? (
teamAPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamAPlayers.length
).toFixed(1)
: '0.0'
}
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
const teamAStats =
hasPlayerData && teamAPlayers.length > 0
? {
totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamAPlayers.reduce(
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
0
),
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST:
teamAPlayers.length > 0
? (
teamAPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamAPlayers.length
).toFixed(1)
: '0.0'
}
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
const teamBStats = hasPlayerData
? {
totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamBPlayers.reduce(
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
0
),
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST:
teamBPlayers.length > 0
? (
teamBPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamBPlayers.length
).toFixed(1)
: '0.0'
}
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
const teamBStats =
hasPlayerData && teamBPlayers.length > 0
? {
totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamBPlayers.reduce(
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
0
),
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST:
teamBPlayers.length > 0
? (
teamBPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamBPlayers.length
).toFixed(1)
: '0.0'
}
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
</script>
<svelte:head>
@@ -267,8 +296,9 @@
</Card>
<!-- Top Performers -->
// Top Performers
<div class="grid gap-6 md:grid-cols-3">
{#if sortedPlayers.length > 0}
{#if sortedPlayers.length > 0 && sortedPlayers[0]}
<!-- Most Kills -->
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
@@ -276,7 +306,9 @@
<h3 class="font-semibold text-base-content">Most Kills</h3>
</div>
<div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-primary">{sortedPlayers[0].kills}</div>
<div class="mt-1 font-mono text-3xl font-bold text-primary">
{sortedPlayers[0].kills}
</div>
<div class="mt-2 text-xs text-base-content/60">
{sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D
</div>
@@ -284,35 +316,39 @@
<!-- Best K/D -->
{@const bestKD = [...sortedPlayers].sort((a, b) => b.kd - a.kd)[0]}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Target class="h-5 w-5 text-success" />
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestKD.name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-success">{bestKD.kd.toFixed(2)}</div>
<div class="mt-2 text-xs text-base-content/60">
{bestKD.kills}K / {bestKD.deaths}D
</div>
</Card>
{#if bestKD}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Target class="h-5 w-5 text-success" />
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestKD.name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-success">{bestKD.kd.toFixed(2)}</div>
<div class="mt-2 text-xs text-base-content/60">
{bestKD.kills}K / {bestKD.deaths}D
</div>
</Card>
{/if}
<!-- Most Utility Damage -->
{@const bestUtility = [...sortedPlayers].sort(
(a, b) => (b.ud_he || 0) + (b.ud_flames || 0) - ((a.ud_he || 0) + (a.ud_flames || 0))
)[0]}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Flame class="h-5 w-5 text-error" />
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-error">
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
</div>
<div class="mt-2 text-xs text-base-content/60">
HE: {bestUtility.ud_he || 0} | Fire: {bestUtility.ud_flames || 0}
</div>
</Card>
{#if bestUtility}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Flame class="h-5 w-5 text-error" />
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-error">
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
</div>
<div class="mt-2 text-xs text-base-content/60">
HE: {bestUtility.ud_he || 0} | Fire: {bestUtility.ud_flames || 0}
</div>
</Card>
{/if}
{/if}
</div>
</div>

View File

@@ -5,7 +5,6 @@
import LineChart from '$lib/components/charts/LineChart.svelte';
import DataTable from '$lib/components/data-display/DataTable.svelte';
import type { PageData } from './$types';
import type { ChartData } from 'chart.js';
interface TeamEconomy {
round: number;
@@ -30,7 +29,17 @@
// Only process if rounds data exists
let teamEconomy = $state<TeamEconomy[]>([]);
let equipmentChartData = $state<ChartData<'line'> | null>(null);
let equipmentChartData = $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);
@@ -41,12 +50,12 @@
// Process rounds data to calculate team totals
for (const roundData of roundsData.rounds) {
const teamAPlayers = roundData.players.filter((p) => {
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
return matchPlayer?.team_id === firstTeamId;
});
const teamBPlayers = roundData.players.filter((p) => {
const matchPlayer = match.players?.find((mp) => mp.id === p.player_id);
const matchPlayer = match.players?.find((mp) => mp.id === String(p.player_id));
return matchPlayer?.team_id === secondTeamId;
});
@@ -116,61 +125,71 @@
// Table columns
const tableColumns = [
{ key: 'round', label: 'Round', sortable: true, align: 'center' as const },
{
key: 'teamA_buyType',
key: 'round' as keyof TeamEconomy,
label: 'Round',
sortable: true,
align: 'center' as const
},
{
key: 'teamA_buyType' as keyof TeamEconomy,
label: 'T Buy',
sortable: true,
render: (value: string) => {
render: (value: string | number | boolean, _row: TeamEconomy) => {
const strValue = value as string;
const variant =
value === 'Full Buy'
strValue === 'Full Buy'
? 'success'
: value === 'Eco'
: strValue === 'Eco'
? 'error'
: value === 'Force'
: strValue === 'Force'
? 'warning'
: 'default';
return `<span class="badge badge-${variant} badge-sm">${value}</span>`;
return `<span class="badge badge-${variant} badge-sm">${strValue}</span>`;
}
},
{
key: 'teamA_equipment',
key: 'teamA_equipment' as keyof TeamEconomy,
label: 'T Equipment',
sortable: true,
align: 'right' as const,
formatter: (value: number) => `$${value.toLocaleString()}`
format: (value: string | number | boolean, _row: TeamEconomy) =>
`$${(value as number).toLocaleString()}`
},
{
key: 'teamB_buyType',
key: 'teamB_buyType' as keyof TeamEconomy,
label: 'CT Buy',
sortable: true,
render: (value: string) => {
render: (value: string | number | boolean, _row: TeamEconomy) => {
const strValue = value as string;
const variant =
value === 'Full Buy'
strValue === 'Full Buy'
? 'success'
: value === 'Eco'
: strValue === 'Eco'
? 'error'
: value === 'Force'
: strValue === 'Force'
? 'warning'
: 'default';
return `<span class="badge badge-${variant} badge-sm">${value}</span>`;
return `<span class="badge badge-${variant} badge-sm">${strValue}</span>`;
}
},
{
key: 'teamB_equipment',
key: 'teamB_equipment' as keyof TeamEconomy,
label: 'CT Equipment',
sortable: true,
align: 'right' as const,
formatter: (value: number) => `$${value.toLocaleString()}`
format: (value: string | number | boolean, _row: TeamEconomy) =>
`$${(value as number).toLocaleString()}`
},
{
key: 'winner',
key: 'winner' as keyof TeamEconomy,
label: 'Winner',
align: 'center' as const,
render: (value: number) => {
if (value === 2)
render: (value: string | number | boolean, _row: TeamEconomy) => {
const numValue = value as number;
if (numValue === 2)
return '<span class="badge badge-sm" style="background-color: rgb(249, 115, 22); color: white;">T</span>';
if (value === 3)
if (numValue === 3)
return '<span class="badge badge-sm" style="background-color: rgb(59, 130, 246); color: white;">CT</span>';
return '<span class="text-base-content/40">-</span>';
}

View File

@@ -50,39 +50,52 @@
const teamBTotals = calcTeamTotals(teamBFlashStats);
// Table columns with fixed widths for consistency across multiple tables
interface FlashStat {
name: string;
team_id: number;
enemies_blinded: number;
teammates_blinded: number;
self_blinded: number;
enemy_blind_duration: number;
team_blind_duration: number;
self_blind_duration: number;
flash_assists: number;
avg_blind_duration: string;
}
const columns = [
{ key: 'name', label: 'Player', sortable: true, width: '200px' },
{ key: 'name' as const, label: 'Player', sortable: true, width: '200px' },
{
key: 'enemies_blinded',
key: 'enemies_blinded' as const,
label: 'Enemies Blinded',
sortable: true,
align: 'center' as const,
width: '150px'
},
{
key: 'avg_blind_duration',
key: 'avg_blind_duration' as const,
label: 'Avg Duration (s)',
sortable: true,
align: 'center' as const,
formatter: (value: string) => `${value}s`,
format: (value: string | number | boolean, _row: FlashStat) => `${value as string}s`,
width: '150px'
},
{
key: 'flash_assists',
key: 'flash_assists' as const,
label: 'Flash Assists',
sortable: true,
align: 'center' as const,
width: '130px'
},
{
key: 'teammates_blinded',
key: 'teammates_blinded' as const,
label: 'Team Flashed',
sortable: true,
align: 'center' as const,
width: '130px'
},
{
key: 'self_blinded',
key: 'self_blinded' as const,
label: 'Self Flashed',
sortable: true,
align: 'center' as const,

View File

@@ -1,5 +1,15 @@
<script lang="ts">
import { Search, Filter, Calendar, Loader2 } from 'lucide-svelte';
import {
Search,
Filter,
Calendar,
Loader2,
Download,
FileDown,
FileJson,
LayoutGrid,
Table as TableIcon
} from 'lucide-svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { api } from '$lib/api';
@@ -7,8 +17,17 @@
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import MatchCard from '$lib/components/match/MatchCard.svelte';
import ShareCodeInput from '$lib/components/match/ShareCodeInput.svelte';
import DataTable from '$lib/components/data-display/DataTable.svelte';
import type { PageData } from './$types';
import type { MatchListItem } from '$lib/types';
import { exportMatchesToCSV, exportMatchesToJSON } from '$lib/utils/export';
import {
getMatchesState,
scrollToMatch,
clearMatchesState,
storeMatchesState
} from '$lib/utils/navigation';
let { data }: { data: PageData } = $props();
@@ -19,6 +38,29 @@
let searchQuery = $state(currentSearch);
let showFilters = $state(false);
let exportDropdownOpen = $state(false);
let exportMessage = $state<string | null>(null);
// View mode state with localStorage persistence
let viewMode = $state<'grid' | 'table'>('grid');
// Initialize view mode from localStorage on client side
$effect(() => {
if (!import.meta.env.SSR && typeof window !== 'undefined') {
const savedViewMode = localStorage.getItem('matches-view-mode');
if (savedViewMode === 'grid' || savedViewMode === 'table') {
viewMode = savedViewMode;
}
}
});
// Save view mode to localStorage when it changes
const setViewMode = (mode: 'grid' | 'table') => {
viewMode = mode;
if (!import.meta.env.SSR && typeof window !== 'undefined') {
localStorage.setItem('matches-view-mode', mode);
}
};
// Pagination state
let matches = $state<MatchListItem[]>(data.matches);
@@ -31,6 +73,13 @@
let sortOrder = $state<'desc' | 'asc'>('desc');
let resultFilter = $state<'all' | 'win' | 'loss' | 'tie'>('all');
// Date range filter state
let fromDate = $state<string>('');
let toDate = $state<string>('');
// Future filters (disabled until API supports them)
let rankTier = $state<string>('all');
// Reset pagination when data changes (new filters applied)
$effect(() => {
matches = data.matches;
@@ -38,10 +87,103 @@
nextPageTime = data.nextPageTime;
});
// Infinite scroll setup
let loadMoreTriggerRef = $state<HTMLDivElement | null>(null);
let observer = $state<IntersectionObserver | null>(null);
let loadMoreTimeout = $state<number | null>(null);
// Set up intersection observer for infinite scroll
$effect(() => {
if (typeof window !== 'undefined' && loadMoreTriggerRef && hasMore && !isLoadingMore) {
// Clean up existing observer
if (observer) {
observer.disconnect();
}
// Create new observer
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && hasMore && !isLoadingMore) {
// Debounce the load more call to prevent too frequent requests
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
}
loadMoreTimeout = window.setTimeout(() => {
loadMore();
}, 300); // 300ms debounce
}
});
},
{
root: null,
rootMargin: '100px', // Trigger 100px before element is visible
threshold: 0.1
}
);
observer.observe(loadMoreTriggerRef);
// Cleanup function
return () => {
if (observer) {
observer.disconnect();
}
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
}
};
}
return () => {}; // Return empty cleanup function for server-side rendering
});
// Track window width for responsive slides
// Scroll restoration when returning from a match detail page
$effect(() => {
const navState = getMatchesState();
if (navState) {
// Check if we need to load more matches to find the target match
const targetMatch = matches.find((m) => m.match_id === navState.matchId);
if (targetMatch) {
// Match found, scroll to it
scrollToMatch(navState.matchId, navState.scrollY);
clearMatchesState();
} else if (hasMore && matches.length < navState.loadedCount) {
// Match not found but we had more matches loaded before, try loading more
loadMore().then(() => {
// After loading, check again
const found = matches.find((m) => m.match_id === navState.matchId);
if (found) {
scrollToMatch(navState.matchId, navState.scrollY);
} else {
// Still not found, just use scroll position
window.scrollTo({ top: navState.scrollY, behavior: 'instant' });
}
clearMatchesState();
});
} else {
// Match not found and can't load more, fallback to scroll position
window.scrollTo({ top: navState.scrollY, behavior: 'instant' });
clearMatchesState();
}
}
});
// Computed filtered and sorted matches
const displayMatches = $derived.by(() => {
let filtered = [...matches];
// Apply date range filter
if (fromDate || toDate) {
filtered = filtered.filter((match) => {
const matchDate = new Date(match.date);
if (fromDate && matchDate < new Date(fromDate + 'T00:00:00')) return false;
if (toDate && matchDate > new Date(toDate + 'T23:59:59')) return false;
return true;
});
}
// Apply result filter
if (resultFilter !== 'all') {
filtered = filtered.filter((match) => {
@@ -81,26 +223,89 @@
if (searchQuery) params.set('search', searchQuery);
if (currentMap) params.set('map', currentMap);
if (currentPlayerId) params.set('player_id', currentPlayerId);
if (fromDate) params.set('from_date', fromDate);
if (toDate) params.set('to_date', toDate);
goto(`/matches?${params.toString()}`);
};
// Date preset functions
const setDatePreset = (preset: 'today' | 'week' | 'month' | 'all') => {
const now = new Date();
if (preset === 'all') {
fromDate = '';
toDate = '';
} else if (preset === 'today') {
const dateStr = now.toISOString().substring(0, 10);
fromDate = toDate = dateStr;
} else if (preset === 'week') {
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
fromDate = weekAgo.toISOString().substring(0, 10);
toDate = now.toISOString().substring(0, 10);
} else if (preset === 'month') {
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
fromDate = monthAgo.toISOString().substring(0, 10);
toDate = now.toISOString().substring(0, 10);
}
};
// Clear all filters function
const clearAllFilters = () => {
fromDate = '';
toDate = '';
rankTier = 'all';
resultFilter = 'all';
sortBy = 'date';
sortOrder = 'desc';
};
// Count active client-side filters
const activeFilterCount = $derived(() => {
let count = 0;
if (fromDate) count++;
if (toDate) count++;
if (resultFilter !== 'all') count++;
if (sortBy !== 'date') count++;
if (sortOrder !== 'desc') count++;
return count;
});
const loadMore = async () => {
if (!hasMore || isLoadingMore || !nextPageTime) return;
// Prevent multiple simultaneous requests
if (!hasMore || isLoadingMore || matches.length === 0) {
return;
}
// Clear any pending auto-load timeout
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
loadMoreTimeout = null;
}
// Get the date of the last match for pagination
const lastMatch = matches[matches.length - 1];
if (!lastMatch) {
isLoadingMore = false;
return;
}
const lastMatchDate = lastMatch.date;
const lastMatchTimestamp = Math.floor(new Date(lastMatchDate).getTime() / 1000);
isLoadingMore = true;
try {
const matchesData = await api.matches.getMatches({
limit: 50,
limit: 20,
map: data.filters.map,
player_id: data.filters.playerId,
before_time: nextPageTime
player_id: data.filters.playerId ? String(data.filters.playerId) : undefined,
before_time: lastMatchTimestamp
});
// Append new matches to existing list
matches = [...matches, ...matchesData.matches];
hasMore = matchesData.has_more;
nextPageTime = matchesData.next_page_time;
console.log('Updated state:', { matchesLength: matches.length, hasMore, nextPageTime });
} catch (error) {
console.error('Failed to load more matches:', error);
// Show error toast or message here
@@ -118,18 +323,183 @@
'de_ancient',
'de_anubis'
];
// Export handlers
const handleExportCSV = () => {
try {
exportMatchesToCSV(displayMatches);
exportMessage = `Successfully exported ${displayMatches.length} matches to CSV`;
exportDropdownOpen = false;
setTimeout(() => {
exportMessage = null;
}, 3000);
} catch (error) {
exportMessage = error instanceof Error ? error.message : 'Failed to export matches';
setTimeout(() => {
exportMessage = null;
}, 3000);
}
};
const handleExportJSON = () => {
try {
exportMatchesToJSON(displayMatches);
exportMessage = `Successfully exported ${displayMatches.length} matches to JSON`;
exportDropdownOpen = false;
setTimeout(() => {
exportMessage = null;
}, 3000);
} catch (error) {
exportMessage = error instanceof Error ? error.message : 'Failed to export matches';
setTimeout(() => {
exportMessage = null;
}, 3000);
}
};
// Table column definitions
const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const capitalizeMap = (map: string): string => {
return map
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const getResultBadge = (match: MatchListItem): string => {
const teamAWon = match.score_team_a > match.score_team_b;
const teamBWon = match.score_team_b > match.score_team_a;
if (teamAWon) {
return '<span class="badge badge-success">Win</span>';
} else if (teamBWon) {
return '<span class="badge badge-error">Loss</span>';
} else {
return '<span class="badge badge-warning">Tie</span>';
}
};
const tableColumns = [
{
key: 'date' as const,
label: 'Date',
sortable: true,
width: '150px',
format: (value: string | number | boolean | undefined, _row: MatchListItem) =>
formatDate(value as string)
},
{
key: 'map' as const,
label: 'Map',
sortable: true,
width: '150px',
format: (value: string | number | boolean | undefined, _row: MatchListItem) =>
capitalizeMap(value as string)
},
{
key: 'score_team_a' as const,
label: 'Score',
sortable: true,
width: '120px',
align: 'center' as const,
render: (_value: string | number | boolean | undefined, row: MatchListItem) => {
const teamAColor = 'text-[#F97316]'; // Terrorist orange
const teamBColor = 'text-[#06B6D4]'; // CT cyan
return `<span class="${teamAColor} font-bold">${row.score_team_a}</span> - <span class="${teamBColor} font-bold">${row.score_team_b}</span>`;
}
},
{
key: 'duration' as const,
label: 'Duration',
sortable: true,
width: '100px',
align: 'center' as const,
format: (value: string | number | boolean | undefined, _row: MatchListItem) =>
formatDuration(value as number)
},
{
key: 'player_count' as const,
label: 'Players',
sortable: false,
width: '90px',
align: 'center' as const,
format: (value: string | number | boolean | undefined, _row: MatchListItem) =>
value ? `${value as number}` : '-'
},
{
key: 'demo_parsed' as const,
label: 'Result',
sortable: false,
width: '100px',
align: 'center' as const,
render: (_value: string | number | boolean | undefined, row: MatchListItem) =>
getResultBadge(row)
},
{
key: 'match_id' as keyof MatchListItem,
label: 'Actions',
sortable: false,
width: '120px',
align: 'center' as const,
render: (value: string | number | boolean | undefined, row: MatchListItem) =>
`<a href="/match/${value}" class="btn btn-primary btn-sm" data-match-id="${row.match_id}" data-table-link="true">View</a>`
}
];
// Handle table link clicks to store navigation state
function handleTableLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const link = target.closest('a[data-table-link]');
if (link) {
const matchId = link.getAttribute('data-match-id');
if (matchId) {
storeMatchesState(matchId, matches.length);
}
}
}
</script>
<svelte:head>
<title>Matches - CS2.WTF</title>
</svelte:head>
<!-- Export Toast Notification -->
{#if exportMessage}
<div class="toast toast-center toast-top z-50">
<div class="alert alert-success shadow-lg">
<div>
<Download class="h-5 w-5" />
<span>{exportMessage}</span>
</div>
</div>
</div>
{/if}
<div class="container mx-auto px-4 py-8">
<div class="mb-8">
<h1 class="mb-2 text-4xl font-bold">Matches</h1>
<p class="text-base-content/60">Browse and search through CS2 competitive matches</p>
</div>
<!-- Share Code Input -->
<Card padding="lg" class="mb-8">
<ShareCodeInput />
</Card>
<!-- Search & Filters -->
<Card padding="lg" class="mb-8">
<form
@@ -158,12 +528,103 @@
<Button type="button" variant="ghost" onclick={() => (showFilters = !showFilters)}>
<Filter class="mr-2 h-5 w-5" />
Filters
{#if activeFilterCount() > 0}
<Badge variant="info" size="sm" class="ml-2">{activeFilterCount()}</Badge>
{/if}
</Button>
<!-- Export Dropdown -->
<div class="dropdown dropdown-end">
<Button
type="button"
variant="ghost"
disabled={displayMatches.length === 0}
onclick={() => (exportDropdownOpen = !exportDropdownOpen)}
>
<Download class="mr-2 h-5 w-5" />
Export
</Button>
{#if exportDropdownOpen}
<ul class="menu dropdown-content z-[1] mt-2 w-52 rounded-box bg-base-100 p-2 shadow-lg">
<li>
<button type="button" onclick={handleExportCSV}>
<FileDown class="h-4 w-4" />
Export as CSV
</button>
</li>
<li>
<button type="button" onclick={handleExportJSON}>
<FileJson class="h-4 w-4" />
Export as JSON
</button>
</li>
</ul>
{/if}
</div>
</div>
<!-- Filter Panel (Collapsible) -->
{#if showFilters}
<div class="space-y-4 border-t border-base-300 pt-4">
<!-- Date Range Filter -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Date Range</h3>
<div class="flex flex-col gap-3">
<!-- Preset Buttons -->
<div class="flex flex-wrap gap-2">
<button
type="button"
class="btn btn-outline btn-sm"
onclick={() => setDatePreset('today')}
>
Today
</button>
<button
type="button"
class="btn btn-outline btn-sm"
onclick={() => setDatePreset('week')}
>
This Week
</button>
<button
type="button"
class="btn btn-outline btn-sm"
onclick={() => setDatePreset('month')}
>
This Month
</button>
<button
type="button"
class="btn btn-outline btn-sm"
onclick={() => setDatePreset('all')}
>
All Time
</button>
</div>
<!-- Date Inputs -->
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex flex-1 items-center gap-2">
<label for="from-date" class="text-sm font-medium">From:</label>
<input
id="from-date"
type="date"
bind:value={fromDate}
class="input input-sm input-bordered flex-1"
/>
</div>
<div class="flex flex-1 items-center gap-2">
<label for="to-date" class="text-sm font-medium">To:</label>
<input
id="to-date"
type="date"
bind:value={toDate}
class="input input-sm input-bordered flex-1"
/>
</div>
</div>
</div>
</div>
<!-- Map Filter -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3>
@@ -180,6 +641,51 @@
</div>
</div>
<!-- Rank Tier Filter (Coming Soon) -->
<div>
<div class="mb-3 flex items-center gap-2">
<h3 class="font-semibold text-base-content">Filter by Rank Tier</h3>
<div
class="tooltip"
data-tip="This filter will be available when the API supports rank data"
>
<Badge variant="warning" size="sm">Coming Soon</Badge>
</div>
</div>
<select
bind:value={rankTier}
class="select select-bordered select-sm w-full max-w-xs"
disabled
>
<option value="all">All Ranks</option>
<option value="0-5000">&lt;5,000 (Gray)</option>
<option value="5000-10000">5,000-10,000 (Blue)</option>
<option value="10000-15000">10,000-15,000 (Purple)</option>
<option value="15000-20000">15,000-20,000 (Pink)</option>
<option value="20000-25000">20,000-25,000 (Red)</option>
<option value="25000-30000">25,000+ (Gold)</option>
</select>
</div>
<!-- Game Mode Filter (Coming Soon) -->
<div>
<div class="mb-3 flex items-center gap-2">
<h3 class="font-semibold text-base-content">Filter by Game Mode</h3>
<div
class="tooltip"
data-tip="This filter will be available when the API supports game mode data"
>
<Badge variant="warning" size="sm">Coming Soon</Badge>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-sm" disabled>All Modes</button>
<button type="button" class="btn btn-outline btn-sm" disabled>Premier</button>
<button type="button" class="btn btn-outline btn-sm" disabled>Competitive</button>
<button type="button" class="btn btn-outline btn-sm" disabled>Wingman</button>
</div>
</div>
<!-- Result Filter -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Result</h3>
@@ -241,12 +747,19 @@
</button>
</div>
</div>
<!-- Clear All Filters Button -->
<div class="border-t border-base-300 pt-3">
<button type="button" class="btn btn-ghost btn-sm w-full" onclick={clearAllFilters}>
Clear All Filters
</button>
</div>
</div>
{/if}
</form>
<!-- Active Filters -->
{#if currentMap || currentPlayerId || currentSearch}
{#if currentMap || currentPlayerId || currentSearch || fromDate || toDate}
<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}
@@ -258,31 +771,104 @@
{#if currentPlayerId}
<Badge variant="info">Player ID: {currentPlayerId}</Badge>
{/if}
<Button variant="ghost" size="sm" href="/matches">Clear All</Button>
{#if fromDate}
<Badge variant="info">From: {fromDate}</Badge>
{/if}
{#if toDate}
<Badge variant="info">To: {toDate}</Badge>
{/if}
<Button
variant="ghost"
size="sm"
onclick={() => {
clearAllFilters();
goto('/matches');
}}>Clear All</Button
>
</div>
{/if}
</Card>
<!-- Results Summary -->
{#if matches.length > 0 && resultFilter !== 'all'}
<div class="mb-4">
<!-- View Mode Toggle & Results Summary -->
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<!-- View Mode Toggle -->
<div class="join">
<button
type="button"
class="btn join-item"
class:btn-active={viewMode === 'grid'}
onclick={() => setViewMode('grid')}
aria-label="Grid view"
>
<LayoutGrid class="h-5 w-5" />
<span class="ml-2 hidden sm:inline">Grid</span>
</button>
<button
type="button"
class="btn join-item"
class:btn-active={viewMode === 'table'}
onclick={() => setViewMode('table')}
aria-label="Table view"
>
<TableIcon class="h-5 w-5" />
<span class="ml-2 hidden sm:inline">Table</span>
</button>
</div>
<!-- Results Summary -->
{#if matches.length > 0 && resultFilter !== 'all'}
<Badge variant="info">
Showing {displayMatches.length} of {matches.length} matches
</Badge>
</div>
{/if}
{/if}
</div>
<!-- Matches Grid -->
<!-- Matches Display (Grid or Table) -->
{#if displayMatches.length > 0}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each displayMatches as match}
<MatchCard {match} />
{/each}
</div>
{#if viewMode === 'grid'}
<!-- Grid View -->
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each displayMatches as match}
<MatchCard {match} loadedCount={matches.length} />
{/each}
</div>
{:else}
<!-- Table View -->
<div
class="rounded-lg border border-base-300 bg-base-100"
onclick={handleTableLinkClick}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
// Create a mock MouseEvent to match the expected type
const mockEvent = {
target: e.target,
currentTarget: e.currentTarget
} as unknown as MouseEvent;
handleTableLinkClick(mockEvent);
e.preventDefault();
}
}}
>
<DataTable
data={displayMatches}
columns={tableColumns}
striped={true}
hoverable={true}
fixedLayout={true}
class="w-full"
/>
</div>
{/if}
<!-- Load More Button -->
<!-- Load More Trigger (for infinite scroll) -->
{#if hasMore}
<div class="mt-8 text-center">
<!-- Hidden trigger element for intersection observer -->
<div bind:this={loadMoreTriggerRef} class="h-1 w-full"></div>
<!-- Visible load more button for manual loading -->
<Button variant="primary" size="lg" onclick={loadMore} disabled={isLoadingMore}>
{#if isLoadingMore}
<Loader2 class="mr-2 h-5 w-5 animate-spin" />
@@ -291,8 +877,11 @@
Load More Matches
{/if}
</Button>
{#if isLoadingMore}
<p class="mt-2 text-sm text-base-content/60">Loading more matches...</p>
{/if}
<p class="mt-2 text-sm text-base-content/60">
Showing {matches.length} matches
Showing {matches.length} matches {hasMore ? '(more available)' : '(all loaded)'}
</p>
</div>
{:else if matches.length > 0}
@@ -312,10 +901,24 @@
<p class="text-base-content/60">
No matches match your current filters. Try adjusting your filter settings.
</p>
<div class="mt-4">
<Button variant="primary" onclick={() => (resultFilter = 'all')}>
Clear Result Filter
</Button>
<div class="mt-4 flex flex-wrap justify-center gap-2">
{#if resultFilter !== 'all'}
<Button variant="primary" onclick={() => (resultFilter = 'all')}>
Clear Result Filter
</Button>
{/if}
{#if fromDate || toDate}
<Button
variant="primary"
onclick={() => {
fromDate = '';
toDate = '';
}}
>
Clear Date Filter
</Button>
{/if}
<Button variant="ghost" onclick={clearAllFilters}>Clear All Filters</Button>
</div>
</div>
</Card>

View File

@@ -7,9 +7,8 @@ import { api } from '$lib/api';
export const load: PageLoad = async ({ url }) => {
// Get query parameters
const map = url.searchParams.get('map') || undefined;
const playerIdStr = url.searchParams.get('player_id');
const playerId = playerIdStr ? Number(playerIdStr) : undefined;
const limit = Number(url.searchParams.get('limit')) || 50;
const playerId = url.searchParams.get('player_id') || undefined;
const limit = Number(url.searchParams.get('limit')) || 20; // Request 20 matches for initial load
try {
// Load matches with filters
@@ -33,7 +32,10 @@ export const load: PageLoad = async ({ url }) => {
}
};
} catch (error) {
console.error('Failed to load matches:', error instanceof Error ? error.message : String(error));
console.error(
'Failed to load matches:',
error instanceof Error ? error.message : String(error)
);
// Return empty state on error
return {

View File

@@ -1,16 +1,40 @@
<script lang="ts">
import { User, Target, TrendingUp, Calendar, Trophy, Heart, Crosshair } from 'lucide-svelte';
import {
User,
Target,
TrendingUp,
Calendar,
Trophy,
Heart,
Crosshair,
UserCheck
} from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Button from '$lib/components/ui/Button.svelte';
import MatchCard from '$lib/components/match/MatchCard.svelte';
import LineChart from '$lib/components/charts/LineChart.svelte';
import BarChart from '$lib/components/charts/BarChart.svelte';
import PremierRatingBadge from '$lib/components/ui/PremierRatingBadge.svelte';
import TrackPlayerModal from '$lib/components/player/TrackPlayerModal.svelte';
import { preferences } from '$lib/stores';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { profile, recentMatches, playerStats } = data;
// Track player modal state
let isTrackModalOpen = $state(false);
// Handle tracking events
async function handleTracked() {
await invalidateAll();
}
async function handleUntracked() {
await invalidateAll();
}
// Calculate stats from PlayerMeta and aggregated match data
const kd =
profile.avg_deaths > 0
@@ -18,6 +42,12 @@
: profile.avg_kills.toFixed(2);
const winRate = (profile.win_rate * 100).toFixed(1);
// Get current Premier rating from most recent match
const currentRating =
playerStats.length > 0 && playerStats[0] ? playerStats[0].rank_new : undefined;
const previousRating =
playerStats.length > 0 && playerStats[0] ? playerStats[0].rank_old : undefined;
// Calculate headshot percentage from playerStats if available
const totalKills = playerStats.reduce((sum, stat) => sum + stat.kills, 0);
const totalHeadshots = playerStats.reduce((sum, stat) => sum + (stat.headshot || 0), 0);
@@ -37,7 +67,7 @@
// Performance trend chart data (K/D ratio over time)
const performanceTrendData = {
labels: playerStats.map((stat, i) => `Match ${playerStats.length - i}`).reverse(),
labels: playerStats.map((_stat, i) => `Match ${playerStats.length - i}`).reverse(),
datasets: [
{
label: 'K/D Ratio',
@@ -51,7 +81,7 @@
},
{
label: 'KAST %',
data: playerStats.map((stat) => stat.kast).reverse(),
data: playerStats.map((stat) => stat.kast || 0).reverse(),
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
tension: 0.4,
@@ -176,6 +206,62 @@
<Heart class="h-5 w-5 {isFavorite ? 'fill-error text-error' : ''}" />
</button>
</div>
<div class="mb-3 flex flex-wrap items-center gap-3">
<PremierRatingBadge
rating={currentRating}
oldRating={previousRating}
size="lg"
showTier={true}
showChange={true}
/>
<!-- VAC/Game Ban Status Badges -->
{#if profile.vac_count && profile.vac_count > 0}
<div class="badge badge-error badge-lg gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-4 w-4 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
VAC Ban{profile.vac_count > 1 ? `s (${profile.vac_count})` : ''}
{#if profile.vac_date}
<span class="text-xs opacity-80">
{new Date(profile.vac_date).toLocaleDateString()}
</span>
{/if}
</div>
{/if}
{#if profile.game_ban_count && profile.game_ban_count > 0}
<div class="badge badge-warning badge-lg gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-4 w-4 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
Game Ban{profile.game_ban_count > 1 ? `s (${profile.game_ban_count})` : ''}
{#if profile.game_ban_date}
<span class="text-xs opacity-80">
{new Date(profile.game_ban_date).toLocaleDateString()}
</span>
{/if}
</div>
{/if}
</div>
<div class="flex flex-wrap gap-3 text-sm text-base-content/60">
<span>Steam ID: {profile.id}</span>
<span>Last match: {new Date(profile.last_match_date).toLocaleDateString()}</span>
@@ -184,6 +270,14 @@
<!-- Actions -->
<div class="flex gap-2">
<Button
variant={profile.tracked ? 'success' : 'primary'}
size="sm"
onclick={() => (isTrackModalOpen = true)}
>
<UserCheck class="h-4 w-4" />
{profile.tracked ? 'Tracked' : 'Track Player'}
</Button>
<Button variant="ghost" size="sm" href={`/matches?player_id=${profile.id}`}>
View All Matches
</Button>
@@ -191,6 +285,16 @@
</div>
</Card>
<!-- Track Player Modal -->
<TrackPlayerModal
playerId={profile.id}
playerName={profile.name}
isTracked={profile.tracked || false}
bind:isOpen={isTrackModalOpen}
ontracked={handleTracked}
onuntracked={handleUntracked}
/>
<!-- Career Statistics -->
<div>
<h2 class="mb-4 text-2xl font-bold text-base-content">Career Statistics</h2>