Files
csgowtf/src/lib/api/matches.ts
vikingowl 05e6182bcf 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>
2025-11-12 23:11:50 +01:00

211 lines
6.7 KiB
TypeScript

import { apiClient } from './client';
import {
parseMatchRoundsSafe,
parseMatchWeapons,
parseMatchChat,
parseMatchParseResponse
} from '$lib/schemas';
import {
transformMatchesListResponse,
transformMatchDetail,
type LegacyMatchListItem,
type LegacyMatchDetail
} from './transformers';
import type {
Match,
MatchesListResponse,
MatchesQueryParams,
MatchParseResponse,
MatchRoundsResponse,
MatchWeaponsResponse,
MatchChatResponse
} from '$lib/types';
/**
* Match API endpoints
*/
export const matchesAPI = {
/**
* Parse match from share code
* @param shareCode - CS:GO/CS2 match share code
* @returns Parse status response
*/
async parseMatch(shareCode: string): Promise<MatchParseResponse> {
const url = `/match/parse/${shareCode}`;
const data = await apiClient.get<MatchParseResponse>(url);
// Validate with Zod schema
return parseMatchParseResponse(data);
},
/**
* Get match details with player statistics
* @param matchId - Match ID (uint64 as string)
* @returns Complete match data
*/
async getMatch(matchId: string): Promise<Match> {
const url = `/match/${matchId}`;
// API returns legacy format
const data = await apiClient.get<LegacyMatchDetail>(url);
// Transform legacy API response to new format
return transformMatchDetail(data);
},
/**
* Get match weapons statistics
* @param matchId - Match ID
* @returns Weapon statistics for all players
*/
async getMatchWeapons(matchId: string | number): Promise<MatchWeaponsResponse> {
const url = `/match/${matchId}/weapons`;
const data = await apiClient.get<MatchWeaponsResponse>(url);
// Validate with Zod schema
return parseMatchWeapons(data);
},
/**
* 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<unknown>(url);
// 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;
},
/**
* Get match chat messages
* @param matchId - Match ID
* @returns Chat messages from the match
*/
async getMatchChat(matchId: string | number): Promise<MatchChatResponse> {
const url = `/match/${matchId}/chat`;
const data = await apiClient.get<MatchChatResponse>(url);
// Validate with Zod schema
return parseMatchChat(data);
},
/**
* Get paginated list of matches
*
* IMPORTANT: The API returns a plain array, not an object with properties.
* We must manually implement pagination by:
* 1. Requesting limit + 1 matches
* 2. Checking if we got more than limit (means there are more pages)
* 3. Extracting timestamp from last match for next page
*
* Pagination flow:
* - First call: GET /matches?limit=20 → returns array of up to 20 matches
* - Next call: GET /matches/next/{timestamp}?limit=20 → returns next 20 matches
* - Continue until response.length < limit (reached the end)
*
* @param params - Query parameters (filters, pagination)
* @param params.limit - Number of matches to return (default: 50)
* @param params.before_time - Unix timestamp for pagination (get matches before this time)
* @param params.map - Filter by map name (e.g., "de_inferno")
* @param params.player_id - Filter by player Steam ID
* @returns List of matches with pagination metadata
*/
async getMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
const limit = params?.limit || 50;
// CRITICAL: API returns a plain array, not a wrapped object
// 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,
map: params?.map,
player_id: params?.player_id
}
});
// Handle null or empty response
if (!data || !Array.isArray(data)) {
console.warn('[API] getMatches received null or invalid data');
return transformMatchesListResponse([], false, undefined);
}
// 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;
// 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(data, hasMore, nextPageTime);
},
/**
* Search matches (cancelable for live search)
* @param params - Search parameters
* @returns List of matching matches
*/
async searchMatches(params?: MatchesQueryParams): Promise<MatchesListResponse> {
const url = params?.before_time ? `/matches/next/${params.before_time}` : '/matches';
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,
map: params?.map,
player_id: params?.player_id
}
});
// If we got exactly the limit, assume there might be more
const hasMore = data.length === limit;
// 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(data, hasMore, nextPageTime);
},
/**
* Get match by share code
* Convenience method that extracts match ID from share code if needed
* @param shareCodeOrId - Share code or match ID
* @returns Match data
*/
async getMatchByShareCode(shareCodeOrId: string): Promise<Match> {
// If it looks like a share code, parse it first
if (shareCodeOrId.startsWith('CSGO-')) {
const parseResult = await this.parseMatch(shareCodeOrId);
return this.getMatch(parseResult.match_id);
}
// Otherwise treat as match ID
return this.getMatch(shareCodeOrId);
}
};
/**
* Match API with default export
*/
export default matchesAPI;