wip
This commit is contained in:
@@ -1,45 +1,45 @@
|
||||
export * from './use-cases/JoinLeagueUseCase';
|
||||
export * from './use-cases/RegisterForRaceUseCase';
|
||||
export * from './use-cases/WithdrawFromRaceUseCase';
|
||||
export * from './use-cases/IsDriverRegisteredForRaceQuery';
|
||||
export * from './use-cases/GetRaceRegistrationsQuery';
|
||||
export * from './use-cases/IsDriverRegisteredForRaceUseCase';
|
||||
export * from './use-cases/GetRaceRegistrationsUseCase';
|
||||
export * from './use-cases/CreateTeamUseCase';
|
||||
export * from './use-cases/JoinTeamUseCase';
|
||||
export * from './use-cases/LeaveTeamUseCase';
|
||||
export * from './use-cases/ApproveTeamJoinRequestUseCase';
|
||||
export * from './use-cases/RejectTeamJoinRequestUseCase';
|
||||
export * from './use-cases/UpdateTeamUseCase';
|
||||
export * from './use-cases/GetAllTeamsQuery';
|
||||
export * from './use-cases/GetTeamDetailsQuery';
|
||||
export * from './use-cases/GetTeamMembersQuery';
|
||||
export * from './use-cases/GetTeamJoinRequestsQuery';
|
||||
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/GetAllTeamsUseCase';
|
||||
export * from './use-cases/GetTeamDetailsUseCase';
|
||||
export * from './use-cases/GetTeamMembersUseCase';
|
||||
export * from './use-cases/GetTeamJoinRequestsUseCase';
|
||||
export * from './use-cases/GetDriverTeamUseCase';
|
||||
export * from './use-cases/GetLeagueStandingsUseCase';
|
||||
export * from './use-cases/GetLeagueDriverSeasonStatsUseCase';
|
||||
export * from './use-cases/GetAllLeaguesWithCapacityUseCase';
|
||||
export * from './use-cases/GetAllLeaguesWithCapacityAndScoringUseCase';
|
||||
export * from './use-cases/ListLeagueScoringPresetsUseCase';
|
||||
export * from './use-cases/GetLeagueScoringConfigUseCase';
|
||||
export * from './use-cases/RecalculateChampionshipStandingsUseCase';
|
||||
export * from './use-cases/CreateLeagueWithSeasonAndScoringUseCase';
|
||||
export * from './use-cases/GetLeagueFullConfigQuery';
|
||||
export * from './use-cases/PreviewLeagueScheduleQuery';
|
||||
export * from './use-cases/GetRaceWithSOFQuery';
|
||||
export * from './use-cases/GetLeagueStatsQuery';
|
||||
export * from './use-cases/GetLeagueFullConfigUseCase';
|
||||
export * from './use-cases/PreviewLeagueScheduleUseCase';
|
||||
export * from './use-cases/GetRaceWithSOFUseCase';
|
||||
export * from './use-cases/GetLeagueStatsUseCase';
|
||||
export * from './use-cases/FileProtestUseCase';
|
||||
export * from './use-cases/ReviewProtestUseCase';
|
||||
export * from './use-cases/ApplyPenaltyUseCase';
|
||||
export * from './use-cases/GetRaceProtestsQuery';
|
||||
export * from './use-cases/GetRacePenaltiesQuery';
|
||||
export * from './use-cases/GetRaceProtestsUseCase';
|
||||
export * from './use-cases/GetRacePenaltiesUseCase';
|
||||
export * from './use-cases/RequestProtestDefenseUseCase';
|
||||
export * from './use-cases/SubmitProtestDefenseUseCase';
|
||||
export * from './use-cases/GetSponsorDashboardQuery';
|
||||
export * from './use-cases/GetSponsorSponsorshipsQuery';
|
||||
export * from './use-cases/GetSponsorDashboardUseCase';
|
||||
export * from './use-cases/GetSponsorSponsorshipsUseCase';
|
||||
export * from './use-cases/ApplyForSponsorshipUseCase';
|
||||
export * from './use-cases/AcceptSponsorshipRequestUseCase';
|
||||
export * from './use-cases/RejectSponsorshipRequestUseCase';
|
||||
export * from './use-cases/GetPendingSponsorshipRequestsQuery';
|
||||
export * from './use-cases/GetEntitySponsorshipPricingQuery';
|
||||
export * from './use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
export * from './use-cases/GetEntitySponsorshipPricingUseCase';
|
||||
|
||||
// Export ports
|
||||
export * from './ports/DriverRatingProvider';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type AllRacesStatus = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all';
|
||||
|
||||
export interface AllRacesListItemViewModel {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
strengthOfField: number | null;
|
||||
}
|
||||
|
||||
export interface AllRacesFilterOptionsViewModel {
|
||||
statuses: { value: AllRacesStatus; label: string }[];
|
||||
leagues: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export interface AllRacesPageViewModel {
|
||||
races: AllRacesListItemViewModel[];
|
||||
filters: AllRacesFilterOptionsViewModel;
|
||||
}
|
||||
|
||||
export interface IAllRacesPagePresenter {
|
||||
present(viewModel: AllRacesPageViewModel): void;
|
||||
getViewModel(): AllRacesPageViewModel | null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
export interface DashboardDriverSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
rating: number | null;
|
||||
globalRank: number | null;
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
consistency: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardRaceSummaryViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
isMyLeague: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardRecentResultViewModel {
|
||||
raceId: string;
|
||||
raceName: string;
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
finishedAt: string;
|
||||
position: number;
|
||||
incidents: number;
|
||||
}
|
||||
|
||||
export interface DashboardLeagueStandingSummaryViewModel {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
totalDrivers: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface DashboardFeedItemSummaryViewModel {
|
||||
id: string;
|
||||
type: string;
|
||||
headline: string;
|
||||
body?: string;
|
||||
timestamp: string;
|
||||
ctaLabel?: string;
|
||||
ctaHref?: string;
|
||||
}
|
||||
|
||||
export interface DashboardFeedSummaryViewModel {
|
||||
notificationCount: number;
|
||||
items: DashboardFeedItemSummaryViewModel[];
|
||||
}
|
||||
|
||||
export interface DashboardFriendSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface DashboardOverviewViewModel {
|
||||
currentDriver: DashboardDriverSummaryViewModel | null;
|
||||
myUpcomingRaces: DashboardRaceSummaryViewModel[];
|
||||
otherUpcomingRaces: DashboardRaceSummaryViewModel[];
|
||||
/**
|
||||
* All upcoming races for the driver, already sorted by scheduledAt ascending.
|
||||
*/
|
||||
upcomingRaces: DashboardRaceSummaryViewModel[];
|
||||
/**
|
||||
* Count of distinct leagues that are currently "active" for the driver,
|
||||
* based on upcoming races and league standings.
|
||||
*/
|
||||
activeLeaguesCount: number;
|
||||
nextRace: DashboardRaceSummaryViewModel | null;
|
||||
recentResults: DashboardRecentResultViewModel[];
|
||||
leagueStandingsSummaries: DashboardLeagueStandingSummaryViewModel[];
|
||||
feedSummary: DashboardFeedSummaryViewModel;
|
||||
friends: DashboardFriendSummaryViewModel[];
|
||||
}
|
||||
|
||||
export interface IDashboardOverviewPresenter {
|
||||
present(viewModel: DashboardOverviewViewModel): void;
|
||||
getViewModel(): DashboardOverviewViewModel | null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GetEntitySponsorshipPricingResultDTO } from '../use-cases/GetEntitySponsorshipPricingQuery';
|
||||
import type { GetEntitySponsorshipPricingResultDTO } from '../use-cases/GetEntitySponsorshipPricingUseCase';
|
||||
|
||||
export interface IEntitySponsorshipPricingPresenter {
|
||||
present(data: GetEntitySponsorshipPricingResultDTO | null): void;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface ImportRaceResultsSummaryViewModel {
|
||||
importedCount: number;
|
||||
standingsRecalculated: boolean;
|
||||
}
|
||||
|
||||
export interface IImportRaceResultsPresenter {
|
||||
present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel;
|
||||
getViewModel(): ImportRaceResultsSummaryViewModel | null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GetPendingSponsorshipRequestsResultDTO } from '../use-cases/GetPendingSponsorshipRequestsQuery';
|
||||
import type { GetPendingSponsorshipRequestsResultDTO } from '../use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
|
||||
export interface IPendingSponsorshipRequestsPresenter {
|
||||
present(data: GetPendingSponsorshipRequestsResultDTO): void;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
export interface ProfileOverviewDriverSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
iracingId: string | null;
|
||||
joinedAt: string;
|
||||
rating: number | null;
|
||||
globalRank: number | null;
|
||||
consistency: number | null;
|
||||
bio: string | null;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewStatsViewModel {
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
dnfs: number;
|
||||
avgFinish: number | null;
|
||||
bestFinish: number | null;
|
||||
worstFinish: number | null;
|
||||
finishRate: number | null;
|
||||
winRate: number | null;
|
||||
podiumRate: number | null;
|
||||
percentile: number | null;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewFinishDistributionViewModel {
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
topTen: number;
|
||||
dnfs: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewTeamMembershipViewModel {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
teamTag: string | null;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewSocialFriendSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewSocialSummaryViewModel {
|
||||
friendsCount: number;
|
||||
friends: ProfileOverviewSocialFriendSummaryViewModel[];
|
||||
}
|
||||
|
||||
export type ProfileOverviewSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
|
||||
|
||||
export type ProfileOverviewAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
|
||||
|
||||
export interface ProfileOverviewAchievementViewModel {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
|
||||
rarity: ProfileOverviewAchievementRarity;
|
||||
earnedAt: string;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewSocialHandleViewModel {
|
||||
platform: ProfileOverviewSocialPlatform;
|
||||
handle: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewExtendedProfileViewModel {
|
||||
socialHandles: ProfileOverviewSocialHandleViewModel[];
|
||||
achievements: ProfileOverviewAchievementViewModel[];
|
||||
racingStyle: string;
|
||||
favoriteTrack: string;
|
||||
favoriteCar: string;
|
||||
timezone: string;
|
||||
availableHours: string;
|
||||
lookingForTeam: boolean;
|
||||
openToRequests: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileOverviewViewModel {
|
||||
currentDriver: ProfileOverviewDriverSummaryViewModel | null;
|
||||
stats: ProfileOverviewStatsViewModel | null;
|
||||
finishDistribution: ProfileOverviewFinishDistributionViewModel | null;
|
||||
teamMemberships: ProfileOverviewTeamMembershipViewModel[];
|
||||
socialSummary: ProfileOverviewSocialSummaryViewModel;
|
||||
extendedProfile: ProfileOverviewExtendedProfileViewModel | null;
|
||||
}
|
||||
|
||||
export interface IProfileOverviewPresenter {
|
||||
present(viewModel: ProfileOverviewViewModel): void;
|
||||
getViewModel(): ProfileOverviewViewModel | null;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SessionType, RaceStatus } from '../../domain/entities/Race';
|
||||
|
||||
export interface RaceDetailEntryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarUrl: string;
|
||||
rating: number | null;
|
||||
isCurrentUser: boolean;
|
||||
}
|
||||
|
||||
export interface RaceDetailUserResultViewModel {
|
||||
position: number;
|
||||
startPosition: number;
|
||||
incidents: number;
|
||||
fastestLap: number;
|
||||
positionChange: number;
|
||||
isPodium: boolean;
|
||||
isClean: boolean;
|
||||
ratingChange: number | null;
|
||||
}
|
||||
|
||||
export interface RaceDetailRaceViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
sessionType: SessionType;
|
||||
status: RaceStatus;
|
||||
strengthOfField: number | null;
|
||||
registeredCount?: number;
|
||||
maxParticipants?: number;
|
||||
}
|
||||
|
||||
export interface RaceDetailLeagueViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
settings: {
|
||||
maxDrivers?: number;
|
||||
qualifyingFormat?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RaceDetailViewModel {
|
||||
race: RaceDetailRaceViewModel | null;
|
||||
league: RaceDetailLeagueViewModel | null;
|
||||
entryList: RaceDetailEntryViewModel[];
|
||||
registration: {
|
||||
isUserRegistered: boolean;
|
||||
canRegister: boolean;
|
||||
};
|
||||
userResult: RaceDetailUserResultViewModel | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IRaceDetailPresenter {
|
||||
present(viewModel: RaceDetailViewModel): RaceDetailViewModel;
|
||||
getViewModel(): RaceDetailViewModel | null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RaceStatus } from '../../domain/entities/Race';
|
||||
import type { Result } from '../../domain/entities/Result';
|
||||
import type { Driver } from '../../domain/entities/Driver';
|
||||
import type { PenaltyType } from '../../domain/entities/Penalty';
|
||||
|
||||
export interface RaceResultsHeaderViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
track: string;
|
||||
scheduledAt: Date;
|
||||
status: RaceStatus;
|
||||
}
|
||||
|
||||
export interface RaceResultsLeagueViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RaceResultsPenaltySummaryViewModel {
|
||||
driverId: string;
|
||||
type: PenaltyType;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface RaceResultsDetailViewModel {
|
||||
race: RaceResultsHeaderViewModel | null;
|
||||
league: RaceResultsLeagueViewModel | null;
|
||||
results: Result[];
|
||||
drivers: Driver[];
|
||||
penalties: RaceResultsPenaltySummaryViewModel[];
|
||||
pointsSystem: Record<number, number>;
|
||||
fastestLapTime?: number;
|
||||
currentDriverId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IRaceResultsDetailPresenter {
|
||||
present(viewModel: RaceResultsDetailViewModel): RaceResultsDetailViewModel;
|
||||
getViewModel(): RaceResultsDetailViewModel | null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SponsoredLeagueDTO, SponsorDashboardDTO } from '../use-cases/GetSponsorDashboardQuery';
|
||||
import type { SponsoredLeagueDTO, SponsorDashboardDTO } from '../use-cases/GetSponsorDashboardUseCase';
|
||||
|
||||
export interface ISponsorDashboardPresenter {
|
||||
present(data: SponsorDashboardDTO | null): void;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SponsorSponsorshipsDTO } from '../use-cases/GetSponsorSponsorshipsQuery';
|
||||
import type { SponsorSponsorshipsDTO } from '../use-cases/GetSponsorSponsorshipsUseCase';
|
||||
|
||||
export interface ISponsorSponsorshipsPresenter {
|
||||
present(data: SponsorSponsorshipsDTO | null): void;
|
||||
|
||||
@@ -19,6 +19,14 @@ export interface TeamLeaderboardItemViewModel {
|
||||
export interface TeamsLeaderboardViewModel {
|
||||
teams: TeamLeaderboardItemViewModel[];
|
||||
recruitingCount: number;
|
||||
/**
|
||||
* Teams grouped by their skill level for UI display.
|
||||
*/
|
||||
groupsBySkillLevel: Record<SkillLevel, TeamLeaderboardItemViewModel[]>;
|
||||
/**
|
||||
* Precomputed top teams ordered for leaderboard preview.
|
||||
*/
|
||||
topTeams: TeamLeaderboardItemViewModel[];
|
||||
}
|
||||
|
||||
export interface ITeamsLeaderboardPresenter {
|
||||
|
||||
32
packages/racing/application/use-cases/CancelRaceUseCase.ts
Normal file
32
packages/racing/application/use-cases/CancelRaceUseCase.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
|
||||
/**
|
||||
* Use Case: CancelRaceUseCase
|
||||
*
|
||||
* Encapsulates the workflow for cancelling a race:
|
||||
* - loads the race by id
|
||||
* - throws if the race does not exist
|
||||
* - delegates cancellation rules to the Race domain entity
|
||||
* - persists the updated race via the repository.
|
||||
*/
|
||||
export interface CancelRaceCommandDTO {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export class CancelRaceUseCase {
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: CancelRaceCommandDTO): Promise<void> {
|
||||
const { raceId } = command;
|
||||
|
||||
const race = await this.raceRepository.findById(raceId);
|
||||
if (!race) {
|
||||
throw new Error('Race not found');
|
||||
}
|
||||
|
||||
const cancelledRace = race.cancel();
|
||||
await this.raceRepository.update(cancelledRace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type {
|
||||
IAllRacesPagePresenter,
|
||||
AllRacesPageViewModel,
|
||||
AllRacesListItemViewModel,
|
||||
AllRacesFilterOptionsViewModel,
|
||||
} from '../presenters/IAllRacesPagePresenter';
|
||||
|
||||
export class GetAllRacesPageDataUseCase {
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
public readonly presenter: IAllRacesPagePresenter,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const [allRaces, allLeagues] = await Promise.all([
|
||||
this.raceRepository.findAll(),
|
||||
this.leagueRepository.findAll(),
|
||||
]);
|
||||
|
||||
const leagueMap = new Map(allLeagues.map((league) => [league.id, league.name]));
|
||||
|
||||
const races: AllRacesListItemViewModel[] = allRaces
|
||||
.slice()
|
||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
|
||||
.map((race) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt.toISOString(),
|
||||
status: race.status,
|
||||
leagueId: race.leagueId,
|
||||
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
|
||||
strengthOfField: race.strengthOfField ?? null,
|
||||
}));
|
||||
|
||||
const uniqueLeagues = new Map<string, { id: string; name: string }>();
|
||||
for (const league of allLeagues) {
|
||||
uniqueLeagues.set(league.id, { id: league.id, name: league.name });
|
||||
}
|
||||
|
||||
const filters: AllRacesFilterOptionsViewModel = {
|
||||
statuses: [
|
||||
{ value: 'all', label: 'All Statuses' },
|
||||
{ value: 'scheduled', label: 'Scheduled' },
|
||||
{ value: 'running', label: 'Live' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
],
|
||||
leagues: Array.from(uniqueLeagues.values()),
|
||||
};
|
||||
|
||||
const viewModel: AllRacesPageViewModel = {
|
||||
races,
|
||||
filters,
|
||||
};
|
||||
|
||||
this.presenter.present(viewModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
|
||||
import type { IImageService } from '../../domain/services/IImageService';
|
||||
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
|
||||
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
|
||||
import type {
|
||||
IDashboardOverviewPresenter,
|
||||
DashboardOverviewViewModel,
|
||||
DashboardDriverSummaryViewModel,
|
||||
DashboardRaceSummaryViewModel,
|
||||
DashboardRecentResultViewModel,
|
||||
DashboardLeagueStandingSummaryViewModel,
|
||||
DashboardFeedItemSummaryViewModel,
|
||||
DashboardFeedSummaryViewModel,
|
||||
DashboardFriendSummaryViewModel,
|
||||
} from '../presenters/IDashboardOverviewPresenter';
|
||||
|
||||
interface DashboardDriverStatsAdapter {
|
||||
rating: number | null;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
totalRaces: number;
|
||||
overallRank: number | null;
|
||||
consistency: number | null;
|
||||
}
|
||||
|
||||
export interface GetDashboardOverviewParams {
|
||||
driverId: string;
|
||||
}
|
||||
|
||||
export class GetDashboardOverviewUseCase {
|
||||
constructor(
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
||||
private readonly feedRepository: IFeedRepository,
|
||||
private readonly socialRepository: ISocialGraphRepository,
|
||||
private readonly imageService: IImageService,
|
||||
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
|
||||
public readonly presenter: IDashboardOverviewPresenter,
|
||||
) {}
|
||||
|
||||
async execute(params: GetDashboardOverviewParams): Promise<void> {
|
||||
const { driverId } = params;
|
||||
|
||||
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
|
||||
this.driverRepository.findById(driverId),
|
||||
this.leagueRepository.findAll(),
|
||||
this.raceRepository.findAll(),
|
||||
this.resultRepository.findAll(),
|
||||
this.feedRepository.getFeedForDriver(driverId),
|
||||
this.socialRepository.getFriends(driverId),
|
||||
]);
|
||||
|
||||
const leagueMap = new Map(allLeagues.map(league => [league.id, league.name]));
|
||||
|
||||
const driverStats = this.getDriverStats(driverId);
|
||||
|
||||
const currentDriver: DashboardDriverSummaryViewModel | null = driver
|
||||
? {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
country: driver.country,
|
||||
avatarUrl: this.imageService.getDriverAvatar(driver.id),
|
||||
rating: driverStats?.rating ?? null,
|
||||
globalRank: driverStats?.overallRank ?? null,
|
||||
totalRaces: driverStats?.totalRaces ?? 0,
|
||||
wins: driverStats?.wins ?? 0,
|
||||
podiums: driverStats?.podiums ?? 0,
|
||||
consistency: driverStats?.consistency ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const driverLeagues = await this.getDriverLeagues(allLeagues, driverId);
|
||||
const driverLeagueIds = new Set(driverLeagues.map(league => league.id));
|
||||
|
||||
const now = new Date();
|
||||
const upcomingRaces = allRaces
|
||||
.filter(race => race.status === 'scheduled' && race.scheduledAt > now)
|
||||
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||
|
||||
const upcomingRacesInDriverLeagues = upcomingRaces.filter(race =>
|
||||
driverLeagueIds.has(race.leagueId),
|
||||
);
|
||||
|
||||
const { myUpcomingRaces, otherUpcomingRaces } =
|
||||
await this.partitionUpcomingRacesByRegistration(upcomingRacesInDriverLeagues, driverId, leagueMap);
|
||||
|
||||
const nextRace: DashboardRaceSummaryViewModel | null =
|
||||
myUpcomingRaces.length > 0 ? myUpcomingRaces[0]! : null;
|
||||
|
||||
const upcomingRacesSummaries: DashboardRaceSummaryViewModel[] = [
|
||||
...myUpcomingRaces,
|
||||
...otherUpcomingRaces,
|
||||
].slice().sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
|
||||
);
|
||||
|
||||
const recentResults = this.buildRecentResults(allResults, allRaces, allLeagues, driverId);
|
||||
|
||||
const leagueStandingsSummaries = await this.buildLeagueStandingsSummaries(
|
||||
driverLeagues,
|
||||
driverId,
|
||||
);
|
||||
|
||||
const activeLeaguesCount = this.computeActiveLeaguesCount(
|
||||
upcomingRacesSummaries,
|
||||
leagueStandingsSummaries,
|
||||
);
|
||||
|
||||
const feedSummary = this.buildFeedSummary(feedItems);
|
||||
|
||||
const friendsSummary = this.buildFriendsSummary(friends);
|
||||
|
||||
const viewModel: DashboardOverviewViewModel = {
|
||||
currentDriver,
|
||||
myUpcomingRaces,
|
||||
otherUpcomingRaces,
|
||||
upcomingRaces: upcomingRacesSummaries,
|
||||
activeLeaguesCount,
|
||||
nextRace,
|
||||
recentResults,
|
||||
leagueStandingsSummaries,
|
||||
feedSummary,
|
||||
friends: friendsSummary,
|
||||
};
|
||||
|
||||
this.presenter.present(viewModel);
|
||||
}
|
||||
|
||||
private async getDriverLeagues(allLeagues: any[], driverId: string): Promise<any[]> {
|
||||
const driverLeagues: any[] = [];
|
||||
|
||||
for (const league of allLeagues) {
|
||||
const membership = await this.leagueMembershipRepository.getMembership(league.id, driverId);
|
||||
if (membership && membership.status === 'active') {
|
||||
driverLeagues.push(league);
|
||||
}
|
||||
}
|
||||
|
||||
return driverLeagues;
|
||||
}
|
||||
|
||||
private async partitionUpcomingRacesByRegistration(
|
||||
upcomingRaces: any[],
|
||||
driverId: string,
|
||||
leagueMap: Map<string, string>,
|
||||
): Promise<{
|
||||
myUpcomingRaces: DashboardRaceSummaryViewModel[];
|
||||
otherUpcomingRaces: DashboardRaceSummaryViewModel[];
|
||||
}> {
|
||||
const myUpcomingRaces: DashboardRaceSummaryViewModel[] = [];
|
||||
const otherUpcomingRaces: DashboardRaceSummaryViewModel[] = [];
|
||||
|
||||
for (const race of upcomingRaces) {
|
||||
const isRegistered = await this.raceRegistrationRepository.isRegistered(race.id, driverId);
|
||||
const summary = this.mapRaceToSummary(race, leagueMap, true);
|
||||
|
||||
if (isRegistered) {
|
||||
myUpcomingRaces.push(summary);
|
||||
} else {
|
||||
otherUpcomingRaces.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return { myUpcomingRaces, otherUpcomingRaces };
|
||||
}
|
||||
|
||||
private mapRaceToSummary(
|
||||
race: any,
|
||||
leagueMap: Map<string, string>,
|
||||
isMyLeague: boolean,
|
||||
): DashboardRaceSummaryViewModel {
|
||||
return {
|
||||
id: race.id,
|
||||
leagueId: race.leagueId,
|
||||
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt.toISOString(),
|
||||
status: race.status,
|
||||
isMyLeague,
|
||||
};
|
||||
}
|
||||
|
||||
private buildRecentResults(
|
||||
allResults: any[],
|
||||
allRaces: any[],
|
||||
allLeagues: any[],
|
||||
driverId: string,
|
||||
): DashboardRecentResultViewModel[] {
|
||||
const raceById = new Map(allRaces.map(race => [race.id, race]));
|
||||
const leagueById = new Map(allLeagues.map(league => [league.id, league]));
|
||||
|
||||
const driverResults = allResults.filter(result => result.driverId === driverId);
|
||||
|
||||
const enriched = driverResults
|
||||
.map(result => {
|
||||
const race = raceById.get(result.raceId);
|
||||
if (!race) return null;
|
||||
|
||||
const league = leagueById.get(race.leagueId);
|
||||
|
||||
const finishedAt = race.scheduledAt.toISOString();
|
||||
|
||||
const item: DashboardRecentResultViewModel = {
|
||||
raceId: race.id,
|
||||
raceName: race.track,
|
||||
leagueId: race.leagueId,
|
||||
leagueName: league?.name ?? 'Unknown League',
|
||||
finishedAt,
|
||||
position: result.position,
|
||||
incidents: result.incidents,
|
||||
};
|
||||
|
||||
return item;
|
||||
})
|
||||
.filter((item): item is DashboardRecentResultViewModel => !!item)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.finishedAt).getTime() - new Date(a.finishedAt).getTime(),
|
||||
);
|
||||
|
||||
const RECENT_RESULTS_LIMIT = 5;
|
||||
|
||||
return enriched.slice(0, RECENT_RESULTS_LIMIT);
|
||||
}
|
||||
|
||||
private async buildLeagueStandingsSummaries(
|
||||
driverLeagues: any[],
|
||||
driverId: string,
|
||||
): Promise<DashboardLeagueStandingSummaryViewModel[]> {
|
||||
const summaries: DashboardLeagueStandingSummaryViewModel[] = [];
|
||||
|
||||
for (const league of driverLeagues.slice(0, 3)) {
|
||||
const standings = await this.standingRepository.findByLeagueId(league.id);
|
||||
const driverStanding = standings.find(
|
||||
(standing: any) => standing.driverId === driverId,
|
||||
);
|
||||
|
||||
summaries.push({
|
||||
leagueId: league.id,
|
||||
leagueName: league.name,
|
||||
position: driverStanding?.position ?? 0,
|
||||
points: driverStanding?.points ?? 0,
|
||||
totalDrivers: standings.length,
|
||||
});
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private computeActiveLeaguesCount(
|
||||
upcomingRaces: DashboardRaceSummaryViewModel[],
|
||||
leagueStandingsSummaries: DashboardLeagueStandingSummaryViewModel[],
|
||||
): number {
|
||||
const activeLeagueIds = new Set<string>();
|
||||
|
||||
for (const race of upcomingRaces) {
|
||||
activeLeagueIds.add(race.leagueId);
|
||||
}
|
||||
|
||||
for (const standing of leagueStandingsSummaries) {
|
||||
activeLeagueIds.add(standing.leagueId);
|
||||
}
|
||||
|
||||
return activeLeagueIds.size;
|
||||
}
|
||||
|
||||
private buildFeedSummary(feedItems: any[]): DashboardFeedSummaryViewModel {
|
||||
const items: DashboardFeedItemSummaryViewModel[] = feedItems.map(item => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
headline: item.headline,
|
||||
body: item.body,
|
||||
timestamp:
|
||||
item.timestamp instanceof Date
|
||||
? item.timestamp.toISOString()
|
||||
: new Date(item.timestamp).toISOString(),
|
||||
ctaLabel: item.ctaLabel,
|
||||
ctaHref: item.ctaHref,
|
||||
}));
|
||||
|
||||
return {
|
||||
notificationCount: items.length,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private buildFriendsSummary(friends: any[]): DashboardFriendSummaryViewModel[] {
|
||||
return friends.map(friend => ({
|
||||
id: friend.id,
|
||||
name: friend.name,
|
||||
country: friend.country,
|
||||
avatarUrl: this.imageService.getDriverAvatar(friend.id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { IImageService } from '../../domain/services/IImageService';
|
||||
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
|
||||
import type {
|
||||
IProfileOverviewPresenter,
|
||||
ProfileOverviewViewModel,
|
||||
ProfileOverviewDriverSummaryViewModel,
|
||||
ProfileOverviewStatsViewModel,
|
||||
ProfileOverviewFinishDistributionViewModel,
|
||||
ProfileOverviewTeamMembershipViewModel,
|
||||
ProfileOverviewSocialSummaryViewModel,
|
||||
ProfileOverviewExtendedProfileViewModel,
|
||||
} from '../presenters/IProfileOverviewPresenter';
|
||||
|
||||
interface ProfileDriverStatsAdapter {
|
||||
rating: number | null;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
dnfs: number;
|
||||
totalRaces: number;
|
||||
avgFinish: number | null;
|
||||
bestFinish: number | null;
|
||||
worstFinish: number | null;
|
||||
overallRank: number | null;
|
||||
consistency: number | null;
|
||||
percentile: number | null;
|
||||
}
|
||||
|
||||
interface DriverRankingEntry {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface GetProfileOverviewParams {
|
||||
driverId: string;
|
||||
}
|
||||
|
||||
export class GetProfileOverviewUseCase {
|
||||
constructor(
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly teamRepository: ITeamRepository,
|
||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||
private readonly socialRepository: ISocialGraphRepository,
|
||||
private readonly imageService: IImageService,
|
||||
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
|
||||
private readonly getAllDriverRankings: () => DriverRankingEntry[],
|
||||
public readonly presenter: IProfileOverviewPresenter,
|
||||
) {}
|
||||
|
||||
async execute(params: GetProfileOverviewParams): Promise<void> {
|
||||
const { driverId } = params;
|
||||
|
||||
const driver = await this.driverRepository.findById(driverId);
|
||||
|
||||
if (!driver) {
|
||||
const emptyViewModel: ProfileOverviewViewModel = {
|
||||
currentDriver: null,
|
||||
stats: null,
|
||||
finishDistribution: null,
|
||||
teamMemberships: [],
|
||||
socialSummary: {
|
||||
friendsCount: 0,
|
||||
friends: [],
|
||||
},
|
||||
extendedProfile: null,
|
||||
};
|
||||
|
||||
this.presenter.present(emptyViewModel);
|
||||
return;
|
||||
}
|
||||
|
||||
const [statsAdapter, teams, friends] = await Promise.all([
|
||||
Promise.resolve(this.getDriverStats(driverId)),
|
||||
this.teamRepository.findAll(),
|
||||
this.socialRepository.getFriends(driverId),
|
||||
]);
|
||||
|
||||
const driverSummary = this.buildDriverSummary(driver, statsAdapter);
|
||||
const stats = this.buildStats(statsAdapter);
|
||||
const finishDistribution = this.buildFinishDistribution(statsAdapter);
|
||||
const teamMemberships = await this.buildTeamMemberships(driver.id, teams);
|
||||
const socialSummary = this.buildSocialSummary(friends);
|
||||
const extendedProfile = this.buildExtendedProfile(driver.id);
|
||||
|
||||
const viewModel: ProfileOverviewViewModel = {
|
||||
currentDriver: driverSummary,
|
||||
stats,
|
||||
finishDistribution,
|
||||
teamMemberships,
|
||||
socialSummary,
|
||||
extendedProfile,
|
||||
};
|
||||
|
||||
this.presenter.present(viewModel);
|
||||
}
|
||||
|
||||
private buildDriverSummary(
|
||||
driver: any,
|
||||
stats: ProfileDriverStatsAdapter | null,
|
||||
): ProfileOverviewDriverSummaryViewModel {
|
||||
const rankings = this.getAllDriverRankings();
|
||||
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
|
||||
|
||||
return {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
country: driver.country,
|
||||
avatarUrl: this.imageService.getDriverAvatar(driver.id),
|
||||
iracingId: driver.iracingId ?? null,
|
||||
joinedAt: driver.joinedAt instanceof Date
|
||||
? driver.joinedAt.toISOString()
|
||||
: new Date(driver.joinedAt).toISOString(),
|
||||
rating: stats?.rating ?? null,
|
||||
globalRank: stats?.overallRank ?? fallbackRank,
|
||||
consistency: stats?.consistency ?? null,
|
||||
bio: driver.bio ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private computeFallbackRank(
|
||||
driverId: string,
|
||||
rankings: DriverRankingEntry[],
|
||||
): number | null {
|
||||
const index = rankings.findIndex(entry => entry.driverId === driverId);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
private buildStats(
|
||||
stats: ProfileDriverStatsAdapter | null,
|
||||
): ProfileOverviewStatsViewModel | null {
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalRaces = stats.totalRaces;
|
||||
const dnfs = stats.dnfs;
|
||||
const finishedRaces = Math.max(totalRaces - dnfs, 0);
|
||||
|
||||
const finishRate =
|
||||
totalRaces > 0 ? (finishedRaces / totalRaces) * 100 : null;
|
||||
const winRate =
|
||||
totalRaces > 0 ? (stats.wins / totalRaces) * 100 : null;
|
||||
const podiumRate =
|
||||
totalRaces > 0 ? (stats.podiums / totalRaces) * 100 : null;
|
||||
|
||||
return {
|
||||
totalRaces,
|
||||
wins: stats.wins,
|
||||
podiums: stats.podiums,
|
||||
dnfs,
|
||||
avgFinish: stats.avgFinish,
|
||||
bestFinish: stats.bestFinish,
|
||||
worstFinish: stats.worstFinish,
|
||||
finishRate,
|
||||
winRate,
|
||||
podiumRate,
|
||||
percentile: stats.percentile,
|
||||
};
|
||||
}
|
||||
|
||||
private buildFinishDistribution(
|
||||
stats: ProfileDriverStatsAdapter | null,
|
||||
): ProfileOverviewFinishDistributionViewModel | null {
|
||||
if (!stats || stats.totalRaces <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalRaces = stats.totalRaces;
|
||||
const dnfs = stats.dnfs;
|
||||
const finishedRaces = Math.max(totalRaces - dnfs, 0);
|
||||
|
||||
const estimatedTopTen = Math.min(
|
||||
finishedRaces,
|
||||
Math.round(totalRaces * 0.7),
|
||||
);
|
||||
|
||||
const topTen = Math.max(estimatedTopTen, stats.podiums);
|
||||
const other = Math.max(totalRaces - topTen, 0);
|
||||
|
||||
return {
|
||||
totalRaces,
|
||||
wins: stats.wins,
|
||||
podiums: stats.podiums,
|
||||
topTen,
|
||||
dnfs,
|
||||
other,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildTeamMemberships(
|
||||
driverId: string,
|
||||
teams: any[],
|
||||
): Promise<ProfileOverviewTeamMembershipViewModel[]> {
|
||||
const memberships: ProfileOverviewTeamMembershipViewModel[] = [];
|
||||
|
||||
for (const team of teams) {
|
||||
const membership = await this.teamMembershipRepository.getMembership(
|
||||
team.id,
|
||||
driverId,
|
||||
);
|
||||
if (!membership) continue;
|
||||
|
||||
memberships.push({
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
teamTag: team.tag ?? null,
|
||||
role: membership.role,
|
||||
joinedAt:
|
||||
membership.joinedAt instanceof Date
|
||||
? membership.joinedAt.toISOString()
|
||||
: new Date(membership.joinedAt).toISOString(),
|
||||
isCurrent: membership.status === 'active',
|
||||
});
|
||||
}
|
||||
|
||||
memberships.sort((a, b) => a.joinedAt.localeCompare(b.joinedAt));
|
||||
|
||||
return memberships;
|
||||
}
|
||||
|
||||
private buildSocialSummary(friends: any[]): ProfileOverviewSocialSummaryViewModel {
|
||||
return {
|
||||
friendsCount: friends.length,
|
||||
friends: friends.map(friend => ({
|
||||
id: friend.id,
|
||||
name: friend.name,
|
||||
country: friend.country,
|
||||
avatarUrl: this.imageService.getDriverAvatar(friend.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private buildExtendedProfile(driverId: string): ProfileOverviewExtendedProfileViewModel {
|
||||
const hash = driverId
|
||||
.split('')
|
||||
.reduce((acc: number, char: string) => acc + char.charCodeAt(0), 0);
|
||||
|
||||
const socialOptions: Array<
|
||||
Array<{
|
||||
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
|
||||
handle: string;
|
||||
url: string;
|
||||
}>
|
||||
> = [
|
||||
[
|
||||
{
|
||||
platform: 'twitter',
|
||||
handle: '@speedracer',
|
||||
url: 'https://twitter.com/speedracer',
|
||||
},
|
||||
{
|
||||
platform: 'youtube',
|
||||
handle: 'SpeedRacer Racing',
|
||||
url: 'https://youtube.com/@speedracer',
|
||||
},
|
||||
{
|
||||
platform: 'twitch',
|
||||
handle: 'speedracer_live',
|
||||
url: 'https://twitch.tv/speedracer_live',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
platform: 'twitter',
|
||||
handle: '@racingpro',
|
||||
url: 'https://twitter.com/racingpro',
|
||||
},
|
||||
{
|
||||
platform: 'discord',
|
||||
handle: 'RacingPro#1234',
|
||||
url: '#',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
platform: 'twitch',
|
||||
handle: 'simracer_elite',
|
||||
url: 'https://twitch.tv/simracer_elite',
|
||||
},
|
||||
{
|
||||
platform: 'youtube',
|
||||
handle: 'SimRacer Elite',
|
||||
url: 'https://youtube.com/@simracerelite',
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const achievementSets: Array<
|
||||
Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
earnedAt: Date;
|
||||
}>
|
||||
> = [
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
title: 'First Victory',
|
||||
description: 'Win your first race',
|
||||
icon: 'trophy',
|
||||
rarity: 'common',
|
||||
earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Clean Racer',
|
||||
description: '10 races without incidents',
|
||||
icon: 'star',
|
||||
rarity: 'rare',
|
||||
earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Podium Streak',
|
||||
description: '5 consecutive podium finishes',
|
||||
icon: 'medal',
|
||||
rarity: 'epic',
|
||||
earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Championship Glory',
|
||||
description: 'Win a league championship',
|
||||
icon: 'crown',
|
||||
rarity: 'legendary',
|
||||
earnedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
title: 'Rookie No More',
|
||||
description: 'Complete 25 races',
|
||||
icon: 'target',
|
||||
rarity: 'common',
|
||||
earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Consistent Performer',
|
||||
description: 'Maintain 80%+ consistency rating',
|
||||
icon: 'zap',
|
||||
rarity: 'rare',
|
||||
earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Endurance Master',
|
||||
description: 'Complete a 24-hour race',
|
||||
icon: 'star',
|
||||
rarity: 'epic',
|
||||
earnedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: '1',
|
||||
title: 'Welcome Racer',
|
||||
description: 'Join GridPilot',
|
||||
icon: 'star',
|
||||
rarity: 'common',
|
||||
earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Team Player',
|
||||
description: 'Join a racing team',
|
||||
icon: 'medal',
|
||||
rarity: 'rare',
|
||||
earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const tracks = [
|
||||
'Spa-Francorchamps',
|
||||
'Nürburgring Nordschleife',
|
||||
'Suzuka',
|
||||
'Monza',
|
||||
'Interlagos',
|
||||
'Silverstone',
|
||||
];
|
||||
const cars = [
|
||||
'Porsche 911 GT3 R',
|
||||
'Ferrari 488 GT3',
|
||||
'Mercedes-AMG GT3',
|
||||
'BMW M4 GT3',
|
||||
'Audi R8 LMS',
|
||||
];
|
||||
const styles = [
|
||||
'Aggressive Overtaker',
|
||||
'Consistent Pacer',
|
||||
'Strategic Calculator',
|
||||
'Late Braker',
|
||||
'Smooth Operator',
|
||||
];
|
||||
const timezones = [
|
||||
'EST (UTC-5)',
|
||||
'CET (UTC+1)',
|
||||
'PST (UTC-8)',
|
||||
'GMT (UTC+0)',
|
||||
'JST (UTC+9)',
|
||||
];
|
||||
const hours = [
|
||||
'Evenings (18:00-23:00)',
|
||||
'Weekends only',
|
||||
'Late nights (22:00-02:00)',
|
||||
'Flexible schedule',
|
||||
];
|
||||
|
||||
const socialHandles = socialOptions[hash % socialOptions.length];
|
||||
const achievementsSource = achievementSets[hash % achievementSets.length];
|
||||
|
||||
return {
|
||||
socialHandles,
|
||||
achievements: achievementsSource.map(achievement => ({
|
||||
id: achievement.id,
|
||||
title: achievement.title,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
earnedAt: achievement.earnedAt.toISOString(),
|
||||
})),
|
||||
racingStyle: styles[hash % styles.length],
|
||||
favoriteTrack: tracks[hash % tracks.length],
|
||||
favoriteCar: cars[hash % cars.length],
|
||||
timezone: timezones[hash % timezones.length],
|
||||
availableHours: hours[hash % hours.length],
|
||||
lookingForTeam: hash % 3 === 0,
|
||||
openToRequests: hash % 2 === 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
159
packages/racing/application/use-cases/GetRaceDetailUseCase.ts
Normal file
159
packages/racing/application/use-cases/GetRaceDetailUseCase.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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 { IImageService } from '../../domain/services/IImageService';
|
||||
import type {
|
||||
IRaceDetailPresenter,
|
||||
RaceDetailViewModel,
|
||||
RaceDetailRaceViewModel,
|
||||
RaceDetailLeagueViewModel,
|
||||
RaceDetailEntryViewModel,
|
||||
RaceDetailUserResultViewModel,
|
||||
} from '../presenters/IRaceDetailPresenter';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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: IImageService,
|
||||
public readonly presenter: IRaceDetailPresenter,
|
||||
) {}
|
||||
|
||||
async execute(params: GetRaceDetailQueryParams): Promise<void> {
|
||||
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',
|
||||
};
|
||||
this.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<typeof d> => 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,
|
||||
registeredCount: race.registeredCount,
|
||||
maxParticipants: race.maxParticipants,
|
||||
};
|
||||
|
||||
const leagueView: RaceDetailLeagueViewModel | null = league
|
||||
? {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
settings: {
|
||||
maxDrivers: league.settings.maxDrivers,
|
||||
qualifyingFormat: league.settings.qualifyingFormat,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
const viewModel: RaceDetailViewModel = {
|
||||
race: raceView,
|
||||
league: leagueView,
|
||||
entryList,
|
||||
registration: {
|
||||
isUserRegistered,
|
||||
canRegister,
|
||||
},
|
||||
userResult: userResultView,
|
||||
};
|
||||
|
||||
this.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
|
||||
import type {
|
||||
IRaceResultsDetailPresenter,
|
||||
RaceResultsDetailViewModel,
|
||||
RaceResultsPenaltySummaryViewModel,
|
||||
} from '../presenters/IRaceResultsDetailPresenter';
|
||||
import type { League } from '../../domain/entities/League';
|
||||
import type { Result } from '../../domain/entities/Result';
|
||||
import type { Driver } from '../../domain/entities/Driver';
|
||||
import type { Penalty } from '../../domain/entities/Penalty';
|
||||
|
||||
export interface GetRaceResultsDetailParams {
|
||||
raceId: string;
|
||||
driverId?: string;
|
||||
}
|
||||
|
||||
function buildPointsSystem(league: League | null): Record<number, number> {
|
||||
if (!league) return {};
|
||||
|
||||
const pointsSystems: Record<string, Record<number, number>> = {
|
||||
'f1-2024': {
|
||||
1: 25,
|
||||
2: 18,
|
||||
3: 15,
|
||||
4: 12,
|
||||
5: 10,
|
||||
6: 8,
|
||||
7: 6,
|
||||
8: 4,
|
||||
9: 2,
|
||||
10: 1,
|
||||
},
|
||||
indycar: {
|
||||
1: 50,
|
||||
2: 40,
|
||||
3: 35,
|
||||
4: 32,
|
||||
5: 30,
|
||||
6: 28,
|
||||
7: 26,
|
||||
8: 24,
|
||||
9: 22,
|
||||
10: 20,
|
||||
11: 19,
|
||||
12: 18,
|
||||
13: 17,
|
||||
14: 16,
|
||||
15: 15,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
league.settings.customPoints ||
|
||||
pointsSystems[league.settings.pointsSystem] ||
|
||||
pointsSystems['f1-2024']
|
||||
);
|
||||
}
|
||||
|
||||
function getFastestLapTime(results: Result[]): number | undefined {
|
||||
if (results.length === 0) return undefined;
|
||||
return Math.min(...results.map((r) => r.fastestLap));
|
||||
}
|
||||
|
||||
function mapPenaltySummary(penalties: Penalty[]): RaceResultsPenaltySummaryViewModel[] {
|
||||
return penalties.map((p) => ({
|
||||
driverId: p.driverId,
|
||||
type: p.type,
|
||||
value: p.value,
|
||||
}));
|
||||
}
|
||||
|
||||
export class GetRaceResultsDetailUseCase {
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly penaltyRepository: IPenaltyRepository,
|
||||
public readonly presenter: IRaceResultsDetailPresenter,
|
||||
) {}
|
||||
|
||||
async execute(params: GetRaceResultsDetailParams): Promise<void> {
|
||||
const { raceId, driverId } = params;
|
||||
|
||||
const race = await this.raceRepository.findById(raceId);
|
||||
|
||||
if (!race) {
|
||||
const errorViewModel: RaceResultsDetailViewModel = {
|
||||
race: null,
|
||||
league: null,
|
||||
results: [],
|
||||
drivers: [],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: undefined,
|
||||
currentDriverId: driverId,
|
||||
error: 'Race not found',
|
||||
};
|
||||
this.presenter.present(errorViewModel);
|
||||
return;
|
||||
}
|
||||
|
||||
const [league, results, drivers, penalties] = await Promise.all([
|
||||
this.leagueRepository.findById(race.leagueId),
|
||||
this.resultRepository.findByRaceId(raceId),
|
||||
this.driverRepository.findAll(),
|
||||
this.penaltyRepository.findByRaceId(raceId),
|
||||
]);
|
||||
|
||||
const effectiveCurrentDriverId =
|
||||
driverId || (drivers.length > 0 ? drivers[0]!.id : undefined);
|
||||
|
||||
const pointsSystem = buildPointsSystem(league as League | null);
|
||||
const fastestLapTime = getFastestLapTime(results);
|
||||
const penaltySummary = mapPenaltySummary(penalties);
|
||||
|
||||
const viewModel: RaceResultsDetailViewModel = {
|
||||
race: {
|
||||
id: race.id,
|
||||
leagueId: race.leagueId,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status,
|
||||
},
|
||||
league: league
|
||||
? {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
}
|
||||
: null,
|
||||
results,
|
||||
drivers,
|
||||
penalties: penaltySummary,
|
||||
pointsSystem,
|
||||
fastestLapTime,
|
||||
currentDriverId: effectiveCurrentDriverId,
|
||||
};
|
||||
|
||||
this.presenter.present(viewModel);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { inject, injectable } from 'tsyringe';
|
||||
import type { ITeamRepository } from '@gridpilot/racing/domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '@gridpilot/racing/domain/repositories/ITeamMembershipRepository';
|
||||
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
||||
@@ -11,15 +10,19 @@ interface DriverStatsAdapter {
|
||||
totalRaces: number;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
/**
|
||||
* Use case: GetTeamsLeaderboardUseCase
|
||||
*
|
||||
* Plain constructor-injected dependencies (no decorators) to keep the
|
||||
* application layer framework-agnostic and compatible with test tooling.
|
||||
*/
|
||||
export class GetTeamsLeaderboardUseCase {
|
||||
constructor(
|
||||
@inject('ITeamRepository') private readonly teamRepository: ITeamRepository,
|
||||
@inject('ITeamMembershipRepository')
|
||||
private readonly teamRepository: ITeamRepository,
|
||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||
@inject('IDriverRepository') private readonly driverRepository: IDriverRepository,
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly getDriverStats: (driverId: string) => DriverStatsAdapter | null,
|
||||
public readonly presenter: ITeamsLeaderboardPresenter
|
||||
public readonly presenter: ITeamsLeaderboardPresenter,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import { Result } from '../../domain/entities/Result';
|
||||
import type {
|
||||
IImportRaceResultsPresenter,
|
||||
ImportRaceResultsSummaryViewModel,
|
||||
} from '../presenters/IImportRaceResultsPresenter';
|
||||
|
||||
export interface ImportRaceResultDTO {
|
||||
id: string;
|
||||
raceId: string;
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
|
||||
export interface ImportRaceResultsParams {
|
||||
raceId: string;
|
||||
results: ImportRaceResultDTO[];
|
||||
}
|
||||
|
||||
export class ImportRaceResultsUseCase {
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly leagueRepository: ILeagueRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly standingRepository: IStandingRepository,
|
||||
public readonly presenter: IImportRaceResultsPresenter,
|
||||
) {}
|
||||
|
||||
async execute(params: ImportRaceResultsParams): Promise<void> {
|
||||
const { raceId, results } = params;
|
||||
|
||||
const race = await this.raceRepository.findById(raceId);
|
||||
if (!race) {
|
||||
throw new Error('Race not found');
|
||||
}
|
||||
|
||||
const league = await this.leagueRepository.findById(race.leagueId);
|
||||
if (!league) {
|
||||
throw new Error('League not found');
|
||||
}
|
||||
|
||||
const existing = await this.resultRepository.existsByRaceId(raceId);
|
||||
if (existing) {
|
||||
throw new Error('Results already exist for this race');
|
||||
}
|
||||
|
||||
const entities = results.map((dto) =>
|
||||
Result.create({
|
||||
id: dto.id,
|
||||
raceId: dto.raceId,
|
||||
driverId: dto.driverId,
|
||||
position: dto.position,
|
||||
fastestLap: dto.fastestLap,
|
||||
incidents: dto.incidents,
|
||||
startPosition: dto.startPosition,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.resultRepository.createMany(entities);
|
||||
await this.standingRepository.recalculate(league.id);
|
||||
|
||||
const viewModel: ImportRaceResultsSummaryViewModel = {
|
||||
importedCount: results.length,
|
||||
standingsRecalculated: true,
|
||||
};
|
||||
|
||||
this.presenter.present(viewModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import type { DriverDTO } from '../dto/DriverDTO';
|
||||
import { EntityMappers } from '../mappers/EntityMappers';
|
||||
|
||||
export interface UpdateDriverProfileInput {
|
||||
driverId: string;
|
||||
bio?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application use case responsible for updating basic driver profile details.
|
||||
* Encapsulates domain entity mutation and mapping to a DriverDTO.
|
||||
*/
|
||||
export class UpdateDriverProfileUseCase {
|
||||
constructor(private readonly driverRepository: IDriverRepository) {}
|
||||
|
||||
async execute(input: UpdateDriverProfileInput): Promise<DriverDTO | null> {
|
||||
const { driverId, bio, country } = input;
|
||||
|
||||
const existing = await this.driverRepository.findById(driverId);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated = existing.update({
|
||||
bio: bio ?? existing.bio,
|
||||
country: country ?? existing.country,
|
||||
});
|
||||
|
||||
const persisted = await this.driverRepository.update(updated);
|
||||
const dto = EntityMappers.toDriverDTO(persisted);
|
||||
return dto ?? null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user