451 lines
13 KiB
TypeScript
451 lines
13 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 '@core/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<ProfileOverviewViewModel | null> {
|
|
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 emptyViewModel;
|
|
}
|
|
|
|
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);
|
|
return viewModel;
|
|
}
|
|
|
|
private buildDriverSummary(
|
|
driver: any,
|
|
stats: ProfileDriverStatsAdapter | null,
|
|
): ProfileOverviewDriverSummaryViewModel {
|
|
const rankings = this.getAllDriverRankings();
|
|
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
|
|
const totalDrivers = rankings.length;
|
|
|
|
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,
|
|
totalDrivers,
|
|
};
|
|
}
|
|
|
|
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,
|
|
rating: stats.rating,
|
|
consistency: stats.consistency,
|
|
overallRank: stats.overallRank,
|
|
};
|
|
}
|
|
|
|
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] ?? 'Consistent Pacer',
|
|
favoriteTrack: tracks[hash % tracks.length] ?? 'Unknown Track',
|
|
favoriteCar: cars[hash % cars.length] ?? 'Unknown Car',
|
|
timezone: timezones[hash % timezones.length] ?? 'UTC',
|
|
availableHours: hours[hash % hours.length] ?? 'Flexible schedule',
|
|
lookingForTeam: hash % 3 === 0,
|
|
openToRequests: hash % 2 === 0,
|
|
};
|
|
}
|
|
} |