feat: complete Phase 3 - Domain Modeling & Data Layer

Implements comprehensive type system, runtime validation, API client,
and testing infrastructure for CS2.WTF.

TypeScript Interfaces (src/lib/types/):
- Match.ts: Match, MatchPlayer, MatchListItem types
- Player.ts: Player, PlayerMeta, PlayerProfile types
- RoundStats.ts: Round economy and performance data
- Weapon.ts: Weapon statistics with hit groups (HitGroup, WeaponType enums)
- Message.ts: Chat messages with filtering support
- api.ts: API responses, errors, and APIException class
- Complete type safety with strict null checks

Zod Schemas (src/lib/schemas/):
- Runtime validation for all data models
- Schema parsers with safe/unsafe variants
- Special handling for backend typo (looses → losses)
- Share code validation regex
- CS2-specific validations (rank 0-30000, MR12 rounds)

API Client (src/lib/api/):
- client.ts: Axios-based HTTP client with error handling
  - Request cancellation support (AbortController)
  - Automatic retry logic for transient failures
  - Timeout handling (10s default)
  - Typed APIException errors
- players.ts: Player endpoints (profile, meta, track/untrack, search)
- matches.ts: Match endpoints (parse, details, weapons, rounds, chat, search)
- Zod validation on all API responses

MSW Mock Handlers (src/mocks/):
- fixtures.ts: Comprehensive mock data for testing
- handlers/players.ts: Mock player API endpoints
- handlers/matches.ts: Mock match API endpoints
- browser.ts: Browser MSW worker for development
- server.ts: Node MSW server for Vitest tests
- Realistic responses with delays and pagination
- Safe integer IDs to avoid precision loss

Configuration:
- .env.example: Complete environment variable documentation
- src/vite-env.d.ts: Vite environment type definitions
- All strict TypeScript checks passing (0 errors, 0 warnings)

Features:
- Cancellable requests for search (prevent race conditions)
- Data normalization (backend typo handling)
- Comprehensive error types (NetworkError, Timeout, etc.)
- Share code parsing and validation
- Pagination support for players and matches
- Mock data for offline development and testing

Build Status: ✓ Production build successful
Type Check: ✓ 0 errors, 0 warnings
Lint: ✓ All checks passed
Phase 3 Completion: 100%

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-04 20:31:20 +01:00
parent 66aea51c39
commit d811efc394
26 changed files with 2444 additions and 6 deletions

View File

@@ -0,0 +1,79 @@
import { z } from 'zod';
import { matchListItemSchema } from './match.schema';
/**
* Zod schemas for API responses and error handling
*/
/** APIError schema */
export const apiErrorSchema = z.object({
error: z.string(),
message: z.string(),
status_code: z.number().int(),
timestamp: z.string().datetime().optional()
});
/** Generic APIResponse schema */
export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
data: dataSchema,
success: z.boolean(),
error: apiErrorSchema.optional()
});
/** MatchParseResponse schema */
export const matchParseResponseSchema = z.object({
match_id: z.number().positive(),
status: z.enum(['parsing', 'queued', 'completed', 'error']),
message: z.string(),
estimated_time: z.number().int().positive().optional()
});
/** MatchParseStatus schema */
export const matchParseStatusSchema = z.object({
match_id: z.number().positive(),
status: z.enum(['pending', 'parsing', 'completed', 'error']),
progress: z.number().int().min(0).max(100).optional(),
error_message: z.string().optional()
});
/** MatchesListResponse schema */
export const matchesListResponseSchema = z.object({
matches: z.array(matchListItemSchema),
next_page_time: z.number().int().optional(),
has_more: z.boolean(),
total_count: z.number().int().nonnegative().optional()
});
/** MatchesQueryParams schema */
export const matchesQueryParamsSchema = z.object({
limit: z.number().int().min(1).max(100).optional(),
map: z.string().optional(),
player_id: z.number().positive().optional(),
before_time: z.number().int().positive().optional()
});
/** TrackPlayerResponse schema */
export const trackPlayerResponseSchema = z.object({
success: z.boolean(),
message: z.string()
});
/** Parser functions */
export const parseAPIError = (data: unknown) => apiErrorSchema.parse(data);
export const parseMatchParseResponse = (data: unknown) => matchParseResponseSchema.parse(data);
export const parseMatchesList = (data: unknown) => matchesListResponseSchema.parse(data);
export const parseMatchesQueryParams = (data: unknown) => matchesQueryParamsSchema.parse(data);
export const parseTrackPlayerResponse = (data: unknown) => trackPlayerResponseSchema.parse(data);
/** Safe parser functions */
export const parseMatchesListSafe = (data: unknown) => matchesListResponseSchema.safeParse(data);
export const parseAPIErrorSafe = (data: unknown) => apiErrorSchema.safeParse(data);
/** Infer TypeScript types */
export type APIErrorSchema = z.infer<typeof apiErrorSchema>;
export type MatchParseResponseSchema = z.infer<typeof matchParseResponseSchema>;
export type MatchParseStatusSchema = z.infer<typeof matchParseStatusSchema>;
export type MatchesListResponseSchema = z.infer<typeof matchesListResponseSchema>;
export type MatchesQueryParamsSchema = z.infer<typeof matchesQueryParamsSchema>;
export type TrackPlayerResponseSchema = z.infer<typeof trackPlayerResponseSchema>;

