wip
This commit is contained in:
60
packages/racing/application/dto/LeagueConfigFormDTO.ts
Normal file
60
packages/racing/application/dto/LeagueConfigFormDTO.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type LeagueStructureMode = 'solo' | 'fixedTeams';
|
||||
|
||||
export interface LeagueStructureFormDTO {
|
||||
mode: LeagueStructureMode;
|
||||
maxDrivers: number;
|
||||
maxTeams?: number;
|
||||
driversPerTeam?: number;
|
||||
multiClassEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueChampionshipsFormDTO {
|
||||
enableDriverChampionship: boolean;
|
||||
enableTeamChampionship: boolean;
|
||||
enableNationsChampionship: boolean;
|
||||
enableTrophyChampionship: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueScoringFormDTO {
|
||||
patternId?: string; // e.g. 'sprint-main-driver', 'club-ladder-solo'
|
||||
// For now, keep customScoring optional and simple:
|
||||
customScoringEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueDropPolicyFormDTO {
|
||||
strategy: 'none' | 'bestNResults' | 'dropWorstN';
|
||||
n?: number;
|
||||
}
|
||||
|
||||
export interface LeagueTimingsFormDTO {
|
||||
practiceMinutes?: number;
|
||||
qualifyingMinutes: number;
|
||||
sprintRaceMinutes?: number;
|
||||
mainRaceMinutes: number;
|
||||
sessionCount: number;
|
||||
roundsPlanned?: number;
|
||||
|
||||
seasonStartDate?: string; // ISO date YYYY-MM-DD
|
||||
raceStartTime?: string; // "HH:MM" 24h
|
||||
timezoneId?: string; // IANA ID, e.g. "Europe/Berlin"
|
||||
recurrenceStrategy?: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
intervalWeeks?: number;
|
||||
weekdays?: import('../../domain/value-objects/Weekday').Weekday[];
|
||||
monthlyOrdinal?: 1 | 2 | 3 | 4;
|
||||
monthlyWeekday?: import('../../domain/value-objects/Weekday').Weekday;
|
||||
}
|
||||
|
||||
export interface LeagueConfigFormModel {
|
||||
leagueId?: string; // present for admin, omitted for create
|
||||
basics: {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: 'public' | 'private';
|
||||
gameId: string;
|
||||
};
|
||||
structure: LeagueStructureFormDTO;
|
||||
championships: LeagueChampionshipsFormDTO;
|
||||
scoring: LeagueScoringFormDTO;
|
||||
dropPolicy: LeagueDropPolicyFormDTO;
|
||||
timings: LeagueTimingsFormDTO;
|
||||
}
|
||||
114
packages/racing/application/dto/LeagueScheduleDTO.ts
Normal file
114
packages/racing/application/dto/LeagueScheduleDTO.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { LeagueTimingsFormDTO } from './LeagueConfigFormDTO';
|
||||
import type { Weekday } from '../../domain/value-objects/Weekday';
|
||||
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
|
||||
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
|
||||
import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
|
||||
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
|
||||
import type { RecurrenceStrategy } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
|
||||
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
|
||||
|
||||
export interface LeagueScheduleDTO {
|
||||
seasonStartDate: string;
|
||||
raceStartTime: string;
|
||||
timezoneId: string;
|
||||
recurrenceStrategy: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
intervalWeeks?: number;
|
||||
weekdays?: Weekday[];
|
||||
monthlyOrdinal?: 1 | 2 | 3 | 4;
|
||||
monthlyWeekday?: Weekday;
|
||||
plannedRounds: number;
|
||||
}
|
||||
|
||||
export interface LeagueSchedulePreviewDTO {
|
||||
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export function leagueTimingsToScheduleDTO(
|
||||
timings: LeagueTimingsFormDTO,
|
||||
): LeagueScheduleDTO | null {
|
||||
if (
|
||||
!timings.seasonStartDate ||
|
||||
!timings.raceStartTime ||
|
||||
!timings.timezoneId ||
|
||||
!timings.recurrenceStrategy ||
|
||||
!timings.roundsPlanned
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
seasonStartDate: timings.seasonStartDate,
|
||||
raceStartTime: timings.raceStartTime,
|
||||
timezoneId: timings.timezoneId,
|
||||
recurrenceStrategy: timings.recurrenceStrategy,
|
||||
intervalWeeks: timings.intervalWeeks,
|
||||
weekdays: timings.weekdays,
|
||||
monthlyOrdinal: timings.monthlyOrdinal,
|
||||
monthlyWeekday: timings.monthlyWeekday,
|
||||
plannedRounds: timings.roundsPlanned,
|
||||
};
|
||||
}
|
||||
|
||||
export function scheduleDTOToSeasonSchedule(dto: LeagueScheduleDTO): SeasonSchedule {
|
||||
if (!dto.seasonStartDate) {
|
||||
throw new Error('seasonStartDate is required');
|
||||
}
|
||||
if (!dto.raceStartTime) {
|
||||
throw new Error('raceStartTime is required');
|
||||
}
|
||||
if (!dto.timezoneId) {
|
||||
throw new Error('timezoneId is required');
|
||||
}
|
||||
if (!dto.recurrenceStrategy) {
|
||||
throw new Error('recurrenceStrategy is required');
|
||||
}
|
||||
if (!Number.isInteger(dto.plannedRounds) || dto.plannedRounds <= 0) {
|
||||
throw new Error('plannedRounds must be a positive integer');
|
||||
}
|
||||
|
||||
const startDate = new Date(dto.seasonStartDate);
|
||||
if (Number.isNaN(startDate.getTime())) {
|
||||
throw new Error(`seasonStartDate must be a valid date, got "${dto.seasonStartDate}"`);
|
||||
}
|
||||
|
||||
const timeOfDay = RaceTimeOfDay.fromString(dto.raceStartTime);
|
||||
const timezone = new LeagueTimezone(dto.timezoneId);
|
||||
|
||||
let recurrence: RecurrenceStrategy;
|
||||
|
||||
if (dto.recurrenceStrategy === 'weekly') {
|
||||
if (!dto.weekdays || dto.weekdays.length === 0) {
|
||||
throw new Error('weekdays are required for weekly recurrence');
|
||||
}
|
||||
recurrence = RecurrenceStrategyFactory.weekly(new WeekdaySet(dto.weekdays));
|
||||
} else if (dto.recurrenceStrategy === 'everyNWeeks') {
|
||||
if (!dto.weekdays || dto.weekdays.length === 0) {
|
||||
throw new Error('weekdays are required for everyNWeeks recurrence');
|
||||
}
|
||||
if (dto.intervalWeeks == null) {
|
||||
throw new Error('intervalWeeks is required for everyNWeeks recurrence');
|
||||
}
|
||||
recurrence = RecurrenceStrategyFactory.everyNWeeks(
|
||||
dto.intervalWeeks,
|
||||
new WeekdaySet(dto.weekdays),
|
||||
);
|
||||
} else if (dto.recurrenceStrategy === 'monthlyNthWeekday') {
|
||||
if (!dto.monthlyOrdinal || !dto.monthlyWeekday) {
|
||||
throw new Error('monthlyOrdinal and monthlyWeekday are required for monthlyNthWeekday');
|
||||
}
|
||||
const pattern = new MonthlyRecurrencePattern(dto.monthlyOrdinal, dto.monthlyWeekday);
|
||||
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
|
||||
} else {
|
||||
throw new Error(`Unknown recurrenceStrategy "${dto.recurrenceStrategy}"`);
|
||||
}
|
||||
|
||||
return new SeasonSchedule({
|
||||
startDate,
|
||||
timeOfDay,
|
||||
timezone,
|
||||
recurrence,
|
||||
plannedRounds: dto.plannedRounds,
|
||||
});
|
||||
}
|
||||
20
packages/racing/application/dto/LeagueScoringConfigDTO.ts
Normal file
20
packages/racing/application/dto/LeagueScoringConfigDTO.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface LeagueScoringChampionshipDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'driver' | 'team' | 'nations' | 'trophy';
|
||||
sessionTypes: string[];
|
||||
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
|
||||
bonusSummary: string[];
|
||||
dropPolicyDescription: string;
|
||||
}
|
||||
|
||||
export interface LeagueScoringConfigDTO {
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
gameId: string;
|
||||
gameName: string;
|
||||
scoringPresetId?: string;
|
||||
scoringPresetName?: string;
|
||||
dropPolicySummary: string;
|
||||
championships: LeagueScoringChampionshipDTO[];
|
||||
}
|
||||
41
packages/racing/application/dto/LeagueSummaryDTO.ts
Normal file
41
packages/racing/application/dto/LeagueSummaryDTO.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface LeagueSummaryScoringDTO {
|
||||
gameId: string;
|
||||
gameName: string;
|
||||
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
|
||||
scoringPresetId: string;
|
||||
scoringPresetName: string;
|
||||
dropPolicySummary: string;
|
||||
/**
|
||||
* Human-readable scoring pattern summary combining preset name and drop policy,
|
||||
* e.g. "Sprint + Main • Best 6 results of 8 count towards the championship."
|
||||
*/
|
||||
scoringPatternSummary: string;
|
||||
}
|
||||
|
||||
export interface LeagueSummaryDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
ownerId: string;
|
||||
maxDrivers?: number;
|
||||
usedDriverSlots?: number;
|
||||
maxTeams?: number;
|
||||
usedTeamSlots?: number;
|
||||
/**
|
||||
* Human-readable structure summary derived from capacity and (future) team settings,
|
||||
* e.g. "Solo • 24 drivers" or "Teams • 12 × 2 drivers".
|
||||
*/
|
||||
structureSummary?: string;
|
||||
/**
|
||||
* Human-readable scoring pattern summary for list views,
|
||||
* e.g. "Sprint + Main • Best 6 results of 8 count towards the championship."
|
||||
*/
|
||||
scoringPatternSummary?: string;
|
||||
/**
|
||||
* Human-readable timing summary for list views,
|
||||
* e.g. "30 min Quali • 40 min Race".
|
||||
*/
|
||||
timingSummary?: string;
|
||||
scoring?: LeagueSummaryScoringDTO;
|
||||
}
|
||||
@@ -17,15 +17,21 @@ export * from './use-cases/GetDriverTeamQuery';
|
||||
export * from './use-cases/GetLeagueStandingsQuery';
|
||||
export * from './use-cases/GetLeagueDriverSeasonStatsQuery';
|
||||
export * from './use-cases/GetAllLeaguesWithCapacityQuery';
|
||||
export * from './use-cases/GetAllLeaguesWithCapacityAndScoringQuery';
|
||||
export * from './use-cases/ListLeagueScoringPresetsQuery';
|
||||
export * from './use-cases/GetLeagueScoringConfigQuery';
|
||||
export * from './use-cases/RecalculateChampionshipStandingsUseCase';
|
||||
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
||||
export * from './use-cases/GetLeagueFullConfigQuery';
|
||||
export * from './use-cases/PreviewLeagueScheduleQuery';
|
||||
|
||||
// Re-export domain types for legacy callers (type-only)
|
||||
export type {
|
||||
LeagueMembership,
|
||||
MembershipRole,
|
||||
MembershipStatus,
|
||||
JoinRequest,
|
||||
} from '../domain/entities/LeagueMembership';
|
||||
// Re-export domain types for legacy callers (type-only)
|
||||
export type {
|
||||
LeagueMembership,
|
||||
MembershipRole,
|
||||
MembershipStatus,
|
||||
JoinRequest,
|
||||
} from '../domain/entities/LeagueMembership';
|
||||
|
||||
export type { RaceRegistration } from '../domain/entities/RaceRegistration';
|
||||
|
||||
@@ -43,7 +49,20 @@ export type { RaceDTO } from './dto/RaceDTO';
|
||||
export type { ResultDTO } from './dto/ResultDTO';
|
||||
export type { StandingDTO } from './dto/StandingDTO';
|
||||
export type { LeagueDriverSeasonStatsDTO } from './dto/LeagueDriverSeasonStatsDTO';
|
||||
export type {
|
||||
LeagueScheduleDTO,
|
||||
LeagueSchedulePreviewDTO,
|
||||
} from './dto/LeagueScheduleDTO';
|
||||
export type {
|
||||
ChampionshipStandingsDTO,
|
||||
ChampionshipStandingsRowDTO,
|
||||
} from './dto/ChampionshipStandingsDTO';
|
||||
} from './dto/ChampionshipStandingsDTO';
|
||||
export type {
|
||||
LeagueConfigFormModel,
|
||||
LeagueStructureFormDTO,
|
||||
LeagueChampionshipsFormDTO,
|
||||
LeagueScoringFormDTO,
|
||||
LeagueDropPolicyFormDTO,
|
||||
LeagueStructureMode,
|
||||
LeagueTimingsFormDTO,
|
||||
} from './dto/LeagueConfigFormDTO';
|
||||
@@ -0,0 +1,26 @@
|
||||
export type LeagueScoringPresetPrimaryChampionshipType =
|
||||
| 'driver'
|
||||
| 'team'
|
||||
| 'nations'
|
||||
| 'trophy';
|
||||
|
||||
export interface LeagueScoringPresetDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType;
|
||||
sessionSummary: string;
|
||||
bonusSummary: string;
|
||||
dropPolicySummary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider abstraction for league scoring presets used by application-layer queries.
|
||||
*
|
||||
* In-memory implementation is backed by the preset registry in
|
||||
* InMemoryScoringRepositories.
|
||||
*/
|
||||
export interface LeagueScoringPresetProvider {
|
||||
listPresets(): LeagueScoringPresetDTO[];
|
||||
getPresetById(id: string): LeagueScoringPresetDTO | undefined;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { League } from '../../domain/entities/League';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
|
||||
import type {
|
||||
LeagueScoringPresetProvider,
|
||||
LeagueScoringPresetDTO,
|
||||
} from '../ports/LeagueScoringPresetProvider';
|
||||
|
||||
export interface CreateLeagueWithSeasonAndScoringCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: 'public' | 'private';
|
||||
ownerId: string;
|
||||
gameId: string;
|
||||
maxDrivers?: number;
|
||||
maxTeams?: number;
|
||||
enableDriverChampionship: boolean;
|
||||
enableTeamChampionship: boolean;
|
||||
enableNationsChampionship: boolean;
|
||||
enableTrophyChampionship: boolean;
|
||||
scoringPresetId?: string;
|
||||
}
|
||||
|
||||
export interface CreateLeagueWithSeasonAndScoringResultDTO {
|
||||
leagueId: string;
|
||||
seasonId: string;
|
||||
scoringPresetId?: string;
|
||||
scoringPresetName?: string;
|
||||
}
|
||||
|
||||
export class CreateLeagueWithSeasonAndScoringUseCase {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||
private readonly presetProvider: LeagueScoringPresetProvider,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: CreateLeagueWithSeasonAndScoringCommand,
|
||||
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
|
||||
this.validate(command);
|
||||
|
||||
const leagueId = uuidv4();
|
||||
|
||||
const league = League.create({
|
||||
id: leagueId,
|
||||
name: command.name,
|
||||
description: command.description ?? '',
|
||||
ownerId: command.ownerId,
|
||||
settings: {
|
||||
pointsSystem: (command.scoringPresetId as any) ?? 'custom',
|
||||
maxDrivers: command.maxDrivers,
|
||||
},
|
||||
});
|
||||
|
||||
await this.leagueRepository.create(league);
|
||||
|
||||
const seasonId = uuidv4();
|
||||
const season = {
|
||||
id: seasonId,
|
||||
leagueId: league.id,
|
||||
gameId: command.gameId,
|
||||
name: `${command.name} Season 1`,
|
||||
year: new Date().getFullYear(),
|
||||
order: 1,
|
||||
status: 'active' as const,
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
};
|
||||
|
||||
// Season is a domain entity; use the repository's create, but shape matches Season.create expectations.
|
||||
// To keep this use case independent, we rely on repository to persist the plain object.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await this.seasonRepository.create(season as any);
|
||||
|
||||
const presetId = command.scoringPresetId ?? 'club-default';
|
||||
const preset: LeagueScoringPresetDTO | undefined =
|
||||
this.presetProvider.getPresetById(presetId);
|
||||
|
||||
if (!preset) {
|
||||
throw new Error(`Unknown scoring preset: ${presetId}`);
|
||||
}
|
||||
|
||||
const scoringConfig: LeagueScoringConfig = {
|
||||
id: uuidv4(),
|
||||
seasonId,
|
||||
scoringPresetId: preset.id,
|
||||
championships: [],
|
||||
};
|
||||
|
||||
// For the initial alpha slice, we keep using the preset's config shape from the in-memory registry.
|
||||
// The preset registry is responsible for building the full LeagueScoringConfig; we only attach the preset id here.
|
||||
const fullConfigFactory = (await import(
|
||||
'../../infrastructure/repositories/InMemoryScoringRepositories'
|
||||
)) as typeof import('../../infrastructure/repositories/InMemoryScoringRepositories');
|
||||
|
||||
const presetFromInfra = fullConfigFactory.getLeagueScoringPresetById(
|
||||
preset.id,
|
||||
);
|
||||
if (!presetFromInfra) {
|
||||
throw new Error(`Preset registry missing preset: ${preset.id}`);
|
||||
}
|
||||
|
||||
const infraConfig = presetFromInfra.createConfig({ seasonId });
|
||||
const finalConfig: LeagueScoringConfig = {
|
||||
...infraConfig,
|
||||
scoringPresetId: preset.id,
|
||||
};
|
||||
|
||||
await this.leagueScoringConfigRepository.save(finalConfig);
|
||||
|
||||
return {
|
||||
leagueId: league.id,
|
||||
seasonId,
|
||||
scoringPresetId: preset.id,
|
||||
scoringPresetName: preset.name,
|
||||
};
|
||||
}
|
||||
|
||||
private validate(command: CreateLeagueWithSeasonAndScoringCommand): void {
|
||||
if (!command.name || command.name.trim().length === 0) {
|
||||
throw new Error('League name is required');
|
||||
}
|
||||
if (!command.ownerId || command.ownerId.trim().length === 0) {
|
||||
throw new Error('League ownerId is required');
|
||||
}
|
||||
if (!command.gameId || command.gameId.trim().length === 0) {
|
||||
throw new Error('gameId is required');
|
||||
}
|
||||
if (!command.visibility) {
|
||||
throw new Error('visibility is required');
|
||||
}
|
||||
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
|
||||
throw new Error('maxDrivers must be greater than 0 when provided');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||
import type {
|
||||
LeagueScoringPresetProvider,
|
||||
LeagueScoringPresetDTO,
|
||||
} from '../ports/LeagueScoringPresetProvider';
|
||||
import type {
|
||||
LeagueSummaryDTO,
|
||||
LeagueSummaryScoringDTO,
|
||||
} from '../dto/LeagueSummaryDTO';
|
||||
|
||||
/**
|
||||
* Combined capacity + scoring summary query for leagues.
|
||||
*
|
||||
* Extends the behavior of GetAllLeaguesWithCapacityQuery by including
|
||||
* scoring preset and game summaries when an active season and
|
||||
* LeagueScoringConfig are available.
|
||||
*/
|
||||
export class GetAllLeaguesWithCapacityAndScoringQuery {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||
private readonly gameRepository: IGameRepository,
|
||||
private readonly presetProvider: LeagueScoringPresetProvider,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<LeagueSummaryDTO[]> {
|
||||
const leagues = await this.leagueRepository.findAll();
|
||||
|
||||
const results: LeagueSummaryDTO[] = [];
|
||||
|
||||
for (const league of leagues) {
|
||||
const members = await this.leagueMembershipRepository.getLeagueMembers(
|
||||
league.id,
|
||||
);
|
||||
|
||||
const usedDriverSlots = members.filter(
|
||||
(m) =>
|
||||
m.status === 'active' &&
|
||||
(m.role === 'owner' ||
|
||||
m.role === 'admin' ||
|
||||
m.role === 'steward' ||
|
||||
m.role === 'member'),
|
||||
).length;
|
||||
|
||||
const configuredMaxDrivers = league.settings.maxDrivers ?? usedDriverSlots;
|
||||
const safeMaxDrivers = Math.max(configuredMaxDrivers, usedDriverSlots);
|
||||
|
||||
const scoringSummary = await this.buildScoringSummary(league.id);
|
||||
|
||||
const structureSummary = `Solo • ${safeMaxDrivers} drivers`;
|
||||
|
||||
const qualifyingMinutes = 30;
|
||||
const mainRaceMinutes =
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: 40;
|
||||
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
|
||||
|
||||
const dto: LeagueSummaryDTO = {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: league.createdAt,
|
||||
maxDrivers: safeMaxDrivers,
|
||||
usedDriverSlots,
|
||||
maxTeams: undefined,
|
||||
usedTeamSlots: undefined,
|
||||
structureSummary,
|
||||
scoringPatternSummary: scoringSummary?.scoringPatternSummary,
|
||||
timingSummary,
|
||||
scoring: scoringSummary,
|
||||
};
|
||||
|
||||
results.push(dto);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async buildScoringSummary(
|
||||
leagueId: string,
|
||||
): Promise<LeagueSummaryScoringDTO | undefined> {
|
||||
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
||||
if (!seasons || seasons.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const activeSeason =
|
||||
seasons.find((s) => s.status === 'active') ?? seasons[0];
|
||||
|
||||
const scoringConfig =
|
||||
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
|
||||
if (!scoringConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const game = await this.gameRepository.findById(activeSeason.gameId);
|
||||
if (!game) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const presetId = scoringConfig.scoringPresetId;
|
||||
let preset: LeagueScoringPresetDTO | undefined;
|
||||
if (presetId) {
|
||||
preset = this.presetProvider.getPresetById(presetId);
|
||||
}
|
||||
|
||||
const dropPolicySummary =
|
||||
preset?.dropPolicySummary ?? this.deriveDropPolicySummary(scoringConfig);
|
||||
const primaryChampionshipType =
|
||||
preset?.primaryChampionshipType ??
|
||||
(scoringConfig.championships[0]?.type ?? 'driver');
|
||||
|
||||
const scoringPresetName = preset?.name ?? 'Custom';
|
||||
const scoringPatternSummary = `${scoringPresetName} • ${dropPolicySummary}`;
|
||||
|
||||
return {
|
||||
gameId: game.id,
|
||||
gameName: game.name,
|
||||
primaryChampionshipType,
|
||||
scoringPresetId: presetId ?? 'custom',
|
||||
scoringPresetName,
|
||||
dropPolicySummary,
|
||||
scoringPatternSummary,
|
||||
};
|
||||
}
|
||||
|
||||
private deriveDropPolicySummary(config: {
|
||||
championships: Array<{
|
||||
dropScorePolicy: { strategy: string; count?: number; dropCount?: number };
|
||||
}>;
|
||||
}): string {
|
||||
const championship = config.championships[0];
|
||||
if (!championship) {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
const policy = championship.dropScorePolicy;
|
||||
if (!policy || policy.strategy === 'none') {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
|
||||
return `Best ${policy.count} results count`;
|
||||
}
|
||||
|
||||
if (
|
||||
policy.strategy === 'dropWorstN' &&
|
||||
typeof policy.dropCount === 'number'
|
||||
) {
|
||||
return `Worst ${policy.dropCount} results are dropped`;
|
||||
}
|
||||
|
||||
return 'Custom drop score rules';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
|
||||
import type { DropScorePolicy } from '../../domain/value-objects/DropScorePolicy';
|
||||
import type {
|
||||
LeagueConfigFormModel,
|
||||
LeagueDropPolicyFormDTO,
|
||||
} from '../dto/LeagueConfigFormDTO';
|
||||
|
||||
/**
|
||||
* Query returning a unified LeagueConfigFormModel for a given league.
|
||||
*
|
||||
* First iteration focuses on:
|
||||
* - Basics derived from League
|
||||
* - Simple solo structure derived from League.settings.maxDrivers
|
||||
* - Championships flags with driver enabled and others disabled
|
||||
* - Scoring pattern id taken from LeagueScoringConfig.scoringPresetId
|
||||
* - Drop policy inferred from the primary championship configuration
|
||||
*/
|
||||
export class GetLeagueFullConfigQuery {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||
private readonly gameRepository: IGameRepository,
|
||||
) {}
|
||||
|
||||
async execute(params: { leagueId: string }): Promise<LeagueConfigFormModel | null> {
|
||||
const { leagueId } = params;
|
||||
|
||||
const league = await this.leagueRepository.findById(leagueId);
|
||||
if (!league) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
||||
const activeSeason =
|
||||
seasons && seasons.length > 0
|
||||
? seasons.find((s) => s.status === 'active') ?? seasons[0]
|
||||
: null;
|
||||
|
||||
const scoringConfig = activeSeason
|
||||
? await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id)
|
||||
: null;
|
||||
|
||||
const game =
|
||||
activeSeason && activeSeason.gameId
|
||||
? await this.gameRepository.findById(activeSeason.gameId)
|
||||
: null;
|
||||
|
||||
const patternId = scoringConfig?.scoringPresetId;
|
||||
|
||||
const primaryChampionship: ChampionshipConfig | undefined =
|
||||
scoringConfig && scoringConfig.championships && scoringConfig.championships.length > 0
|
||||
? scoringConfig.championships[0]
|
||||
: undefined;
|
||||
|
||||
const dropPolicy: DropScorePolicy | undefined =
|
||||
primaryChampionship?.dropScorePolicy ?? undefined;
|
||||
|
||||
const dropPolicyForm: LeagueDropPolicyFormDTO = this.mapDropPolicy(dropPolicy);
|
||||
|
||||
const defaultQualifyingMinutes = 30;
|
||||
const defaultMainRaceMinutes = 40;
|
||||
const mainRaceMinutes =
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: defaultMainRaceMinutes;
|
||||
const qualifyingMinutes = defaultQualifyingMinutes;
|
||||
|
||||
const roundsPlanned = 8;
|
||||
|
||||
let sessionCount = 2;
|
||||
if (
|
||||
primaryChampionship &&
|
||||
Array.isArray((primaryChampionship as any).sessionTypes) &&
|
||||
(primaryChampionship as any).sessionTypes.length > 0
|
||||
) {
|
||||
sessionCount = (primaryChampionship as any).sessionTypes.length;
|
||||
}
|
||||
|
||||
const practiceMinutes = 20;
|
||||
const sprintRaceMinutes = patternId === 'sprint-main-driver' ? 20 : undefined;
|
||||
|
||||
const form: LeagueConfigFormModel = {
|
||||
leagueId: league.id,
|
||||
basics: {
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
visibility: 'public', // current domain model does not track visibility; default to public for now
|
||||
gameId: game?.id ?? 'iracing',
|
||||
},
|
||||
structure: {
|
||||
// First slice: treat everything as solo structure based on maxDrivers
|
||||
mode: 'solo',
|
||||
maxDrivers: league.settings.maxDrivers ?? 32,
|
||||
maxTeams: undefined,
|
||||
driversPerTeam: undefined,
|
||||
multiClassEnabled: false,
|
||||
},
|
||||
championships: {
|
||||
enableDriverChampionship: true,
|
||||
enableTeamChampionship: false,
|
||||
enableNationsChampionship: false,
|
||||
enableTrophyChampionship: false,
|
||||
},
|
||||
scoring: {
|
||||
patternId: patternId ?? undefined,
|
||||
customScoringEnabled: !patternId,
|
||||
},
|
||||
dropPolicy: dropPolicyForm,
|
||||
timings: {
|
||||
practiceMinutes,
|
||||
qualifyingMinutes,
|
||||
sprintRaceMinutes,
|
||||
mainRaceMinutes,
|
||||
sessionCount,
|
||||
roundsPlanned,
|
||||
},
|
||||
};
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
private mapDropPolicy(policy: DropScorePolicy | undefined): LeagueDropPolicyFormDTO {
|
||||
if (!policy || policy.strategy === 'none') {
|
||||
return { strategy: 'none' };
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults') {
|
||||
const n = typeof policy.count === 'number' ? policy.count : undefined;
|
||||
return n !== undefined ? { strategy: 'bestNResults', n } : { strategy: 'none' };
|
||||
}
|
||||
|
||||
if (policy.strategy === 'dropWorstN') {
|
||||
const n = typeof policy.dropCount === 'number' ? policy.dropCount : undefined;
|
||||
return n !== undefined ? { strategy: 'dropWorstN', n } : { strategy: 'none' };
|
||||
}
|
||||
|
||||
return { strategy: 'none' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||
import type { LeagueScoringConfigDTO } from '../dto/LeagueScoringConfigDTO';
|
||||
import type { LeagueScoringChampionshipDTO } from '../dto/LeagueScoringConfigDTO';
|
||||
import type {
|
||||
LeagueScoringPresetProvider,
|
||||
LeagueScoringPresetDTO,
|
||||
} from '../ports/LeagueScoringPresetProvider';
|
||||
import type { ChampionshipConfig } from '../../domain/value-objects/ChampionshipConfig';
|
||||
import type { PointsTable } from '../../domain/value-objects/PointsTable';
|
||||
import type { BonusRule } from '../../domain/value-objects/BonusRule';
|
||||
|
||||
/**
|
||||
* Query returning a league's scoring configuration for its active season.
|
||||
*
|
||||
* Designed for the league detail "Scoring" tab.
|
||||
*/
|
||||
export class GetLeagueScoringConfigQuery {
|
||||
constructor(
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly seasonRepository: ISeasonRepository,
|
||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||
private readonly gameRepository: IGameRepository,
|
||||
private readonly presetProvider: LeagueScoringPresetProvider,
|
||||
) {}
|
||||
|
||||
async execute(params: { leagueId: string }): Promise<LeagueScoringConfigDTO | null> {
|
||||
const { leagueId } = params;
|
||||
|
||||
const league = await this.leagueRepository.findById(leagueId);
|
||||
if (!league) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
||||
if (!seasons || seasons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeSeason =
|
||||
seasons.find((s) => s.status === 'active') ?? seasons[0];
|
||||
|
||||
const scoringConfig =
|
||||
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
|
||||
if (!scoringConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const game = await this.gameRepository.findById(activeSeason.gameId);
|
||||
if (!game) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const presetId = scoringConfig.scoringPresetId;
|
||||
const preset: LeagueScoringPresetDTO | undefined =
|
||||
presetId ? this.presetProvider.getPresetById(presetId) : undefined;
|
||||
|
||||
const championships: LeagueScoringChampionshipDTO[] =
|
||||
scoringConfig.championships.map((champ) =>
|
||||
this.mapChampionship(champ),
|
||||
);
|
||||
|
||||
const dropPolicySummary =
|
||||
preset?.dropPolicySummary ??
|
||||
this.deriveDropPolicyDescriptionFromChampionships(
|
||||
scoringConfig.championships,
|
||||
);
|
||||
|
||||
return {
|
||||
leagueId: league.id,
|
||||
seasonId: activeSeason.id,
|
||||
gameId: game.id,
|
||||
gameName: game.name,
|
||||
scoringPresetId: presetId,
|
||||
scoringPresetName: preset?.name,
|
||||
dropPolicySummary,
|
||||
championships,
|
||||
};
|
||||
}
|
||||
|
||||
private mapChampionship(championship: ChampionshipConfig): LeagueScoringChampionshipDTO {
|
||||
const sessionTypes = championship.sessionTypes.map((s) => s.toString());
|
||||
const pointsPreview = this.buildPointsPreview(championship.pointsTableBySessionType);
|
||||
const bonusSummary = this.buildBonusSummary(
|
||||
championship.bonusRulesBySessionType ?? {},
|
||||
);
|
||||
const dropPolicyDescription = this.deriveDropPolicyDescription(
|
||||
championship.dropScorePolicy,
|
||||
);
|
||||
|
||||
return {
|
||||
id: championship.id,
|
||||
name: championship.name,
|
||||
type: championship.type,
|
||||
sessionTypes,
|
||||
pointsPreview,
|
||||
bonusSummary,
|
||||
dropPolicyDescription,
|
||||
};
|
||||
}
|
||||
|
||||
private buildPointsPreview(
|
||||
tables: Record<string, PointsTable>,
|
||||
): Array<{ sessionType: string; position: number; points: number }> {
|
||||
const preview: Array<{
|
||||
sessionType: string;
|
||||
position: number;
|
||||
points: number;
|
||||
}> = [];
|
||||
|
||||
const maxPositions = 10;
|
||||
|
||||
for (const [sessionType, table] of Object.entries(tables)) {
|
||||
for (let pos = 1; pos <= maxPositions; pos++) {
|
||||
const points = table.getPoints(pos);
|
||||
if (points && points !== 0) {
|
||||
preview.push({
|
||||
sessionType,
|
||||
position: pos,
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
private buildBonusSummary(
|
||||
bonusRulesBySessionType: Record<string, BonusRule[]>,
|
||||
): string[] {
|
||||
const summaries: string[] = [];
|
||||
|
||||
for (const [sessionType, rules] of Object.entries(bonusRulesBySessionType)) {
|
||||
for (const rule of rules) {
|
||||
if (rule.type === 'fastestLap') {
|
||||
const base = `Fastest lap in ${sessionType}`;
|
||||
if (rule.requiresFinishInTopN) {
|
||||
summaries.push(
|
||||
`${base} +${rule.points} points if finishing P${rule.requiresFinishInTopN} or better`,
|
||||
);
|
||||
} else {
|
||||
summaries.push(`${base} +${rule.points} points`);
|
||||
}
|
||||
} else {
|
||||
summaries.push(
|
||||
`${rule.type} bonus in ${sessionType} worth ${rule.points} points`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private deriveDropPolicyDescriptionFromChampionships(
|
||||
championships: ChampionshipConfig[],
|
||||
): string {
|
||||
const first = championships[0];
|
||||
if (!first) {
|
||||
return 'All results count';
|
||||
}
|
||||
return this.deriveDropPolicyDescription(first.dropScorePolicy);
|
||||
}
|
||||
|
||||
private deriveDropPolicyDescription(policy: {
|
||||
strategy: string;
|
||||
count?: number;
|
||||
dropCount?: number;
|
||||
}): string {
|
||||
if (!policy || policy.strategy === 'none') {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
|
||||
return `Best ${policy.count} results count towards the championship`;
|
||||
}
|
||||
|
||||
if (
|
||||
policy.strategy === 'dropWorstN' &&
|
||||
typeof policy.dropCount === 'number'
|
||||
) {
|
||||
return `Worst ${policy.dropCount} results are dropped from the championship total`;
|
||||
}
|
||||
|
||||
return 'Custom drop score rules apply';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
LeagueScoringPresetDTO,
|
||||
LeagueScoringPresetProvider,
|
||||
} from '../ports/LeagueScoringPresetProvider';
|
||||
|
||||
/**
|
||||
* Read-only query exposing league scoring presets for UI consumption.
|
||||
*
|
||||
* Backed by the in-memory preset registry via a LeagueScoringPresetProvider
|
||||
* implementation in the infrastructure layer.
|
||||
*/
|
||||
export class ListLeagueScoringPresetsQuery {
|
||||
constructor(private readonly presetProvider: LeagueScoringPresetProvider) {}
|
||||
|
||||
async execute(): Promise<LeagueScoringPresetDTO[]> {
|
||||
return this.presetProvider.listPresets();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator';
|
||||
import type { LeagueSchedulePreviewDTO, LeagueScheduleDTO } from '../dto/LeagueScheduleDTO';
|
||||
import { scheduleDTOToSeasonSchedule } from '../dto/LeagueScheduleDTO';
|
||||
|
||||
interface PreviewLeagueScheduleQueryParams {
|
||||
schedule: LeagueScheduleDTO;
|
||||
maxRounds?: number;
|
||||
}
|
||||
|
||||
export class PreviewLeagueScheduleQuery {
|
||||
constructor(
|
||||
private readonly scheduleGenerator: typeof SeasonScheduleGenerator = SeasonScheduleGenerator,
|
||||
) {}
|
||||
|
||||
execute(params: PreviewLeagueScheduleQueryParams): LeagueSchedulePreviewDTO {
|
||||
const seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule);
|
||||
|
||||
const maxRounds =
|
||||
params.maxRounds && params.maxRounds > 0
|
||||
? Math.min(params.maxRounds, seasonSchedule.plannedRounds)
|
||||
: seasonSchedule.plannedRounds;
|
||||
|
||||
const slots = this.scheduleGenerator.generateSlotsUpTo(seasonSchedule, maxRounds);
|
||||
|
||||
const rounds = slots.map((slot) => ({
|
||||
roundNumber: slot.roundNumber,
|
||||
scheduledAt: slot.scheduledAt.toISOString(),
|
||||
timezoneId: slot.timezone.getId(),
|
||||
}));
|
||||
|
||||
const summary = this.buildSummary(params.schedule, rounds);
|
||||
|
||||
return {
|
||||
rounds,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSummary(
|
||||
schedule: LeagueScheduleDTO,
|
||||
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>,
|
||||
): string {
|
||||
if (rounds.length === 0) {
|
||||
return 'No rounds scheduled.';
|
||||
}
|
||||
|
||||
const first = new Date(rounds[0].scheduledAt);
|
||||
const last = new Date(rounds[rounds.length - 1].scheduledAt);
|
||||
|
||||
const firstDate = first.toISOString().slice(0, 10);
|
||||
const lastDate = last.toISOString().slice(0, 10);
|
||||
|
||||
const timePart = schedule.raceStartTime;
|
||||
const tz = schedule.timezoneId;
|
||||
|
||||
let recurrenceDescription: string;
|
||||
|
||||
if (schedule.recurrenceStrategy === 'weekly') {
|
||||
const days = (schedule.weekdays ?? []).join(', ');
|
||||
recurrenceDescription = `Every ${days}`;
|
||||
} else if (schedule.recurrenceStrategy === 'everyNWeeks') {
|
||||
const interval = schedule.intervalWeeks ?? 1;
|
||||
const days = (schedule.weekdays ?? []).join(', ');
|
||||
recurrenceDescription = `Every ${interval} week(s) on ${days}`;
|
||||
} else if (schedule.recurrenceStrategy === 'monthlyNthWeekday') {
|
||||
const ordinalLabel = this.ordinalToLabel(schedule.monthlyOrdinal ?? 1);
|
||||
const weekday = schedule.monthlyWeekday ?? 'Mon';
|
||||
recurrenceDescription = `Every ${ordinalLabel} ${weekday}`;
|
||||
} else {
|
||||
recurrenceDescription = 'Custom recurrence';
|
||||
}
|
||||
|
||||
return `${recurrenceDescription} at ${timePart} ${tz}, starting ${firstDate} — ${rounds.length} rounds from ${firstDate} to ${lastDate}.`;
|
||||
}
|
||||
|
||||
private ordinalToLabel(ordinal: 1 | 2 | 3 | 4): string {
|
||||
switch (ordinal) {
|
||||
case 1:
|
||||
return '1st';
|
||||
case 2:
|
||||
return '2nd';
|
||||
case 3:
|
||||
return '3rd';
|
||||
case 4:
|
||||
return '4th';
|
||||
default:
|
||||
return `${ordinal}th`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user