Files
csgowtf/src/lib/api/transformers.ts
vikingowl 8f3b652740 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>
2025-11-12 19:31:18 +01:00

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