116
src/lib/schemas/index.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* Central export for all Zod schemas
* Provides runtime validation for CS2.WTF data models
*/
// Match schemas
export {
matchSchema,
matchPlayerSchema,
matchListItemSchema,
parseMatch,
parseMatchSafe,
parseMatchPlayer,
parseMatchListItem,
type MatchSchema,
type MatchPlayerSchema,
type MatchListItemSchema
} from './match.schema';
// Player schemas
export {
playerSchema,
playerMetaSchema,
playerProfileSchema,
parsePlayer,
parsePlayerSafe,
parsePlayerMeta,
parsePlayerProfile,
normalizePlayerData,
type PlayerSchema,
type PlayerMetaSchema,
type PlayerProfileSchema
} from './player.schema';
// Round statistics schemas
export {
roundStatsSchema,
roundDetailSchema,
matchRoundsResponseSchema,
teamRoundStatsSchema,
parseRoundStats,
parseRoundDetail,
parseMatchRounds,
parseTeamRoundStats,
parseRoundStatsSafe,
parseMatchRoundsSafe,
type RoundStatsSchema,
type RoundDetailSchema,
type MatchRoundsResponseSchema,
type TeamRoundStatsSchema
} from './roundStats.schema';
// Weapon schemas
export {
weaponSchema,
hitGroupsSchema,
weaponStatsSchema,
playerWeaponStatsSchema,
matchWeaponsResponseSchema,
parseWeapon,
parseWeaponStats,
parsePlayerWeaponStats,
parseMatchWeapons,
parseWeaponSafe,
parseMatchWeaponsSafe,
type WeaponSchema,
type HitGroupsSchema,
type WeaponStatsSchema,
type PlayerWeaponStatsSchema,
type MatchWeaponsResponseSchema
} from './weapon.schema';
// Message/Chat schemas
export {
messageSchema,
matchChatResponseSchema,
enrichedMessageSchema,
chatFilterSchema,
chatStatsSchema,
parseMessage,
parseMatchChat,
parseEnrichedMessage,
parseChatFilter,
parseChatStats,
parseMessageSafe,
parseMatchChatSafe,
type MessageSchema,
type MatchChatResponseSchema,
type EnrichedMessageSchema,
type ChatFilterSchema,
type ChatStatsSchema
} from './message.schema';
// API schemas
export {
apiErrorSchema,
apiResponseSchema,
matchParseResponseSchema,
matchParseStatusSchema,
matchesListResponseSchema,
matchesQueryParamsSchema,
trackPlayerResponseSchema,
parseAPIError,
parseMatchParseResponse,
parseMatchesList,
parseMatchesQueryParams,
parseTrackPlayerResponse,
parseMatchesListSafe,
parseAPIErrorSafe,
type APIErrorSchema,
type MatchParseResponseSchema,
type MatchParseStatusSchema,
type MatchesListResponseSchema,
type MatchesQueryParamsSchema,
type TrackPlayerResponseSchema
} from './api.schema';

