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

@@ -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'
});
}
});
}