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

@@ -45,63 +45,90 @@
// Prepare data table columns
const detailsColumns = [
{
key: 'name',
key: 'name' as keyof (typeof playersWithStats)[0],
label: 'Player',
sortable: true,
render: (value: string, row: (typeof playersWithStats)[0]) => {
render: (value: string | number | boolean | undefined, row: (typeof playersWithStats)[0]) => {
const strValue = value !== undefined ? String(value) : '';
const teamClass = row.team_id === firstTeamId ? 'text-terrorist' : 'text-ct';
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${value}</a>`;
return `<a href="/player/${row.id}" class="font-medium hover:underline ${teamClass}">${strValue}</a>`;
}
},
{
key: 'kills',
key: 'kills' as keyof (typeof playersWithStats)[0],
label: 'K',
sortable: true,
align: 'center' as const,
class: 'font-mono font-semibold'
},
{ key: 'deaths', label: 'D', sortable: true, align: 'center' as const, class: 'font-mono' },
{ key: 'assists', label: 'A', sortable: true, align: 'center' as const, class: 'font-mono' },
{
key: 'kd',
key: 'deaths' as keyof (typeof playersWithStats)[0],
label: 'D',
sortable: true,
align: 'center' as const,
class: 'font-mono'
},
{
key: 'assists' as keyof (typeof playersWithStats)[0],
label: 'A',
sortable: true,
align: 'center' as const,
class: 'font-mono'
},
{
key: 'kd' as keyof (typeof playersWithStats)[0],
label: 'K/D',
sortable: true,
align: 'center' as const,
class: 'font-mono',
format: (v: number) => v.toFixed(2)
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
v !== undefined ? (v as number).toFixed(2) : '0.00'
},
{
key: 'adr',
key: 'adr' as keyof (typeof playersWithStats)[0],
label: 'ADR',
sortable: true,
align: 'center' as const,
class: 'font-mono',
format: (v: number) => v.toFixed(1)
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
v !== undefined ? (v as number).toFixed(1) : '0.0'
},
{
key: 'hsPercent',
key: 'hsPercent' as keyof (typeof playersWithStats)[0],
label: 'HS%',
sortable: true,
align: 'center' as const,
class: 'font-mono',
format: (v: number) => `${v.toFixed(1)}%`
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
v !== undefined ? (v as number).toFixed(1) : '0.0'
},
{
key: 'kast',
key: 'kast' as keyof (typeof playersWithStats)[0],
label: 'KAST%',
sortable: true,
align: 'center' as const,
class: 'font-mono',
format: (v: number) => `${v.toFixed(1)}%`
format: (v: string | number | undefined, _row: (typeof playersWithStats)[0]) =>
v !== undefined ? (v as number).toFixed(1) : '-'
},
{ key: 'mvp', label: 'MVP', sortable: true, align: 'center' as const, class: 'font-mono' },
{
key: 'mk_5',
key: 'mvp' as keyof (typeof playersWithStats)[0],
label: 'MVP',
sortable: true,
align: 'center' as const,
class: 'font-mono'
},
{
key: 'mk_5' as keyof (typeof playersWithStats)[0],
label: 'Aces',
sortable: true,
align: 'center' as const,
render: (value: number) => {
if (value > 0) return `<span class="badge badge-warning badge-sm">${value}</span>`;
render: (
value: string | number | boolean | undefined,
_row: (typeof playersWithStats)[0]
) => {
const numValue = value !== undefined ? (value as number) : 0;
if (numValue > 0) return `<span class="badge badge-warning badge-sm">${numValue}</span>`;
return '<span class="text-base-content/40">-</span>';
}
}
@@ -142,39 +169,41 @@
? playersWithStats.filter((p) => p.team_id === secondTeamId)
: [];
const teamAStats = hasPlayerData
? {
totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamAPlayers.reduce(
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
0
),
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST:
teamAPlayers.length > 0
? (
teamAPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamAPlayers.length
).toFixed(1)
: '0.0'
}
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
const teamAStats =
hasPlayerData && teamAPlayers.length > 0
? {
totalDamage: teamAPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamAPlayers.reduce(
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
0
),
totalFlashAssists: teamAPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST:
teamAPlayers.length > 0
? (
teamAPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamAPlayers.length
).toFixed(1)
: '0.0'
}
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
const teamBStats = hasPlayerData
? {
totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamBPlayers.reduce(
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
0
),
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST:
teamBPlayers.length > 0
? (
teamBPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamBPlayers.length
).toFixed(1)
: '0.0'
}
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
const teamBStats =
hasPlayerData && teamBPlayers.length > 0
? {
totalDamage: teamBPlayers.reduce((sum, p) => sum + (p.dmg_enemy || 0), 0),
totalUtilityDamage: teamBPlayers.reduce(
(sum, p) => sum + (p.ud_he || 0) + (p.ud_flames || 0),
0
),
totalFlashAssists: teamBPlayers.reduce((sum, p) => sum + (p.flash_assists || 0), 0),
avgKAST:
teamBPlayers.length > 0
? (
teamBPlayers.reduce((sum, p) => sum + (p.kast || 0), 0) / teamBPlayers.length
).toFixed(1)
: '0.0'
}
: { totalDamage: 0, totalUtilityDamage: 0, totalFlashAssists: 0, avgKAST: '0.0' };
</script>
<svelte:head>
@@ -267,8 +296,9 @@
</Card>
<!-- Top Performers -->
// Top Performers
<div class="grid gap-6 md:grid-cols-3">
{#if sortedPlayers.length > 0}
{#if sortedPlayers.length > 0 && sortedPlayers[0]}
<!-- Most Kills -->
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
@@ -276,7 +306,9 @@
<h3 class="font-semibold text-base-content">Most Kills</h3>
</div>
<div class="text-2xl font-bold text-base-content">{sortedPlayers[0].name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-primary">{sortedPlayers[0].kills}</div>
<div class="mt-1 font-mono text-3xl font-bold text-primary">
{sortedPlayers[0].kills}
</div>
<div class="mt-2 text-xs text-base-content/60">
{sortedPlayers[0].deaths} deaths, {sortedPlayers[0].kd.toFixed(2)} K/D
</div>
@@ -284,35 +316,39 @@
<!-- Best K/D -->
{@const bestKD = [...sortedPlayers].sort((a, b) => b.kd - a.kd)[0]}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Target class="h-5 w-5 text-success" />
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestKD.name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-success">{bestKD.kd.toFixed(2)}</div>
<div class="mt-2 text-xs text-base-content/60">
{bestKD.kills}K / {bestKD.deaths}D
</div>
</Card>
{#if bestKD}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Target class="h-5 w-5 text-success" />
<h3 class="font-semibold text-base-content">Best K/D Ratio</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestKD.name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-success">{bestKD.kd.toFixed(2)}</div>
<div class="mt-2 text-xs text-base-content/60">
{bestKD.kills}K / {bestKD.deaths}D
</div>
</Card>
{/if}
<!-- Most Utility Damage -->
{@const bestUtility = [...sortedPlayers].sort(
(a, b) => (b.ud_he || 0) + (b.ud_flames || 0) - ((a.ud_he || 0) + (a.ud_flames || 0))
)[0]}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Flame class="h-5 w-5 text-error" />
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-error">
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
</div>
<div class="mt-2 text-xs text-base-content/60">
HE: {bestUtility.ud_he || 0} | Fire: {bestUtility.ud_flames || 0}
</div>
</Card>
{#if bestUtility}
<Card padding="lg">
<div class="mb-3 flex items-center gap-2">
<Flame class="h-5 w-5 text-error" />
<h3 class="font-semibold text-base-content">Most Utility Damage</h3>
</div>
<div class="text-2xl font-bold text-base-content">{bestUtility.name}</div>
<div class="mt-1 font-mono text-3xl font-bold text-error">
{((bestUtility.ud_he || 0) + (bestUtility.ud_flames || 0)).toLocaleString()}
</div>
<div class="mt-2 text-xs text-base-content/60">
HE: {bestUtility.ud_he || 0} | Fire: {bestUtility.ud_flames || 0}
</div>
</Card>
{/if}
{/if}
</div>
</div>