View File

@@ -0,0 +1,101 @@
import { z } from 'zod';
/**
* Zod schemas for Match data models
* Provides runtime validation and type safety
*/
/** MatchPlayer schema */
export const matchPlayerSchema = z.object({
id: z.number().positive(),
name: z.string().min(1),
avatar: z.string().url(),
team_id: z.number().int().min(2).max(3), // 2 = T, 3 = CT
// Performance metrics
kills: z.number().int().nonnegative(),
deaths: z.number().int().nonnegative(),
assists: z.number().int().nonnegative(),
headshot: z.number().int().nonnegative(),
mvp: z.number().int().nonnegative(),
score: z.number().int().nonnegative(),
kast: z.number().int().min(0).max(100),
// Rank (CS2 Premier rating: 0-30000)
rank_old: z.number().int().min(0).max(30000).optional(),
rank_new: z.number().int().min(0).max(30000).optional(),
// Damage
dmg_enemy: z.number().int().nonnegative().optional(),
dmg_team: z.number().int().nonnegative().optional(),
// Multi-kills
mk_2: z.number().int().nonnegative().optional(),
mk_3: z.number().int().nonnegative().optional(),
mk_4: z.number().int().nonnegative().optional(),
mk_5: z.number().int().nonnegative().optional(),
// Utility damage
ud_he: z.number().int().nonnegative().optional(),
ud_flames: z.number().int().nonnegative().optional(),
ud_flash: z.number().int().nonnegative().optional(),
ud_smoke: z.number().int().nonnegative().optional(),
ud_decoy: z.number().int().nonnegative().optional(),
// Flash statistics
flash_assists: z.number().int().nonnegative().optional(),
flash_duration_enemy: z.number().nonnegative().optional(),
flash_duration_team: z.number().nonnegative().optional(),
flash_duration_self: z.number().nonnegative().optional(),
flash_total_enemy: z.number().int().nonnegative().optional(),
flash_total_team: z.number().int().nonnegative().optional(),
flash_total_self: z.number().int().nonnegative().optional(),
// Other
crosshair: z.string().optional(),
color: z.enum(['green', 'yellow', 'purple', 'blue', 'orange', 'grey']).optional(),
avg_ping: z.number().nonnegative().optional()
});
/** Match schema */
export const matchSchema = z.object({
match_id: z.number().positive(),
share_code: z
.string()
.regex(/^CSGO-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$/),
map: z.string().min(1),
date: z.string().datetime(),
score_team_a: z.number().int().nonnegative(),
score_team_b: z.number().int().nonnegative(),
duration: z.number().int().positive(),
match_result: z.number().int().min(0).max(2), // 0 = tie, 1 = team_a win, 2 = team_b win
max_rounds: z.number().int().positive(),
demo_parsed: z.boolean(),
vac_present: z.boolean(),
gameban_present: z.boolean(),
tick_rate: z.number().positive(),
players: z.array(matchPlayerSchema).optional()
});
/** MatchListItem schema */
export const matchListItemSchema = z.object({
match_id: z.number().positive(),
map: z.string().min(1),
date: z.string().datetime(),
score_team_a: z.number().int().nonnegative(),
score_team_b: z.number().int().nonnegative(),
duration: z.number().int().positive(),
demo_parsed: z.boolean(),
player_count: z.number().int().min(2).max(10)
});
/** Parser functions for safe data validation */
export const parseMatch = (data: unknown) => matchSchema.parse(data);
export const parseMatchSafe = (data: unknown) => matchSchema.safeParse(data);
export const parseMatchPlayer = (data: unknown) => matchPlayerSchema.parse(data);
export const parseMatchListItem = (data: unknown) => matchListItemSchema.parse(data);
/** Infer TypeScript types from schemas */
export type MatchSchema = z.infer<typeof matchSchema>;
export type MatchPlayerSchema = z.infer<typeof matchPlayerSchema>;
export type MatchListItemSchema = z.infer<typeof matchListItemSchema>;

