import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; import type { IImageServicePort } from '../ports/IImageServicePort'; import type { IRaceDetailPresenter, RaceDetailViewModel, RaceDetailRaceViewModel, RaceDetailLeagueViewModel, RaceDetailEntryViewModel, RaceDetailUserResultViewModel, } from '../presenters/IRaceDetailPresenter'; import type { UseCase } from '@gridpilot/shared/application/UseCase'; /** * Use Case: GetRaceDetailUseCase * * Given a race id and current driver id: * - When the race exists, it builds a view model with race, league, entry list, registration flags and user result. * - When the race does not exist, it presents a view model with an error and no race data. * * Given a completed race with a result for the driver: * - When computing rating change, it applies the same position-based formula used in the legacy UI. */ export interface GetRaceDetailQueryParams { raceId: string; driverId: string; } export class GetRaceDetailUseCase implements UseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, private readonly driverRepository: IDriverRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly resultRepository: IResultRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly driverRatingProvider: DriverRatingProvider, private readonly imageService: IImageServicePort, ) {} async execute(params: GetRaceDetailQueryParams, presenter: IRaceDetailPresenter): Promise { presenter.reset(); const { raceId, driverId } = params; const race = await this.raceRepository.findById(raceId); if (!race) { const emptyViewModel: RaceDetailViewModel = { race: null, league: null, entryList: [], registration: { isUserRegistered: false, canRegister: false, }, userResult: null, error: 'Race not found', }; presenter.present(emptyViewModel); return; } const [league, registeredDriverIds, membership] = await Promise.all([ this.leagueRepository.findById(race.leagueId), this.raceRegistrationRepository.getRegisteredDrivers(race.id), this.leagueMembershipRepository.getMembership(race.leagueId, driverId), ]); const ratings = this.driverRatingProvider.getRatings(registeredDriverIds); const drivers = await Promise.all( registeredDriverIds.map(id => this.driverRepository.findById(id)), ); const entryList: RaceDetailEntryViewModel[] = drivers .filter((d): d is NonNullable => d !== null) .map(driver => ({ id: driver.id, name: driver.name, country: driver.country, avatarUrl: this.imageService.getDriverAvatar(driver.id), rating: ratings.get(driver.id) ?? null, isCurrentUser: driver.id === driverId, })); const isUserRegistered = registeredDriverIds.includes(driverId); const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date(); const canRegister = !!membership && membership.status === 'active' && isUpcoming; let userResultView: RaceDetailUserResultViewModel | null = null; if (race.status === 'completed') { const results = await this.resultRepository.findByRaceId(race.id); const userResult = results.find(r => r.driverId === driverId) ?? null; if (userResult) { const ratingChange = this.calculateRatingChange(userResult.position); userResultView = { position: userResult.position, startPosition: userResult.startPosition, incidents: userResult.incidents, fastestLap: userResult.fastestLap, positionChange: userResult.getPositionChange(), isPodium: userResult.isPodium(), isClean: userResult.isClean(), ratingChange, }; } } const raceView: RaceDetailRaceViewModel = { id: race.id, leagueId: race.leagueId, track: race.track, car: race.car, scheduledAt: race.scheduledAt.toISOString(), sessionType: race.sessionType, status: race.status, strengthOfField: race.strengthOfField ?? null, ...(race.registeredCount !== undefined ? { registeredCount: race.registeredCount } : {}), ...(race.maxParticipants !== undefined ? { maxParticipants: race.maxParticipants } : {}), }; const leagueView: RaceDetailLeagueViewModel | null = league ? { id: league.id, name: league.name, description: league.description, settings: { ...(league.settings.maxDrivers !== undefined ? { maxDrivers: league.settings.maxDrivers } : {}), ...(league.settings.qualifyingFormat !== undefined ? { qualifyingFormat: league.settings.qualifyingFormat } : {}), }, } : null; const viewModel: RaceDetailViewModel = { race: raceView, league: leagueView, entryList, registration: { isUserRegistered, canRegister, }, userResult: userResultView, }; presenter.present(viewModel); } private calculateRatingChange(position: number): number { const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5; const positionBonus = Math.max(0, (20 - position) * 2); return baseChange + positionBonus; } }