website refactor

This commit is contained in:
2026-01-12 01:01:49 +01:00
parent 5ca6023a5a
commit fefd8d1cd6
294 changed files with 4628 additions and 4991 deletions

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { RaceResultsService } from './RaceResultsService';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
import type { RaceResultsDetailDTO, RaceWithSOFDTO } from '../../types/generated';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
import { RaceWithSOFViewModel } from '@/lib/view-models/RaceWithSOFViewModel';
import { ImportRaceResultsSummaryViewModel } from '@/lib/view-models/ImportRaceResultsSummaryViewModel';
import type { RaceResultsDetailDTO, RaceWithSOFDTO } from '@/lib/types/generated';
describe('RaceResultsService', () => {
let mockApiClient: Mocked<RacesApiClient>;

View File

@@ -1,178 +0,0 @@
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceResultsDetailViewModel } from '../../view-models/RaceResultsDetailViewModel';
import { RaceWithSOFViewModel } from '../../view-models/RaceWithSOFViewModel';
import { ImportRaceResultsSummaryViewModel } from '../../view-models/ImportRaceResultsSummaryViewModel';
import type { ImportRaceResultsDTO } from '../../types/generated/ImportRaceResultsDTO';
import { v4 as uuidv4 } from 'uuid';
// Define types
type ImportRaceResultsInputDto = ImportRaceResultsDTO;
type ImportRaceResultsSummaryDto = {
success: boolean;
raceId: string;
driversProcessed: number;
resultsRecorded: number;
errors?: string[];
};
export interface ImportResultRowDTO {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
export interface CSVRow {
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
/**
* Race Results Service
*
* Orchestrates race results operations including viewing, importing, and SOF calculations.
* All dependencies are injected via constructor.
*/
export class RaceResultsService {
constructor(
private readonly apiClient: RacesApiClient
) {}
/**
* Get race results detail with view model transformation
*/
async getResultsDetail(raceId: string, currentUserId?: string): Promise<RaceResultsDetailViewModel> {
const dto = await this.apiClient.getResultsDetail(raceId);
return new RaceResultsDetailViewModel(dto, currentUserId || '');
}
/**
* Get race with strength of field calculation
*/
async getWithSOF(raceId: string): Promise<RaceWithSOFViewModel> {
const dto = await this.apiClient.getWithSOF(raceId);
return new RaceWithSOFViewModel(dto);
}
/**
* Import race results and get summary
*/
async importResults(raceId: string, input: ImportRaceResultsInputDto): Promise<ImportRaceResultsSummaryViewModel> {
const dto = await this.apiClient.importResults(raceId, input);
return new ImportRaceResultsSummaryViewModel(dto);
}
/**
* Parse CSV content and validate results
* @throws Error with descriptive message if validation fails
*/
parseCSV(content: string): CSVRow[] {
const lines = content.trim().split('\n');
if (lines.length < 2) {
throw new Error('CSV file is empty or invalid');
}
const headerLine = lines[0]!;
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
for (const field of requiredFields) {
if (!header.includes(field)) {
throw new Error(`Missing required field: ${field}`);
}
}
const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) {
continue;
}
const values = line.split(',').map((v) => v.trim());
if (values.length !== header.length) {
throw new Error(
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
);
}
const row: Record<string, string> = {};
header.forEach((field, index) => {
row[field] = values[index] ?? '';
});
const driverId = row['driverid'] ?? '';
const position = parseInt(row['position'] ?? '', 10);
const fastestLap = parseFloat(row['fastestlap'] ?? '');
const incidents = parseInt(row['incidents'] ?? '', 10);
const startPosition = parseInt(row['startposition'] ?? '', 10);
if (!driverId || driverId.length === 0) {
throw new Error(`Row ${i}: driverId is required`);
}
if (Number.isNaN(position) || position < 1) {
throw new Error(`Row ${i}: position must be a positive integer`);
}
if (Number.isNaN(fastestLap) || fastestLap < 0) {
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
}
if (Number.isNaN(incidents) || incidents < 0) {
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
}
if (Number.isNaN(startPosition) || startPosition < 1) {
throw new Error(`Row ${i}: startPosition must be a positive integer`);
}
rows.push({ driverId, position, fastestLap, incidents, startPosition });
}
const positions = rows.map((r) => r.position);
const uniquePositions = new Set(positions);
if (positions.length !== uniquePositions.size) {
throw new Error('Duplicate positions found in CSV');
}
const driverIds = rows.map((r) => r.driverId);
const uniqueDrivers = new Set(driverIds);
if (driverIds.length !== uniqueDrivers.size) {
throw new Error('Duplicate driver IDs found in CSV');
}
return rows;
}
/**
* Transform parsed CSV rows into ImportResultRowDTO array
*/
transformToImportResults(rows: CSVRow[], raceId: string): ImportResultRowDTO[] {
return rows.map((row) => ({
id: uuidv4(),
raceId,
driverId: row.driverId,
position: row.position,
fastestLap: row.fastestLap,
incidents: row.incidents,
startPosition: row.startPosition,
}));
}
/**
* Parse CSV file content and transform to import results
* @throws Error with descriptive message if parsing or validation fails
*/
parseAndTransformCSV(content: string, raceId: string): ImportResultRowDTO[] {
const rows = this.parseCSV(content);
return this.transformToImportResults(rows, raceId);
}
}

View File

@@ -1,167 +0,0 @@
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { RaceDetailEntryViewModel } from '../../view-models/RaceDetailEntryViewModel';
import { RaceDetailUserResultViewModel } from '../../view-models/RaceDetailUserResultViewModel';
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
import type { RaceDetailsViewModel } from '../../view-models/RaceDetailsViewModel';
import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO';
import type { RaceStatsDTO } from '../../types/generated/RaceStatsDTO';
/**
* Race Service
*
* Orchestrates race operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class RaceService {
constructor(
private readonly apiClient: RacesApiClient
) {}
/**
* Get race detail with view model transformation
*/
async getRaceDetail(
raceId: string,
driverId: string
): Promise<RaceDetailViewModel> {
const dto = await this.apiClient.getDetail(raceId, driverId);
return new RaceDetailViewModel(dto, driverId);
}
/**
* Get race details for pages/components (DTO-free shape)
*/
async getRaceDetails(
raceId: string,
driverId: string
): Promise<RaceDetailsViewModel> {
const dto: any = await this.apiClient.getDetail(raceId, driverId);
const raceDto: any = dto?.race ?? null;
const leagueDto: any = dto?.league ?? null;
const registrationDto: any = dto?.registration ?? {};
const isUserRegistered = Boolean(registrationDto.isUserRegistered ?? registrationDto.isRegistered ?? false);
const canRegister = Boolean(registrationDto.canRegister);
const status = String(raceDto?.status ?? '');
const canReopenRace = status === 'completed' || status === 'cancelled';
return {
race: raceDto
? {
id: String(raceDto.id ?? ''),
track: String(raceDto.track ?? ''),
car: String(raceDto.car ?? ''),
scheduledAt: String(raceDto.scheduledAt ?? ''),
status,
sessionType: String(raceDto.sessionType ?? ''),
}
: null,
league: leagueDto
? {
id: String(leagueDto.id ?? ''),
name: String(leagueDto.name ?? ''),
description: leagueDto.description ?? null,
settings: leagueDto.settings,
}
: null,
entryList: (dto?.entryList ?? []).map((entry: any) => new RaceDetailEntryViewModel(entry, driverId)),
registration: {
canRegister,
isUserRegistered,
},
userResult: dto?.userResult ? new RaceDetailUserResultViewModel(dto.userResult) : null,
canReopenRace,
error: dto?.error,
};
}
/**
* Get races page data with view model transformation
*/
async getRacesPageData(): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData();
return new RacesPageViewModel(dto);
}
/**
* Get races page data filtered by league
*/
async getLeagueRacesPageData(leagueId: string): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData(leagueId);
return new RacesPageViewModel(dto);
}
/**
* Get all races page data with view model transformation
* Currently same as getRacesPageData, but can be extended for different filtering
*/
async getAllRacesPageData(): Promise<RacesPageViewModel> {
const dto = await this.apiClient.getPageData();
return new RacesPageViewModel(dto);
}
/**
* Get total races statistics with view model transformation
*/
async getRacesTotal(): Promise<RaceStatsViewModel> {
const dto: RaceStatsDTO = await this.apiClient.getTotal();
return new RaceStatsViewModel(dto);
}
/**
* Register for a race
*/
async registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void> {
await this.apiClient.register(raceId, { raceId, leagueId, driverId });
}
/**
* Withdraw from a race
*/
async withdrawFromRace(raceId: string, driverId: string): Promise<void> {
await this.apiClient.withdraw(raceId, { raceId, driverId });
}
/**
* Cancel a race
*/
async cancelRace(raceId: string): Promise<void> {
await this.apiClient.cancel(raceId);
}
/**
* Complete a race
*/
async completeRace(raceId: string): Promise<void> {
await this.apiClient.complete(raceId);
}
/**
* Re-open a race
*/
async reopenRace(raceId: string): Promise<void> {
await this.apiClient.reopen(raceId);
}
/**
* File a protest
*/
async fileProtest(input: FileProtestCommandDTO): Promise<void> {
await this.apiClient.fileProtest(input);
}
/**
* Find races by league ID
*
* The races API does not currently expose a league-filtered listing endpoint in this build,
* so this method deliberately signals that the operation is unavailable instead of making
* assumptions about URL structure.
*/
async findByLeagueId(leagueId: string): Promise<RacesPageViewModel['races']> {
const page = await this.getLeagueRacesPageData(leagueId);
return page.races;
}
}

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, Mocked } from 'vitest';
import { RaceStewardingService } from './RaceStewardingService';
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { ProtestsApiClient } from '@/lib/api/protests/ProtestsApiClient';
import { PenaltiesApiClient } from '@/lib/api/penalties/PenaltiesApiClient';
import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
describe('RaceStewardingService', () => {
let mockRacesApiClient: Mocked<RacesApiClient>;

View File

@@ -1,70 +0,0 @@
import { RacesApiClient } from '../../api/races/RacesApiClient';
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
import { RaceStewardingViewModel } from '../../view-models/RaceStewardingViewModel';
/**
* Race Stewarding Service
*
* Orchestrates race stewarding operations by coordinating API calls for race details,
* protests, and penalties, and returning a unified view model.
*/
export class RaceStewardingService {
constructor(
private readonly racesApiClient: RacesApiClient,
private readonly protestsApiClient: ProtestsApiClient,
private readonly penaltiesApiClient: PenaltiesApiClient
) {}
/**
* Get race stewarding data with view model transformation
*/
async getRaceStewardingData(raceId: string, driverId: string): Promise<RaceStewardingViewModel> {
// Fetch all data in parallel
const [raceDetail, protests, penalties] = await Promise.all([
this.racesApiClient.getDetail(raceId, driverId),
this.protestsApiClient.getRaceProtests(raceId),
this.penaltiesApiClient.getRacePenalties(raceId),
]);
// Convert API responses to match RaceStewardingViewModel expectations
const convertedProtests = {
protests: protests.protests.map(p => ({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
incident: {
lap: p.lap,
description: p.description
},
filedAt: p.filedAt,
status: p.status
})),
driverMap: Object.entries(protests.driverMap).reduce((acc, [id, name]) => {
acc[id] = { id, name: name as string };
return acc;
}, {} as Record<string, { id: string; name: string }>)
};
const convertedPenalties = {
penalties: penalties.penalties.map(p => ({
id: p.id,
driverId: p.driverId,
type: p.type,
value: p.value,
reason: p.reason,
notes: p.notes
})),
driverMap: Object.entries(penalties.driverMap).reduce((acc, [id, name]) => {
acc[id] = { id, name: name as string };
return acc;
}, {} as Record<string, { id: string; name: string }>)
};
return new RaceStewardingViewModel({
raceDetail,
protests: convertedProtests,
penalties: convertedPenalties,
});
}
}