View File

@@ -0,0 +1,69 @@
import { z } from 'zod';
/**
* Zod schemas for Message/Chat data models
*/
/** Message schema */
export const messageSchema = z.object({
message: z.string(),
all_chat: z.boolean(),
tick: z.number().int().nonnegative(),
match_player_id: z.number().positive().optional(),
player_id: z.number().positive().optional(),
player_name: z.string().optional(),
round: z.number().int().positive().optional(),
timestamp: z.string().datetime().optional()
});
/** MatchChatResponse schema */
export const matchChatResponseSchema = z.object({
match_id: z.number().positive(),
messages: z.array(messageSchema)
});
/** EnrichedMessage schema (with player data) */
export const enrichedMessageSchema = messageSchema.extend({
player_name: z.string().min(1),
player_avatar: z.string().url().optional(),
team_id: z.number().int().min(2).max(3).optional(),
round: z.number().int().positive()
});
/** ChatFilter schema */
export const chatFilterSchema = z.object({
player_id: z.number().positive().optional(),
chat_type: z.enum(['all', 'team', 'all_chat']).optional(),
round: z.number().int().positive().optional(),
search: z.string().optional()
});
/** ChatStats schema */
export const chatStatsSchema = z.object({
total_messages: z.number().int().nonnegative(),
team_chat_count: z.number().int().nonnegative(),
all_chat_count: z.number().int().nonnegative(),
messages_per_player: z.record(z.number().int().nonnegative()),
most_active_player: z.object({
player_id: z.number().positive(),
message_count: z.number().int().positive()
})
});
/** Parser functions */
export const parseMessage = (data: unknown) => messageSchema.parse(data);
export const parseMatchChat = (data: unknown) => matchChatResponseSchema.parse(data);
export const parseEnrichedMessage = (data: unknown) => enrichedMessageSchema.parse(data);
export const parseChatFilter = (data: unknown) => chatFilterSchema.parse(data);
export const parseChatStats = (data: unknown) => chatStatsSchema.parse(data);
/** Safe parser functions */
export const parseMessageSafe = (data: unknown) => messageSchema.safeParse(data);
export const parseMatchChatSafe = (data: unknown) => matchChatResponseSchema.safeParse(data);
/** Infer TypeScript types */
export type MessageSchema = z.infer<typeof messageSchema>;
export type MatchChatResponseSchema = z.infer<typeof matchChatResponseSchema>;
export type EnrichedMessageSchema = z.infer<typeof enrichedMessageSchema>;
export type ChatFilterSchema = z.infer<typeof chatFilterSchema>;
export type ChatStatsSchema = z.infer<typeof chatStatsSchema>;

View File

