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