alpha wip

This commit is contained in:
2025-12-03 01:16:37 +01:00
parent 97e29d3d80
commit a572e6edce
104 changed files with 187 additions and 68 deletions

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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';

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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,
}));
}
}

View File

@@ -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>;
}

View File

@@ -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[]>;
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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[]>;
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -1,6 +1,6 @@
'use client';
import { Race } from '../../domain/entities/Race';
import { Race } from '@gridpilot/racing-domain/entities/Race';
interface RaceCardProps {
race: Race;

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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[];

View File

@@ -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,
});
}
}

View File

@@ -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,
});
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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