2137 lines
55 KiB
Markdown
2137 lines
55 KiB
Markdown
# Super Detailed DTO Refactor Plan for apps/website
|
|
|
|
## Executive Summary
|
|
|
|
This plan addresses the massive DTO/ViewModel pollution in `apps/website/lib/apiClient.ts` (1160 lines, 80+ types) and inline definitions in pages like `apps/website/app/races/[id]/results/page.tsx`. It enforces [DATA_FLOW.md](apps/website/DATA_FLOW.md) strictly: API DTOs → Presenters → View Models → Services → UI. Results in ~100 new files, apiClient shrunk 95%, zero inline DTOs in UI.
|
|
|
|
## Current State Analysis
|
|
|
|
### Problem 1: Monolithic apiClient.ts
|
|
**Location**: `apps/website/lib/apiClient.ts` (1160 lines)
|
|
|
|
**Violations**:
|
|
- 80+ type definitions mixed (transport DTOs + UI ViewModels + inputs/outputs)
|
|
- Single file responsibility violation
|
|
- No separation between HTTP layer and data transformation
|
|
- Direct UI coupling to transport shapes
|
|
|
|
### Problem 2: Inline Page DTOs
|
|
**Location**: `apps/website/app/races/[id]/results/page.tsx` (lines 17-56)
|
|
|
|
**Violations**:
|
|
- Pages defining transport contracts
|
|
- No reusability
|
|
- Tight coupling to implementation details
|
|
- Violates presenter pattern
|
|
|
|
### Problem 3: Missing Architecture Layers
|
|
**Current**: UI → apiClient (mixed DTOs/ViewModels)
|
|
**Required**: UI → Services → Presenters → API (DTOs only)
|
|
|
|
## Phase 1: Complete Type Inventory & Classification
|
|
|
|
### Step 1.1: Catalog All apiClient.ts Types (Lines 13-634)
|
|
|
|
Create spreadsheet/document with columns:
|
|
1. Type Name (current)
|
|
2. Line Number Range
|
|
3. Classification (DTO/ViewModel/Input/Output)
|
|
4. Target Location
|
|
5. Dependencies
|
|
6. Used By (pages/components)
|
|
|
|
**Complete List of 80+ Types**:
|
|
|
|
#### Common/Shared Types (Lines 13-54)
|
|
```typescript
|
|
// Line 13-19: DriverDTO
|
|
export interface DriverDTO {
|
|
id: string;
|
|
name: string;
|
|
avatarUrl?: string;
|
|
iracingId?: string;
|
|
rating?: number;
|
|
}
|
|
// Classification: Transport DTO
|
|
// Target: apps/website/lib/dtos/DriverDto.ts
|
|
// Dependencies: None
|
|
// Used By: Multiple (races, teams, leagues)
|
|
|
|
// Line 21-29: ProtestViewModel
|
|
export interface ProtestViewModel {
|
|
id: string;
|
|
raceId: string;
|
|
complainantId: string;
|
|
defendantId: string;
|
|
description: string;
|
|
status: string;
|
|
createdAt: string;
|
|
}
|
|
// Classification: UI ViewModel
|
|
// Target: apps/website/lib/view-models/ProtestViewModel.ts
|
|
// Dependencies: None
|
|
// Used By: races/[id]/protests, leagues/[id]/admin
|
|
|
|
// Line 31-36: LeagueMemberViewModel
|
|
export interface LeagueMemberViewModel {
|
|
driverId: string;
|
|
driver?: DriverDTO;
|
|
role: string;
|
|
joinedAt: string;
|
|
}
|
|
// Classification: UI ViewModel
|
|
// Target: apps/website/lib/view-models/LeagueMemberViewModel.ts
|
|
// Dependencies: DriverDTO
|
|
// Used By: leagues/[id]/members
|
|
|
|
// ... (continue for ALL 80 types with same detail level)
|
|
```
|
|
|
|
#### League Domain Types (Lines 57-152)
|
|
- LeagueSummaryViewModel (57-70) → DTO + ViewModel
|
|
- AllLeaguesWithCapacityViewModel (72-74) → DTO
|
|
- LeagueStatsDto (76-79) → DTO
|
|
- LeagueJoinRequestViewModel (80-87) → ViewModel
|
|
- LeagueAdminPermissionsViewModel (88-95) → ViewModel
|
|
- LeagueOwnerSummaryViewModel (97-102) → ViewModel
|
|
- LeagueConfigFormModelDto (104-111) → DTO
|
|
- LeagueAdminProtestsViewModel (113-115) → ViewModel
|
|
- LeagueSeasonSummaryViewModel (117-123) → ViewModel
|
|
- LeagueMembershipsViewModel (125-127) → ViewModel
|
|
- LeagueStandingsViewModel (129-131) → ViewModel
|
|
- LeagueScheduleViewModel (133-135) → ViewModel
|
|
- LeagueStatsViewModel (137-145) → ViewModel
|
|
- LeagueAdminViewModel (147-151) → ViewModel
|
|
- CreateLeagueInput (153-159) → Input DTO
|
|
- CreateLeagueOutput (161-164) → Output DTO
|
|
|
|
#### Driver Domain Types (Lines 166-199)
|
|
- DriverLeaderboardItemViewModel (167-175) → ViewModel
|
|
- DriversLeaderboardViewModel (177-179) → ViewModel
|
|
- DriverStatsDto (181-183) → DTO
|
|
- CompleteOnboardingInput (185-188) → Input DTO
|
|
- CompleteOnboardingOutput (190-193) → Output DTO
|
|
- DriverRegistrationStatusViewModel (195-199) → ViewModel
|
|
|
|
#### Team Domain Types (Lines 201-273)
|
|
- TeamSummaryViewModel (202-208) → ViewModel
|
|
- AllTeamsViewModel (210-212) → ViewModel
|
|
- TeamMemberViewModel (214-219) → ViewModel
|
|
- TeamJoinRequestItemViewModel (221-227) → ViewModel
|
|
- TeamDetailsViewModel (229-237) → ViewModel
|
|
- TeamMembersViewModel (239-241) → ViewModel
|
|
- TeamJoinRequestsViewModel (243-245) → ViewModel
|
|
- DriverTeamViewModel (247-252) → ViewModel
|
|
- CreateTeamInput (254-258) → Input DTO
|
|
- CreateTeamOutput (260-263) → Output DTO
|
|
- UpdateTeamInput (265-269) → Input DTO
|
|
- UpdateTeamOutput (271-273) → Output DTO
|
|
|
|
#### Race Domain Types (Lines 275-447)
|
|
- RaceListItemViewModel (276-284) → ViewModel
|
|
- AllRacesPageViewModel (286-288) → ViewModel
|
|
- RaceStatsDto (290-292) → DTO
|
|
- RaceDetailEntryViewModel (295-302) → ViewModel
|
|
- RaceDetailUserResultViewModel (304-313) → ViewModel
|
|
- RaceDetailRaceViewModel (315-326) → ViewModel
|
|
- RaceDetailLeagueViewModel (328-336) → ViewModel
|
|
- RaceDetailRegistrationViewModel (338-341) → ViewModel
|
|
- RaceDetailViewModel (343-350) → ViewModel
|
|
- RacesPageDataRaceViewModel (352-364) → ViewModel
|
|
- RacesPageDataViewModel (366-368) → ViewModel
|
|
- RaceResultViewModel (370-381) → ViewModel
|
|
- RaceResultsDetailViewModel (383-387) → ViewModel
|
|
- RaceWithSOFViewModel (389-393) → ViewModel
|
|
- RaceProtestViewModel (395-405) → ViewModel
|
|
- RaceProtestsViewModel (407-410) → ViewModel
|
|
- RacePenaltyViewModel (412-420) → ViewModel
|
|
- RacePenaltiesViewModel (423-426) → ViewModel
|
|
- RegisterForRaceParams (428-431) → Input DTO
|
|
- WithdrawFromRaceParams (433-435) → Input DTO
|
|
- ImportRaceResultsInput (437-439) → Input DTO
|
|
- ImportRaceResultsSummaryViewModel (441-447) → ViewModel
|
|
|
|
#### Sponsor Domain Types (Lines 449-502)
|
|
- GetEntitySponsorshipPricingResultDto (450-454) → DTO
|
|
- SponsorViewModel (456-461) → ViewModel
|
|
- GetSponsorsOutput (463-465) → Output DTO
|
|
- CreateSponsorInput (467-472) → Input DTO
|
|
- CreateSponsorOutput (474-477) → Output DTO
|
|
- SponsorDashboardDTO (479-485) → DTO
|
|
- SponsorshipDetailViewModel (487-496) → ViewModel
|
|
- SponsorSponsorshipsDTO (498-502) → DTO
|
|
|
|
#### Media Domain Types (Lines 504-514)
|
|
- RequestAvatarGenerationInput (505-508) → Input DTO
|
|
- RequestAvatarGenerationOutput (510-514) → Output DTO
|
|
|
|
#### Analytics Domain Types (Lines 516-536)
|
|
- RecordPageViewInput (517-521) → Input DTO
|
|
- RecordPageViewOutput (523-525) → Output DTO
|
|
- RecordEngagementInput (527-532) → Input DTO
|
|
- RecordEngagementOutput (534-536) → Output DTO
|
|
|
|
#### Auth Domain Types (Lines 538-556)
|
|
- LoginParams (539-542) → Input DTO
|
|
- SignupParams (544-548) → Input DTO
|
|
- SessionData (550-556) → DTO
|
|
|
|
#### Payments Domain Types (Lines 558-633)
|
|
- PaymentViewModel (559-565) → ViewModel
|
|
- GetPaymentsOutput (567-569) → Output DTO
|
|
- CreatePaymentInput (571-577) → Input DTO
|
|
- CreatePaymentOutput (579-582) → Output DTO
|
|
- MembershipFeeViewModel (584-589) → ViewModel
|
|
- MemberPaymentViewModel (591-596) → ViewModel
|
|
- GetMembershipFeesOutput (598-601) → Output DTO
|
|
- PrizeViewModel (603-609) → ViewModel
|
|
- GetPrizesOutput (611-613) → Output DTO
|
|
- WalletTransactionViewModel (615-621) → ViewModel
|
|
- WalletViewModel (623-628) → ViewModel
|
|
- GetWalletOutput (630-633) → Output DTO
|
|
|
|
### Step 1.2: Catalog Page Inline DTOs
|
|
|
|
**File**: `apps/website/app/races/[id]/results/page.tsx`
|
|
|
|
```typescript
|
|
// Lines 17-24: PenaltyTypeDTO
|
|
type PenaltyTypeDTO =
|
|
| 'time_penalty'
|
|
| 'grid_penalty'
|
|
| 'points_deduction'
|
|
| 'disqualification'
|
|
| 'warning'
|
|
| 'license_points'
|
|
| string;
|
|
// Target: apps/website/lib/dtos/PenaltyTypeDto.ts
|
|
|
|
// Lines 26-30: PenaltyData
|
|
interface PenaltyData {
|
|
driverId: string;
|
|
type: PenaltyTypeDTO;
|
|
value?: number;
|
|
}
|
|
// Target: apps/website/lib/dtos/PenaltyDataDto.ts
|
|
|
|
// Lines 32-42: RaceResultRowDTO
|
|
interface RaceResultRowDTO {
|
|
id: string;
|
|
raceId: string;
|
|
driverId: string;
|
|
position: number;
|
|
fastestLap: number;
|
|
incidents: number;
|
|
startPosition: number;
|
|
getPositionChange(): number;
|
|
}
|
|
// Target: apps/website/lib/dtos/RaceResultRowDto.ts
|
|
// Note: Remove method, make pure data
|
|
|
|
// Lines 44-46: DriverRowDTO
|
|
interface DriverRowDTO {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
// Target: Reuse DriverDto from common
|
|
|
|
// Lines 48-56: ImportResultRowDTO
|
|
interface ImportResultRowDTO {
|
|
id: string;
|
|
raceId: string;
|
|
driverId: string;
|
|
position: number;
|
|
fastestLap: number;
|
|
incidents: number;
|
|
startPosition: number;
|
|
}
|
|
// Target: apps/website/lib/dtos/ImportResultRowDto.ts
|
|
```
|
|
|
|
**Action Items**:
|
|
1. Scan all files in `apps/website/app/` for inline type/interface definitions
|
|
2. Create extraction plan for each
|
|
3. Document dependencies and usage
|
|
|
|
## Phase 2: Directory Structure Creation
|
|
|
|
### Step 2.1: Create Base Directories
|
|
|
|
```bash
|
|
# Execute these commands in order:
|
|
mkdir -p apps/website/lib/dtos
|
|
mkdir -p apps/website/lib/view-models
|
|
mkdir -p apps/website/lib/presenters
|
|
mkdir -p apps/website/lib/services
|
|
mkdir -p apps/website/lib/api/base
|
|
mkdir -p apps/website/lib/api/leagues
|
|
mkdir -p apps/website/lib/api/drivers
|
|
mkdir -p apps/website/lib/api/teams
|
|
mkdir -p apps/website/lib/api/races
|
|
mkdir -p apps/website/lib/api/sponsors
|
|
mkdir -p apps/website/lib/api/media
|
|
mkdir -p apps/website/lib/api/analytics
|
|
mkdir -p apps/website/lib/api/auth
|
|
mkdir -p apps/website/lib/api/payments
|
|
```
|
|
|
|
### Step 2.2: Create Placeholder Index Files
|
|
|
|
```typescript
|
|
// apps/website/lib/dtos/index.ts
|
|
// This file will be populated in Phase 3
|
|
export {};
|
|
|
|
// apps/website/lib/view-models/index.ts
|
|
// This file will be populated in Phase 4
|
|
export {};
|
|
|
|
// apps/website/lib/presenters/index.ts
|
|
// This file will be populated in Phase 6
|
|
export {};
|
|
|
|
// apps/website/lib/services/index.ts
|
|
// This file will be populated in Phase 7
|
|
export {};
|
|
|
|
// apps/website/lib/api/index.ts
|
|
// This file will be populated in Phase 5
|
|
export {};
|
|
```
|
|
|
|
## Phase 3: Extract DTOs (60+ Files)
|
|
|
|
### Step 3.1: Common DTOs
|
|
|
|
#### apps/website/lib/dtos/DriverDto.ts
|
|
```typescript
|
|
/**
|
|
* Driver transport object
|
|
* Represents a driver as received from the API
|
|
*/
|
|
export interface DriverDto {
|
|
id: string;
|
|
name: string;
|
|
avatarUrl?: string;
|
|
iracingId?: string;
|
|
rating?: number;
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/dtos/PenaltyTypeDto.ts
|
|
```typescript
|
|
/**
|
|
* Penalty type enumeration
|
|
* Defines all possible penalty types in the system
|
|
*/
|
|
export type PenaltyTypeDto =
|
|
| 'time_penalty'
|
|
| 'grid_penalty'
|
|
| 'points_deduction'
|
|
| 'disqualification'
|
|
| 'warning'
|
|
| 'license_points';
|
|
```
|
|
|
|
#### apps/website/lib/dtos/PenaltyDataDto.ts
|
|
```typescript
|
|
import type { PenaltyTypeDto } from './PenaltyTypeDto';
|
|
|
|
/**
|
|
* Penalty data structure
|
|
* Used when creating or updating penalties
|
|
*/
|
|
export interface PenaltyDataDto {
|
|
driverId: string;
|
|
type: PenaltyTypeDto;
|
|
value?: number;
|
|
}
|
|
```
|
|
|
|
### Step 3.2: League DTOs
|
|
|
|
#### apps/website/lib/dtos/LeagueStatsDto.ts
|
|
```typescript
|
|
/**
|
|
* League statistics transport object
|
|
*/
|
|
export interface LeagueStatsDto {
|
|
totalLeagues: number;
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/dtos/LeagueSummaryDto.ts
|
|
```typescript
|
|
/**
|
|
* League summary transport object
|
|
* Contains basic league information for list views
|
|
*/
|
|
export interface LeagueSummaryDto {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
logoUrl?: string;
|
|
coverImage?: string;
|
|
memberCount: number;
|
|
maxMembers: number;
|
|
isPublic: boolean;
|
|
ownerId: string;
|
|
ownerName?: string;
|
|
scoringType?: string;
|
|
status?: string;
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/dtos/CreateLeagueInputDto.ts
|
|
```typescript
|
|
/**
|
|
* Create league input
|
|
* Data required to create a new league
|
|
*/
|
|
export interface CreateLeagueInputDto {
|
|
name: string;
|
|
description?: string;
|
|
isPublic: boolean;
|
|
maxMembers: number;
|
|
ownerId: string;
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/dtos/CreateLeagueOutputDto.ts
|
|
```typescript
|
|
/**
|
|
* Create league output
|
|
* Response from league creation
|
|
*/
|
|
export interface CreateLeagueOutputDto {
|
|
leagueId: string;
|
|
success: boolean;
|
|
}
|
|
```
|
|
|
|
### Step 3.3: Race DTOs
|
|
|
|
#### apps/website/lib/dtos/RaceStatsDto.ts
|
|
```typescript
|
|
/**
|
|
* Race statistics transport object
|
|
*/
|
|
export interface RaceStatsDto {
|
|
totalRaces: number;
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/dtos/RaceResultRowDto.ts
|
|
```typescript
|
|
/**
|
|
* Individual race result transport object
|
|
* Pure data, no methods
|
|
*/
|
|
export interface RaceResultRowDto {
|
|
id: string;
|
|
raceId: string;
|
|
driverId: string;
|
|
position: number;
|
|
fastestLap: number;
|
|
incidents: number;
|
|
startPosition: number;
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/dtos/RaceResultsDetailDto.ts
|
|
```typescript
|
|
import type { RaceResultRowDto } from './RaceResultRowDto';
|
|
|
|
/**
|
|
* Complete race results transport object
|
|
*/
|
|
export interface RaceResultsDetailDto {
|
|
raceId: string;
|
|
track: string;
|
|
results: RaceResultRowDto[];
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/dtos/RegisterForRaceInputDto.ts
|
|
```typescript
|
|
/**
|
|
* Register for race input
|
|
*/
|
|
export interface RegisterForRaceInputDto {
|
|
leagueId: string;
|
|
driverId: string;
|
|
}
|
|
```
|
|
|
|
### Step 3.4: Driver DTOs
|
|
|
|
#### apps/website/lib/dtos/DriverStatsDto.ts
|
|
```typescript
|
|
/**
|
|
* Driver statistics transport object
|
|
*/
|
|
export interface DriverStatsDto {
|
|
totalDrivers: number;
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/dtos/CompleteOnboardingInputDto.ts
|
|
```typescript
|
|
/**
|
|
* Complete onboarding input
|
|
*/
|
|
export interface CompleteOnboardingInputDto {
|
|
iracingId: string;
|
|
displayName: string;
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/dtos/CompleteOnboardingOutputDto.ts
|
|
```typescript
|
|
/**
|
|
* Complete onboarding output
|
|
*/
|
|
export interface CompleteOnboardingOutputDto {
|
|
driverId: string;
|
|
success: boolean;
|
|
}
|
|
```
|
|
|
|
### Step 3.5: Barrel Export (apps/website/lib/dtos/index.ts)
|
|
|
|
```typescript
|
|
// Common
|
|
export * from './DriverDto';
|
|
export * from './PenaltyTypeDto';
|
|
export * from './PenaltyDataDto';
|
|
|
|
// League
|
|
export * from './LeagueStatsDto';
|
|
export * from './LeagueSummaryDto';
|
|
export * from './CreateLeagueInputDto';
|
|
export * from './CreateLeagueOutputDto';
|
|
// ... add all league DTOs
|
|
|
|
// Race
|
|
export * from './RaceStatsDto';
|
|
export * from './RaceResultRowDto';
|
|
export * from './RaceResultsDetailDto';
|
|
export * from './RegisterForRaceInputDto';
|
|
// ... add all race DTOs
|
|
|
|
// Driver
|
|
export * from './DriverStatsDto';
|
|
export * from './CompleteOnboardingInputDto';
|
|
export * from './CompleteOnboardingOutputDto';
|
|
// ... add all driver DTOs
|
|
|
|
// Team, Sponsor, Media, Analytics, Auth, Payments...
|
|
// Continue for all domains
|
|
```
|
|
|
|
**Total DTO Files**: ~60-70 files
|
|
|
|
## Phase 4: Create View Models (30+ Files)
|
|
|
|
### Step 4.1: Understanding ViewModel Pattern
|
|
|
|
**What ViewModels Add**:
|
|
1. UI-specific derived fields
|
|
2. Computed properties
|
|
3. Display formatting
|
|
4. UI state indicators
|
|
5. Grouped/sorted data for rendering
|
|
|
|
**What ViewModels DO NOT Have**:
|
|
1. Business logic
|
|
2. Validation rules
|
|
3. API calls
|
|
4. Side effects
|
|
|
|
### Step 4.2: League ViewModels
|
|
|
|
#### apps/website/lib/view-models/LeagueSummaryViewModel.ts
|
|
```typescript
|
|
import type { LeagueSummaryDto } from '../dtos';
|
|
|
|
/**
|
|
* League summary view model
|
|
* Extends DTO with UI-specific computed properties
|
|
*/
|
|
export interface LeagueSummaryViewModel extends LeagueSummaryDto {
|
|
// Formatted capacity display (e.g., "25/50")
|
|
formattedCapacity: string;
|
|
|
|
// Percentage for progress bars (0-100)
|
|
capacityBarPercent: number;
|
|
|
|
// Button label based on state
|
|
joinButtonLabel: string;
|
|
|
|
// Quick check flags
|
|
isFull: boolean;
|
|
isJoinable: boolean;
|
|
|
|
// Color indicator for UI
|
|
memberProgressColor: 'green' | 'yellow' | 'red';
|
|
|
|
// Badge type for status display
|
|
statusBadgeVariant: 'success' | 'warning' | 'info';
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/view-models/LeagueStandingsViewModel.ts
|
|
```typescript
|
|
import type { DriverDto } from '../dtos';
|
|
|
|
/**
|
|
* Single standings entry view model
|
|
*/
|
|
export interface StandingEntryViewModel {
|
|
// From DTO
|
|
driverId: string;
|
|
driver?: DriverDto;
|
|
position: number;
|
|
points: number;
|
|
wins: number;
|
|
podiums: number;
|
|
races: number;
|
|
|
|
// UI additions
|
|
positionBadge: 'gold' | 'silver' | 'bronze' | 'default';
|
|
pointsGapToLeader: number;
|
|
pointsGapToNext: number;
|
|
isCurrentUser: boolean;
|
|
trend: 'up' | 'down' | 'stable';
|
|
trendArrow: '↑' | '↓' | '→';
|
|
}
|
|
|
|
/**
|
|
* Complete standings view model
|
|
*/
|
|
export interface LeagueStandingsViewModel {
|
|
standings: StandingEntryViewModel[];
|
|
totalEntries: number;
|
|
currentUserPosition?: number;
|
|
}
|
|
```
|
|
|
|
### Step 4.3: Race ViewModels
|
|
|
|
#### apps/website/lib/view-models/RaceResultViewModel.ts
|
|
```typescript
|
|
/**
|
|
* Individual race result view model
|
|
* Extends result data with UI-specific fields
|
|
*/
|
|
export interface RaceResultViewModel {
|
|
// From DTO
|
|
driverId: string;
|
|
driverName: string;
|
|
avatarUrl: string;
|
|
position: number;
|
|
startPosition: number;
|
|
incidents: number;
|
|
fastestLap: number;
|
|
|
|
// Computed UI fields
|
|
positionChange: number;
|
|
positionChangeDisplay: string; // "+3", "-2", "0"
|
|
positionChangeColor: 'green' | 'red' | 'gray';
|
|
|
|
// Status flags
|
|
isPodium: boolean;
|
|
isWinner: boolean;
|
|
isClean: boolean;
|
|
hasFastestLap: boolean;
|
|
|
|
// Display helpers
|
|
positionBadge: 'gold' | 'silver' | 'bronze' | 'default';
|
|
incidentsBadgeColor: 'green' | 'yellow' | 'red';
|
|
lapTimeFormatted: string; // "1:23.456"
|
|
}
|
|
```
|
|
|
|
#### apps/website/lib/view-models/RaceResultsDetailViewModel.ts
|
|
```typescript
|
|
import type { RaceResultViewModel } from './RaceResultViewModel';
|
|
|
|
/**
|
|
* Complete race results view model
|
|
* Includes statistics and sorted views
|
|
*/
|
|
export interface RaceResultsDetailViewModel {
|
|
raceId: string;
|
|
track: string;
|
|
results: RaceResultViewModel[];
|
|
|
|
// Statistics for display
|
|
stats: {
|
|
totalFinishers: number;
|
|
podiumFinishers: number;
|
|
cleanRaces: number;
|
|
averageIncidents: number;
|
|
fastestLapTime: number;
|
|
fastestLapDriver: string;
|
|
};
|
|
|
|
// Sorted views for different displays
|
|
resultsByPosition: RaceResultViewModel[];
|
|
resultsByFastestLap: RaceResultViewModel[];
|
|
cleanDrivers: RaceResultViewModel[];
|
|
|
|
// User-specific data
|
|
currentUserResult?: RaceResultViewModel;
|
|
currentUserHighlighted: boolean;
|
|
}
|
|
```
|
|
|
|
### Step 4.4: Driver ViewModels
|
|
|
|
#### apps/website/lib/view-models/DriverLeaderboardViewModel.ts
|
|
```typescript
|
|
/**
|
|
* Single leaderboard entry view model
|
|
*/
|
|
export interface DriverLeaderboardItemViewModel {
|
|
// From DTO
|
|
id: string;
|
|
name: string;
|
|
avatarUrl?: string;
|
|
rating: number;
|
|
wins: number;
|
|
races: number;
|
|
|
|
// UI additions
|
|
skillLevel: string;
|
|
skillLevelColor: string;
|
|
skillLevelIcon: string;
|
|
winRate: number;
|
|
winRateFormatted: string; // "45.2%"
|
|
ratingTrend: 'up' | 'down' | 'stable';
|
|
ratingChangeIndicator: string; // "+50", "-20"
|
|
position: number;
|
|
positionBadge: 'gold' | 'silver' | 'bronze' | 'default';
|
|
}
|
|
|
|
/**
|
|
* Complete leaderboard view model
|
|
*/
|
|
export interface DriversLeaderboardViewModel {
|
|
drivers: DriverLeaderboardItemViewModel[];
|
|
totalDrivers: number;
|
|
currentPage: number;
|
|
pageSize: number;
|
|
hasMore: boolean;
|
|
}
|
|
```
|
|
|
|
### Step 4.5: Barrel Export (apps/website/lib/view-models/index.ts)
|
|
|
|
```typescript
|
|
// League
|
|
export * from './LeagueSummaryViewModel';
|
|
export * from './LeagueStandingsViewModel';
|
|
export * from './LeagueMemberViewModel';
|
|
// ... all league ViewModels
|
|
|
|
// Race
|
|
export * from './RaceResultViewModel';
|
|
export * from './RaceResultsDetailViewModel';
|
|
export * from './RaceListItemViewModel';
|
|
// ... all race ViewModels
|
|
|
|
// Driver
|
|
export * from './DriverLeaderboardViewModel';
|
|
export * from './DriverRegistrationStatusViewModel';
|
|
// ... all driver ViewModels
|
|
|
|
// Team, etc.
|
|
```
|
|
|
|
**Total ViewModel Files**: ~30-40 files
|
|
|
|
## Phase 5: API Client Refactor (10+ Files)
|
|
|
|
### Step 5.1: Base API Client
|
|
|
|
#### apps/website/lib/api/base/BaseApiClient.ts
|
|
```typescript
|
|
/**
|
|
* Base HTTP client for all API communication
|
|
* Provides common request/response handling
|
|
*/
|
|
export class BaseApiClient {
|
|
private baseUrl: string;
|
|
|
|
constructor(baseUrl: string) {
|
|
this.baseUrl = baseUrl;
|
|
}
|
|
|
|
/**
|
|
* Generic request handler
|
|
* @param method HTTP method
|
|
* @param path API path
|
|
* @param data Request body (optional)
|
|
* @returns Response data
|
|
*/
|
|
protected async request<T>(
|
|
method: string,
|
|
path: string,
|
|
data?: object
|
|
): Promise<T> {
|
|
const headers: HeadersInit = {
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
const config: RequestInit = {
|
|
method,
|
|
headers,
|
|
credentials: 'include', // Include cookies for auth
|
|
};
|
|
|
|
if (data) {
|
|
config.body = JSON.stringify(data);
|
|
}
|
|
|
|
const response = await fetch(`${this.baseUrl}${path}`, config);
|
|
|
|
if (!response.ok) {
|
|
let errorData: { message?: string } = { message: response.statusText };
|
|
try {
|
|
errorData = await response.json();
|
|
} catch {
|
|
// Keep default error message
|
|
}
|
|
throw new Error(
|
|
errorData.message || `API request failed with status ${response.status}`
|
|
);
|
|
}
|
|
|
|
const text = await response.text();
|
|
if (!text) {
|
|
return null as T;
|
|
}
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
protected get<T>(path: string): Promise<T> {
|
|
return this.request<T>('GET', path);
|
|
}
|
|
|
|
protected post<T>(path: string, data: object): Promise<T> {
|
|
return this.request<T>('POST', path, data);
|
|
}
|
|
|
|
protected put<T>(path: string, data: object): Promise<T> {
|
|
return this.request<T>('PUT', path, data);
|
|
}
|
|
|
|
protected delete<T>(path: string): Promise<T> {
|
|
return this.request<T>('DELETE', path);
|
|
}
|
|
|
|
protected patch<T>(path: string, data: object): Promise<T> {
|
|
return this.request<T>('PATCH', path, data);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 5.2: Leagues API Client
|
|
|
|
#### apps/website/lib/api/leagues/LeaguesApiClient.ts
|
|
```typescript
|
|
import { BaseApiClient } from '../base/BaseApiClient';
|
|
import type {
|
|
LeagueSummaryDto,
|
|
LeagueStatsDto,
|
|
LeagueStandingsDto,
|
|
LeagueScheduleDto,
|
|
LeagueMembershipsDto,
|
|
CreateLeagueInputDto,
|
|
CreateLeagueOutputDto,
|
|
} from '../../dtos';
|
|
|
|
/**
|
|
* Leagues API client
|
|
* Handles all league-related HTTP operations
|
|
* Returns DTOs only - no UI transformation
|
|
*/
|
|
export class LeaguesApiClient extends BaseApiClient {
|
|
/**
|
|
* Get all leagues with capacity information
|
|
* @returns List of leagues with member counts
|
|
*/
|
|
async getAllWithCapacity(): Promise<{ leagues: LeagueSummaryDto[] }> {
|
|
return this.get<{ leagues: LeagueSummaryDto[] }>(
|
|
'/leagues/all-with-capacity'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get total number of leagues
|
|
* @returns League statistics
|
|
*/
|
|
async getTotal(): Promise<LeagueStatsDto> {
|
|
return this.get<LeagueStatsDto>('/leagues/total-leagues');
|
|
}
|
|
|
|
/**
|
|
* Get league standings
|
|
* @param leagueId League identifier
|
|
* @returns Current standings
|
|
*/
|
|
async getStandings(leagueId: string): Promise<LeagueStandingsDto> {
|
|
return this.get<LeagueStandingsDto>(`/leagues/${leagueId}/standings`);
|
|
}
|
|
|
|
/**
|
|
* Get league schedule
|
|
* @param leagueId League identifier
|
|
* @returns Scheduled races
|
|
*/
|
|
async getSchedule(leagueId: string): Promise<LeagueScheduleDto> {
|
|
return this.get<LeagueScheduleDto>(`/leagues/${leagueId}/schedule`);
|
|
}
|
|
|
|
/**
|
|
* Get league memberships
|
|
* @param leagueId League identifier
|
|
* @returns Current members
|
|
*/
|
|
async getMemberships(leagueId: string): Promise<LeagueMembershipsDto> {
|
|
return this.get<LeagueMembershipsDto>(`/leagues/${leagueId}/memberships`);
|
|
}
|
|
|
|
/**
|
|
* Create a new league
|
|
* @param input League creation data
|
|
* @returns Created league info
|
|
*/
|
|
async create(input: CreateLeagueInputDto): Promise<CreateLeagueOutputDto> {
|
|
return this.post<CreateLeagueOutputDto>('/leagues', input);
|
|
}
|
|
|
|
/**
|
|
* Remove a member from league
|
|
* @param leagueId League identifier
|
|
* @param performerDriverId Driver performing the action
|
|
* @param targetDriverId Driver to remove
|
|
* @returns Success status
|
|
*/
|
|
async removeMember(
|
|
leagueId: string,
|
|
performerDriverId: string,
|
|
targetDriverId: string
|
|
): Promise<{ success: boolean }> {
|
|
return this.patch<{ success: boolean }>(
|
|
`/leagues/${leagueId}/members/${targetDriverId}/remove`,
|
|
{ performerDriverId }
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 5.3: Races API Client
|
|
|
|
#### apps/website/lib/api/races/RacesApiClient.ts
|
|
```typescript
|
|
import { BaseApiClient } from '../base/BaseApiClient';
|
|
import type {
|
|
RaceStatsDto,
|
|
RacesPageDataDto,
|
|
RaceDetailDto,
|
|
RaceResultsDetailDto,
|
|
RaceWithSOFDto,
|
|
RegisterForRaceInputDto,
|
|
ImportRaceResultsInputDto,
|
|
ImportRaceResultsSummaryDto,
|
|
} from '../../dtos';
|
|
|
|
/**
|
|
* Races API client
|
|
* Handles all race-related HTTP operations
|
|
*/
|
|
export class RacesApiClient extends BaseApiClient {
|
|
/**
|
|
* Get total number of races
|
|
*/
|
|
async getTotal(): Promise<RaceStatsDto> {
|
|
return this.get<RaceStatsDto>('/races/total-races');
|
|
}
|
|
|
|
/**
|
|
* Get races page data
|
|
*/
|
|
async getPageData(): Promise<RacesPageDataDto> {
|
|
return this.get<RacesPageDataDto>('/races/page-data');
|
|
}
|
|
|
|
/**
|
|
* Get race detail
|
|
* @param raceId Race identifier
|
|
* @param driverId Driver identifier for personalization
|
|
*/
|
|
async getDetail(raceId: string, driverId: string): Promise<RaceDetailDto> {
|
|
return this.get<RaceDetailDto>(`/races/${raceId}?driverId=${driverId}`);
|
|
}
|
|
|
|
/**
|
|
* Get race results detail
|
|
* @param raceId Race identifier
|
|
*/
|
|
async getResultsDetail(raceId: string): Promise<RaceResultsDetailDto> {
|
|
return this.get<RaceResultsDetailDto>(`/races/${raceId}/results`);
|
|
}
|
|
|
|
/**
|
|
* Get race with strength of field
|
|
* @param raceId Race identifier
|
|
*/
|
|
async getWithSOF(raceId: string): Promise<RaceWithSOFDto> {
|
|
return this.get<RaceWithSOFDto>(`/races/${raceId}/sof`);
|
|
}
|
|
|
|
/**
|
|
* Register for race
|
|
* @param raceId Race identifier
|
|
* @param input Registration data
|
|
*/
|
|
async register(raceId: string, input: RegisterForRaceInputDto): Promise<void> {
|
|
return this.post<void>(`/races/${raceId}/register`, input);
|
|
}
|
|
|
|
/**
|
|
* Import race results
|
|
* @param raceId Race identifier
|
|
* @param input Results file content
|
|
*/
|
|
async importResults(
|
|
raceId: string,
|
|
input: ImportRaceResultsInputDto
|
|
): Promise<ImportRaceResultsSummaryDto> {
|
|
return this.post<ImportRaceResultsSummaryDto>(
|
|
`/races/${raceId}/import-results`,
|
|
input
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 5.4: Main API Client
|
|
|
|
#### apps/website/lib/api/index.ts
|
|
```typescript
|
|
import { LeaguesApiClient } from './leagues/LeaguesApiClient';
|
|
import { RacesApiClient } from './races/RacesApiClient';
|
|
import { DriversApiClient } from './drivers/DriversApiClient';
|
|
import { TeamsApiClient } from './teams/TeamsApiClient';
|
|
import { SponsorsApiClient } from './sponsors/SponsorsApiClient';
|
|
import { MediaApiClient } from './media/MediaApiClient';
|
|
import { AnalyticsApiClient } from './analytics/AnalyticsApiClient';
|
|
import { AuthApiClient } from './auth/AuthApiClient';
|
|
import { PaymentsApiClient } from './payments/PaymentsApiClient';
|
|
|
|
/**
|
|
* Main API client with domain-specific namespaces
|
|
* Single point of access for all HTTP operations
|
|
*/
|
|
export class ApiClient {
|
|
public readonly leagues: LeaguesApiClient;
|
|
public readonly races: RacesApiClient;
|
|
public readonly drivers: DriversApiClient;
|
|
public readonly teams: TeamsApiClient;
|
|
public readonly sponsors: SponsorsApiClient;
|
|
public readonly media: MediaApiClient;
|
|
public readonly analytics: AnalyticsApiClient;
|
|
public readonly auth: AuthApiClient;
|
|
public readonly payments: PaymentsApiClient;
|
|
|
|
constructor(baseUrl: string) {
|
|
this.leagues = new LeaguesApiClient(baseUrl);
|
|
this.races = new RacesApiClient(baseUrl);
|
|
this.drivers = new DriversApiClient(baseUrl);
|
|
this.teams = new TeamsApiClient(baseUrl);
|
|
this.sponsors = new SponsorsApiClient(baseUrl);
|
|
this.media = new MediaApiClient(baseUrl);
|
|
this.analytics = new AnalyticsApiClient(baseUrl);
|
|
this.auth = new AuthApiClient(baseUrl);
|
|
this.payments = new PaymentsApiClient(baseUrl);
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
|
export const api = new ApiClient(API_BASE_URL);
|
|
|
|
// Default export for convenience
|
|
export default api;
|
|
```
|
|
|
|
### Step 5.5: Legacy apiClient.ts Replacement
|
|
|
|
#### apps/website/lib/apiClient.ts
|
|
```typescript
|
|
/**
|
|
* Legacy API client re-export
|
|
* Maintained for backward compatibility during migration
|
|
* TODO: Remove this file once all imports are updated
|
|
*/
|
|
export { api as apiClient, api as default } from './api';
|
|
export type * from './dtos';
|
|
export type * from './view-models';
|
|
```
|
|
|
|
**Total API Files**: ~12 files (1 base + 9 domain + 1 main + 1 legacy)
|
|
|
|
## Phase 6: Create Presenters (20+ Files)
|
|
|
|
### Step 6.1: Understanding Presenter Pattern
|
|
|
|
**Presenter Responsibilities**:
|
|
1. Transform DTO → ViewModel
|
|
2. Compute derived fields
|
|
3. Format data for display
|
|
4. Apply UI-specific logic
|
|
|
|
**Presenter Rules**:
|
|
1. Pure functions (no side effects)
|
|
2. Deterministic (same input = same output)
|
|
3. No API calls
|
|
4. No state mutation
|
|
5. Testable in isolation
|
|
|
|
### Step 6.2: League Presenters
|
|
|
|
#### apps/website/lib/presenters/leagues/LeagueSummaryPresenter.ts
|
|
```typescript
|
|
import type { LeagueSummaryDto } from '../../dtos';
|
|
import type { LeagueSummaryViewModel } from '../../view-models';
|
|
|
|
/**
|
|
* League summary presenter
|
|
* Transforms league DTO into UI-ready view model
|
|
*/
|
|
export const presentLeagueSummary = (
|
|
dto: LeagueSummaryDto
|
|
): LeagueSummaryViewModel => {
|
|
const capacityPercent = (dto.memberCount / dto.maxMembers) * 100;
|
|
|
|
return {
|
|
...dto,
|
|
formattedCapacity: `${dto.memberCount}/${dto.maxMembers}`,
|
|
capacityBarPercent: Math.min(capacityPercent, 100),
|
|
joinButtonLabel: getJoinButtonLabel(dto),
|
|
isFull: dto.memberCount >= dto.maxMembers,
|
|
isJoinable: dto.isPublic && dto.memberCount < dto.maxMembers,
|
|
memberProgressColor: getMemberProgressColor(capacityPercent),
|
|
statusBadgeVariant: getStatusBadgeVariant(dto.status),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Determine join button label based on league state
|
|
*/
|
|
function getJoinButtonLabel(dto: LeagueSummaryDto): string {
|
|
if (dto.memberCount >= dto.maxMembers) return 'Full';
|
|
if (!dto.isPublic) return 'Private';
|
|
return 'Join League';
|
|
}
|
|
|
|
/**
|
|
* Determine progress bar color based on capacity
|
|
*/
|
|
function getMemberProgressColor(percent: number): 'green' | 'yellow' | 'red' {
|
|
if (percent < 70) return 'green';
|
|
if (percent < 90) return 'yellow';
|
|
return 'red';
|
|
}
|
|
|
|
/**
|
|
* Determine status badge variant
|
|
*/
|
|
function getStatusBadgeVariant(
|
|
status?: string
|
|
): 'success' | 'warning' | 'info' {
|
|
if (!status) return 'info';
|
|
if (status === 'active') return 'success';
|
|
if (status === 'pending') return 'warning';
|
|
return 'info';
|
|
}
|
|
|
|
/**
|
|
* Batch presenter for league lists
|
|
*/
|
|
export const presentLeagueSummaries = (
|
|
dtos: LeagueSummaryDto[]
|
|
): LeagueSummaryViewModel[] => {
|
|
return dtos.map(presentLeagueSummary);
|
|
};
|
|
```
|
|
|
|
#### apps/website/lib/presenters/leagues/LeagueStandingsPresenter.ts
|
|
```typescript
|
|
import type { StandingEntryDto, DriverDto } from '../../dtos';
|
|
import type {
|
|
StandingEntryViewModel,
|
|
LeagueStandingsViewModel,
|
|
} from '../../view-models';
|
|
|
|
/**
|
|
* Single standings entry presenter
|
|
*/
|
|
export const presentStandingEntry = (
|
|
dto: StandingEntryDto,
|
|
leaderPoints: number,
|
|
previousPoints: number,
|
|
isCurrentUser: boolean
|
|
): StandingEntryViewModel => {
|
|
return {
|
|
...dto,
|
|
positionBadge: getPositionBadge(dto.position),
|
|
pointsGapToLeader: leaderPoints - dto.points,
|
|
pointsGapToNext: previousPoints - dto.points,
|
|
isCurrentUser,
|
|
trend: getTrend(dto.position), // Would need historical data
|
|
trendArrow: getTrendArrow(dto.position),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Complete standings presenter
|
|
*/
|
|
export const presentLeagueStandings = (
|
|
standings: StandingEntryDto[],
|
|
currentUserId?: string
|
|
): LeagueStandingsViewModel => {
|
|
const sorted = [...standings].sort((a, b) => a.position - b.position);
|
|
const leaderPoints = sorted[0]?.points ?? 0;
|
|
|
|
const viewModels = sorted.map((entry, index) => {
|
|
const previousPoints = index > 0 ? sorted[index - 1].points : leaderPoints;
|
|
const isCurrentUser = entry.driverId === currentUserId;
|
|
return presentStandingEntry(entry, leaderPoints, previousPoints, isCurrentUser);
|
|
});
|
|
|
|
return {
|
|
standings: viewModels,
|
|
totalEntries: standings.length,
|
|
currentUserPosition: viewModels.find((s) => s.isCurrentUser)?.position,
|
|
};
|
|
};
|
|
|
|
function getPositionBadge(
|
|
position: number
|
|
): 'gold' | 'silver' | 'bronze' | 'default' {
|
|
if (position === 1) return 'gold';
|
|
if (position === 2) return 'silver';
|
|
if (position === 3) return 'bronze';
|
|
return 'default';
|
|
}
|
|
|
|
function getTrend(position: number): 'up' | 'down' | 'stable' {
|
|
// Placeholder - would need historical data
|
|
return 'stable';
|
|
}
|
|
|
|
function getTrendArrow(position: number): '↑' | '↓' | '→' {
|
|
const trend = getTrend(position);
|
|
if (trend === 'up') return '↑';
|
|
if (trend === 'down') return '↓';
|
|
return '→';
|
|
}
|
|
```
|
|
|
|
### Step 6.3: Race Presenters
|
|
|
|
#### apps/website/lib/presenters/races/RaceResultsPresenter.ts
|
|
```typescript
|
|
import type { RaceResultRowDto, RaceResultsDetailDto } from '../../dtos';
|
|
import type {
|
|
RaceResultViewModel,
|
|
RaceResultsDetailViewModel,
|
|
} from '../../view-models';
|
|
|
|
/**
|
|
* Single race result presenter
|
|
*/
|
|
export const presentRaceResult = (
|
|
dto: RaceResultRowDto,
|
|
fastestLapTime: number,
|
|
isCurrentUser: boolean
|
|
): RaceResultViewModel => {
|
|
const positionChange = dto.position - dto.startPosition;
|
|
|
|
return {
|
|
driverId: dto.driverId,
|
|
driverName: '', // Would be populated from driver data
|
|
avatarUrl: '',
|
|
position: dto.position,
|
|
startPosition: dto.startPosition,
|
|
incidents: dto.incidents,
|
|
fastestLap: dto.fastestLap,
|
|
|
|
// Computed fields
|
|
positionChange,
|
|
positionChangeDisplay: formatPositionChange(positionChange),
|
|
positionChangeColor: getPositionChangeColor(positionChange),
|
|
|
|
// Status flags
|
|
isPodium: dto.position <= 3,
|
|
isWinner: dto.position === 1,
|
|
isClean: dto.incidents === 0,
|
|
hasFastestLap: dto.fastestLap === fastestLapTime,
|
|
|
|
// Display helpers
|
|
positionBadge: getPositionBadge(dto.position),
|
|
incidentsBadgeColor: getIncidentsBadgeColor(dto.incidents),
|
|
lapTimeFormatted: formatLapTime(dto.fastestLap),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Complete race results presenter
|
|
*/
|
|
export const presentRaceResultsDetail = (
|
|
dto: RaceResultsDetailDto,
|
|
currentUserId?: string
|
|
): RaceResultsDetailViewModel => {
|
|
const fastestLapTime = Math.min(...dto.results.map((r) => r.fastestLap));
|
|
|
|
const results = dto.results.map((r) =>
|
|
presentRaceResult(r, fastestLapTime, r.driverId === currentUserId)
|
|
);
|
|
|
|
const sortedByPosition = [...results].sort((a, b) => a.position - b.position);
|
|
const sortedByFastestLap = [...results].sort((a, b) => a.fastestLap - b.fastestLap);
|
|
const cleanDrivers = results.filter((r) => r.isClean);
|
|
|
|
const currentUserResult = results.find((r) => r.driverId === currentUserId);
|
|
|
|
return {
|
|
raceId: dto.raceId,
|
|
track: dto.track,
|
|
results,
|
|
|
|
stats: {
|
|
totalFinishers: results.length,
|
|
podiumFinishers: results.filter((r) => r.isPodium).length,
|
|
cleanRaces: cleanDrivers.length,
|
|
averageIncidents:
|
|
results.reduce((sum, r) => sum + r.incidents, 0) / results.length,
|
|
fastestLapTime,
|
|
fastestLapDriver:
|
|
sortedByFastestLap[0]?.driverName ?? 'Unknown',
|
|
},
|
|
|
|
resultsByPosition: sortedByPosition,
|
|
resultsByFastestLap: sortedByFastestLap,
|
|
cleanDrivers,
|
|
|
|
currentUserResult,
|
|
currentUserHighlighted: !!currentUserResult,
|
|
};
|
|
};
|
|
|
|
function formatPositionChange(change: number): string {
|
|
if (change > 0) return `+${change}`;
|
|
return change.toString();
|
|
}
|
|
|
|
function getPositionChangeColor(
|
|
change: number
|
|
): 'green' | 'red' | 'gray' {
|
|
if (change > 0) return 'green';
|
|
if (change < 0) return 'red';
|
|
return 'gray';
|
|
}
|
|
|
|
function getPositionBadge(
|
|
position: number
|
|
): 'gold' | 'silver' | 'bronze' | 'default' {
|
|
if (position === 1) return 'gold';
|
|
if (position === 2) return 'silver';
|
|
if (position === 3) return 'bronze';
|
|
return 'default';
|
|
}
|
|
|
|
function getIncidentsBadgeColor(
|
|
incidents: number
|
|
): 'green' | 'yellow' | 'red' {
|
|
if (incidents === 0) return 'green';
|
|
if (incidents <= 4) return 'yellow';
|
|
return 'red';
|
|
}
|
|
|
|
function formatLapTime(milliseconds: number): string {
|
|
const minutes = Math.floor(milliseconds / 60000);
|
|
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
|
const ms = milliseconds % 1000;
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
|
|
}
|
|
```
|
|
|
|
### Step 6.4: Driver Presenters
|
|
|
|
#### apps/website/lib/presenters/drivers/DriverLeaderboardPresenter.ts
|
|
```typescript
|
|
import type { DriverLeaderboardItemDto } from '../../dtos';
|
|
import type {
|
|
DriverLeaderboardItemViewModel,
|
|
DriversLeaderboardViewModel,
|
|
} from '../../view-models';
|
|
|
|
/**
|
|
* Single leaderboard entry presenter
|
|
*/
|
|
export const presentDriverLeaderboardItem = (
|
|
dto: DriverLeaderboardItemDto,
|
|
position: number
|
|
): DriverLeaderboardItemViewModel => {
|
|
const winRate = dto.races > 0 ? (dto.wins / dto.races) * 100 : 0;
|
|
|
|
return {
|
|
...dto,
|
|
skillLevel: getSkillLevel(dto.rating),
|
|
skillLevelColor: getSkillLevelColor(dto.rating),
|
|
skillLevelIcon: getSkillLevelIcon(dto.rating),
|
|
winRate,
|
|
winRateFormatted: `${winRate.toFixed(1)}%`,
|
|
ratingTrend: 'stable', // Would need historical data
|
|
ratingChangeIndicator: '+0', // Would need historical data
|
|
position,
|
|
positionBadge: getPositionBadge(position),
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Complete leaderboard presenter
|
|
*/
|
|
export const presentDriversLeaderboard = (
|
|
dtos: DriverLeaderboardItemDto[],
|
|
page: number = 1,
|
|
pageSize: number = 50
|
|
): DriversLeaderboardViewModel => {
|
|
const sorted = [...dtos].sort((a, b) => b.rating - a.rating);
|
|
const drivers = sorted.map((dto, index) =>
|
|
presentDriverLeaderboardItem(dto, index + 1)
|
|
);
|
|
|
|
return {
|
|
drivers,
|
|
totalDrivers: dtos.length,
|
|
currentPage: page,
|
|
pageSize,
|
|
hasMore: false, // Would be based on actual pagination
|
|
};
|
|
};
|
|
|
|
function getSkillLevel(rating: number): string {
|
|
if (rating >= 2000) return 'Pro';
|
|
if (rating >= 1500) return 'Advanced';
|
|
if (rating >= 1000) return 'Intermediate';
|
|
return 'Rookie';
|
|
}
|
|
|
|
function getSkillLevelColor(rating: number): string {
|
|
if (rating >= 2000) return 'purple';
|
|
if (rating >= 1500) return 'blue';
|
|
if (rating >= 1000) return 'green';
|
|
return 'gray';
|
|
}
|
|
|
|
function getSkillLevelIcon(rating: number): string {
|
|
if (rating >= 2000) return '⭐';
|
|
if (rating >= 1500) return '🔷';
|
|
if (rating >= 1000) return '🟢';
|
|
return '⚪';
|
|
}
|
|
|
|
function getPositionBadge(
|
|
position: number
|
|
): 'gold' | 'silver' | 'bronze' | 'default' {
|
|
if (position === 1) return 'gold';
|
|
if (position === 2) return 'silver';
|
|
if (position === 3) return 'bronze';
|
|
return 'default';
|
|
}
|
|
```
|
|
|
|
### Step 6.5: Barrel Export (apps/website/lib/presenters/index.ts)
|
|
|
|
```typescript
|
|
// Leagues
|
|
export * from './leagues/LeagueSummaryPresenter';
|
|
export * from './leagues/LeagueStandingsPresenter';
|
|
|
|
// Races
|
|
export * from './races/RaceResultsPresenter';
|
|
|
|
// Drivers
|
|
export * from './drivers/DriverLeaderboardPresenter';
|
|
|
|
// Teams, etc.
|
|
```
|
|
|
|
**Total Presenter Files**: ~20-25 files
|
|
|
|
## Phase 7: Create Services (15+ Files)
|
|
|
|
### Step 7.1: Understanding Service Pattern
|
|
|
|
**Service Responsibilities**:
|
|
1. Orchestrate API calls
|
|
2. Call presenters for transformation
|
|
3. Combine multiple data sources
|
|
4. Return ViewModels to UI
|
|
5. Handle errors appropriately
|
|
|
|
**Service Rules**:
|
|
1. May call multiple API endpoints
|
|
2. Must use presenters for DTO→ViewModel
|
|
3. Return ViewModels only (never DTOs)
|
|
4. May have async operations
|
|
5. May throw/handle errors
|
|
|
|
### Step 7.2: Race Services
|
|
|
|
#### apps/website/lib/services/races/RaceResultsService.ts
|
|
```typescript
|
|
import { api } from '../../api';
|
|
import { presentRaceResultsDetail } from '../../presenters/races/RaceResultsPresenter';
|
|
import type { RaceResultsDetailViewModel } from '../../view-models';
|
|
|
|
/**
|
|
* Get race results with full view model
|
|
* @param raceId Race identifier
|
|
* @param currentUserId Optional current user for highlighting
|
|
* @returns Complete race results view model
|
|
*/
|
|
export async function getRaceResults(
|
|
raceId: string,
|
|
currentUserId?: string
|
|
): Promise<RaceResultsDetailViewModel> {
|
|
const dto = await api.races.getResultsDetail(raceId);
|
|
return presentRaceResultsDetail(dto, currentUserId);
|
|
}
|
|
|
|
/**
|
|
* Get race strength of field
|
|
* @param raceId Race identifier
|
|
* @returns SOF value
|
|
*/
|
|
export async function getRaceSOF(raceId: string): Promise<number> {
|
|
const dto = await api.races.getWithSOF(raceId);
|
|
return dto.strengthOfField ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Import race results and refresh
|
|
* @param raceId Race identifier
|
|
* @param fileContent Results file content
|
|
* @returns Import summary
|
|
*/
|
|
export async function importRaceResults(
|
|
raceId: string,
|
|
fileContent: string
|
|
): Promise<{ success: boolean; message: string }> {
|
|
try {
|
|
const summary = await api.races.importResults(raceId, {
|
|
resultsFileContent: fileContent,
|
|
});
|
|
|
|
return {
|
|
success: summary.success,
|
|
message: `Imported ${summary.resultsRecorded} results for ${summary.driversProcessed} drivers`,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
message: error instanceof Error ? error.message : 'Import failed',
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 7.3: League Services
|
|
|
|
#### apps/website/lib/services/leagues/LeagueService.ts
|
|
```typescript
|
|
import { api } from '../../api';
|
|
import {
|
|
presentLeagueSummaries,
|
|
presentLeagueStandings,
|
|
} from '../../presenters';
|
|
import type {
|
|
LeagueSummaryViewModel,
|
|
LeagueStandingsViewModel,
|
|
} from '../../view-models';
|
|
import type { CreateLeagueInputDto } from '../../dtos';
|
|
|
|
/**
|
|
* Get all leagues with UI-ready data
|
|
* @returns List of league view models
|
|
*/
|
|
export async function getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
|
|
const dto = await api.leagues.getAllWithCapacity();
|
|
return presentLeagueSummaries(dto.leagues);
|
|
}
|
|
|
|
/**
|
|
* Get league standings with computed data
|
|
* @param leagueId League identifier
|
|
* @param currentUserId Optional current user for highlighting
|
|
* @returns Standings view model
|
|
*/
|
|
export async function getLeagueStandings(
|
|
leagueId: string,
|
|
currentUserId?: string
|
|
): Promise<LeagueStandingsViewModel> {
|
|
const dto = await api.leagues.getStandings(leagueId);
|
|
return presentLeagueStandings(dto.standings, currentUserId);
|
|
}
|
|
|
|
/**
|
|
* Create a new league
|
|
* @param input League creation data
|
|
* @returns Created league ID
|
|
*/
|
|
export async function createLeague(
|
|
input: Omit<CreateLeagueInputDto, 'ownerId'>,
|
|
ownerId: string
|
|
): Promise<string> {
|
|
const result = await api.leagues.create({ ...input, ownerId });
|
|
|
|
if (!result.success) {
|
|
throw new Error('Failed to create league');
|
|
}
|
|
|
|
return result.leagueId;
|
|
}
|
|
|
|
/**
|
|
* Get complete league admin view
|
|
* Combines multiple API calls
|
|
*/
|
|
export async function getLeagueAdminView(
|
|
leagueId: string,
|
|
performerId: string
|
|
) {
|
|
const [config, members, standings, schedule] = await Promise.all([
|
|
api.leagues.getConfig(leagueId),
|
|
api.leagues.getMemberships(leagueId),
|
|
api.leagues.getStandings(leagueId),
|
|
api.leagues.getSchedule(leagueId),
|
|
]);
|
|
|
|
return {
|
|
config,
|
|
members: members.members,
|
|
standings: presentLeagueStandings(standings.standings, performerId),
|
|
schedule: schedule.races,
|
|
};
|
|
}
|
|
```
|
|
|
|
### Step 7.4: Driver Services
|
|
|
|
#### apps/website/lib/services/drivers/DriverService.ts
|
|
```typescript
|
|
import { api } from '../../api';
|
|
import { presentDriversLeaderboard } from '../../presenters';
|
|
import type { DriversLeaderboardViewModel } from '../../view-models';
|
|
import type { CompleteOnboardingInputDto } from '../../dtos';
|
|
|
|
/**
|
|
* Get driver leaderboard with computed rankings
|
|
* @returns Leaderboard view model
|
|
*/
|
|
export async function getDriversLeaderboard(): Promise<DriversLeaderboardViewModel> {
|
|
const dto = await api.drivers.getLeaderboard();
|
|
return presentDriversLeaderboard(dto.drivers);
|
|
}
|
|
|
|
/**
|
|
* Complete driver onboarding
|
|
* @param iracingId iRacing ID
|
|
* @param displayName Display name
|
|
* @returns New driver ID
|
|
*/
|
|
export async function completeDriverOnboarding(
|
|
iracingId: string,
|
|
displayName: string
|
|
): Promise<string> {
|
|
const input: CompleteOnboardingInputDto = { iracingId, displayName };
|
|
const result = await api.drivers.completeOnboarding(input);
|
|
|
|
if (!result.success) {
|
|
throw new Error('Failed to complete onboarding');
|
|
}
|
|
|
|
return result.driverId;
|
|
}
|
|
|
|
/**
|
|
* Get current driver info
|
|
* @returns Current driver or null
|
|
*/
|
|
export async function getCurrentDriver() {
|
|
return api.drivers.getCurrent();
|
|
}
|
|
```
|
|
|
|
### Step 7.5: Barrel Export (apps/website/lib/services/index.ts)
|
|
|
|
```typescript
|
|
// Races
|
|
export * from './races/RaceResultsService';
|
|
|
|
// Leagues
|
|
export * from './leagues/LeagueService';
|
|
|
|
// Drivers
|
|
export * from './drivers/DriverService';
|
|
|
|
// Teams, etc.
|
|
```
|
|
|
|
**Total Service Files**: ~15-20 files
|
|
|
|
## Phase 8: Update Pages (All app/ Pages)
|
|
|
|
### Step 8.1: Update races/[id]/results/page.tsx
|
|
|
|
**Before (lines 1-300)**:
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { apiClient } from '@/lib/apiClient';
|
|
import type { RaceResultsDetailViewModel } from '@/lib/apiClient';
|
|
|
|
// Inline DTOs (DELETE THESE)
|
|
type PenaltyTypeDTO = 'time_penalty' | 'grid_penalty' | ...;
|
|
interface PenaltyData { ... }
|
|
interface RaceResultRowDTO { ... }
|
|
|
|
export default function RaceResultsPage() {
|
|
const [raceData, setRaceData] = useState<RaceResultsDetailViewModel | null>(null);
|
|
|
|
const loadData = async () => {
|
|
const data = await apiClient.races.getResultsDetail(raceId);
|
|
setRaceData(data);
|
|
};
|
|
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**After**:
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { getRaceResults, getRaceSOF } from '@/lib/services/races/RaceResultsService';
|
|
import type { RaceResultsDetailViewModel } from '@/lib/view-models';
|
|
|
|
// No inline DTOs!
|
|
|
|
export default function RaceResultsPage() {
|
|
const [raceData, setRaceData] = useState<RaceResultsDetailViewModel | null>(null);
|
|
const [raceSOF, setRaceSOF] = useState<number | null>(null);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
// Use service, not apiClient
|
|
const data = await getRaceResults(raceId, currentDriverId);
|
|
setRaceData(data);
|
|
|
|
const sof = await getRaceSOF(raceId);
|
|
setRaceSOF(sof);
|
|
} catch (error) {
|
|
setError(error instanceof Error ? error.message : 'Failed to load data');
|
|
}
|
|
};
|
|
|
|
// Component now uses ViewModel fields:
|
|
// - raceData.stats.totalFinishers
|
|
// - raceData.stats.podiumFinishers
|
|
// - raceData.currentUserResult?.positionBadge
|
|
// - raceData.resultsByPosition
|
|
}
|
|
```
|
|
|
|
### Step 8.2: Update leagues/[id]/standings/page.tsx
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { getLeagueStandings } from '@/lib/services/leagues/LeagueService';
|
|
import type { LeagueStandingsViewModel } from '@/lib/view-models';
|
|
|
|
export default function LeagueStandingsPage() {
|
|
const [standings, setStandings] = useState<LeagueStandingsViewModel | null>(null);
|
|
|
|
useEffect(() => {
|
|
async function loadStandings() {
|
|
const data = await getLeagueStandings(leagueId, currentUserId);
|
|
setStandings(data);
|
|
}
|
|
loadStandings();
|
|
}, [leagueId, currentUserId]);
|
|
|
|
return (
|
|
<div>
|
|
{standings?.standings.map((entry) => (
|
|
<div key={entry.driverId}>
|
|
<span>{entry.positionBadge}</span>
|
|
<span>{entry.driver?.name}</span>
|
|
<span>{entry.points}</span>
|
|
<span>{entry.trendArrow}</span>
|
|
<span>{entry.pointsGapToLeader} behind</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Step 8.3: Update drivers/leaderboard/page.tsx
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { getDriversLeaderboard } from '@/lib/services/drivers/DriverService';
|
|
import type { DriversLeaderboardViewModel } from '@/lib/view-models';
|
|
|
|
export default function DriversLeaderboardPage() {
|
|
const [leaderboard, setLeaderboard] = useState<DriversLeaderboardViewModel | null>(null);
|
|
|
|
useEffect(() => {
|
|
async function loadLeaderboard() {
|
|
const data = await getDriversLeaderboard();
|
|
setLeaderboard(data);
|
|
}
|
|
loadLeaderboard();
|
|
}, []);
|
|
|
|
return (
|
|
<div>
|
|
{leaderboard?.drivers.map((driver) => (
|
|
<div key={driver.id}>
|
|
<span>{driver.position}</span>
|
|
<span>{driver.positionBadge}</span>
|
|
<span>{driver.name}</span>
|
|
<span style={{ color: driver.skillLevelColor }}>
|
|
{driver.skillLevel} {driver.skillLevelIcon}
|
|
</span>
|
|
<span>{driver.rating}</span>
|
|
<span>{driver.winRateFormatted}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Step 8.4: Search & Replace Pattern
|
|
|
|
```bash
|
|
# Find all apiClient direct imports
|
|
grep -r "from '@/lib/apiClient'" apps/website/app/
|
|
|
|
# Find all pages with inline type definitions
|
|
grep -r "^type \|^interface " apps/website/app/**/*.tsx
|
|
|
|
# Replace pattern (manual review required):
|
|
# 1. Import from services, not apiClient
|
|
# 2. Import types from view-models, not dtos
|
|
# 3. Remove inline types
|
|
# 4. Use service functions
|
|
# 5. Use ViewModel fields in JSX
|
|
```
|
|
|
|
**Pages to Update** (estimated 15-20):
|
|
- races/page.tsx
|
|
- races/[id]/page.tsx
|
|
- races/[id]/results/page.tsx
|
|
- leagues/page.tsx
|
|
- leagues/[id]/page.tsx
|
|
- leagues/[id]/standings/page.tsx
|
|
- leagues/[id]/members/page.tsx
|
|
- drivers/leaderboard/page.tsx
|
|
- teams/page.tsx
|
|
- teams/[id]/page.tsx
|
|
- onboarding/page.tsx
|
|
- dashboard/page.tsx
|
|
- profile/settings/page.tsx
|
|
|
|
## Phase 9: Barrels & Naming Enforcement
|
|
|
|
### Step 9.1: Final Barrel Exports
|
|
|
|
All index.ts files should follow this pattern:
|
|
|
|
```typescript
|
|
// apps/website/lib/dtos/index.ts
|
|
// Export all DTOs alphabetically by domain
|
|
|
|
// Common
|
|
export * from './DriverDto';
|
|
export * from './PenaltyDataDto';
|
|
export * from './PenaltyTypeDto';
|
|
|
|
// Analytics
|
|
export * from './RecordEngagementInputDto';
|
|
export * from './RecordEngagementOutputDto';
|
|
export * from './RecordPageViewInputDto';
|
|
export * from './RecordPageViewOutputDto';
|
|
|
|
// Auth
|
|
export * from './LoginParamsDto';
|
|
export * from './SessionDataDto';
|
|
export * from './SignupParamsDto';
|
|
|
|
// (Continue for all domains...)
|
|
```
|
|
|
|
### Step 9.2: Naming Convention Audit
|
|
|
|
**Checklist**:
|
|
- [ ] All DTO files end with `Dto.ts`
|
|
- [ ] All ViewModel files end with `ViewModel.ts`
|
|
- [ ] All Presenter files end with `Presenter.ts`
|
|
- [ ] All Service files end with `Service.ts`
|
|
- [ ] All files are PascalCase
|
|
- [ ] All exports match filename
|
|
- [ ] One export per file
|
|
|
|
**Automated Check**:
|
|
```bash
|
|
# Find files not following naming convention
|
|
find apps/website/lib/dtos -type f ! -name "*Dto.ts" ! -name "index.ts"
|
|
find apps/website/lib/view-models -type f ! -name "*ViewModel.ts" ! -name "index.ts"
|
|
find apps/website/lib/presenters -type f ! -name "*Presenter.ts" ! -name "index.ts"
|
|
find apps/website/lib/services -type f ! -name "*Service.ts" ! -name "index.ts"
|
|
```
|
|
|
|
## Phase 10: Enforcement & Validation
|
|
|
|
### Step 10.1: ESLint Rules
|
|
|
|
#### .eslintrc.json additions
|
|
```json
|
|
{
|
|
"rules": {
|
|
"no-restricted-imports": [
|
|
"error",
|
|
{
|
|
"patterns": [
|
|
{
|
|
"group": ["**/apiClient"],
|
|
"message": "Import from specific services instead of apiClient"
|
|
},
|
|
{
|
|
"group": ["**/dtos"],
|
|
"message": "UI components should not import DTOs directly. Use ViewModels instead."
|
|
},
|
|
{
|
|
"group": ["**/api/*"],
|
|
"message": "UI components should use services, not API clients directly"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 10.2: TypeScript Path Mappings
|
|
|
|
#### tsconfig.json additions
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"paths": {
|
|
"@/lib/dtos": ["./apps/website/lib/dtos"],
|
|
"@/lib/view-models": ["./apps/website/lib/view-models"],
|
|
"@/lib/presenters": ["./apps/website/lib/presenters"],
|
|
"@/lib/services": ["./apps/website/lib/services"],
|
|
"@/lib/api": ["./apps/website/lib/api"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 10.3: DATA_FLOW.md Mermaid Diagram
|
|
|
|
Add to DATA_FLOW.md:
|
|
|
|
```markdown
|
|
## Architecture Diagram
|
|
|
|
```mermaid
|
|
graph TD
|
|
UI[UI Components/Pages] --> Services[Services Layer]
|
|
Services --> API[API Clients]
|
|
Services --> Presenters[Presenters Layer]
|
|
API --> DTOs[DTOs Transport]
|
|
Presenters --> DTOs
|
|
Presenters --> ViewModels[ViewModels UI]
|
|
Services --> ViewModels
|
|
UI --> ViewModels
|
|
|
|
style UI fill:#e1f5ff
|
|
style Services fill:#fff4e1
|
|
style Presenters fill:#f0e1ff
|
|
style API fill:#e1ffe1
|
|
style DTOs fill:#ffe1e1
|
|
style ViewModels fill:#e1f5ff
|
|
```
|
|
|
|
**Dependency Rules**:
|
|
- ✅ UI → Services → (API + Presenters) → (DTOs + ViewModels)
|
|
- ❌ UI ↛ API
|
|
- ❌ UI ↛ DTOs
|
|
- ❌ Presenters ↛ API
|
|
- ❌ API ↛ ViewModels
|
|
```
|
|
|
|
### Step 10.4: Testing
|
|
|
|
#### Unit Test Example: Presenter
|
|
```typescript
|
|
// apps/website/lib/presenters/races/RaceResultsPresenter.test.ts
|
|
import { presentRaceResult } from './RaceResultsPresenter';
|
|
import type { RaceResultRowDto } from '../../dtos';
|
|
|
|
describe('presentRaceResult', () => {
|
|
it('should compute position change correctly', () => {
|
|
const dto: RaceResultRowDto = {
|
|
id: '1',
|
|
raceId: 'race-1',
|
|
driverId: 'driver-1',
|
|
position: 3,
|
|
startPosition: 8,
|
|
fastestLap: 90000,
|
|
incidents: 0,
|
|
};
|
|
|
|
const result = presentRaceResult(dto, 89000, false);
|
|
|
|
expect(result.positionChange).toBe(-5);
|
|
expect(result.positionChangeDisplay).toBe('+5');
|
|
expect(result.positionChangeColor).toBe('green');
|
|
});
|
|
|
|
it('should identify podium finishes', () => {
|
|
const dto: RaceResultRowDto = {
|
|
id: '1',
|
|
raceId: 'race-1',
|
|
driverId: 'driver-1',
|
|
position: 2,
|
|
startPosition: 2,
|
|
fastestLap: 90000,
|
|
incidents: 0,
|
|
};
|
|
|
|
const result = presentRaceResult(dto, 89000, false);
|
|
|
|
expect(result.isPodium).toBe(true);
|
|
expect(result.positionBadge).toBe('silver');
|
|
});
|
|
});
|
|
```
|
|
|
|
#### Integration Test Example: Service
|
|
```typescript
|
|
// apps/website/lib/services/races/RaceResultsService.test.ts
|
|
import { getRaceResults } from './RaceResultsService';
|
|
import { api } from '../../api';
|
|
|
|
jest.mock('../../api');
|
|
|
|
describe('getRaceResults', () => {
|
|
it('should return view model with computed fields', async () => {
|
|
const mockDto = {
|
|
raceId: 'race-1',
|
|
track: 'Spa',
|
|
results: [
|
|
{
|
|
id: '1',
|
|
raceId: 'race-1',
|
|
driverId: 'driver-1',
|
|
position: 1,
|
|
startPosition: 3,
|
|
fastestLap: 89000,
|
|
incidents: 0,
|
|
},
|
|
],
|
|
};
|
|
|
|
(api.races.getResultsDetail as jest.Mock).mockResolvedValue(mockDto);
|
|
|
|
const result = await getRaceResults('race-1');
|
|
|
|
expect(result.stats.totalFinishers).toBe(1);
|
|
expect(result.stats.podiumFinishers).toBe(1);
|
|
expect(result.resultsByPosition).toHaveLength(1);
|
|
expect(result.resultsByPosition[0].positionBadge).toBe('gold');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Step 10.5: Verification Checklist
|
|
|
|
**Final Checklist**:
|
|
- [ ] All 60+ DTO files created
|
|
- [ ] All 30+ ViewModel files created
|
|
- [ ] All 10+ API client files created
|
|
- [ ] All 20+ Presenter files created
|
|
- [ ] All 15+ Service files created
|
|
- [ ] All pages updated to use services
|
|
- [ ] No inline DTOs in pages
|
|
- [ ] All barrel exports complete
|
|
- [ ] ESLint rules enforced
|
|
- [ ] TypeScript compiles
|
|
- [ ] All tests passing
|
|
- [ ] DATA_FLOW.md updated
|
|
- [ ] Documentation complete
|
|
- [ ] Original apiClient.ts marked deprecated
|
|
|
|
**Build Verification**:
|
|
```bash
|
|
# Ensure clean build
|
|
npm run build
|
|
|
|
# Run all tests
|
|
npm run test
|
|
|
|
# ESLint check
|
|
npm run lint
|
|
|
|
# Type check
|
|
npm run type-check
|
|
```
|
|
|
|
## Summary
|
|
|
|
**Total Changes**:
|
|
- **Files Created**: ~150
|
|
- **Files Modified**: ~20 pages
|
|
- **Files Deleted**: None (apiClient.ts kept for compatibility)
|
|
- **Lines of Code**: +8000, -1160 (apiClient.ts)
|
|
- **apiClient.ts Size Reduction**: 95%
|
|
- **Architecture Compliance**: 100%
|
|
|
|
**Benefits**:
|
|
1. ✅ Strict layer separation
|
|
2. ✅ No inline DTOs
|
|
3. ✅ Reusable ViewModels
|
|
4. ✅ Testable presenters
|
|
5. ✅ Clear data flow
|
|
6. ✅ Maintainable structure
|
|
7. ✅ Type safety
|
|
8. ✅ Enforced conventions |