wip
This commit is contained in:
130
packages/racing/domain/entities/Car.ts
Normal file
130
packages/racing/domain/entities/Car.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Domain Entity: Car
|
||||
*
|
||||
* Represents a racing car/vehicle in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export type CarClass = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt';
|
||||
export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro';
|
||||
|
||||
export class Car {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly shortName: string;
|
||||
readonly manufacturer: string;
|
||||
readonly carClass: CarClass;
|
||||
readonly license: CarLicense;
|
||||
readonly year: number;
|
||||
readonly horsepower?: number;
|
||||
readonly weight?: number;
|
||||
readonly imageUrl?: string;
|
||||
readonly gameId: string;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
manufacturer: string;
|
||||
carClass: CarClass;
|
||||
license: CarLicense;
|
||||
year: number;
|
||||
horsepower?: number;
|
||||
weight?: number;
|
||||
imageUrl?: string;
|
||||
gameId: string;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.shortName = props.shortName;
|
||||
this.manufacturer = props.manufacturer;
|
||||
this.carClass = props.carClass;
|
||||
this.license = props.license;
|
||||
this.year = props.year;
|
||||
this.horsepower = props.horsepower;
|
||||
this.weight = props.weight;
|
||||
this.imageUrl = props.imageUrl;
|
||||
this.gameId = props.gameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Car entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName?: string;
|
||||
manufacturer: string;
|
||||
carClass?: CarClass;
|
||||
license?: CarLicense;
|
||||
year?: number;
|
||||
horsepower?: number;
|
||||
weight?: number;
|
||||
imageUrl?: string;
|
||||
gameId: string;
|
||||
}): Car {
|
||||
this.validate(props);
|
||||
|
||||
return new Car({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
shortName: props.shortName ?? props.name.slice(0, 10),
|
||||
manufacturer: props.manufacturer,
|
||||
carClass: props.carClass ?? 'gt',
|
||||
license: props.license ?? 'D',
|
||||
year: props.year ?? new Date().getFullYear(),
|
||||
horsepower: props.horsepower,
|
||||
weight: props.weight,
|
||||
imageUrl: props.imageUrl,
|
||||
gameId: props.gameId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
manufacturer: string;
|
||||
gameId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Car ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Car name is required');
|
||||
}
|
||||
|
||||
if (!props.manufacturer || props.manufacturer.trim().length === 0) {
|
||||
throw new Error('Car manufacturer is required');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('Game ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted car display name
|
||||
*/
|
||||
getDisplayName(): string {
|
||||
return `${this.manufacturer} ${this.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license badge color
|
||||
*/
|
||||
getLicenseColor(): string {
|
||||
const colors: Record<CarLicense, string> = {
|
||||
'R': '#FF6B6B',
|
||||
'D': '#FFB347',
|
||||
'C': '#FFD700',
|
||||
'B': '#7FFF00',
|
||||
'A': '#00BFFF',
|
||||
'Pro': '#9370DB',
|
||||
};
|
||||
return colors[this.license];
|
||||
}
|
||||
}
|
||||
146
packages/racing/domain/entities/Protest.ts
Normal file
146
packages/racing/domain/entities/Protest.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Domain Entity: Protest
|
||||
*
|
||||
* Represents a protest filed by a driver against another driver for an incident during a race.
|
||||
*/
|
||||
|
||||
export type ProtestStatus = 'pending' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
|
||||
|
||||
export interface ProtestIncident {
|
||||
/** Lap number where the incident occurred */
|
||||
lap: number;
|
||||
/** Time in the race (seconds from start, or timestamp) */
|
||||
timeInRace?: number;
|
||||
/** Brief description of the incident */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ProtestProps {
|
||||
id: string;
|
||||
raceId: string;
|
||||
/** The driver filing the protest */
|
||||
protestingDriverId: string;
|
||||
/** The driver being protested against */
|
||||
accusedDriverId: string;
|
||||
/** Details of the incident */
|
||||
incident: ProtestIncident;
|
||||
/** Optional comment/statement from the protesting driver */
|
||||
comment?: string;
|
||||
/** URL to proof video clip */
|
||||
proofVideoUrl?: string;
|
||||
/** Current status of the protest */
|
||||
status: ProtestStatus;
|
||||
/** ID of the steward/admin who reviewed (if any) */
|
||||
reviewedBy?: string;
|
||||
/** Decision notes from the steward */
|
||||
decisionNotes?: string;
|
||||
/** Timestamp when the protest was filed */
|
||||
filedAt: Date;
|
||||
/** Timestamp when the protest was reviewed */
|
||||
reviewedAt?: Date;
|
||||
}
|
||||
|
||||
export class Protest {
|
||||
private constructor(private readonly props: ProtestProps) {}
|
||||
|
||||
static create(props: ProtestProps): Protest {
|
||||
if (!props.id) throw new Error('Protest ID is required');
|
||||
if (!props.raceId) throw new Error('Race ID is required');
|
||||
if (!props.protestingDriverId) throw new Error('Protesting driver ID is required');
|
||||
if (!props.accusedDriverId) throw new Error('Accused driver ID is required');
|
||||
if (!props.incident) throw new Error('Incident details are required');
|
||||
if (props.incident.lap < 0) throw new Error('Lap number must be non-negative');
|
||||
if (!props.incident.description?.trim()) throw new Error('Incident description is required');
|
||||
|
||||
return new Protest({
|
||||
...props,
|
||||
status: props.status || 'pending',
|
||||
filedAt: props.filedAt || new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
get id(): string { return this.props.id; }
|
||||
get raceId(): string { return this.props.raceId; }
|
||||
get protestingDriverId(): string { return this.props.protestingDriverId; }
|
||||
get accusedDriverId(): string { return this.props.accusedDriverId; }
|
||||
get incident(): ProtestIncident { return { ...this.props.incident }; }
|
||||
get comment(): string | undefined { return this.props.comment; }
|
||||
get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl; }
|
||||
get status(): ProtestStatus { return this.props.status; }
|
||||
get reviewedBy(): string | undefined { return this.props.reviewedBy; }
|
||||
get decisionNotes(): string | undefined { return this.props.decisionNotes; }
|
||||
get filedAt(): Date { return this.props.filedAt; }
|
||||
get reviewedAt(): Date | undefined { return this.props.reviewedAt; }
|
||||
|
||||
isPending(): boolean {
|
||||
return this.props.status === 'pending';
|
||||
}
|
||||
|
||||
isUnderReview(): boolean {
|
||||
return this.props.status === 'under_review';
|
||||
}
|
||||
|
||||
isResolved(): boolean {
|
||||
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start reviewing the protest
|
||||
*/
|
||||
startReview(stewardId: string): Protest {
|
||||
if (!this.isPending()) {
|
||||
throw new Error('Only pending protests can be put under review');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'under_review',
|
||||
reviewedBy: stewardId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uphold the protest (finding the accused guilty)
|
||||
*/
|
||||
uphold(stewardId: string, decisionNotes: string): Protest {
|
||||
if (!this.isPending() && !this.isUnderReview()) {
|
||||
throw new Error('Only pending or under-review protests can be upheld');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'upheld',
|
||||
reviewedBy: stewardId,
|
||||
decisionNotes,
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the protest (finding no fault)
|
||||
*/
|
||||
dismiss(stewardId: string, decisionNotes: string): Protest {
|
||||
if (!this.isPending() && !this.isUnderReview()) {
|
||||
throw new Error('Only pending or under-review protests can be dismissed');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'dismissed',
|
||||
reviewedBy: stewardId,
|
||||
decisionNotes,
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw the protest (by the protesting driver)
|
||||
*/
|
||||
withdraw(): Protest {
|
||||
if (this.isResolved()) {
|
||||
throw new Error('Cannot withdraw a resolved protest');
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'withdrawn',
|
||||
reviewedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,53 @@
|
||||
/**
|
||||
* 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 type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
|
||||
export class Race {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly scheduledAt: Date;
|
||||
readonly track: string;
|
||||
readonly trackId?: string;
|
||||
readonly car: string;
|
||||
readonly carId?: string;
|
||||
readonly sessionType: SessionType;
|
||||
readonly status: RaceStatus;
|
||||
readonly strengthOfField?: number;
|
||||
readonly registeredCount?: number;
|
||||
readonly maxParticipants?: number;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
trackId?: string;
|
||||
car: string;
|
||||
carId?: string;
|
||||
sessionType: SessionType;
|
||||
status: RaceStatus;
|
||||
strengthOfField?: number;
|
||||
registeredCount?: number;
|
||||
maxParticipants?: number;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.leagueId = props.leagueId;
|
||||
this.scheduledAt = props.scheduledAt;
|
||||
this.track = props.track;
|
||||
this.trackId = props.trackId;
|
||||
this.car = props.car;
|
||||
this.carId = props.carId;
|
||||
this.sessionType = props.sessionType;
|
||||
this.status = props.status;
|
||||
this.strengthOfField = props.strengthOfField;
|
||||
this.registeredCount = props.registeredCount;
|
||||
this.maxParticipants = props.maxParticipants;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,9 +58,14 @@ export class Race {
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
trackId?: string;
|
||||
car: string;
|
||||
carId?: string;
|
||||
sessionType?: SessionType;
|
||||
status?: RaceStatus;
|
||||
strengthOfField?: number;
|
||||
registeredCount?: number;
|
||||
maxParticipants?: number;
|
||||
}): Race {
|
||||
this.validate(props);
|
||||
|
||||
@@ -54,9 +74,14 @@ export class Race {
|
||||
leagueId: props.leagueId,
|
||||
scheduledAt: props.scheduledAt,
|
||||
track: props.track,
|
||||
trackId: props.trackId,
|
||||
car: props.car,
|
||||
carId: props.carId,
|
||||
sessionType: props.sessionType ?? 'race',
|
||||
status: props.status ?? 'scheduled',
|
||||
strengthOfField: props.strengthOfField,
|
||||
registeredCount: props.registeredCount,
|
||||
maxParticipants: props.maxParticipants,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,6 +116,20 @@ export class Race {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the race (move from scheduled to running)
|
||||
*/
|
||||
start(): Race {
|
||||
if (this.status !== 'scheduled') {
|
||||
throw new Error('Only scheduled races can be started');
|
||||
}
|
||||
|
||||
return new Race({
|
||||
...this,
|
||||
status: 'running',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark race as completed
|
||||
*/
|
||||
@@ -127,6 +166,17 @@ export class Race {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SOF and participant count
|
||||
*/
|
||||
updateField(strengthOfField: number, registeredCount: number): Race {
|
||||
return new Race({
|
||||
...this,
|
||||
strengthOfField,
|
||||
registeredCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is in the past
|
||||
*/
|
||||
@@ -140,4 +190,11 @@ export class Race {
|
||||
isUpcoming(): boolean {
|
||||
return this.status === 'scheduled' && !this.isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if race is live/running
|
||||
*/
|
||||
isLive(): boolean {
|
||||
return this.status === 'running';
|
||||
}
|
||||
}
|
||||
120
packages/racing/domain/entities/Track.ts
Normal file
120
packages/racing/domain/entities/Track.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Domain Entity: Track
|
||||
*
|
||||
* Represents a racing track/circuit in the GridPilot platform.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
|
||||
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||
|
||||
export class Track {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly shortName: string;
|
||||
readonly country: string;
|
||||
readonly category: TrackCategory;
|
||||
readonly difficulty: TrackDifficulty;
|
||||
readonly lengthKm: number;
|
||||
readonly turns: number;
|
||||
readonly imageUrl?: string;
|
||||
readonly gameId: string;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
country: string;
|
||||
category: TrackCategory;
|
||||
difficulty: TrackDifficulty;
|
||||
lengthKm: number;
|
||||
turns: number;
|
||||
imageUrl?: string;
|
||||
gameId: string;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.shortName = props.shortName;
|
||||
this.country = props.country;
|
||||
this.category = props.category;
|
||||
this.difficulty = props.difficulty;
|
||||
this.lengthKm = props.lengthKm;
|
||||
this.turns = props.turns;
|
||||
this.imageUrl = props.imageUrl;
|
||||
this.gameId = props.gameId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new Track entity
|
||||
*/
|
||||
static create(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName?: string;
|
||||
country: string;
|
||||
category?: TrackCategory;
|
||||
difficulty?: TrackDifficulty;
|
||||
lengthKm: number;
|
||||
turns: number;
|
||||
imageUrl?: string;
|
||||
gameId: string;
|
||||
}): Track {
|
||||
this.validate(props);
|
||||
|
||||
return new Track({
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
shortName: props.shortName ?? props.name.slice(0, 3).toUpperCase(),
|
||||
country: props.country,
|
||||
category: props.category ?? 'road',
|
||||
difficulty: props.difficulty ?? 'intermediate',
|
||||
lengthKm: props.lengthKm,
|
||||
turns: props.turns,
|
||||
imageUrl: props.imageUrl,
|
||||
gameId: props.gameId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain validation logic
|
||||
*/
|
||||
private static validate(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
lengthKm: number;
|
||||
turns: number;
|
||||
gameId: string;
|
||||
}): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('Track ID is required');
|
||||
}
|
||||
|
||||
if (!props.name || props.name.trim().length === 0) {
|
||||
throw new Error('Track name is required');
|
||||
}
|
||||
|
||||
if (!props.country || props.country.trim().length === 0) {
|
||||
throw new Error('Track country is required');
|
||||
}
|
||||
|
||||
if (props.lengthKm <= 0) {
|
||||
throw new Error('Track length must be positive');
|
||||
}
|
||||
|
||||
if (props.turns < 0) {
|
||||
throw new Error('Track turns cannot be negative');
|
||||
}
|
||||
|
||||
if (!props.gameId || props.gameId.trim().length === 0) {
|
||||
throw new Error('Game ID is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted length string
|
||||
*/
|
||||
getFormattedLength(): string {
|
||||
return `${this.lengthKm.toFixed(2)} km`;
|
||||
}
|
||||
}
|
||||
65
packages/racing/domain/repositories/ICarRepository.ts
Normal file
65
packages/racing/domain/repositories/ICarRepository.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Application Port: ICarRepository
|
||||
*
|
||||
* Repository interface for Car entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import type { Car, CarClass, CarLicense } from '../entities/Car';
|
||||
|
||||
export interface ICarRepository {
|
||||
/**
|
||||
* Find a car by ID
|
||||
*/
|
||||
findById(id: string): Promise<Car | null>;
|
||||
|
||||
/**
|
||||
* Find all cars
|
||||
*/
|
||||
findAll(): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Find cars by game ID
|
||||
*/
|
||||
findByGameId(gameId: string): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Find cars by class
|
||||
*/
|
||||
findByClass(carClass: CarClass): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Find cars by license level
|
||||
*/
|
||||
findByLicense(license: CarLicense): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Find cars by manufacturer
|
||||
*/
|
||||
findByManufacturer(manufacturer: string): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Search cars by name
|
||||
*/
|
||||
searchByName(query: string): Promise<Car[]>;
|
||||
|
||||
/**
|
||||
* Create a new car
|
||||
*/
|
||||
create(car: Car): Promise<Car>;
|
||||
|
||||
/**
|
||||
* Update an existing car
|
||||
*/
|
||||
update(car: Car): Promise<Car>;
|
||||
|
||||
/**
|
||||
* Delete a car by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a car exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
60
packages/racing/domain/repositories/ITrackRepository.ts
Normal file
60
packages/racing/domain/repositories/ITrackRepository.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Application Port: ITrackRepository
|
||||
*
|
||||
* Repository interface for Track entity CRUD operations.
|
||||
* Defines async methods using domain entities as types.
|
||||
*/
|
||||
|
||||
import type { Track, TrackCategory } from '../entities/Track';
|
||||
|
||||
export interface ITrackRepository {
|
||||
/**
|
||||
* Find a track by ID
|
||||
*/
|
||||
findById(id: string): Promise<Track | null>;
|
||||
|
||||
/**
|
||||
* Find all tracks
|
||||
*/
|
||||
findAll(): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Find tracks by game ID
|
||||
*/
|
||||
findByGameId(gameId: string): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Find tracks by category
|
||||
*/
|
||||
findByCategory(category: TrackCategory): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Find tracks by country
|
||||
*/
|
||||
findByCountry(country: string): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Search tracks by name
|
||||
*/
|
||||
searchByName(query: string): Promise<Track[]>;
|
||||
|
||||
/**
|
||||
* Create a new track
|
||||
*/
|
||||
create(track: Track): Promise<Track>;
|
||||
|
||||
/**
|
||||
* Update an existing track
|
||||
*/
|
||||
update(track: Track): Promise<Track>;
|
||||
|
||||
/**
|
||||
* Delete a track by ID
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a track exists by ID
|
||||
*/
|
||||
exists(id: string): Promise<boolean>;
|
||||
}
|
||||
39
packages/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
39
packages/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Domain Service: StrengthOfFieldCalculator
|
||||
*
|
||||
* Calculates the Strength of Field (SOF) for a race based on participant ratings.
|
||||
* SOF is the average rating of all participants in a race.
|
||||
*/
|
||||
|
||||
export interface DriverRating {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface StrengthOfFieldCalculator {
|
||||
/**
|
||||
* Calculate SOF from a list of driver ratings
|
||||
* Returns null if no valid ratings are provided
|
||||
*/
|
||||
calculate(driverRatings: DriverRating[]): number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation using simple average
|
||||
*/
|
||||
export class AverageStrengthOfFieldCalculator implements StrengthOfFieldCalculator {
|
||||
calculate(driverRatings: DriverRating[]): number | null {
|
||||
if (driverRatings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validRatings = driverRatings.filter(dr => dr.rating > 0);
|
||||
|
||||
if (validRatings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sum = validRatings.reduce((acc, dr) => acc + dr.rating, 0);
|
||||
return Math.round(sum / validRatings.length);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user