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>
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
/**
|
|
* 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 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; // 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 (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;
|
|
share_code?: string;
|
|
map: string;
|
|
date: number; // Unix timestamp
|
|
score: [number, number]; // [team_a, team_b]
|
|
duration: number;
|
|
match_result: number;
|
|
max_rounds: number;
|
|
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 (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;
|
|
kills: number;
|
|
deaths: number;
|
|
assists: number;
|
|
headshot: number;
|
|
mvp: number;
|
|
score: number;
|
|
player: {
|
|
steamid64: string;
|
|
name: string;
|
|
avatar: string;
|
|
vac: boolean;
|
|
game_ban: boolean;
|
|
vanity_url?: string;
|
|
};
|
|
rank: Record<string, unknown>;
|
|
multi_kills?: {
|
|
duo?: number;
|
|
triple?: number;
|
|
quad?: number;
|
|
ace?: number;
|
|
};
|
|
dmg?: Record<string, unknown>;
|
|
flash?: {
|
|
duration?: {
|
|
self?: number;
|
|
team?: number;
|
|
enemy?: number;
|
|
};
|
|
total?: {
|
|
self?: number;
|
|
team?: number;
|
|
enemy?: number;
|
|
};
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
match_id: legacy.match_id, // Keep as string to preserve uint64 precision
|
|
map: legacy.map || 'unknown', // Handle empty map names
|
|
date: new Date(legacy.date * 1000).toISOString(), // Convert Unix timestamp to ISO string
|
|
score_team_a: legacy.score[0],
|
|
score_team_b: legacy.score[1],
|
|
duration: legacy.duration,
|
|
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[],
|
|
hasMore: boolean = false,
|
|
nextPageTime?: number
|
|
): MatchesListResponse {
|
|
return {
|
|
matches: legacyMatches.map(transformMatchListItem),
|
|
has_more: hasMore,
|
|
next_page_time: nextPageTime
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
avatar: `https://avatars.steamstatic.com/${legacy.player.avatar}_full.jpg`,
|
|
team_id: legacy.team_id,
|
|
kills: legacy.kills,
|
|
deaths: legacy.deaths,
|
|
assists: legacy.assists,
|
|
headshot: legacy.headshot,
|
|
mvp: legacy.mvp,
|
|
score: legacy.score,
|
|
// 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,
|
|
mk_4: legacy.multi_kills?.quad,
|
|
mk_5: legacy.multi_kills?.ace,
|
|
// Flash stats
|
|
flash_duration_self: legacy.flash?.duration?.self,
|
|
flash_duration_team: legacy.flash?.duration?.team,
|
|
flash_duration_enemy: legacy.flash?.duration?.enemy,
|
|
flash_total_self: legacy.flash?.total?.self,
|
|
flash_total_team: legacy.flash?.total?.team,
|
|
flash_total_enemy: legacy.flash?.total?.enemy
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Transform legacy match detail to new format
|
|
*/
|
|
export function transformMatchDetail(legacy: LegacyMatchDetail): Match {
|
|
return {
|
|
match_id: legacy.match_id,
|
|
share_code: legacy.share_code || undefined,
|
|
map: legacy.map || 'unknown',
|
|
date: new Date(legacy.date * 1000).toISOString(),
|
|
score_team_a: legacy.score[0],
|
|
score_team_b: legacy.score[1],
|
|
duration: legacy.duration,
|
|
match_result: legacy.match_result,
|
|
max_rounds: legacy.max_rounds,
|
|
demo_parsed: legacy.parsed,
|
|
vac_present: legacy.vac,
|
|
gameban_present: legacy.game_ban,
|
|
players: legacy.stats?.map(transformPlayerStats)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Legacy player profile format from API
|
|
*/
|
|
export interface LegacyPlayerProfile {
|
|
steamid64: string;
|
|
name: string;
|
|
avatar: string; // Hash, not full URL
|
|
vac: boolean;
|
|
vac_date: number; // Unix timestamp
|
|
game_ban: boolean;
|
|
game_ban_date: number; // Unix timestamp
|
|
tracked: boolean;
|
|
match_stats?: {
|
|
win: number;
|
|
loss: number;
|
|
};
|
|
matches?: Array<{
|
|
match_id: string;
|
|
map: string;
|
|
date: number;
|
|
score: [number, number];
|
|
duration: number;
|
|
match_result: number;
|
|
max_rounds: number;
|
|
parsed: boolean;
|
|
vac: boolean;
|
|
game_ban: boolean;
|
|
stats: {
|
|
team_id: number;
|
|
kills: number;
|
|
deaths: number;
|
|
assists: number;
|
|
headshot: number;
|
|
mvp: number;
|
|
score: number;
|
|
rank: Record<string, unknown>;
|
|
multi_kills?: Record<string, number>;
|
|
dmg?: Record<string, unknown>;
|
|
};
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Transform legacy player profile to schema-compatible format
|
|
*/
|
|
export function transformPlayerProfile(legacy: LegacyPlayerProfile) {
|
|
// Unix timestamp -62135596800 represents "no date" (year 0)
|
|
const hasVacDate = legacy.vac_date && legacy.vac_date > 0;
|
|
const hasGameBanDate = legacy.game_ban_date && legacy.game_ban_date > 0;
|
|
|
|
return {
|
|
id: legacy.steamid64,
|
|
name: legacy.name,
|
|
avatar: `https://avatars.steamstatic.com/${legacy.avatar}_full.jpg`,
|
|
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) => {
|
|
// 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
|
|
}
|
|
};
|
|
})
|
|
};
|
|
}
|