@@ -0,0 +1,88 @@
import { z } from 'zod';
import { matchSchema, matchPlayerSchema } from './match.schema';
/**
* Zod schemas for Player data models
*/
/** Player schema */
export const playerSchema = z.object({
id: z.number().positive(),
name: z.string().min(1),
avatar: z.string().url(),
vanity_url: z.string().optional(),
vanity_url_real: z.string().optional(),
steam_updated: z.string().datetime(),
profile_created: z.string().datetime().optional(),
wins: z.number().int().nonnegative().optional(),
losses: z.number().int().nonnegative().optional(),
// Also support backend's typo "looses"
looses: z.number().int().nonnegative().optional(),
ties: z.number().int().nonnegative().optional(),
vac_count: z.number().int().nonnegative().optional(),
vac_date: z.string().datetime().nullable().optional(),
game_ban_count: z.number().int().nonnegative().optional(),
game_ban_date: z.string().datetime().nullable().optional(),
oldest_sharecode_seen: z.string().optional(),
matches: z
.array(
matchSchema.extend({
stats: matchPlayerSchema
})
)
.optional()
});
/** Transform player data to normalize "looses" to "losses" */
export const normalizePlayerData = (data: z.infer<typeof playerSchema>) => {
if (data.looses !== undefined && data.losses === undefined) {
return { ...data, losses: data.looses };
}
return data;
};
/** PlayerMeta schema */
export const playerMetaSchema = z.object({
id: z.number().positive(),
name: z.string().min(1),
avatar: z.string().url(),
recent_matches: z.number().int().nonnegative(),
last_match_date: z.string().datetime(),
avg_kills: z.number().nonnegative(),
avg_deaths: z.number().nonnegative(),
avg_kast: z.number().nonnegative(),
win_rate: z.number().nonnegative()
});
/** PlayerProfile schema (extended with calculated stats) */
export const playerProfileSchema = playerSchema.extend({
total_matches: z.number().int().nonnegative(),
kd_ratio: z.number().nonnegative(),
win_rate: z.number().nonnegative(),
avg_headshot_pct: z.number().nonnegative(),
avg_kast: z.number().nonnegative(),
current_rating: z.number().int().min(0).max(30000).optional(),
peak_rating: z.number().int().min(0).max(30000).optional()
});
/** Parser functions */
export const parsePlayer = (data: unknown) => {
const parsed = playerSchema.parse(data);
return normalizePlayerData(parsed);
};
export const parsePlayerSafe = (data: unknown) => {
const result = playerSchema.safeParse(data);
if (result.success) {
return { ...result, data: normalizePlayerData(result.data) };
}
return result;
};
export const parsePlayerMeta = (data: unknown) => playerMetaSchema.parse(data);
export const parsePlayerProfile = (data: unknown) => playerProfileSchema.parse(data);
/** Infer TypeScript types */
export type PlayerSchema = z.infer<typeof playerSchema>;
export type PlayerMetaSchema = z.infer<typeof playerMetaSchema>;
export type PlayerProfileSchema = z.infer<typeof playerProfileSchema>;

View File

@@ -0,0 +1,62 @@
import { z } from 'zod';
/**
* Zod schemas for Round Statistics data models
*/
/** RoundStats schema */
export const roundStatsSchema = z.object({
round: z.number().int().positive(),
bank: z.number().int().nonnegative(),
equipment: z.number().int().nonnegative(),
spent: z.number().int().nonnegative(),
kills_in_round: z.number().int().nonnegative().optional(),
damage_in_round: z.number().int().nonnegative().optional(),
match_player_id: z.number().positive().optional(),
player_id: z.number().positive().optional()
});
/** RoundDetail schema (with player breakdown) */
export const roundDetailSchema = z.object({
round: z.number().int().positive(),
winner: z.number().int().min(2).max(3), // 2 = T, 3 = CT
win_reason: z.string(),
players: z.array(roundStatsSchema)
});
/** MatchRoundsResponse schema */
export const matchRoundsResponseSchema = z.object({
match_id: z.number().positive(),
rounds: z.array(roundDetailSchema)
});
/** TeamRoundStats schema */
export const teamRoundStatsSchema = z.object({
round: z.number().int().positive(),
team_id: z.number().int().min(2).max(3),
total_bank: z.number().int().nonnegative(),
total_equipment: z.number().int().nonnegative(),
avg_equipment: z.number().nonnegative(),
total_spent: z.number().int().nonnegative(),
winner: z.number().int().min(2).max(3).optional(),
win_reason: z
.enum(['elimination', 'bomb_defused', 'bomb_exploded', 'time', 'target_saved'])
.optional(),
buy_type: z.enum(['eco', 'semi-eco', 'force', 'full']).optional()
});
/** Parser functions */
export const parseRoundStats = (data: unknown) => roundStatsSchema.parse(data);
export const parseRoundDetail = (data: unknown) => roundDetailSchema.parse(data);
export const parseMatchRounds = (data: unknown) => matchRoundsResponseSchema.parse(data);
export const parseTeamRoundStats = (data: unknown) => teamRoundStatsSchema.parse(data);
/** Safe parser functions */
export const parseRoundStatsSafe = (data: unknown) => roundStatsSchema.safeParse(data);
export const parseMatchRoundsSafe = (data: unknown) => matchRoundsResponseSchema.safeParse(data);
/** Infer TypeScript types */
export type RoundStatsSchema = z.infer<typeof roundStatsSchema>;
export type RoundDetailSchema = z.infer<typeof roundDetailSchema>;
export type MatchRoundsResponseSchema = z.infer<typeof matchRoundsResponseSchema>;
export type TeamRoundStatsSchema = z.infer<typeof teamRoundStatsSchema>;

