fix: Fix Svelte 5 reactivity issues in matches page and update API handling

- Fix toast notification imports: change from showToast to toast.success/error
- Remove hover preloading from app.html and Tabs component
- Fix match rounds API handling with safe parsing for incomplete data
- Fix pagination timestamp calculation (API returns Unix timestamp, not ISO string)
- Refactor matches page state management to fix reactivity issues:
  - Replace separate state variables with single matchesState object
  - Completely replace state object on updates to trigger reactivity
  - Fix infinite loop in intersection observer effect
  - Add keyed each blocks for proper list rendering
- Remove client-side filtering (temporarily) to isolate reactivity issues
- Add error state handling with nextPageTime in matches loader

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-12 23:11:50 +01:00
parent 8f21b56223
commit 05e6182bcf
7 changed files with 142 additions and 199 deletions

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client';
import {
parseMatchRounds,
parseMatchRoundsSafe,
parseMatchWeapons,
parseMatchChat,
parseMatchParseResponse
@@ -69,13 +69,22 @@ export const matchesAPI = {
* Get match round-by-round statistics
* @param matchId - Match ID
* @returns Round statistics and economy data
* @throws Error if data is invalid or demo not parsed yet
*/
async getMatchRounds(matchId: string | number): Promise<MatchRoundsResponse> {
const url = `/match/${matchId}/rounds`;
const data = await apiClient.get<MatchRoundsResponse>(url);
const data = await apiClient.get<unknown>(url);
// Validate with Zod schema
return parseMatchRounds(data);
// Validate with Zod schema using safe parse
// This handles cases where the demo hasn't been parsed yet
const result = parseMatchRoundsSafe(data);
if (!result.success) {
// If validation fails, it's likely the demo hasn't been parsed yet
throw new Error('Demo not parsed yet or invalid response format');
}
return result.data;
},
/**
@@ -117,30 +126,33 @@ export const matchesAPI = {
const limit = params?.limit || 50;
// CRITICAL: API returns a plain array, not a wrapped object
// We request limit + 1 to detect if there are more pages
// NOTE: Backend has a hard limit of 20 matches per request
// We assume hasMore = true if we get exactly the limit we requested
const data = await apiClient.get<LegacyMatchListItem[]>(url, {
params: {
limit: limit + 1, // Request one extra to check if there are more
limit: limit,
map: params?.map,
player_id: params?.player_id
}
});
// Check if there are more matches (if we got the extra one)
const hasMore = data.length > limit;
// Handle null or empty response
if (!data || !Array.isArray(data)) {
console.warn('[API] getMatches received null or invalid data');
return transformMatchesListResponse([], false, undefined);
}
// Remove the extra match if we have more
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
// If we got exactly the limit, assume there might be more
// If we got less, we've reached the end
const hasMore = data.length === limit;
// If there are more matches, use the timestamp of the last match for pagination
// This timestamp is used in the next request: /matches/next/{timestamp}
const lastMatch =
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
const nextPageTime =
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
// Get the timestamp from the LAST match BEFORE transformation
// The legacy API format has `date` as a Unix timestamp (number)
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
// Transform legacy API response to new format
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
return transformMatchesListResponse(data, hasMore, nextPageTime);
},
/**
@@ -153,28 +165,25 @@ export const matchesAPI = {
const limit = params?.limit || 20;
// API returns a plain array, not a wrapped object
// Backend has a hard limit of 20 matches per request
const data = await apiClient.getCancelable<LegacyMatchListItem[]>(url, 'match-search', {
params: {
limit: limit + 1, // Request one extra to check if there are more
limit: limit,
map: params?.map,
player_id: params?.player_id
}
});
// Check if there are more matches (if we got the extra one)
const hasMore = data.length > limit;
// If we got exactly the limit, assume there might be more
const hasMore = data.length === limit;
// Remove the extra match if we have more
const matchesToReturn = hasMore ? data.slice(0, limit) : data;
// If there are more matches, use the timestamp of the last match for pagination
const lastMatch =
matchesToReturn.length > 0 ? matchesToReturn[matchesToReturn.length - 1] : undefined;
const nextPageTime =
hasMore && lastMatch ? Math.floor(new Date(lastMatch.date).getTime() / 1000) : undefined;
// Get the timestamp from the LAST match BEFORE transformation
// The legacy API format has `date` as a Unix timestamp (number)
const lastLegacyMatch = data.length > 0 ? data[data.length - 1] : undefined;
const nextPageTime = hasMore && lastLegacyMatch ? lastLegacyMatch.date : undefined;
// Transform legacy API response to new format
return transformMatchesListResponse(matchesToReturn, hasMore, nextPageTime);
return transformMatchesListResponse(data, hasMore, nextPageTime);
},
/**

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Upload, Check, AlertCircle, Loader2 } from 'lucide-svelte';
import { matchesAPI } from '$lib/api/matches';
import { showToast } from '$lib/stores/toast';
import { toast } from '$lib/stores/toast';
import { goto } from '$app/navigation';
let shareCode = $state('');
@@ -21,12 +21,12 @@
const trimmedCode = shareCode.trim().toUpperCase();
if (!trimmedCode) {
showToast('Please enter a share code', 'error');
toast.error('Please enter a share code');
return;
}
if (!isValidShareCode(trimmedCode)) {
showToast('Invalid share code format', 'error');
toast.error('Invalid share code format');
parseStatus = 'error';
statusMessage = 'Share code must be in format: CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX';
return;
@@ -45,7 +45,7 @@
statusMessage =
response.message ||
'Match submitted successfully! Parsing may take a few minutes. You can view the match once parsing is complete.';
showToast('Match submitted for parsing!', 'success');
toast.success('Match submitted for parsing!');
// Wait a moment then redirect to the match page
setTimeout(() => {
@@ -54,12 +54,12 @@
} else {
parseStatus = 'error';
statusMessage = response.message || 'Failed to parse share code';
showToast(statusMessage, 'error');
toast.error(statusMessage);
}
} catch (error: unknown) {
parseStatus = 'error';
statusMessage = error instanceof Error ? error.message : 'Failed to parse share code';
showToast(statusMessage, 'error');
toast.error(statusMessage);
} finally {
isLoading = false;
}

View File

@@ -2,7 +2,7 @@
import { createEventDispatcher } from 'svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import { playersAPI } from '$lib/api/players';
import { showToast } from '$lib/stores/toast';
import { toast } from '$lib/stores/toast';
interface Props {
playerId: string;
@@ -31,12 +31,12 @@
try {
await playersAPI.trackPlayer(playerId, authCode, shareCode || undefined);
showToast('Player tracking activated successfully!', 'success');
toast.success('Player tracking activated successfully!');
isOpen = false;
dispatch('tracked');
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to track player';
showToast(error, 'error');
toast.error(error);
} finally {
isLoading = false;
}
@@ -53,12 +53,12 @@
try {
await playersAPI.untrackPlayer(playerId, authCode);
showToast('Player tracking removed successfully', 'success');
toast.success('Player tracking removed successfully');
isOpen = false;
dispatch('untracked');
} catch (err: unknown) {
error = err instanceof Error ? err.message : 'Failed to untrack player';
showToast(error, 'error');
toast.error(error);
} finally {
isLoading = false;
}

View File

@@ -59,7 +59,6 @@
class:tab-active={isActive(tab)}
class:tab-disabled={tab.disabled}
aria-disabled={tab.disabled}
data-sveltekit-preload-data="hover"
>
{tab.label}
</a>