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:
@@ -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
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user