alpha wip
This commit is contained in:
@@ -5,9 +5,9 @@ import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { Race } from '@/domain/entities/Race';
|
||||
import { Driver } from '@/domain/entities/Driver';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
import { getLeagueRepository, getRaceRepository, getDriverRepository } from '@/lib/di-container';
|
||||
|
||||
export default function LeagueDetailPage() {
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import StandingsTable from '@/components/alpha/StandingsTable';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { Standing } from '@/domain/entities/Standing';
|
||||
import { Driver } from '@/domain/entities/Driver';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
import {
|
||||
getLeagueRepository,
|
||||
getStandingRepository,
|
||||
|
||||
@@ -6,7 +6,7 @@ import LeagueCard from '@/components/alpha/LeagueCard';
|
||||
import CreateLeagueForm from '@/components/alpha/CreateLeagueForm';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { getLeagueRepository } from '@/lib/di-container';
|
||||
|
||||
export default function LeaguesPage() {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useRouter, useParams } from 'next/navigation';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
|
||||
import { Race } from '@/domain/entities/Race';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
import CompanionStatus from '@/components/alpha/CompanionStatus';
|
||||
import CompanionInstructions from '@/components/alpha/CompanionInstructions';
|
||||
|
||||
@@ -6,10 +6,10 @@ import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import ResultsTable from '@/components/alpha/ResultsTable';
|
||||
import ImportResultsForm from '@/components/alpha/ImportResultsForm';
|
||||
import { Race } from '@/domain/entities/Race';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { Result } from '@/domain/entities/Result';
|
||||
import { Driver } from '@/domain/entities/Driver';
|
||||
import { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { Result } from '@gridpilot/racing-domain/entities/Result';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
import {
|
||||
getRaceRepository,
|
||||
getLeagueRepository,
|
||||
|
||||
@@ -6,8 +6,8 @@ import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import RaceCard from '@/components/alpha/RaceCard';
|
||||
import ScheduleRaceForm from '@/components/alpha/ScheduleRaceForm';
|
||||
import { Race, RaceStatus } from '@/domain/entities/Race';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { Race, RaceStatus } from '@gridpilot/racing-domain/entities/Race';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
|
||||
export default function RacesPage() {
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Application Layer: Entity to DTO Mappers
|
||||
*
|
||||
* Transforms domain entities to plain objects for crossing architectural boundaries.
|
||||
* These mappers handle the Server Component -> Client Component boundary in Next.js 15.
|
||||
*/
|
||||
|
||||
import { Driver } from '@/domain/entities/Driver';
|
||||
import { League } from '@/domain/entities/League';
|
||||
import { Race } from '@/domain/entities/Race';
|
||||
import { Result } from '@/domain/entities/Result';
|
||||
import { Standing } from '@/domain/entities/Standing';
|
||||
|
||||
export type DriverDTO = {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt: string;
|
||||
};
|
||||
|
||||
export type LeagueDTO = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings: {
|
||||
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
|
||||
sessionDuration?: number;
|
||||
qualifyingFormat?: 'single-lap' | 'open';
|
||||
customPoints?: Record<number, number>;
|
||||
};
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type RaceDTO = {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: string;
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType: 'practice' | 'qualifying' | 'race';
|
||||
status: 'scheduled' | 'completed' | 'cancelled';
|
||||
};
|
||||
|
||||
export type ResultDTO = {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
};
|
||||
|
||||
export type StandingDTO = {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
points: number;
|
||||
wins: number;
|
||||
position: number;
|
||||
racesCompleted: number;
|
||||
};
|
||||
|
||||
export class EntityMappers {
|
||||
static toDriverDTO(driver: Driver | null): DriverDTO | null {
|
||||
if (!driver) return null;
|
||||
return {
|
||||
id: driver.id,
|
||||
iracingId: driver.iracingId,
|
||||
name: driver.name,
|
||||
country: driver.country,
|
||||
bio: driver.bio,
|
||||
joinedAt: driver.joinedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
static toLeagueDTO(league: League | null): LeagueDTO | null {
|
||||
if (!league) return null;
|
||||
return {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
ownerId: league.ownerId,
|
||||
settings: league.settings,
|
||||
createdAt: league.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
static toLeagueDTOs(leagues: League[]): LeagueDTO[] {
|
||||
return leagues.map(league => ({
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
ownerId: league.ownerId,
|
||||
settings: league.settings,
|
||||
createdAt: league.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
static toRaceDTO(race: Race | null): RaceDTO | null {
|
||||
if (!race) return null;
|
||||
return {
|
||||
id: race.id,
|
||||
leagueId: race.leagueId,
|
||||
scheduledAt: race.scheduledAt.toISOString(),
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
sessionType: race.sessionType,
|
||||
status: race.status,
|
||||
};
|
||||
}
|
||||
|
||||
static toRaceDTOs(races: Race[]): RaceDTO[] {
|
||||
return races.map(race => ({
|
||||
id: race.id,
|
||||
leagueId: race.leagueId,
|
||||
scheduledAt: race.scheduledAt.toISOString(),
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
sessionType: race.sessionType,
|
||||
status: race.status,
|
||||
}));
|
||||
}
|
||||
|
||||
static toResultDTO(result: Result | null): ResultDTO | null {
|
||||
if (!result) return null;
|
||||
return {
|
||||
id: result.id,
|
||||
raceId: result.raceId,
|
||||
driverId: result.driverId,
|
||||
position: result.position,
|
||||
fastestLap: result.fastestLap,
|
||||
incidents: result.incidents,
|
||||
startPosition: result.startPosition,
|
||||
};
|
||||
}
|
||||
|
||||
static toResultDTOs(results: Result[]): ResultDTO[] {
|
||||
return results.map(result => ({
|
||||
id: result.id,
|
||||
raceId: result.raceId,
|
||||
driverId: result.driverId,
|
||||
position: result.position,
|
||||
fastestLap: result.fastestLap,
|
||||
incidents: result.incidents,
|
||||
startPosition: result.startPosition,
|
||||
}));
|
||||
}
|
||||
|
||||
static toStandingDTO(standing: Standing | null): StandingDTO | null {
|
||||
if (!standing) return null;
|
||||
return {
|
||||
leagueId: standing.leagueId,
|
||||
driverId: standing.driverId,
|
||||
points: standing.points,
|
||||
wins: standing.wins,
|
||||
position: standing.position,
|
||||
racesCompleted: standing.racesCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
static toStandingDTOs(standings: Standing[]): StandingDTO[] {
|
||||
return standings.map(standing => ({
|
||||
leagueId: standing.leagueId,
|
||||
driverId: standing.driverId,
|
||||
points: standing.points,
|
||||
wins: standing.wins,
|
||||
position: standing.position,
|
||||
racesCompleted: standing.racesCompleted,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Application Port: IDriverRepository
|
||||
*
|
||||
* Repository interface for Driver entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import { Driver } from '../../domain/entities/Driver';
|
||||
|
||||
export interface IDriverRepository {
|
||||
/**
|
||||
* Find a driver by ID
|
||||
*/
|
||||
findById(id: string): Promise<Driver | null>;
|
||||
|
||||
/**
|
||||
* Find a driver by iRacing ID
|
||||
*/
|
||||
findByIRacingId(iracingId: string): Promise<Driver | null>;
|
||||
|
||||
/**
|
||||
* Find all drivers
|
||||
*/
|
||||
findAll(): Promise<Driver[]>;
|
||||
|
||||
/**
|
||||
* Create a new driver
|
||||
*/
|
||||
create(driver: Driver): Promise<Driver>;
|
||||
|
||||
/**
|
||||
* Update an existing driver
|
||||
*/
|
||||
update(driver: Driver): Promise<Driver>;
|
||||
|
||||
/**
|
||||
* Delete a driver by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a driver exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if an iRacing ID is already registered
|
||||
*/
|
||||
existsByIRacingId(iracingId: string): Promise<boolean>;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Application Port: ILeagueRepository
|
||||
*
|
||||
* Repository interface for League entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import { League } from '../../domain/entities/League';
|
||||
|
||||
export interface ILeagueRepository {
|
||||
/**
|
||||
* Find a league by ID
|
||||
*/
|
||||
findById(id: string): Promise<League | null>;
|
||||
|
||||
/**
|
||||
* Find all leagues
|
||||
*/
|
||||
findAll(): Promise<League[]>;
|
||||
|
||||
/**
|
||||
* Find leagues by owner ID
|
||||
*/
|
||||
findByOwnerId(ownerId: string): Promise<League[]>;
|
||||
|
||||
/**
|
||||
* Create a new league
|
||||
*/
|
||||
create(league: League): Promise<League>;
|
||||
|
||||
/**
|
||||
* Update an existing league
|
||||
*/
|
||||
update(league: League): Promise<League>;
|
||||
|
||||
/**
|
||||
* Delete a league by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a league exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Search leagues by name
|
||||
*/
|
||||
searchByName(query: string): Promise<League[]>;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Application Port: IRaceRepository
|
||||
*
|
||||
* Repository interface for Race entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import { Race, RaceStatus } from '../../domain/entities/Race';
|
||||
|
||||
export interface IRaceRepository {
|
||||
/**
|
||||
* Find a race by ID
|
||||
*/
|
||||
findById(id: string): Promise<Race | null>;
|
||||
|
||||
/**
|
||||
* Find all races
|
||||
*/
|
||||
findAll(): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find races by league ID
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find upcoming races for a league
|
||||
*/
|
||||
findUpcomingByLeagueId(leagueId: string): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find completed races for a league
|
||||
*/
|
||||
findCompletedByLeagueId(leagueId: string): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find races by status
|
||||
*/
|
||||
findByStatus(status: RaceStatus): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Find races scheduled within a date range
|
||||
*/
|
||||
findByDateRange(startDate: Date, endDate: Date): Promise<Race[]>;
|
||||
|
||||
/**
|
||||
* Create a new race
|
||||
*/
|
||||
create(race: Race): Promise<Race>;
|
||||
|
||||
/**
|
||||
* Update an existing race
|
||||
*/
|
||||
update(race: Race): Promise<Race>;
|
||||
|
||||
/**
|
||||
* Delete a race by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a race exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Application Port: IResultRepository
|
||||
*
|
||||
* Repository interface for Result entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import { Result } from '../../domain/entities/Result';
|
||||
|
||||
export interface IResultRepository {
|
||||
/**
|
||||
* Find a result by ID
|
||||
*/
|
||||
findById(id: string): Promise<Result | null>;
|
||||
|
||||
/**
|
||||
* Find all results
|
||||
*/
|
||||
findAll(): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Find results by race ID
|
||||
*/
|
||||
findByRaceId(raceId: string): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Find results by driver ID
|
||||
*/
|
||||
findByDriverId(driverId: string): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Find results by driver ID for a specific league
|
||||
*/
|
||||
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Create a new result
|
||||
*/
|
||||
create(result: Result): Promise<Result>;
|
||||
|
||||
/**
|
||||
* Create multiple results
|
||||
*/
|
||||
createMany(results: Result[]): Promise<Result[]>;
|
||||
|
||||
/**
|
||||
* Update an existing result
|
||||
*/
|
||||
update(result: Result): Promise<Result>;
|
||||
|
||||
/**
|
||||
* Delete a result by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all results for a race
|
||||
*/
|
||||
deleteByRaceId(raceId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a result exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if results exist for a race
|
||||
*/
|
||||
existsByRaceId(raceId: string): Promise<boolean>;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Application Port: IStandingRepository
|
||||
*
|
||||
* Repository interface for Standing entity operations.
|
||||
* Includes methods for calculating and retrieving standings.
|
||||
*/
|
||||
|
||||
import { Standing } from '../../domain/entities/Standing';
|
||||
|
||||
export interface IStandingRepository {
|
||||
/**
|
||||
* Find standings by league ID (sorted by position)
|
||||
*/
|
||||
findByLeagueId(leagueId: string): Promise<Standing[]>;
|
||||
|
||||
/**
|
||||
* Find standing for a specific driver in a league
|
||||
*/
|
||||
findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Standing | null>;
|
||||
|
||||
/**
|
||||
* Find all standings
|
||||
*/
|
||||
findAll(): Promise<Standing[]>;
|
||||
|
||||
/**
|
||||
* Create or update a standing
|
||||
*/
|
||||
save(standing: Standing): Promise<Standing>;
|
||||
|
||||
/**
|
||||
* Create or update multiple standings
|
||||
*/
|
||||
saveMany(standings: Standing[]): Promise<Standing[]>;
|
||||
|
||||
/**
|
||||
* Delete a standing
|
||||
*/
|
||||
delete(leagueId: string, driverId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all standings for a league
|
||||
*/
|
||||
deleteByLeagueId(leagueId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a standing exists
|
||||
*/
|
||||
exists(leagueId: string, driverId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Recalculate standings for a league based on race results
|
||||
*/
|
||||
recalculate(leagueId: string): Promise<Standing[]>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import { Race } from '../../domain/entities/Race';
|
||||
import { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
|
||||
interface CompanionInstructionsProps {
|
||||
race: Race;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import Input from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import DataWarning from './DataWarning';
|
||||
import { Driver } from '../../domain/entities/Driver';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
import { getDriverRepository } from '../../lib/di-container';
|
||||
|
||||
interface FormErrors {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import Input from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import DataWarning from './DataWarning';
|
||||
import { League } from '../../domain/entities/League';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { getLeagueRepository, getDriverRepository } from '../../lib/di-container';
|
||||
|
||||
interface FormErrors {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import Button from '../ui/Button';
|
||||
import DataWarning from './DataWarning';
|
||||
import { Result } from '../../domain/entities/Result';
|
||||
import { Result } from '@gridpilot/racing-domain/entities/Result';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface ImportResultsFormProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { League } from '../../domain/entities/League';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import Card from '../ui/Card';
|
||||
|
||||
interface LeagueCardProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Race } from '../../domain/entities/Race';
|
||||
import { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
|
||||
interface RaceCardProps {
|
||||
race: Race;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Result } from '../../domain/entities/Result';
|
||||
import { Driver } from '../../domain/entities/Driver';
|
||||
import { Result } from '@gridpilot/racing-domain/entities/Result';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
|
||||
interface ResultsTableProps {
|
||||
results: Result[];
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useRouter } from 'next/navigation';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import DataWarning from './DataWarning';
|
||||
import { Race } from '../../domain/entities/Race';
|
||||
import { League } from '../../domain/entities/League';
|
||||
import { SessionType } from '../../domain/entities/Race';
|
||||
import { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { SessionType } from '@gridpilot/racing-domain/entities/Race';
|
||||
import { getRaceRepository, getLeagueRepository } from '../../lib/di-container';
|
||||
import { InMemoryRaceRepository } from '../../infrastructure/repositories/InMemoryRaceRepository';
|
||||
import { InMemoryRaceRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryRaceRepository';
|
||||
|
||||
interface ScheduleRaceFormProps {
|
||||
preSelectedLeagueId?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Standing } from '../../domain/entities/Standing';
|
||||
import { Driver } from '../../domain/entities/Driver';
|
||||
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
|
||||
interface StandingsTableProps {
|
||||
standings: Standing[];
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Domain Entity: Driver
|
||||
*
|
||||
* Represents a driver profile in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export class Driver {
|
||||
readonly id: string;
|
||||
readonly iracingId: string;
|
||||
readonly name: string;
|
||||
readonly country: string;
|
||||
readonly bio?: string;
|
||||
readonly joinedAt: Date;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt: Date;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.iracingId = props.iracingId;
|
||||
this.name = props.name;
|
||||
this.country = props.country;
|
||||
this.bio = props.bio;
|
||||
this.joinedAt = props.joinedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Driver entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt?: Date;
|
||||
}): Driver {
|
||||
this.validate(props);
|
||||
|
||||
return new Driver({
|
||||
...props,
|
||||
joinedAt: props.joinedAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!props.iracingId || props.iracingId.trim().length === 0) {
|
||||
throw new Error('iRacing ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Driver name is required');
|
||||
}
|
||||
|
||||
if (!props.country || props.country.trim().length === 0) {
|
||||
throw new Error('Country code is required');
|
||||
}
|
||||
|
||||
// Validate ISO country code format (2-3 letters)
|
||||
if (!/^[A-Z]{2,3}$/i.test(props.country)) {
|
||||
throw new Error('Country must be a valid ISO code (2-3 letters)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with updated properties
|
||||
*/
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
country: string;
|
||||
bio: string;
|
||||
}>): Driver {
|
||||
return new Driver({
|
||||
id: this.id,
|
||||
iracingId: this.iracingId,
|
||||
name: props.name ?? this.name,
|
||||
country: props.country ?? this.country,
|
||||
bio: props.bio ?? this.bio,
|
||||
joinedAt: this.joinedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Domain Entity: League
|
||||
*
|
||||
* Represents a league in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export interface LeagueSettings {
|
||||
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
|
||||
sessionDuration?: number;
|
||||
qualifyingFormat?: 'single-lap' | 'open';
|
||||
customPoints?: Record<number, number>;
|
||||
}
|
||||
|
||||
export class League {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly ownerId: string;
|
||||
readonly settings: LeagueSettings;
|
||||
readonly createdAt: Date;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings: LeagueSettings;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.description = props.description;
|
||||
this.ownerId = props.ownerId;
|
||||
this.settings = props.settings;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new League entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings?: Partial<LeagueSettings>;
|
||||
createdAt?: Date;
|
||||
}): League {
|
||||
this.validate(props);
|
||||
|
||||
const defaultSettings: LeagueSettings = {
|
||||
pointsSystem: 'f1-2024',
|
||||
sessionDuration: 60,
|
||||
qualifyingFormat: 'open',
|
||||
};
|
||||
|
||||
return new League({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
description: props.description,
|
||||
ownerId: props.ownerId,
|
||||
settings: { ...defaultSettings, ...props.settings },
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('League name is required');
|
||||
}
|
||||
|
||||
if (props.name.length > 100) {
|
||||
throw new Error('League name must be 100 characters or less');
|
||||
}
|
||||
|
||||
if (!props.description || props.description.trim().length === 0) {
|
||||
throw new Error('League description is required');
|
||||
}
|
||||
|
||||
if (!props.ownerId || props.ownerId.trim().length === 0) {
|
||||
throw new Error('League owner ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a copy with updated properties
|
||||
*/
|
||||
update(props: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
settings: LeagueSettings;
|
||||
}>): League {
|
||||
return new League({
|
||||
id: this.id,
|
||||
name: props.name ?? this.name,
|
||||
description: props.description ?? this.description,
|
||||
ownerId: this.ownerId,
|
||||
settings: props.settings ?? this.settings,
|
||||
createdAt: this.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Domain Entity: Race
|
||||
*
|
||||
* Represents a race/session in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export type SessionType = 'practice' | 'qualifying' | 'race';
|
||||
export type RaceStatus = 'scheduled' | 'completed' | 'cancelled';
|
||||
|
||||
export class Race {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly scheduledAt: Date;
|
||||
readonly track: string;
|
||||
readonly car: string;
|
||||
readonly sessionType: SessionType;
|
||||
readonly status: RaceStatus;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType: SessionType;
|
||||
status: RaceStatus;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.scheduledAt = props.scheduledAt;
|
||||
this.track = props.track;
|
||||
this.car = props.car;
|
||||
this.sessionType = props.sessionType;
|
||||
this.status = props.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Race entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType?: SessionType;
|
||||
status?: RaceStatus;
|
||||
}): Race {
|
||||
this.validate(props);
|
||||
|
||||
return new Race({
|
||||
id: props.id,
|
||||
leagueId: props.leagueId,
|
||||
scheduledAt: props.scheduledAt,
|
||||
track: props.track,
|
||||
car: props.car,
|
||||
sessionType: props.sessionType ?? 'race',
|
||||
status: props.status ?? 'scheduled',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
car: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) {
|
||||
throw new Error('Valid scheduled date is required');
|
||||
}
|
||||
|
||||
if (!props.track || props.track.trim().length === 0) {
|
||||
throw new Error('Track is required');
|
||||
}
|
||||
|
||||
if (!props.car || props.car.trim().length === 0) {
|
||||
throw new Error('Car is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark race as completed
|
||||
*/
|
||||
complete(): Race {
|
||||
if (this.status === 'completed') {
|
||||
throw new Error('Race is already completed');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new Error('Cannot complete a cancelled race');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
...this,
|
||||
status: 'completed',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the race
|
||||
*/
|
||||
cancel(): Race {
|
||||
if (this.status === 'completed') {
|
||||
throw new Error('Cannot cancel a completed race');
|
||||
}
|
||||
|
||||
if (this.status === 'cancelled') {
|
||||
throw new Error('Race is already cancelled');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
...this,
|
||||
status: 'cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is in the past
|
||||
*/
|
||||
isPast(): boolean {
|
||||
return this.scheduledAt < new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is upcoming
|
||||
*/
|
||||
isUpcoming(): boolean {
|
||||
return this.status === 'scheduled' && !this.isPast();
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Domain Entity: Result
|
||||
*
|
||||
* Represents a race result in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export class Result {
|
||||
readonly id: string;
|
||||
readonly raceId: string;
|
||||
readonly driverId: string;
|
||||
readonly position: number;
|
||||
readonly fastestLap: number;
|
||||
readonly incidents: number;
|
||||
readonly startPosition: number;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.raceId = props.raceId;
|
||||
this.driverId = props.driverId;
|
||||
this.position = props.position;
|
||||
this.fastestLap = props.fastestLap;
|
||||
this.incidents = props.incidents;
|
||||
this.startPosition = props.startPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Result entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}): Result {
|
||||
this.validate(props);
|
||||
|
||||
return new Result(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Result ID is required');
|
||||
}
|
||||
|
||||
if (!props.raceId || props.raceId.trim().length === 0) {
|
||||
throw new Error('Race ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new Error('Driver ID is required');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.position) || props.position < 1) {
|
||||
throw new Error('Position must be a positive integer');
|
||||
}
|
||||
|
||||
if (props.fastestLap < 0) {
|
||||
throw new Error('Fastest lap cannot be negative');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.incidents) || props.incidents < 0) {
|
||||
throw new Error('Incidents must be a non-negative integer');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(props.startPosition) || props.startPosition < 1) {
|
||||
throw new Error('Start position must be a positive integer');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate positions gained/lost
|
||||
*/
|
||||
getPositionChange(): number {
|
||||
return this.startPosition - this.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver finished on podium
|
||||
*/
|
||||
isPodium(): boolean {
|
||||
return this.position <= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if driver had a clean race (0 incidents)
|
||||
*/
|
||||
isClean(): boolean {
|
||||
return this.incidents === 0;
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Domain Entity: Standing
|
||||
*
|
||||
* Represents a championship standing in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export class Standing {
|
||||
readonly leagueId: string;
|
||||
readonly driverId: string;
|
||||
readonly points: number;
|
||||
readonly wins: number;
|
||||
readonly position: number;
|
||||
readonly racesCompleted: number;
|
||||
|
||||
private constructor(props: {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
points: number;
|
||||
wins: number;
|
||||
position: number;
|
||||
racesCompleted: number;
|
||||
}) {
|
||||
this.leagueId = props.leagueId;
|
||||
this.driverId = props.driverId;
|
||||
this.points = props.points;
|
||||
this.wins = props.wins;
|
||||
this.position = props.position;
|
||||
this.racesCompleted = props.racesCompleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Standing entity
|
||||
*/
|
||||
static create(props: {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
points?: number;
|
||||
wins?: number;
|
||||
position?: number;
|
||||
racesCompleted?: number;
|
||||
}): Standing {
|
||||
this.validate(props);
|
||||
|
||||
return new Standing({
|
||||
leagueId: props.leagueId,
|
||||
driverId: props.driverId,
|
||||
points: props.points ?? 0,
|
||||
wins: props.wins ?? 0,
|
||||
position: props.position ?? 0,
|
||||
racesCompleted: props.racesCompleted ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
}): void {
|
||||
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||
throw new Error('League ID is required');
|
||||
}
|
||||
|
||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||
throw new Error('Driver ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add points from a race result
|
||||
*/
|
||||
addRaceResult(position: number, pointsSystem: Record<number, number>): Standing {
|
||||
const racePoints = pointsSystem[position] ?? 0;
|
||||
const isWin = position === 1;
|
||||
|
||||
return new Standing({
|
||||
leagueId: this.leagueId,
|
||||
driverId: this.driverId,
|
||||
points: this.points + racePoints,
|
||||
wins: this.wins + (isWin ? 1 : 0),
|
||||
position: this.position,
|
||||
racesCompleted: this.racesCompleted + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update championship position
|
||||
*/
|
||||
updatePosition(position: number): Standing {
|
||||
if (!Number.isInteger(position) || position < 1) {
|
||||
throw new Error('Position must be a positive integer');
|
||||
}
|
||||
|
||||
return new Standing({
|
||||
...this,
|
||||
position,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average points per race
|
||||
*/
|
||||
getAveragePoints(): number {
|
||||
if (this.racesCompleted === 0) return 0;
|
||||
return this.points / this.racesCompleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate win percentage
|
||||
*/
|
||||
getWinPercentage(): number {
|
||||
if (this.racesCompleted === 0) return 0;
|
||||
return (this.wins / this.racesCompleted) * 100;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryDriverRepository
|
||||
*
|
||||
* In-memory implementation of IDriverRepository.
|
||||
* Stores data in Map structure with UUID generation.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Driver } from '../../domain/entities/Driver';
|
||||
import { IDriverRepository } from '../../application/ports/IDriverRepository';
|
||||
|
||||
export class InMemoryDriverRepository implements IDriverRepository {
|
||||
private drivers: Map<string, Driver>;
|
||||
|
||||
constructor(seedData?: Driver[]) {
|
||||
this.drivers = new Map();
|
||||
|
||||
if (seedData) {
|
||||
seedData.forEach(driver => {
|
||||
this.drivers.set(driver.id, driver);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Driver | null> {
|
||||
return this.drivers.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByIRacingId(iracingId: string): Promise<Driver | null> {
|
||||
const driver = Array.from(this.drivers.values()).find(
|
||||
d => d.iracingId === iracingId
|
||||
);
|
||||
return driver ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Driver[]> {
|
||||
return Array.from(this.drivers.values());
|
||||
}
|
||||
|
||||
async create(driver: Driver): Promise<Driver> {
|
||||
if (await this.exists(driver.id)) {
|
||||
throw new Error(`Driver with ID ${driver.id} already exists`);
|
||||
}
|
||||
|
||||
if (await this.existsByIRacingId(driver.iracingId)) {
|
||||
throw new Error(`Driver with iRacing ID ${driver.iracingId} already exists`);
|
||||
}
|
||||
|
||||
this.drivers.set(driver.id, driver);
|
||||
return driver;
|
||||
}
|
||||
|
||||
async update(driver: Driver): Promise<Driver> {
|
||||
if (!await this.exists(driver.id)) {
|
||||
throw new Error(`Driver with ID ${driver.id} not found`);
|
||||
}
|
||||
|
||||
this.drivers.set(driver.id, driver);
|
||||
return driver;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (!await this.exists(id)) {
|
||||
throw new Error(`Driver with ID ${id} not found`);
|
||||
}
|
||||
|
||||
this.drivers.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.drivers.has(id);
|
||||
}
|
||||
|
||||
async existsByIRacingId(iracingId: string): Promise<boolean> {
|
||||
return Array.from(this.drivers.values()).some(
|
||||
d => d.iracingId === iracingId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to generate a new UUID
|
||||
*/
|
||||
static generateId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryLeagueRepository
|
||||
*
|
||||
* In-memory implementation of ILeagueRepository.
|
||||
* Stores data in Map structure with UUID generation.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { League } from '../../domain/entities/League';
|
||||
import { ILeagueRepository } from '../../application/ports/ILeagueRepository';
|
||||
|
||||
export class InMemoryLeagueRepository implements ILeagueRepository {
|
||||
private leagues: Map<string, League>;
|
||||
|
||||
constructor(seedData?: League[]) {
|
||||
this.leagues = new Map();
|
||||
|
||||
if (seedData) {
|
||||
seedData.forEach(league => {
|
||||
this.leagues.set(league.id, league);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<League | null> {
|
||||
return this.leagues.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<League[]> {
|
||||
return Array.from(this.leagues.values());
|
||||
}
|
||||
|
||||
async findByOwnerId(ownerId: string): Promise<League[]> {
|
||||
return Array.from(this.leagues.values()).filter(
|
||||
league => league.ownerId === ownerId
|
||||
);
|
||||
}
|
||||
|
||||
async create(league: League): Promise<League> {
|
||||
if (await this.exists(league.id)) {
|
||||
throw new Error(`League with ID ${league.id} already exists`);
|
||||
}
|
||||
|
||||
this.leagues.set(league.id, league);
|
||||
return league;
|
||||
}
|
||||
|
||||
async update(league: League): Promise<League> {
|
||||
if (!await this.exists(league.id)) {
|
||||
throw new Error(`League with ID ${league.id} not found`);
|
||||
}
|
||||
|
||||
this.leagues.set(league.id, league);
|
||||
return league;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (!await this.exists(id)) {
|
||||
throw new Error(`League with ID ${id} not found`);
|
||||
}
|
||||
|
||||
this.leagues.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.leagues.has(id);
|
||||
}
|
||||
|
||||
async searchByName(query: string): Promise<League[]> {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
return Array.from(this.leagues.values()).filter(league =>
|
||||
league.name.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to generate a new UUID
|
||||
*/
|
||||
static generateId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryRaceRepository
|
||||
*
|
||||
* In-memory implementation of IRaceRepository.
|
||||
* Stores data in Map structure with UUID generation.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Race, RaceStatus } from '../../domain/entities/Race';
|
||||
import { IRaceRepository } from '../../application/ports/IRaceRepository';
|
||||
|
||||
export class InMemoryRaceRepository implements IRaceRepository {
|
||||
private races: Map<string, Race>;
|
||||
|
||||
constructor(seedData?: Race[]) {
|
||||
this.races = new Map();
|
||||
|
||||
if (seedData) {
|
||||
seedData.forEach(race => {
|
||||
this.races.set(race.id, race);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Race | null> {
|
||||
return this.races.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Race[]> {
|
||||
return Array.from(this.races.values());
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
return Array.from(this.races.values())
|
||||
.filter(race => race.leagueId === leagueId)
|
||||
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||
}
|
||||
|
||||
async findUpcomingByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
const now = new Date();
|
||||
return Array.from(this.races.values())
|
||||
.filter(race =>
|
||||
race.leagueId === leagueId &&
|
||||
race.status === 'scheduled' &&
|
||||
race.scheduledAt > now
|
||||
)
|
||||
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||
}
|
||||
|
||||
async findCompletedByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
return Array.from(this.races.values())
|
||||
.filter(race =>
|
||||
race.leagueId === leagueId &&
|
||||
race.status === 'completed'
|
||||
)
|
||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
|
||||
}
|
||||
|
||||
async findByStatus(status: RaceStatus): Promise<Race[]> {
|
||||
return Array.from(this.races.values())
|
||||
.filter(race => race.status === status)
|
||||
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<Race[]> {
|
||||
return Array.from(this.races.values())
|
||||
.filter(race =>
|
||||
race.scheduledAt >= startDate &&
|
||||
race.scheduledAt <= endDate
|
||||
)
|
||||
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||
}
|
||||
|
||||
async create(race: Race): Promise<Race> {
|
||||
if (await this.exists(race.id)) {
|
||||
throw new Error(`Race with ID ${race.id} already exists`);
|
||||
}
|
||||
|
||||
this.races.set(race.id, race);
|
||||
return race;
|
||||
}
|
||||
|
||||
async update(race: Race): Promise<Race> {
|
||||
if (!await this.exists(race.id)) {
|
||||
throw new Error(`Race with ID ${race.id} not found`);
|
||||
}
|
||||
|
||||
this.races.set(race.id, race);
|
||||
return race;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (!await this.exists(id)) {
|
||||
throw new Error(`Race with ID ${id} not found`);
|
||||
}
|
||||
|
||||
this.races.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.races.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to generate a new UUID
|
||||
*/
|
||||
static generateId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryResultRepository
|
||||
*
|
||||
* In-memory implementation of IResultRepository.
|
||||
* Stores data in Map structure with UUID generation.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Result } from '../../domain/entities/Result';
|
||||
import { IResultRepository } from '../../application/ports/IResultRepository';
|
||||
import { IRaceRepository } from '../../application/ports/IRaceRepository';
|
||||
|
||||
export class InMemoryResultRepository implements IResultRepository {
|
||||
private results: Map<string, Result>;
|
||||
private raceRepository?: IRaceRepository;
|
||||
|
||||
constructor(seedData?: Result[], raceRepository?: IRaceRepository) {
|
||||
this.results = new Map();
|
||||
this.raceRepository = raceRepository;
|
||||
|
||||
if (seedData) {
|
||||
seedData.forEach(result => {
|
||||
this.results.set(result.id, result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Result | null> {
|
||||
return this.results.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Result[]> {
|
||||
return Array.from(this.results.values());
|
||||
}
|
||||
|
||||
async findByRaceId(raceId: string): Promise<Result[]> {
|
||||
return Array.from(this.results.values())
|
||||
.filter(result => result.raceId === raceId)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
}
|
||||
|
||||
async findByDriverId(driverId: string): Promise<Result[]> {
|
||||
return Array.from(this.results.values())
|
||||
.filter(result => result.driverId === driverId);
|
||||
}
|
||||
|
||||
async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]> {
|
||||
if (!this.raceRepository) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const leagueRaces = await this.raceRepository.findByLeagueId(leagueId);
|
||||
const leagueRaceIds = new Set(leagueRaces.map(race => race.id));
|
||||
|
||||
return Array.from(this.results.values())
|
||||
.filter(result =>
|
||||
result.driverId === driverId &&
|
||||
leagueRaceIds.has(result.raceId)
|
||||
);
|
||||
}
|
||||
|
||||
async create(result: Result): Promise<Result> {
|
||||
if (await this.exists(result.id)) {
|
||||
throw new Error(`Result with ID ${result.id} already exists`);
|
||||
}
|
||||
|
||||
this.results.set(result.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async createMany(results: Result[]): Promise<Result[]> {
|
||||
const created: Result[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (await this.exists(result.id)) {
|
||||
throw new Error(`Result with ID ${result.id} already exists`);
|
||||
}
|
||||
this.results.set(result.id, result);
|
||||
created.push(result);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(result: Result): Promise<Result> {
|
||||
if (!await this.exists(result.id)) {
|
||||
throw new Error(`Result with ID ${result.id} not found`);
|
||||
}
|
||||
|
||||
this.results.set(result.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (!await this.exists(id)) {
|
||||
throw new Error(`Result with ID ${id} not found`);
|
||||
}
|
||||
|
||||
this.results.delete(id);
|
||||
}
|
||||
|
||||
async deleteByRaceId(raceId: string): Promise<void> {
|
||||
const raceResults = await this.findByRaceId(raceId);
|
||||
raceResults.forEach(result => {
|
||||
this.results.delete(result.id);
|
||||
});
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return this.results.has(id);
|
||||
}
|
||||
|
||||
async existsByRaceId(raceId: string): Promise<boolean> {
|
||||
return Array.from(this.results.values()).some(
|
||||
result => result.raceId === raceId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to generate a new UUID
|
||||
*/
|
||||
static generateId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryStandingRepository
|
||||
*
|
||||
* In-memory implementation of IStandingRepository.
|
||||
* Stores data in Map structure and calculates standings from race results.
|
||||
*/
|
||||
|
||||
import { Standing } from '../../domain/entities/Standing';
|
||||
import { IStandingRepository } from '../../application/ports/IStandingRepository';
|
||||
import { IResultRepository } from '../../application/ports/IResultRepository';
|
||||
import { IRaceRepository } from '../../application/ports/IRaceRepository';
|
||||
import { ILeagueRepository } from '../../application/ports/ILeagueRepository';
|
||||
|
||||
/**
|
||||
* Points systems presets
|
||||
*/
|
||||
const POINTS_SYSTEMS: Record<string, Record<number, number>> = {
|
||||
'f1-2024': {
|
||||
1: 25, 2: 18, 3: 15, 4: 12, 5: 10,
|
||||
6: 8, 7: 6, 8: 4, 9: 2, 10: 1
|
||||
},
|
||||
'indycar': {
|
||||
1: 50, 2: 40, 3: 35, 4: 32, 5: 30,
|
||||
6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
|
||||
11: 19, 12: 18, 13: 17, 14: 16, 15: 15
|
||||
}
|
||||
};
|
||||
|
||||
export class InMemoryStandingRepository implements IStandingRepository {
|
||||
private standings: Map<string, Standing>;
|
||||
private resultRepository?: IResultRepository;
|
||||
private raceRepository?: IRaceRepository;
|
||||
private leagueRepository?: ILeagueRepository;
|
||||
|
||||
constructor(
|
||||
seedData?: Standing[],
|
||||
resultRepository?: IResultRepository,
|
||||
raceRepository?: IRaceRepository,
|
||||
leagueRepository?: ILeagueRepository
|
||||
) {
|
||||
this.standings = new Map();
|
||||
this.resultRepository = resultRepository;
|
||||
this.raceRepository = raceRepository;
|
||||
this.leagueRepository = leagueRepository;
|
||||
|
||||
if (seedData) {
|
||||
seedData.forEach(standing => {
|
||||
const key = this.getKey(standing.leagueId, standing.driverId);
|
||||
this.standings.set(key, standing);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getKey(leagueId: string, driverId: string): string {
|
||||
return `${leagueId}:${driverId}`;
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Standing[]> {
|
||||
return Array.from(this.standings.values())
|
||||
.filter(standing => standing.leagueId === leagueId)
|
||||
.sort((a, b) => {
|
||||
// Sort by position (lower is better)
|
||||
if (a.position !== b.position) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
// If positions are equal, sort by points (higher is better)
|
||||
return b.points - a.points;
|
||||
});
|
||||
}
|
||||
|
||||
async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Standing | null> {
|
||||
const key = this.getKey(leagueId, driverId);
|
||||
return this.standings.get(key) ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Standing[]> {
|
||||
return Array.from(this.standings.values());
|
||||
}
|
||||
|
||||
async save(standing: Standing): Promise<Standing> {
|
||||
const key = this.getKey(standing.leagueId, standing.driverId);
|
||||
this.standings.set(key, standing);
|
||||
return standing;
|
||||
}
|
||||
|
||||
async saveMany(standings: Standing[]): Promise<Standing[]> {
|
||||
standings.forEach(standing => {
|
||||
const key = this.getKey(standing.leagueId, standing.driverId);
|
||||
this.standings.set(key, standing);
|
||||
});
|
||||
return standings;
|
||||
}
|
||||
|
||||
async delete(leagueId: string, driverId: string): Promise<void> {
|
||||
const key = this.getKey(leagueId, driverId);
|
||||
this.standings.delete(key);
|
||||
}
|
||||
|
||||
async deleteByLeagueId(leagueId: string): Promise<void> {
|
||||
const toDelete = Array.from(this.standings.values())
|
||||
.filter(standing => standing.leagueId === leagueId);
|
||||
|
||||
toDelete.forEach(standing => {
|
||||
const key = this.getKey(standing.leagueId, standing.driverId);
|
||||
this.standings.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
async exists(leagueId: string, driverId: string): Promise<boolean> {
|
||||
const key = this.getKey(leagueId, driverId);
|
||||
return this.standings.has(key);
|
||||
}
|
||||
|
||||
async recalculate(leagueId: string): Promise<Standing[]> {
|
||||
if (!this.resultRepository || !this.raceRepository || !this.leagueRepository) {
|
||||
throw new Error('Cannot recalculate standings: missing required repositories');
|
||||
}
|
||||
|
||||
// Get league to determine points system
|
||||
const league = await this.leagueRepository.findById(leagueId);
|
||||
if (!league) {
|
||||
throw new Error(`League with ID ${leagueId} not found`);
|
||||
}
|
||||
|
||||
// Get points system
|
||||
const pointsSystem = league.settings.customPoints ??
|
||||
POINTS_SYSTEMS[league.settings.pointsSystem] ??
|
||||
POINTS_SYSTEMS['f1-2024'];
|
||||
|
||||
// Get all completed races for the league
|
||||
const races = await this.raceRepository.findCompletedByLeagueId(leagueId);
|
||||
|
||||
// Get all results for these races
|
||||
const allResults = await Promise.all(
|
||||
races.map(race => this.resultRepository!.findByRaceId(race.id))
|
||||
);
|
||||
const results = allResults.flat();
|
||||
|
||||
// Calculate standings per driver
|
||||
const standingsMap = new Map<string, Standing>();
|
||||
|
||||
results.forEach(result => {
|
||||
let standing = standingsMap.get(result.driverId);
|
||||
|
||||
if (!standing) {
|
||||
standing = Standing.create({
|
||||
leagueId,
|
||||
driverId: result.driverId,
|
||||
});
|
||||
}
|
||||
|
||||
// Add points from this result
|
||||
standing = standing.addRaceResult(result.position, pointsSystem);
|
||||
standingsMap.set(result.driverId, standing);
|
||||
});
|
||||
|
||||
// Sort by points and assign positions
|
||||
const sortedStandings = Array.from(standingsMap.values())
|
||||
.sort((a, b) => {
|
||||
if (b.points !== a.points) {
|
||||
return b.points - a.points;
|
||||
}
|
||||
// Tie-breaker: most wins
|
||||
if (b.wins !== a.wins) {
|
||||
return b.wins - a.wins;
|
||||
}
|
||||
// Tie-breaker: most races completed
|
||||
return b.racesCompleted - a.racesCompleted;
|
||||
});
|
||||
|
||||
// Assign positions
|
||||
const updatedStandings = sortedStandings.map((standing, index) =>
|
||||
standing.updatePosition(index + 1)
|
||||
);
|
||||
|
||||
// Save all standings
|
||||
await this.saveMany(updatedStandings);
|
||||
|
||||
return updatedStandings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available points systems
|
||||
*/
|
||||
static getPointsSystems(): Record<string, Record<number, number>> {
|
||||
return POINTS_SYSTEMS;
|
||||
}
|
||||
}
|
||||
@@ -5,23 +5,23 @@
|
||||
* Allows easy swapping to persistent repositories later.
|
||||
*/
|
||||
|
||||
import { Driver } from '../domain/entities/Driver';
|
||||
import { League } from '../domain/entities/League';
|
||||
import { Race } from '../domain/entities/Race';
|
||||
import { Result } from '../domain/entities/Result';
|
||||
import { Standing } from '../domain/entities/Standing';
|
||||
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
|
||||
import { League } from '@gridpilot/racing-domain/entities/League';
|
||||
import { Race } from '@gridpilot/racing-domain/entities/Race';
|
||||
import { Result } from '@gridpilot/racing-domain/entities/Result';
|
||||
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
|
||||
|
||||
import { IDriverRepository } from '../application/ports/IDriverRepository';
|
||||
import { ILeagueRepository } from '../application/ports/ILeagueRepository';
|
||||
import { IRaceRepository } from '../application/ports/IRaceRepository';
|
||||
import { IResultRepository } from '../application/ports/IResultRepository';
|
||||
import { IStandingRepository } from '../application/ports/IStandingRepository';
|
||||
import type { IDriverRepository } from '@gridpilot/racing-domain/ports/IDriverRepository';
|
||||
import type { ILeagueRepository } from '@gridpilot/racing-domain/ports/ILeagueRepository';
|
||||
import type { IRaceRepository } from '@gridpilot/racing-domain/ports/IRaceRepository';
|
||||
import type { IResultRepository } from '@gridpilot/racing-domain/ports/IResultRepository';
|
||||
import type { IStandingRepository } from '@gridpilot/racing-domain/ports/IStandingRepository';
|
||||
|
||||
import { InMemoryDriverRepository } from '../infrastructure/repositories/InMemoryDriverRepository';
|
||||
import { InMemoryLeagueRepository } from '../infrastructure/repositories/InMemoryLeagueRepository';
|
||||
import { InMemoryRaceRepository } from '../infrastructure/repositories/InMemoryRaceRepository';
|
||||
import { InMemoryResultRepository } from '../infrastructure/repositories/InMemoryResultRepository';
|
||||
import { InMemoryStandingRepository } from '../infrastructure/repositories/InMemoryStandingRepository';
|
||||
import { InMemoryDriverRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryDriverRepository';
|
||||
import { InMemoryLeagueRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryLeagueRepository';
|
||||
import { InMemoryRaceRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryRaceRepository';
|
||||
import { InMemoryResultRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryResultRepository';
|
||||
import { InMemoryStandingRepository } from '@gridpilot/racing-infrastructure/repositories/InMemoryStandingRepository';
|
||||
|
||||
/**
|
||||
* Seed data for development
|
||||
|
||||
Reference in New Issue
Block a user