Files
gridpilot.gg/core/racing/application/use-cases/DashboardOverviewUseCase.ts
2025-12-28 12:04:12 +01:00

383 lines
11 KiB
TypeScript

import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { Driver } from '../../domain/entities/Driver';
import { League } from '../../domain/entities/League';
import { Race } from '../../domain/entities/Race';
import { Standing } from '../../domain/entities/Standing';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { Result as RaceResult } from '../../domain/entities/result/Result';
export interface DashboardOverviewInput {
driverId: string;
}
interface DashboardDriverStatsAdapter {
rating: number | null;
wins: number;
podiums: number;
totalRaces: number;
overallRank: number | null;
consistency: number | null;
}
export interface DashboardDriverSummary {
driver: Driver;
avatarUrl: string | null;
rating: number | null;
globalRank: number | null;
totalRaces: number;
wins: number;
podiums: number;
consistency: number | null;
}
export interface DashboardRaceSummary {
race: Race;
league: League | null;
isMyLeague: boolean;
}
export interface DashboardRecentRaceResultSummary {
race: Race;
league: League | null;
result: RaceResult;
}
export interface DashboardLeagueStandingSummary {
league: League;
standing: Standing | null;
totalDrivers: number;
}
export interface DashboardFeedSummary {
notificationCount: number;
items: FeedItem[];
}
export interface DashboardFriendSummary {
driver: Driver;
avatarUrl: string | null;
}
export interface DashboardOverviewResult {
currentDriver: DashboardDriverSummary | null;
myUpcomingRaces: DashboardRaceSummary[];
otherUpcomingRaces: DashboardRaceSummary[];
upcomingRaces: DashboardRaceSummary[];
activeLeaguesCount: number;
nextRace: DashboardRaceSummary | null;
recentResults: DashboardRecentRaceResultSummary[];
leagueStandingsSummaries: DashboardLeagueStandingSummary[];
feedSummary: DashboardFeedSummary;
friends: DashboardFriendSummary[];
}
export class DashboardOverviewUseCase {
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 getDriverAvatar: (driverId: string) => Promise<string>,
private readonly getDriverStats: (
driverId: string,
) => DashboardDriverStatsAdapter | null,
private readonly output: UseCaseOutputPort<DashboardOverviewResult>,
) {}
async execute(
input: DashboardOverviewInput,
): Promise<
Result<
void,
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
>
> {
const { driverId } = input;
try {
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),
]);
if (!driver) {
return Result.err({
code: 'DRIVER_NOT_FOUND',
details: { message: 'Driver not found' },
});
}
const leagueMap = new Map(allLeagues.map(league => [league.id.toString(), league]));
const driverStats = this.getDriverStats(driverId);
const currentDriver: DashboardDriverSummary = {
driver,
avatarUrl: await this.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,
};
const driverLeagues = await this.getDriverLeagues(allLeagues, driverId);
const driverLeagueIds = new Set(driverLeagues.map(league => league.id.toString()));
const now = new Date();
const upcomingRaces = allRaces
.filter(race => race.status.isScheduled() && race.scheduledAt > now)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
const upcomingRacesInDriverLeagues = upcomingRaces.filter(race =>
driverLeagueIds.has(race.leagueId.toString()),
);
const { myUpcomingRaces, otherUpcomingRaces } =
await this.partitionUpcomingRacesByRegistration(
upcomingRacesInDriverLeagues,
driverId,
leagueMap,
);
const nextRace: DashboardRaceSummary | null =
myUpcomingRaces.length > 0 ? myUpcomingRaces[0]! : null;
const upcomingRacesSummaries: DashboardRaceSummary[] = [
...myUpcomingRaces,
...otherUpcomingRaces,
]
.slice()
.sort(
(a, b) =>
a.race.scheduledAt.getTime() - b.race.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 = await this.buildFriendsSummary(friends);
const result: DashboardOverviewResult = {
currentDriver,
myUpcomingRaces,
otherUpcomingRaces,
upcomingRaces: upcomingRacesSummaries,
activeLeaguesCount,
nextRace,
recentResults,
leagueStandingsSummaries,
feedSummary,
friends: friendsSummary,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : 'Unknown error',
},
});
}
}
private async getDriverLeagues(
allLeagues: League[],
driverId: string,
): Promise<League[]> {
const driverLeagues: League[] = [];
for (const league of allLeagues) {
const membership = await this.leagueMembershipRepository.getMembership(
league.id.toString(),
driverId,
);
if (membership && membership.status.toString() === 'active') {
driverLeagues.push(league);
}
}
return driverLeagues;
}
private async partitionUpcomingRacesByRegistration(
upcomingRaces: Race[],
driverId: string,
leagueMap: Map<string, League>,
): Promise<{
myUpcomingRaces: DashboardRaceSummary[];
otherUpcomingRaces: DashboardRaceSummary[];
}> {
const myUpcomingRaces: DashboardRaceSummary[] = [];
const otherUpcomingRaces: DashboardRaceSummary[] = [];
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: Race,
leagueMap: Map<string, League>,
isMyLeague: boolean,
): DashboardRaceSummary {
return {
race,
league: leagueMap.get(race.leagueId) ?? null,
isMyLeague,
};
}
private buildRecentResults(
allResults: RaceResult[],
allRaces: Race[],
allLeagues: League[],
driverId: string,
): DashboardRecentRaceResultSummary[] {
const raceById = new Map(allRaces.map(race => [race.id, race]));
const leagueById = new Map(allLeagues.map(league => [league.id.toString(), league]));
const driverResults = allResults.filter(result => result.driverId.toString() === driverId);
const enriched = driverResults
.map(result => {
const race = raceById.get(result.raceId.toString());
if (!race) return null;
const league = leagueById.get(race.leagueId) ?? null;
const item: DashboardRecentRaceResultSummary = {
race,
league,
result,
};
return item;
})
.filter((item): item is DashboardRecentRaceResultSummary => !!item)
.sort(
(a, b) =>
b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime(),
);
const RECENT_RESULTS_LIMIT = 5;
return enriched.slice(0, RECENT_RESULTS_LIMIT);
}
private async buildLeagueStandingsSummaries(
driverLeagues: League[],
driverId: string,
): Promise<DashboardLeagueStandingSummary[]> {
const summaries: DashboardLeagueStandingSummary[] = [];
for (const league of driverLeagues.slice(0, 3)) {
const standings = await this.standingRepository.findByLeagueId(league.id.toString());
const driverStanding = standings.find(
(standing: Standing) => standing.driverId.toString() === driverId,
);
summaries.push({
league,
standing: driverStanding ?? null,
totalDrivers: standings.length,
});
}
return summaries;
}
private computeActiveLeaguesCount(
upcomingRaces: DashboardRaceSummary[],
leagueStandingsSummaries: DashboardLeagueStandingSummary[],
): number {
const activeLeagueIds = new Set<string>();
for (const race of upcomingRaces) {
activeLeagueIds.add(race.race.leagueId);
}
for (const standing of leagueStandingsSummaries) {
activeLeagueIds.add(standing.league.id.toString());
}
return activeLeagueIds.size;
}
private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummary {
return {
notificationCount: feedItems.length,
items: feedItems,
};
}
private async buildFriendsSummary(
friends: Driver[],
): Promise<DashboardFriendSummary[]> {
const friendSummaries: DashboardFriendSummary[] = [];
for (const friend of friends) {
const avatarUrl = await this.getDriverAvatar(friend.id);
friendSummaries.push({
driver: friend,
avatarUrl,
});
}
return friendSummaries;
}
}