forked from CSGOWTF/csgowtf
- 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>
211 lines
6.7 KiB
TypeScript
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;
|