View File

@@ -0,0 +1,66 @@
import { z } from 'zod';
/**
* Zod schemas for Weapon data models
*/
/** Weapon schema */
export const weaponSchema = z.object({
victim: z.number().positive(),
dmg: z.number().int().nonnegative(),
eq_type: z.number().int().positive(),
hit_group: z.number().int().min(0).max(7), // 0-7 hit groups
match_player_id: z.number().positive().optional()
});
/** Hit groups breakdown schema */
export const hitGroupsSchema = z.object({
head: z.number().int().nonnegative(),
chest: z.number().int().nonnegative(),
stomach: z.number().int().nonnegative(),
left_arm: z.number().int().nonnegative(),
right_arm: z.number().int().nonnegative(),
left_leg: z.number().int().nonnegative(),
right_leg: z.number().int().nonnegative()
});
/** WeaponStats schema */
export const weaponStatsSchema = z.object({
eq_type: z.number().int().positive(),
weapon_name: z.string().min(1),
kills: z.number().int().nonnegative(),
damage: z.number().int().nonnegative(),
hits: z.number().int().nonnegative(),
hit_groups: hitGroupsSchema,
headshot_pct: z.number().nonnegative().optional(),
accuracy: z.number().nonnegative().optional()
});
/** PlayerWeaponStats schema */
export const playerWeaponStatsSchema = z.object({
player_id: z.number().positive(),
weapon_stats: z.array(weaponStatsSchema)
});
/** MatchWeaponsResponse schema */
export const matchWeaponsResponseSchema = z.object({
match_id: z.number().positive(),
weapons: z.array(playerWeaponStatsSchema)
});
/** Parser functions */
export const parseWeapon = (data: unknown) => weaponSchema.parse(data);
export const parseWeaponStats = (data: unknown) => weaponStatsSchema.parse(data);
export const parsePlayerWeaponStats = (data: unknown) => playerWeaponStatsSchema.parse(data);
export const parseMatchWeapons = (data: unknown) => matchWeaponsResponseSchema.parse(data);
/** Safe parser functions */
export const parseWeaponSafe = (data: unknown) => weaponSchema.safeParse(data);
export const parseMatchWeaponsSafe = (data: unknown) => matchWeaponsResponseSchema.safeParse(data);
/** Infer TypeScript types */
export type WeaponSchema = z.infer<typeof weaponSchema>;
export type HitGroupsSchema = z.infer<typeof hitGroupsSchema>;
export type WeaponStatsSchema = z.infer<typeof weaponStatsSchema>;
export type PlayerWeaponStatsSchema = z.infer<typeof playerWeaponStatsSchema>;
export type MatchWeaponsResponseSchema = z.infer<typeof matchWeaponsResponseSchema>;