55 KiB
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 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:
- Type Name (current)
- Line Number Range
- Classification (DTO/ViewModel/Input/Output)
- Target Location
- Dependencies
- Used By (pages/components)
Complete List of 80+ Types:
Common/Shared Types (Lines 13-54)
// 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
// 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:
- Scan all files in
apps/website/app/for inline type/interface definitions - Create extraction plan for each
- Document dependencies and usage
Phase 2: Directory Structure Creation
Step 2.1: Create Base Directories
# 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
// 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
/**
* 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
/**
* 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
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
/**
* League statistics transport object
*/
export interface LeagueStatsDto {
totalLeagues: number;
}
apps/website/lib/dtos/LeagueSummaryDto.ts
/**
* 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
/**
* 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
/**
* 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
/**
* Race statistics transport object
*/
export interface RaceStatsDto {
totalRaces: number;
}
apps/website/lib/dtos/RaceResultRowDto.ts
/**
* 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
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
/**
* Register for race input
*/
export interface RegisterForRaceInputDto {
leagueId: string;
driverId: string;
}
Step 3.4: Driver DTOs
apps/website/lib/dtos/DriverStatsDto.ts
/**
* Driver statistics transport object
*/
export interface DriverStatsDto {
totalDrivers: number;
}
apps/website/lib/dtos/CompleteOnboardingInputDto.ts
/**
* Complete onboarding input
*/
export interface CompleteOnboardingInputDto {
iracingId: string;
displayName: string;
}
apps/website/lib/dtos/CompleteOnboardingOutputDto.ts
/**
* Complete onboarding output
*/
export interface CompleteOnboardingOutputDto {
driverId: string;
success: boolean;
}
Step 3.5: Barrel Export (apps/website/lib/dtos/index.ts)
// 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:
- UI-specific derived fields
- Computed properties
- Display formatting
- UI state indicators
- Grouped/sorted data for rendering
What ViewModels DO NOT Have:
- Business logic
- Validation rules
- API calls
- Side effects
Step 4.2: League ViewModels
apps/website/lib/view-models/LeagueSummaryViewModel.ts
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
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
/**
* 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
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
/**
* 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)
// 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
/**
* 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
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
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
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
/**
* 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:
- Transform DTO → ViewModel
- Compute derived fields
- Format data for display
- Apply UI-specific logic
Presenter Rules:
- Pure functions (no side effects)
- Deterministic (same input = same output)
- No API calls
- No state mutation
- Testable in isolation
Step 6.2: League Presenters
apps/website/lib/presenters/leagues/LeagueSummaryPresenter.ts
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
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
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
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)
// 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:
- Orchestrate API calls
- Call presenters for transformation
- Combine multiple data sources
- Return ViewModels to UI
- Handle errors appropriately
Service Rules:
- May call multiple API endpoints
- Must use presenters for DTO→ViewModel
- Return ViewModels only (never DTOs)
- May have async operations
- May throw/handle errors
Step 7.2: Race Services
apps/website/lib/services/races/RaceResultsService.ts
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
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
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)
// 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):
'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:
'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
'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
'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
# 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:
// 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:
# 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
{
"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
{
"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:
## 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
// 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:
# 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:
- ✅ Strict layer separation
- ✅ No inline DTOs
- ✅ Reusable ViewModels
- ✅ Testable presenters
- ✅ Clear data flow
- ✅ Maintainable structure
- ✅ Type safety
- ✅ Enforced conventions