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

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