This commit is contained in:
2025-12-11 13:50:38 +01:00
parent e4c1be628d
commit c7e5de40d6
212 changed files with 2965 additions and 763 deletions

View File

@@ -1,17 +1,17 @@
/**
* Domain Entity: Car
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
*
*
* Represents a racing car/vehicle in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type CarClass = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt';
export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro';
export class Car {
export class Car implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly shortName: string;

View File

@@ -1,4 +1,4 @@
import type { ParticipantRef } from '../value-objects/ParticipantRef';
import type { ParticipantRef } from '../types/ParticipantRef';
export class ChampionshipStanding {
readonly seasonId: string;

View File

@@ -4,10 +4,11 @@
* Represents a driver profile in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Driver {
import type { IEntity } from '@gridpilot/shared/domain';
export class Driver implements IEntity<string> {
readonly id: string;
readonly iracingId: string;
readonly name: string;

View File

@@ -1,8 +1,9 @@
/**
* Domain Entity: DriverLivery
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
* Represents a driver's custom livery for a specific car.
* Includes user-placed decals and league-specific overrides.
@@ -31,7 +32,7 @@ export interface DriverLiveryProps {
validatedAt?: Date;
}
export class DriverLivery {
export class DriverLivery implements IEntity<string> {
readonly id: string;
readonly driverId: string;
readonly gameId: string;

View File

@@ -1,6 +1,7 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Game {
import type { IEntity } from '@gridpilot/shared/domain';
export class Game implements IEntity<string> {
readonly id: string;
readonly name: string;

View File

@@ -4,8 +4,9 @@
* Represents a league in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
/**
* Stewarding decision mode for protests
@@ -78,8 +79,8 @@ export interface LeagueSocialLinks {
youtubeUrl?: string;
websiteUrl?: string;
}
export class League {
export class League implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly description: string;

View File

@@ -1,19 +1,75 @@
/**
* Domain Entity: LeagueMembership and JoinRequest
*
* Extracted from racing-application memberships module so that
* membership-related types live in the racing-domain package.
* Represents a driver's membership in a league and join requests.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
export type MembershipStatus = 'active' | 'pending' | 'none';
export interface LeagueMembership {
export interface LeagueMembershipProps {
id?: string;
leagueId: string;
driverId: string;
role: MembershipRole;
status: MembershipStatus;
joinedAt: Date;
status?: MembershipStatus;
joinedAt?: Date;
}
export class LeagueMembership implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly driverId: string;
readonly role: MembershipRole;
readonly status: MembershipStatus;
readonly joinedAt: Date;
private constructor(props: Required<LeagueMembershipProps>) {
this.id = props.id;
this.leagueId = props.leagueId;
this.driverId = props.driverId;
this.role = props.role;
this.status = props.status;
this.joinedAt = props.joinedAt;
}
static create(props: LeagueMembershipProps): LeagueMembership {
this.validate(props);
const id =
props.id && props.id.trim().length > 0
? props.id
: `${props.leagueId}:${props.driverId}`;
const status = props.status ?? 'pending';
const joinedAt = props.joinedAt ?? new Date();
return new LeagueMembership({
id,
leagueId: props.leagueId,
driverId: props.driverId,
role: props.role,
status,
joinedAt,
});
}
private static validate(props: LeagueMembershipProps): void {
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new RacingDomainValidationError('League ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
if (!props.role) {
throw new RacingDomainValidationError('Membership role is required');
}
}
}
export interface JoinRequest {

View File

@@ -1,4 +1,4 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
export interface LeagueScoringConfig {
id: string;

View File

@@ -4,8 +4,9 @@
* Represents a league's financial wallet.
* Aggregate root for managing league finances and transactions.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
import type { Transaction } from './Transaction';
@@ -18,7 +19,7 @@ export interface LeagueWalletProps {
createdAt: Date;
}
export class LeagueWallet {
export class LeagueWallet implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly balance: Money;

View File

@@ -1,8 +1,9 @@
/**
* Domain Entity: LiveryTemplate
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
* Represents an admin-defined livery template for a specific car.
* Contains base image and sponsor decal placements.
@@ -21,7 +22,7 @@ export interface LiveryTemplateProps {
updatedAt?: Date;
}
export class LiveryTemplate {
export class LiveryTemplate implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly seasonId: string;

View File

@@ -4,8 +4,9 @@
* Represents a penalty applied to a driver for an incident during a race.
* Penalties can be applied as a result of an upheld protest or directly by stewards.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export type PenaltyType =
| 'time_penalty' // Add time to race result (e.g., +5 seconds)
@@ -45,7 +46,7 @@ export interface PenaltyProps {
notes?: string;
}
export class Penalty {
export class Penalty implements IEntity<string> {
private constructor(private readonly props: PenaltyProps) {}
static create(props: PenaltyProps): Penalty {

View File

@@ -3,8 +3,9 @@
*
* Represents a prize awarded to a driver for a specific position in a season.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
@@ -23,7 +24,7 @@ export interface PrizeProps {
description?: string;
}
export class Prize {
export class Prize implements IEntity<string> {
readonly id: string;
readonly seasonId: string;
readonly position: number;

View File

@@ -1,8 +1,9 @@
/**
* Domain Entity: Protest
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
* Represents a protest filed by a driver against another driver for an incident during a race.
*
@@ -66,7 +67,7 @@ export interface ProtestProps {
defenseRequestedBy?: string;
}
export class Protest {
export class Protest implements IEntity<string> {
private constructor(private readonly props: ProtestProps) {}
static create(props: ProtestProps): Protest {

View File

@@ -4,13 +4,14 @@
* Represents a race/session in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export type SessionType = 'practice' | 'qualifying' | 'race';
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
export class Race {
export class Race implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly scheduledAt: Date;

View File

@@ -1,12 +1,57 @@
/**
* Domain Entity: RaceRegistration
*
* Extracted from racing-application registrations module so that
* registration-related types live in the racing-domain package.
* Represents a registration of a driver for a specific race.
*/
export interface RaceRegistration {
import type { IEntity } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface RaceRegistrationProps {
id?: string;
raceId: string;
driverId: string;
registeredAt: Date;
registeredAt?: Date;
}
export class RaceRegistration implements IEntity<string> {
readonly id: string;
readonly raceId: string;
readonly driverId: string;
readonly registeredAt: Date;
private constructor(props: Required<RaceRegistrationProps>) {
this.id = props.id;
this.raceId = props.raceId;
this.driverId = props.driverId;
this.registeredAt = props.registeredAt;
}
static create(props: RaceRegistrationProps): RaceRegistration {
this.validate(props);
const id =
props.id && props.id.trim().length > 0
? props.id
: `${props.raceId}:${props.driverId}`;
const registeredAt = props.registeredAt ?? new Date();
return new RaceRegistration({
id,
raceId: props.raceId,
driverId: props.driverId,
registeredAt,
});
}
private static validate(props: RaceRegistrationProps): void {
if (!props.raceId || props.raceId.trim().length === 0) {
throw new RacingDomainValidationError('Race ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
}
}

View File

@@ -4,10 +4,11 @@
* Represents a race result in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export class Result {
export class Result implements IEntity<string> {
readonly id: string;
readonly raceId: string;
readonly driverId: string;

View File

@@ -1,8 +1,9 @@
export type SeasonStatus = 'planned' | 'active' | 'completed';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class Season {
import type { IEntity } from '@gridpilot/shared/domain';
export class Season implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly gameId: string;

View File

@@ -4,8 +4,9 @@
* Represents a sponsorship relationship between a Sponsor and a Season.
* Aggregate root for managing sponsorship slots and pricing.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
@@ -24,7 +25,7 @@ export interface SeasonSponsorshipProps {
description?: string;
}
export class SeasonSponsorship {
export class SeasonSponsorship implements IEntity<string> {
readonly id: string;
readonly seasonId: string;
readonly sponsorId: string;

View File

@@ -1,8 +1,9 @@
/**
* Domain Entity: Sponsor
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
*
* Represents a sponsor that can sponsor leagues/seasons.
* Aggregate root for sponsor information.
@@ -17,7 +18,7 @@ export interface SponsorProps {
createdAt: Date;
}
export class Sponsor {
export class Sponsor implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly contactEmail: string;

View File

@@ -4,8 +4,9 @@
* Represents a sponsorship application from a Sponsor to any sponsorable entity
* (driver, team, race, or league/season). The entity owner must approve/reject.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
import type { Money } from '../value-objects/Money';
import type { SponsorshipTier } from './SeasonSponsorship';
@@ -28,7 +29,7 @@ export interface SponsorshipRequestProps {
rejectionReason?: string;
}
export class SponsorshipRequest {
export class SponsorshipRequest implements IEntity<string> {
readonly id: string;
readonly sponsorId: string;
readonly entityType: SponsorableEntityType;

View File

@@ -1,21 +1,24 @@
/**
* Domain Entity: Standing
*
*
* Represents a championship standing in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export class Standing {
export class Standing implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly driverId: string;
readonly points: number;
readonly wins: number;
readonly position: number;
readonly racesCompleted: number;
private constructor(props: {
id: string;
leagueId: string;
driverId: string;
points: number;
@@ -23,6 +26,7 @@ export class Standing {
position: number;
racesCompleted: number;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.driverId = props.driverId;
this.points = props.points;
@@ -35,6 +39,7 @@ export class Standing {
* Factory method to create a new Standing entity
*/
static create(props: {
id?: string;
leagueId: string;
driverId: string;
points?: number;
@@ -44,7 +49,12 @@ export class Standing {
}): Standing {
this.validate(props);
const id = props.id && props.id.trim().length > 0
? props.id
: `${props.leagueId}:${props.driverId}`;
return new Standing({
id,
leagueId: props.leagueId,
driverId: props.driverId,
points: props.points ?? 0,
@@ -58,15 +68,16 @@ export class Standing {
* Domain validation logic
*/
private static validate(props: {
id?: string;
leagueId: string;
driverId: string;
}): void {
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new RacingDomainError('League ID is required');
throw new RacingDomainValidationError('League ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainError('Driver ID is required');
throw new RacingDomainValidationError('Driver ID is required');
}
}
@@ -78,6 +89,7 @@ export class Standing {
const isWin = position === 1;
return new Standing({
id: this.id,
leagueId: this.leagueId,
driverId: this.driverId,
points: this.points + racePoints,

View File

@@ -1,35 +1,128 @@
/**
* Domain Entities: Team, TeamMembership, TeamJoinRequest
* Domain Entity: Team
*
* Extracted from racing-application teams module so that
* team-related types live in the racing-domain package.
* Represents a racing team in the GridPilot platform.
* Implements the shared IEntity<string> contract and encapsulates
* basic invariants around identity and core properties.
*/
export type TeamRole = 'owner' | 'manager' | 'driver';
export type TeamMembershipStatus = 'active' | 'pending' | 'none';
import type { IEntity } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export interface Team {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
}
export class Team implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly tag: string;
readonly description: string;
readonly ownerId: string;
readonly leagues: string[];
readonly createdAt: Date;
export interface TeamMembership {
teamId: string;
driverId: string;
role: TeamRole;
status: TeamMembershipStatus;
joinedAt: Date;
}
private constructor(props: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
}) {
this.id = props.id;
this.name = props.name;
this.tag = props.tag;
this.description = props.description;
this.ownerId = props.ownerId;
this.leagues = props.leagues;
this.createdAt = props.createdAt;
}
export interface TeamJoinRequest {
id: string;
teamId: string;
driverId: string;
requestedAt: Date;
message?: string;
/**
* Factory method to create a new Team entity.
*/
static create(props: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt?: Date;
}): Team {
this.validate(props);
return new Team({
id: props.id,
name: props.name,
tag: props.tag,
description: props.description,
ownerId: props.ownerId,
leagues: [...props.leagues],
createdAt: props.createdAt ?? new Date(),
});
}
/**
* Create a copy with updated properties.
*/
update(props: Partial<{
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
}>): Team {
const next: Team = new Team({
id: this.id,
name: props.name ?? this.name,
tag: props.tag ?? this.tag,
description: props.description ?? this.description,
ownerId: props.ownerId ?? this.ownerId,
leagues: props.leagues ? [...props.leagues] : [...this.leagues],
createdAt: this.createdAt,
});
// Re-validate updated aggregate
Team.validate({
id: next.id,
name: next.name,
tag: next.tag,
description: next.description,
ownerId: next.ownerId,
leagues: next.leagues,
});
return next;
}
/**
* Domain validation logic for core invariants.
*/
private static validate(props: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Team ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('Team name is required');
}
if (!props.tag || props.tag.trim().length === 0) {
throw new RacingDomainValidationError('Team tag is required');
}
if (!props.ownerId || props.ownerId.trim().length === 0) {
throw new RacingDomainValidationError('Team owner ID is required');
}
if (!Array.isArray(props.leagues)) {
throw new RacingDomainValidationError('Team leagues must be an array');
}
}
}

View File

@@ -4,13 +4,14 @@
* Represents a racing track/circuit in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@gridpilot/shared/domain';
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export class Track {
export class Track implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly shortName: string;

View File

@@ -3,10 +3,11 @@
*
* Represents a financial transaction in the league wallet system.
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { Money } from '../value-objects/Money';
import type { IEntity } from '@gridpilot/shared/domain';
export type TransactionType =
| 'sponsorship_payment'
@@ -30,8 +31,8 @@ export interface TransactionProps {
description?: string;
metadata?: Record<string, unknown>;
}
export class Transaction {
export class Transaction implements IEntity<string> {
readonly id: string;
readonly walletId: string;
readonly type: TransactionType;

View File

@@ -1,5 +1,9 @@
export abstract class RacingDomainError extends Error {
import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors';
export abstract class RacingDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
readonly type = 'domain' as const;
readonly context = 'racing-domain';
abstract readonly kind: CommonDomainErrorKind;
constructor(message: string) {
super(message);
@@ -7,7 +11,10 @@ export abstract class RacingDomainError extends Error {
}
}
export class RacingDomainValidationError extends RacingDomainError {
export class RacingDomainValidationError
extends RacingDomainError
implements IDomainError<'validation'>
{
readonly kind = 'validation' as const;
constructor(message: string) {
@@ -15,7 +22,10 @@ export class RacingDomainValidationError extends RacingDomainError {
}
}
export class RacingDomainInvariantError extends RacingDomainError {
export class RacingDomainInvariantError
extends RacingDomainError
implements IDomainError<'invariant'>
{
readonly kind = 'invariant' as const;
constructor(message: string) {

View File

@@ -8,7 +8,7 @@
import type {
TeamMembership,
TeamJoinRequest,
} from '../entities/Team';
} from '../types/TeamMembership';
export interface ITeamMembershipRepository {
/**

View File

@@ -1,5 +1,5 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { ParticipantRef } from '../value-objects/ParticipantRef';
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
import type { ParticipantRef } from '../types/ParticipantRef';
import { ChampionshipStanding } from '../entities/ChampionshipStanding';
import type { ParticipantEventPoints } from './EventScoringService';
import { DropScoreApplier, type EventPointsEntry } from './DropScoreApplier';

View File

@@ -1,4 +1,5 @@
import type { DropScorePolicy } from '../value-objects/DropScorePolicy';
import type { DropScorePolicy } from '../types/DropScorePolicy';
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
export interface EventPointsEntry {
eventId: string;
@@ -11,7 +12,16 @@ export interface DropScoreResult {
totalPoints: number;
}
export class DropScoreApplier {
export interface DropScoreInput {
policy: DropScorePolicy;
events: EventPointsEntry[];
}
export class DropScoreApplier implements IDomainCalculationService<DropScoreInput, DropScoreResult> {
calculate(input: DropScoreInput): DropScoreResult {
return this.apply(input.policy, input.events);
}
apply(policy: DropScorePolicy, events: EventPointsEntry[]): DropScoreResult {
if (policy.strategy === 'none' || events.length === 0) {
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);

View File

@@ -1,12 +1,13 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { SessionType } from '../value-objects/SessionType';
import type { ParticipantRef } from '../value-objects/ParticipantRef';
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
import type { SessionType } from '../types/SessionType';
import type { ParticipantRef } from '../types/ParticipantRef';
import type { Result } from '../entities/Result';
import type { Penalty } from '../entities/Penalty';
import type { BonusRule } from '../value-objects/BonusRule';
import type { ChampionshipType } from '../value-objects/ChampionshipType';
import type { BonusRule } from '../types/BonusRule';
import type { ChampionshipType } from '../types/ChampionshipType';
import type { PointsTable } from '../value-objects/PointsTable';
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
export interface ParticipantEventPoints {
participant: ParticipantRef;
@@ -16,6 +17,14 @@ export interface ParticipantEventPoints {
totalPoints: number;
}
export interface EventScoringInput {
seasonId: string;
championship: ChampionshipConfig;
sessionType: SessionType;
results: Result[];
penalties: Penalty[];
}
function createDriverParticipant(driverId: string): ParticipantRef {
return {
type: 'driver' as ChampionshipType,
@@ -23,14 +32,14 @@ function createDriverParticipant(driverId: string): ParticipantRef {
};
}
export class EventScoringService {
scoreSession(params: {
seasonId: string;
championship: ChampionshipConfig;
sessionType: SessionType;
results: Result[];
penalties: Penalty[];
}): ParticipantEventPoints[] {
export class EventScoringService
implements IDomainCalculationService<EventScoringInput, ParticipantEventPoints[]>
{
calculate(input: EventScoringInput): ParticipantEventPoints[] {
return this.scoreSession(input);
}
scoreSession(params: EventScoringInput): ParticipantEventPoints[] {
const { championship, sessionType, results } = params;
const pointsTable = this.getPointsTableForSession(championship, sessionType);

View File

@@ -1,12 +1,7 @@
/**
* Domain Service Port: IImageService
* Backwards-compat alias for legacy imports.
*
* Thin abstraction used by racing application use cases to obtain image URLs
* for drivers, teams and leagues without depending directly on UI/media layers.
* New code should depend on IImageServicePort from
* packages/racing/application/ports/IImageServicePort.
*/
export interface IImageService {
getDriverAvatar(driverId: string): string;
getTeamLogo(teamId: string): string;
getLeagueCover(leagueId: string): string;
getLeagueLogo(leagueId: string): string;
}
export type { IImageServicePort as IImageService } from '../../application/ports/IImageServicePort';

View File

@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest';
import { calculateRaceDates, getNextWeekday, type ScheduleConfig } from './ScheduleCalculator';
import type { Weekday } from '../value-objects/Weekday';
describe('ScheduleCalculator', () => {
/**
* Tests for ScheduleCalculator have been moved to:
* tests/unit/domain/services/ScheduleCalculator.test.ts
*
* This file is kept as a stub to avoid placing tests under domain/services.
*/
describe('calculateRaceDates', () => {
describe('with empty or invalid input', () => {
it('should return empty array when weekdays is empty', () => {

View File

@@ -1,4 +1,4 @@
import type { Weekday } from '../value-objects/Weekday';
import type { Weekday } from '../types/Weekday';
export type RecurrenceStrategy = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';

View File

@@ -3,8 +3,9 @@ import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
import type { Weekday } from '../value-objects/Weekday';
import { weekdayToIndex } from '../value-objects/Weekday';
import type { Weekday } from '../types/Weekday';
import { weekdayToIndex } from '../types/Weekday';
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
function cloneDate(date: Date): Date {
return new Date(date.getTime());
@@ -173,4 +174,12 @@ export class SeasonScheduleGenerator {
return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds);
}
}
export class SeasonScheduleGeneratorService
implements IDomainCalculationService<SeasonSchedule, ScheduledRaceSlot[]>
{
calculate(schedule: SeasonSchedule): ScheduledRaceSlot[] {
return SeasonScheduleGenerator.generateSlots(schedule);
}
}

View File

@@ -1,10 +1,13 @@
import type { IDomainService } from '@gridpilot/shared/domain';
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
/**
* Domain service for determining skill level based on rating.
* This encapsulates the business rule for skill tier classification.
*/
export class SkillLevelService {
export class SkillLevelService implements IDomainService {
readonly serviceName = 'SkillLevelService';
/**
* Map driver rating to skill level band.
* Business rule: iRating thresholds determine skill tiers.

View File

@@ -1,6 +1,8 @@
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
/**
* 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.
*/
@@ -21,7 +23,9 @@ export interface StrengthOfFieldCalculator {
/**
* Default implementation using simple average
*/
export class AverageStrengthOfFieldCalculator implements StrengthOfFieldCalculator {
export class AverageStrengthOfFieldCalculator
implements StrengthOfFieldCalculator, IDomainCalculationService<DriverRating[], number | null>
{
calculate(driverRatings: DriverRating[]): number | null {
if (driverRatings.length === 0) {
return null;

View File

@@ -0,0 +1,21 @@
import type { ChampionshipType } from '../types/ChampionshipType';
import type { SessionType } from '../types/SessionType';
import type { PointsTable } from '../value-objects/PointsTable';
import type { BonusRule } from '../types/BonusRule';
import type { DropScorePolicy } from '../types/DropScorePolicy';
/**
* Domain Type: ChampionshipConfig
*
* Pure configuration shape for a championship's scoring model.
* This is not a value object and intentionally lives under domain/types.
*/
export interface ChampionshipConfig {
id: string;
name: string;
type: ChampionshipType;
sessionTypes: SessionType[];
pointsTableBySessionType: Record<SessionType, PointsTable>;
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
dropScorePolicy: DropScorePolicy;
}

View File

@@ -1,6 +1,6 @@
/**
* Domain Value Object: LeagueRoles
*
* Domain Types/Utilities: LeagueRoles
*
* Utility functions for working with league membership roles.
*/

View File

@@ -0,0 +1,25 @@
/**
* Domain Types: TeamRole, TeamMembershipStatus, TeamMembership, TeamJoinRequest
*
* These are pure domain data shapes (no behavior) used across repositories
* and application DTOs. They are not entities or value objects.
*/
export type TeamRole = 'owner' | 'manager' | 'driver';
export type TeamMembershipStatus = 'active' | 'pending' | 'none';
export interface TeamMembership {
teamId: string;
driverId: string;
role: TeamRole;
status: TeamMembershipStatus;
joinedAt: Date;
}
export interface TeamJoinRequest {
id: string;
teamId: string;
driverId: string;
requestedAt: Date;
message?: string;
}

View File

@@ -1,15 +0,0 @@
import type { ChampionshipType } from './ChampionshipType';
import type { SessionType } from './SessionType';
import { PointsTable } from './PointsTable';
import type { BonusRule } from './BonusRule';
import type { DropScorePolicy } from './DropScorePolicy';
export interface ChampionshipConfig {
id: string;
name: string;
type: ChampionshipType;
sessionTypes: SessionType[];
pointsTableBySessionType: Record<SessionType, PointsTable>;
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
dropScorePolicy: DropScorePolicy;
}

View File

@@ -1,10 +1,12 @@
/**
* Domain Value Object: GameConstraints
*
*
* Represents game-specific constraints for leagues.
* Different sim racing games have different maximum grid sizes.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
export interface GameConstraintsData {
readonly maxDrivers: number;
readonly maxTeams: number;
@@ -14,6 +16,11 @@ export interface GameConstraintsData {
readonly supportsMultiClass: boolean;
}
export interface GameConstraintsProps {
gameId: string;
constraints: GameConstraintsData;
}
/**
* Game-specific constraints for popular sim racing games
*/
@@ -69,7 +76,7 @@ const GAME_CONSTRAINTS: Record<string, GameConstraintsData> = {
},
};
export class GameConstraints {
export class GameConstraints implements IValueObject<GameConstraintsProps> {
readonly gameId: string;
readonly constraints: GameConstraintsData;
@@ -78,6 +85,17 @@ export class GameConstraints {
this.constraints = constraints;
}
get props(): GameConstraintsProps {
return {
gameId: this.gameId,
constraints: this.constraints,
};
}
equals(other: IValueObject<GameConstraintsProps>): boolean {
return this.props.gameId === other.props.gameId;
}
/**
* Get constraints for a specific game
*/

View File

@@ -3,8 +3,9 @@
*
* Represents a valid league description with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueDescriptionValidationResult {
valid: boolean;
@@ -17,7 +18,11 @@ export const LEAGUE_DESCRIPTION_CONSTRAINTS = {
recommendedMinLength: 50,
} as const;
export class LeagueDescription {
export interface LeagueDescriptionProps {
value: string;
}
export class LeagueDescription implements IValueObject<LeagueDescriptionProps> {
readonly value: string;
private constructor(value: string) {
@@ -70,6 +75,10 @@ export class LeagueDescription {
return new LeagueDescription(value.trim());
}
get props(): LeagueDescriptionProps {
return { value: this.value };
}
/**
* Try to create a LeagueDescription, returning null if invalid
*/
@@ -84,8 +93,8 @@ export class LeagueDescription {
toString(): string {
return this.value;
}
equals(other: LeagueDescription): boolean {
return this.value === other.value;
equals(other: IValueObject<LeagueDescriptionProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -3,8 +3,9 @@
*
* Represents a valid league name with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueNameValidationResult {
valid: boolean;
@@ -22,7 +23,11 @@ export const LEAGUE_NAME_CONSTRAINTS = {
],
} as const;
export class LeagueName {
export interface LeagueNameProps {
value: string;
}
export class LeagueName implements IValueObject<LeagueNameProps> {
readonly value: string;
private constructor(value: string) {
@@ -83,6 +88,10 @@ export class LeagueName {
return new LeagueName(value.trim());
}
get props(): LeagueNameProps {
return { value: this.value };
}
/**
* Try to create a LeagueName, returning null if invalid
*/
@@ -97,8 +106,8 @@ export class LeagueName {
toString(): string {
return this.value;
}
equals(other: LeagueName): boolean {
return this.value === other.value;
equals(other: IValueObject<LeagueNameProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -1,6 +1,11 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export class LeagueTimezone {
export interface LeagueTimezoneProps {
id: string;
}
export class LeagueTimezone implements IValueObject<LeagueTimezoneProps> {
private readonly id: string;
constructor(id: string) {
@@ -13,4 +18,12 @@ export class LeagueTimezone {
getId(): string {
return this.id;
}
get props(): LeagueTimezoneProps {
return { id: this.id };
}
equals(other: IValueObject<LeagueTimezoneProps>): boolean {
return this.props.id === other.props.id;
}
}

View File

@@ -1,13 +1,16 @@
/**
* Domain Value Object: LeagueVisibility
*
*
* Represents the visibility and ranking status of a league.
*
*
* - 'ranked' (public): Competitive leagues visible to everyone, affects driver ratings.
* Requires minimum 10 players to ensure competitive integrity.
* - 'unranked' (private): Casual leagues for friends/private groups, no rating impact.
* Can have any number of players.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type LeagueVisibilityType = 'ranked' | 'unranked';
@@ -33,7 +36,11 @@ const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConst
},
};
export class LeagueVisibility {
export interface LeagueVisibilityProps {
type: LeagueVisibilityType;
}
export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
readonly type: LeagueVisibilityType;
readonly constraints: LeagueVisibilityConstraints;
@@ -112,6 +119,10 @@ export class LeagueVisibility {
return this.type;
}
get props(): LeagueVisibilityProps {
return { type: this.type };
}
/**
* For backward compatibility with existing 'public'/'private' terminology
*/
@@ -119,8 +130,8 @@ export class LeagueVisibility {
return this.type === 'ranked' ? 'public' : 'private';
}
equals(other: LeagueVisibility): boolean {
return this.type === other.type;
equals(other: IValueObject<LeagueVisibilityProps>): boolean {
return this.props.type === other.props.type;
}
}

View File

@@ -2,6 +2,9 @@
* Value Object: LiveryDecal
* Represents a decal/logo placed on a livery
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type DecalType = 'sponsor' | 'user';
@@ -16,8 +19,8 @@ export interface LiveryDecalProps {
zIndex: number;
type: DecalType;
}
export class LiveryDecal {
export class LiveryDecal implements IValueObject<LiveryDecalProps> {
readonly id: string;
readonly imageUrl: string;
readonly x: number;
@@ -138,6 +141,20 @@ export class LiveryDecal {
return `rotate(${this.rotation}deg)`;
}
get props(): LiveryDecalProps {
return {
id: this.id,
imageUrl: this.imageUrl,
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rotation: this.rotation,
zIndex: this.zIndex,
type: this.type,
};
}
/**
* Check if this decal overlaps with another
*/
@@ -146,7 +163,7 @@ export class LiveryDecal {
const thisBottom = this.y + this.height;
const otherRight = other.x + other.width;
const otherBottom = other.y + other.height;
return !(
thisRight <= other.x ||
this.x >= otherRight ||
@@ -154,4 +171,20 @@ export class LiveryDecal {
this.y >= otherBottom
);
}
equals(other: IValueObject<LiveryDecalProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.id === b.id &&
a.imageUrl === b.imageUrl &&
a.x === b.x &&
a.y === b.y &&
a.width === b.width &&
a.height === b.height &&
a.rotation === b.rotation &&
a.zIndex === b.zIndex &&
a.type === b.type
);
}
}

View File

@@ -2,10 +2,11 @@
* Value Object: MembershipFee
* Represents membership fee configuration for league drivers
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { Money } from './Money';
import type { IValueObject } from '@gridpilot/shared/domain';
export type MembershipFeeType = 'season' | 'monthly' | 'per_race';
@@ -14,7 +15,7 @@ export interface MembershipFeeProps {
amount: Money;
}
export class MembershipFee {
export class MembershipFee implements IValueObject<MembershipFeeProps> {
readonly type: MembershipFeeType;
readonly amount: Money;
@@ -53,6 +54,13 @@ export class MembershipFee {
return this.amount.calculateNetAmount();
}
get props(): MembershipFeeProps {
return {
type: this.type,
amount: this.amount,
};
}
/**
* Check if this is a recurring fee
*/
@@ -60,6 +68,12 @@ export class MembershipFee {
return this.type === 'monthly';
}
equals(other: IValueObject<MembershipFeeProps>): boolean {
const a = this.props;
const b = other.props;
return a.type === b.type && a.amount.equals(b.amount);
}
/**
* Get display name for fee type
*/

View File

@@ -2,12 +2,18 @@
* Value Object: Money
* Represents a monetary amount with currency and platform fee calculation
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type Currency = 'USD' | 'EUR' | 'GBP';
export class Money {
export interface MoneyProps {
amount: number;
currency: Currency;
}
export class Money implements IValueObject<MoneyProps> {
private static readonly PLATFORM_FEE_PERCENTAGE = 0.10;
readonly amount: number;
@@ -78,11 +84,20 @@ export class Money {
return this.amount > other.amount;
}
get props(): MoneyProps {
return {
amount: this.amount,
currency: this.currency,
};
}
/**
* Check if this money equals another
*/
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
equals(other: IValueObject<MoneyProps>): boolean {
const a = this.props;
const b = other.props;
return a.amount === b.amount && a.currency === b.currency;
}
/**

View File

@@ -1,6 +1,12 @@
import type { Weekday } from './Weekday';
import type { IValueObject } from '@gridpilot/shared/domain';
export class MonthlyRecurrencePattern {
export interface MonthlyRecurrencePatternProps {
ordinal: 1 | 2 | 3 | 4;
weekday: Weekday;
}
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
readonly ordinal: 1 | 2 | 3 | 4;
readonly weekday: Weekday;
@@ -8,4 +14,17 @@ export class MonthlyRecurrencePattern {
this.ordinal = ordinal;
this.weekday = weekday;
}
get props(): MonthlyRecurrencePatternProps {
return {
ordinal: this.ordinal,
weekday: this.weekday,
};
}
equals(other: IValueObject<MonthlyRecurrencePatternProps>): boolean {
const a = this.props;
const b = other.props;
return a.ordinal === b.ordinal && a.weekday === b.weekday;
}
}

View File

@@ -1,4 +1,10 @@
export class PointsTable {
import type { IValueObject } from '@gridpilot/shared/domain';
export interface PointsTableProps {
pointsByPosition: Map<number, number>;
}
export class PointsTable implements IValueObject<PointsTableProps> {
private readonly pointsByPosition: Map<number, number>;
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
@@ -18,4 +24,23 @@ export class PointsTable {
const value = this.pointsByPosition.get(position);
return typeof value === 'number' ? value : 0;
}
get props(): PointsTableProps {
return {
pointsByPosition: new Map(this.pointsByPosition),
};
}
equals(other: IValueObject<PointsTableProps>): boolean {
const a = this.props.pointsByPosition;
const b = other.props.pointsByPosition;
if (a.size !== b.size) return false;
for (const [key, value] of a.entries()) {
if (b.get(key) !== value) {
return false;
}
}
return true;
}
}

View File

@@ -1,6 +1,12 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class RaceTimeOfDay {
import type { IValueObject } from '@gridpilot/shared/domain';
export interface RaceTimeOfDayProps {
hour: number;
minute: number;
}
export class RaceTimeOfDay implements IValueObject<RaceTimeOfDayProps> {
readonly hour: number;
readonly minute: number;
@@ -21,16 +27,29 @@ export class RaceTimeOfDay {
if (!match) {
throw new RacingDomainValidationError(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
}
const hour = Number(match[1]);
const minute = Number(match[2]);
return new RaceTimeOfDay(hour, minute);
}
get props(): RaceTimeOfDayProps {
return {
hour: this.hour,
minute: this.minute,
};
}
toString(): string {
const hh = this.hour.toString().padStart(2, '0');
const mm = this.minute.toString().padStart(2, '0');
return `${hh}:${mm}`;
}
equals(other: IValueObject<RaceTimeOfDayProps>): boolean {
const a = this.props;
const b = other.props;
return a.hour === b.hour && a.minute === b.minute;
}
}

View File

@@ -1,56 +0,0 @@
import { WeekdaySet } from './WeekdaySet';
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type RecurrenceStrategyKind = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
export type WeeklyRecurrence = {
kind: 'weekly';
weekdays: WeekdaySet;
};
export type EveryNWeeksRecurrence = {
kind: 'everyNWeeks';
intervalWeeks: number;
weekdays: WeekdaySet;
};
export type MonthlyNthWeekdayRecurrence = {
kind: 'monthlyNthWeekday';
monthlyPattern: MonthlyRecurrencePattern;
};
export type RecurrenceStrategy =
| WeeklyRecurrence
| EveryNWeeksRecurrence
| MonthlyNthWeekdayRecurrence;
export class RecurrenceStrategyFactory {
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
return {
kind: 'weekly',
weekdays,
};
}
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
if (!Number.isInteger(intervalWeeks) || intervalWeeks < 1 || intervalWeeks > 12) {
throw new RacingDomainValidationError(
'everyNWeeks intervalWeeks must be an integer between 1 and 12',
);
}
return {
kind: 'everyNWeeks',
intervalWeeks,
weekdays,
};
}
static monthlyNthWeekday(monthlyPattern: MonthlyRecurrencePattern): RecurrenceStrategy {
return {
kind: 'monthlyNthWeekday',
monthlyPattern,
};
}
}

View File

@@ -1,8 +1,15 @@
import { LeagueTimezone } from './LeagueTimezone';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export class ScheduledRaceSlot {
export interface ScheduledRaceSlotProps {
roundNumber: number;
scheduledAt: Date;
timezone: LeagueTimezone;
}
export class ScheduledRaceSlot implements IValueObject<ScheduledRaceSlotProps> {
readonly roundNumber: number;
readonly scheduledAt: Date;
readonly timezone: LeagueTimezone;
@@ -19,4 +26,22 @@ export class ScheduledRaceSlot {
this.scheduledAt = params.scheduledAt;
this.timezone = params.timezone;
}
get props(): ScheduledRaceSlotProps {
return {
roundNumber: this.roundNumber,
scheduledAt: this.scheduledAt,
timezone: this.timezone,
};
}
equals(other: IValueObject<ScheduledRaceSlotProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.roundNumber === b.roundNumber &&
a.scheduledAt.getTime() === b.scheduledAt.getTime() &&
a.timezone.equals(b.timezone)
);
}
}

View File

@@ -2,8 +2,17 @@ import { RaceTimeOfDay } from './RaceTimeOfDay';
import { LeagueTimezone } from './LeagueTimezone';
import type { RecurrenceStrategy } from './RecurrenceStrategy';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export class SeasonSchedule {
export interface SeasonScheduleProps {
startDate: Date;
timeOfDay: RaceTimeOfDay;
timezone: LeagueTimezone;
recurrence: RecurrenceStrategy;
plannedRounds: number;
}
export class SeasonSchedule implements IValueObject<SeasonScheduleProps> {
readonly startDate: Date;
readonly timeOfDay: RaceTimeOfDay;
readonly timezone: LeagueTimezone;
@@ -34,4 +43,26 @@ export class SeasonSchedule {
this.recurrence = params.recurrence;
this.plannedRounds = params.plannedRounds;
}
get props(): SeasonScheduleProps {
return {
startDate: this.startDate,
timeOfDay: this.timeOfDay,
timezone: this.timezone,
recurrence: this.recurrence,
plannedRounds: this.plannedRounds,
};
}
equals(other: IValueObject<SeasonScheduleProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.startDate.getTime() === b.startDate.getTime() &&
a.timeOfDay.equals(b.timeOfDay) &&
a.timezone.equals(b.timezone) &&
a.recurrence.kind === b.recurrence.kind &&
a.plannedRounds === b.plannedRounds
);
}
}

View File

@@ -1,11 +1,12 @@
/**
* Value Object: SponsorshipPricing
*
*
* Represents the sponsorship slot configuration and pricing for any sponsorable entity.
* Used by drivers, teams, races, and leagues to define their sponsorship offerings.
*/
import { Money } from './Money';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface SponsorshipSlotConfig {
tier: 'main' | 'secondary';
@@ -22,7 +23,7 @@ export interface SponsorshipPricingProps {
customRequirements?: string;
}
export class SponsorshipPricing {
export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps> {
readonly mainSlot?: SponsorshipSlotConfig;
readonly secondarySlots?: SponsorshipSlotConfig;
readonly acceptingApplications: boolean;

View File

@@ -1,19 +1,28 @@
import type { Weekday } from './Weekday';
import { weekdayToIndex } from './Weekday';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export class WeekdaySet {
import type { IValueObject } from '@gridpilot/shared/domain';
export interface WeekdaySetProps {
days: Weekday[];
}
export class WeekdaySet implements IValueObject<WeekdaySetProps> {
private readonly days: Weekday[];
constructor(days: Weekday[]) {
if (!Array.isArray(days) || days.length === 0) {
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');
}
const unique = Array.from(new Set(days));
this.days = unique.sort((a, b) => weekdayToIndex(a) - weekdayToIndex(b));
}
get props(): WeekdaySetProps {
return { days: [...this.days] };
}
getAll(): Weekday[] {
return [...this.days];
}
@@ -21,4 +30,11 @@ export class WeekdaySet {
includes(day: Weekday): boolean {
return this.days.includes(day);
}
equals(other: IValueObject<WeekdaySetProps>): boolean {
const a = this.props.days;
const b = other.props.days;
if (a.length !== b.length) return false;
return a.every((day, index) => day === b[index]);
}
}