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

@@ -9,7 +9,7 @@
<meta name="description" content="Statistics for CS2 matchmaking matches" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

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>

View File

@@ -44,9 +44,11 @@
// View mode state with localStorage persistence
let viewMode = $state<'grid' | 'table'>('grid');
// Initialize view mode from localStorage on client side
// Initialize view mode from localStorage on client side (run once)
let viewModeInitialized = $state(false);
$effect(() => {
if (!import.meta.env.SSR && typeof window !== 'undefined') {
if (!viewModeInitialized && !import.meta.env.SSR && typeof window !== 'undefined') {
viewModeInitialized = true;
const savedViewMode = localStorage.getItem('matches-view-mode');
if (savedViewMode === 'grid' || savedViewMode === 'table') {
viewMode = savedViewMode;
@@ -62,11 +64,16 @@
}
};
// Pagination state
let matches = $state<MatchListItem[]>(data.matches);
let hasMore = $state(data.hasMore);
let nextPageTime = $state(data.nextPageTime);
let isLoadingMore = $state(false);
// Pagination state - initialize from data prop
// When filters change, the page reloads with new data automatically
// Use a container object to ensure Svelte tracks the reference properly
let matchesState = $state({
items: [...data.matches],
hasMore: data.hasMore,
nextPageTime: data.nextPageTime,
isLoadingMore: false,
version: 0
});
// Sorting and filtering state
let sortBy = $state<'date' | 'duration' | 'score'>('date');
@@ -80,38 +87,31 @@
// Future filters (disabled until API supports them)
let rankTier = $state<string>('all');
// Reset pagination when data changes (new filters applied)
$effect(() => {
matches = data.matches;
hasMore = data.hasMore;
nextPageTime = data.nextPageTime;
});
// Infinite scroll setup
let loadMoreTriggerRef = $state<HTMLDivElement | null>(null);
let observer = $state<IntersectionObserver | null>(null);
let loadMoreTimeout = $state<number | null>(null);
// Set up intersection observer for infinite scroll
// Set up intersection observer for infinite scroll - run only when element ref changes
$effect(() => {
if (typeof window !== 'undefined' && loadMoreTriggerRef && hasMore && !isLoadingMore) {
// Clean up existing observer
if (observer) {
observer.disconnect();
}
const element = loadMoreTriggerRef;
if (typeof window !== 'undefined' && element) {
// Create new observer
observer = new IntersectionObserver(
const newObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && hasMore && !isLoadingMore) {
// Debounce the load more call to prevent too frequent requests
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
if (entry.isIntersecting) {
// Check state without creating dependencies
const shouldLoad = matchesState.hasMore && !matchesState.isLoadingMore;
if (shouldLoad) {
// Debounce the load more call to prevent too frequent requests
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
}
loadMoreTimeout = window.setTimeout(() => {
loadMore();
}, 300); // 300ms debounce
}
loadMoreTimeout = window.setTimeout(() => {
loadMore();
}, 300); // 300ms debounce
}
});
},
@@ -122,13 +122,11 @@
}
);
observer.observe(loadMoreTriggerRef);
newObserver.observe(element);
// Cleanup function
return () => {
if (observer) {
observer.disconnect();
}
newObserver.disconnect();
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
}
@@ -137,23 +135,30 @@
return () => {}; // Return empty cleanup function for server-side rendering
});
// Track window width for responsive slides
// Track if we've processed the navigation state to prevent infinite loops
let navStateProcessed = $state(false);
// Scroll restoration when returning from a match detail page
$effect(() => {
// Only run once per page load
if (navStateProcessed) return;
const navState = getMatchesState();
if (navState) {
navStateProcessed = true;
// Check if we need to load more matches to find the target match
const targetMatch = matches.find((m) => m.match_id === navState.matchId);
const targetMatch = matchesState.items.find((m) => m.match_id === navState.matchId);
if (targetMatch) {
// Match found, scroll to it
scrollToMatch(navState.matchId, navState.scrollY);
clearMatchesState();
} else if (hasMore && matches.length < navState.loadedCount) {
} else if (matchesState.hasMore && matchesState.items.length < navState.loadedCount) {
// Match not found but we had more matches loaded before, try loading more
loadMore().then(() => {
// After loading, check again
const found = matches.find((m) => m.match_id === navState.matchId);
const found = matchesState.items.find((m) => m.match_id === navState.matchId);
if (found) {
scrollToMatch(navState.matchId, navState.scrollY);
} else {
@@ -170,54 +175,6 @@
}
});
// Computed filtered and sorted matches
const displayMatches = $derived.by(() => {
let filtered = [...matches];
// Apply date range filter
if (fromDate || toDate) {
filtered = filtered.filter((match) => {
const matchDate = new Date(match.date);
if (fromDate && matchDate < new Date(fromDate + 'T00:00:00')) return false;
if (toDate && matchDate > new Date(toDate + 'T23:59:59')) return false;
return true;
});
}
// Apply result filter
if (resultFilter !== 'all') {
filtered = filtered.filter((match) => {
const teamAWon = match.score_team_a > match.score_team_b;
const teamBWon = match.score_team_b > match.score_team_a;
const tie = match.score_team_a === match.score_team_b;
if (resultFilter === 'win') return teamAWon;
if (resultFilter === 'loss') return teamBWon;
if (resultFilter === 'tie') return tie;
return true;
});
}
// Apply sorting
filtered.sort((a, b) => {
let comparison = 0;
if (sortBy === 'date') {
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
} else if (sortBy === 'duration') {
comparison = a.duration - b.duration;
} else if (sortBy === 'score') {
const aScoreDiff = Math.abs(a.score_team_a - a.score_team_b);
const bScoreDiff = Math.abs(b.score_team_a - b.score_team_b);
comparison = aScoreDiff - bScoreDiff;
}
return sortOrder === 'desc' ? -comparison : comparison;
});
return filtered;
});
const handleSearch = () => {
const params = new URLSearchParams();
if (searchQuery) params.set('search', searchQuery);
@@ -260,7 +217,7 @@
};
// Count active client-side filters
const activeFilterCount = $derived(() => {
const activeFilterCount = $derived.by(() => {
let count = 0;
if (fromDate) count++;
if (toDate) count++;
@@ -272,7 +229,7 @@
const loadMore = async () => {
// Prevent multiple simultaneous requests
if (!hasMore || isLoadingMore || matches.length === 0) {
if (!matchesState.hasMore || matchesState.isLoadingMore || matchesState.items.length === 0) {
return;
}
@@ -282,35 +239,38 @@
loadMoreTimeout = null;
}
// Get the date of the last match for pagination
const lastMatch = matches[matches.length - 1];
if (!lastMatch) {
isLoadingMore = false;
return;
// Use nextPageTime if available, otherwise calculate from last match
let beforeTime: number;
if (matchesState.nextPageTime) {
beforeTime = matchesState.nextPageTime;
} else {
const lastMatch = matchesState.items[matchesState.items.length - 1];
if (!lastMatch) {
return;
}
beforeTime = Math.floor(new Date(lastMatch.date).getTime() / 1000);
}
const lastMatchDate = lastMatch.date;
const lastMatchTimestamp = Math.floor(new Date(lastMatchDate).getTime() / 1000);
isLoadingMore = true;
matchesState.isLoadingMore = true;
try {
const matchesData = await api.matches.getMatches({
limit: 20,
map: data.filters.map,
player_id: data.filters.playerId ? String(data.filters.playerId) : undefined,
before_time: lastMatchTimestamp
before_time: beforeTime
});
// Append new matches to existing list
matches = [...matches, ...matchesData.matches];
hasMore = matchesData.has_more;
nextPageTime = matchesData.next_page_time;
console.log('Updated state:', { matchesLength: matches.length, hasMore, nextPageTime });
// Update state - completely replace the state object
matchesState = {
items: [...matchesState.items, ...matchesData.matches],
hasMore: matchesData.has_more,
nextPageTime: matchesData.next_page_time,
isLoadingMore: false,
version: matchesState.version + 1
};
} catch (error) {
console.error('Failed to load more matches:', error);
// Show error toast or message here
} finally {
isLoadingMore = false;
matchesState.isLoadingMore = false;
}
};
@@ -327,8 +287,8 @@
// Export handlers
const handleExportCSV = () => {
try {
exportMatchesToCSV(displayMatches);
exportMessage = `Successfully exported ${displayMatches.length} matches to CSV`;
exportMatchesToCSV(matchesState.items);
exportMessage = `Successfully exported ${matchesState.items.length} matches to CSV`;
exportDropdownOpen = false;
setTimeout(() => {
exportMessage = null;
@@ -343,8 +303,8 @@
const handleExportJSON = () => {
try {
exportMatchesToJSON(displayMatches);
exportMessage = `Successfully exported ${displayMatches.length} matches to JSON`;
exportMatchesToJSON(matchesState.items);
exportMessage = `Successfully exported ${matchesState.items.length} matches to JSON`;
exportDropdownOpen = false;
setTimeout(() => {
exportMessage = null;
@@ -467,7 +427,7 @@
if (link) {
const matchId = link.getAttribute('data-match-id');
if (matchId) {
storeMatchesState(matchId, matches.length);
storeMatchesState(matchId, matchesState.items.length);
}
}
}
@@ -528,8 +488,8 @@
<Button type="button" variant="ghost" onclick={() => (showFilters = !showFilters)}>
<Filter class="mr-2 h-5 w-5" />
Filters
{#if activeFilterCount() > 0}
<Badge variant="info" size="sm" class="ml-2">{activeFilterCount()}</Badge>
{#if activeFilterCount > 0}
<Badge variant="info" size="sm" class="ml-2">{activeFilterCount}</Badge>
{/if}
</Button>
@@ -538,7 +498,7 @@
<Button
type="button"
variant="ghost"
disabled={displayMatches.length === 0}
disabled={matchesState.items.length === 0}
onclick={() => (exportDropdownOpen = !exportDropdownOpen)}
>
<Download class="mr-2 h-5 w-5" />
@@ -816,20 +776,20 @@
</div>
<!-- Results Summary -->
{#if matches.length > 0 && resultFilter !== 'all'}
{#if matchesState.items.length > 0}
<Badge variant="info">
Showing {displayMatches.length} of {matches.length} matches
Showing {matchesState.items.length} matches
</Badge>
{/if}
</div>
<!-- Matches Display (Grid or Table) -->
{#if displayMatches.length > 0}
{#if matchesState.items.length > 0}
{#if viewMode === 'grid'}
<!-- Grid View -->
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each displayMatches as match}
<MatchCard {match} loadedCount={matches.length} />
{#each matchesState.items as match (match.match_id)}
<MatchCard {match} loadedCount={matchesState.items.length} />
{/each}
</div>
{:else}
@@ -852,7 +812,7 @@
}}
>
<DataTable
data={displayMatches}
data={matchesState.items}
columns={tableColumns}
striped={true}
hoverable={true}
@@ -863,65 +823,39 @@
{/if}
<!-- Load More Trigger (for infinite scroll) -->
{#if hasMore}
{#if matchesState.hasMore}
<div class="mt-8 text-center">
<!-- Hidden trigger element for intersection observer -->
<div bind:this={loadMoreTriggerRef} class="h-1 w-full"></div>
<!-- Visible load more button for manual loading -->
<Button variant="primary" size="lg" onclick={loadMore} disabled={isLoadingMore}>
{#if isLoadingMore}
<Button
variant="primary"
size="lg"
onclick={loadMore}
disabled={matchesState.isLoadingMore}
>
{#if matchesState.isLoadingMore}
<Loader2 class="mr-2 h-5 w-5 animate-spin" />
Loading...
{:else}
Load More Matches
{/if}
</Button>
{#if isLoadingMore}
{#if matchesState.isLoadingMore}
<p class="mt-2 text-sm text-base-content/60">Loading more matches...</p>
{/if}
<p class="mt-2 text-sm text-base-content/60">
Showing {matches.length} matches {hasMore ? '(more available)' : '(all loaded)'}
Showing {matchesState.items.length} matches (more available)
</p>
</div>
{:else if matches.length > 0}
{:else if matchesState.items.length > 0}
<div class="mt-8 text-center">
<Badge variant="default">
All matches loaded ({matches.length} total{resultFilter !== 'all'
? `, ${displayMatches.length} shown`
: ''})
All matches loaded ({matchesState.items.length} total)
</Badge>
</div>
{/if}
{:else if matches.length > 0 && displayMatches.length === 0}
<Card padding="lg">
<div class="text-center">
<Calendar class="mx-auto mb-4 h-16 w-16 text-base-content/40" />
<h2 class="mb-2 text-xl font-semibold text-base-content">No Matches Found</h2>
<p class="text-base-content/60">
No matches match your current filters. Try adjusting your filter settings.
</p>
<div class="mt-4 flex flex-wrap justify-center gap-2">
{#if resultFilter !== 'all'}
<Button variant="primary" onclick={() => (resultFilter = 'all')}>
Clear Result Filter
</Button>
{/if}
{#if fromDate || toDate}
<Button
variant="primary"
onclick={() => {
fromDate = '';
toDate = '';
}}
>
Clear Date Filter
</Button>
{/if}
<Button variant="ghost" onclick={clearAllFilters}>Clear All Filters</Button>
</div>
</div>
</Card>
{:else}
<Card padding="lg">
<div class="text-center">

View File

@@ -41,6 +41,7 @@ export const load: PageLoad = async ({ url }) => {
return {
matches: [],
hasMore: false,
nextPageTime: undefined,
filters: { map, playerId },
meta: {
title: 'Browse Matches - CS2.WTF',