# 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( method: string, path: string, data?: object ): Promise { 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(path: string): Promise { return this.request('GET', path); } protected post(path: string, data: object): Promise { return this.request('POST', path, data); } protected put(path: string, data: object): Promise { return this.request('PUT', path, data); } protected delete(path: string): Promise { return this.request('DELETE', path); } protected patch(path: string, data: object): Promise { return this.request('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 { return this.get('/leagues/total-leagues'); } /** * Get league standings * @param leagueId League identifier * @returns Current standings */ async getStandings(leagueId: string): Promise { return this.get(`/leagues/${leagueId}/standings`); } /** * Get league schedule * @param leagueId League identifier * @returns Scheduled races */ async getSchedule(leagueId: string): Promise { return this.get(`/leagues/${leagueId}/schedule`); } /** * Get league memberships * @param leagueId League identifier * @returns Current members */ async getMemberships(leagueId: string): Promise { return this.get(`/leagues/${leagueId}/memberships`); } /** * Create a new league * @param input League creation data * @returns Created league info */ async create(input: CreateLeagueInputDto): Promise { return this.post('/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 { return this.get('/races/total-races'); } /** * Get races page data */ async getPageData(): Promise { return this.get('/races/page-data'); } /** * Get race detail * @param raceId Race identifier * @param driverId Driver identifier for personalization */ async getDetail(raceId: string, driverId: string): Promise { return this.get(`/races/${raceId}?driverId=${driverId}`); } /** * Get race results detail * @param raceId Race identifier */ async getResultsDetail(raceId: string): Promise { return this.get(`/races/${raceId}/results`); } /** * Get race with strength of field * @param raceId Race identifier */ async getWithSOF(raceId: string): Promise { return this.get(`/races/${raceId}/sof`); } /** * Register for race * @param raceId Race identifier * @param input Registration data */ async register(raceId: string, input: RegisterForRaceInputDto): Promise { return this.post(`/races/${raceId}/register`, input); } /** * Import race results * @param raceId Race identifier * @param input Results file content */ async importResults( raceId: string, input: ImportRaceResultsInputDto ): Promise { return this.post( `/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 { 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 { 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 { 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 { 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, ownerId: string ): Promise { 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 { 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 { 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(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(null); const [raceSOF, setRaceSOF] = useState(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(null); useEffect(() => { async function loadStandings() { const data = await getLeagueStandings(leagueId, currentUserId); setStandings(data); } loadStandings(); }, [leagueId, currentUserId]); return (
{standings?.standings.map((entry) => (
{entry.positionBadge} {entry.driver?.name} {entry.points} {entry.trendArrow} {entry.pointsGapToLeader} behind
))}
); } ``` ### 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(null); useEffect(() => { async function loadLeaderboard() { const data = await getDriversLeaderboard(); setLeaderboard(data); } loadLeaderboard(); }, []); return (
{leaderboard?.drivers.map((driver) => (
{driver.position} {driver.positionBadge} {driver.name} {driver.skillLevel} {driver.skillLevelIcon} {driver.rating} {driver.winRateFormatted}
))}
); } ``` ### 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