Files
gridpilot.gg/packages/racing/application/use-cases/GetProfileOverviewUseCase.ts
2025-12-11 13:50:38 +01:00

442 lines
12 KiB
TypeScript

import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
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: IImageServicePort,
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,
};
}
}