Files
gridpilot.gg/plans/DTO-Refactor-Super-Detailed-Plan.md
2025-12-17 18:01:47 +01:00

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:

  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)

// 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:

  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

# 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:

  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

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:

  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

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:

  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

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:

  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