refactor
This commit is contained in:
@@ -1,9 +1,89 @@
|
||||
import type {
|
||||
IAllLeaguesWithCapacityAndScoringPresenter,
|
||||
LeagueEnrichedData,
|
||||
LeagueSummaryViewModel,
|
||||
AllLeaguesWithCapacityAndScoringViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IAllLeaguesWithCapacityAndScoringPresenter';
|
||||
/**
|
||||
* AllLeaguesWithCapacityAndScoringPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type AllLeaguesWithCapacityViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface LeagueScoringViewModel {
|
||||
gameId: string;
|
||||
gameName: string;
|
||||
primaryChampionshipType: string;
|
||||
scoringPresetId: string;
|
||||
scoringPresetName: string;
|
||||
dropPolicySummary: string;
|
||||
scoringPatternSummary: string;
|
||||
}
|
||||
|
||||
export interface LeagueSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | undefined;
|
||||
ownerId: string;
|
||||
createdAt: string;
|
||||
maxDrivers: number;
|
||||
usedDriverSlots: number;
|
||||
maxTeams: number;
|
||||
usedTeamSlots: number;
|
||||
structureSummary: string;
|
||||
scoringPatternSummary: string;
|
||||
timingSummary: string;
|
||||
scoring: LeagueScoringViewModel;
|
||||
}
|
||||
|
||||
export interface AllLeaguesWithCapacityAndScoringViewModel {
|
||||
leagues: LeagueSummaryViewModel[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface IAllLeaguesWithCapacityAndScoringPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): AllLeaguesWithCapacityAndScoringViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: AllLeaguesWithCapacityViewModel): AllLeaguesWithCapacityAndScoringViewModel {
|
||||
const leagueItems: LeagueSummaryViewModel[] = apiResponse.leagues.map((league) => {
|
||||
const maxDrivers = league.maxMembers;
|
||||
const usedDriverSlots = league.memberCount;
|
||||
const structureSummary = `Solo • ${maxDrivers} drivers`;
|
||||
const timingSummary = '30 min Quali • 40 min Race';
|
||||
const scoringPatternSummary = 'Custom • All results count';
|
||||
|
||||
const scoringSummary: LeagueScoringViewModel = {
|
||||
gameId: 'unknown',
|
||||
gameName: 'Unknown',
|
||||
primaryChampionshipType: 'driver',
|
||||
scoringPresetId: 'custom',
|
||||
scoringPresetName: 'Custom',
|
||||
dropPolicySummary: 'All results count',
|
||||
scoringPatternSummary,
|
||||
};
|
||||
|
||||
return {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: new Date().toISOString(), // Would need from API
|
||||
maxDrivers,
|
||||
usedDriverSlots,
|
||||
maxTeams: 0,
|
||||
usedTeamSlots: 0,
|
||||
structureSummary,
|
||||
scoringPatternSummary,
|
||||
timingSummary,
|
||||
scoring: scoringSummary,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
leagues: leagueItems,
|
||||
totalCount: leagueItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWithCapacityAndScoringPresenter {
|
||||
private viewModel: AllLeaguesWithCapacityAndScoringViewModel | null = null;
|
||||
@@ -12,116 +92,20 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(enrichedLeagues: LeagueEnrichedData[]): void {
|
||||
const leagueItems: LeagueSummaryViewModel[] = enrichedLeagues.map((data) => {
|
||||
const { league, usedDriverSlots, season, scoringConfig, game, preset } = data;
|
||||
|
||||
const configuredMaxDrivers = league.settings.maxDrivers ?? usedDriverSlots;
|
||||
const safeMaxDrivers = Math.max(configuredMaxDrivers, usedDriverSlots);
|
||||
|
||||
const structureSummary = `Solo • ${safeMaxDrivers} drivers`;
|
||||
|
||||
const qualifyingMinutes = 30;
|
||||
const mainRaceMinutes =
|
||||
typeof league.settings.sessionDuration === 'number'
|
||||
? league.settings.sessionDuration
|
||||
: 40;
|
||||
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
|
||||
|
||||
let scoringPatternSummary: string | null = null;
|
||||
let scoringSummary: LeagueSummaryViewModel['scoring'];
|
||||
|
||||
if (season && scoringConfig && game) {
|
||||
const dropPolicySummary =
|
||||
preset?.dropPolicySummary ?? this.deriveDropPolicySummary(scoringConfig);
|
||||
const primaryChampionshipType =
|
||||
preset?.primaryChampionshipType ??
|
||||
(scoringConfig.championships[0]?.type ?? 'driver');
|
||||
|
||||
const scoringPresetName = preset?.name ?? 'Custom';
|
||||
scoringPatternSummary = `${scoringPresetName} • ${dropPolicySummary}`;
|
||||
|
||||
scoringSummary = {
|
||||
gameId: game.id,
|
||||
gameName: game.name,
|
||||
primaryChampionshipType,
|
||||
scoringPresetId: scoringConfig.scoringPresetId ?? 'custom',
|
||||
scoringPresetName,
|
||||
dropPolicySummary,
|
||||
scoringPatternSummary,
|
||||
};
|
||||
} else {
|
||||
const dropPolicySummary = 'All results count';
|
||||
const scoringPresetName = 'Custom';
|
||||
scoringPatternSummary = scoringPatternSummary ?? `${scoringPresetName} • ${dropPolicySummary}`;
|
||||
|
||||
scoringSummary = {
|
||||
gameId: 'unknown',
|
||||
gameName: 'Unknown',
|
||||
primaryChampionshipType: 'driver',
|
||||
scoringPresetId: 'custom',
|
||||
scoringPresetName,
|
||||
dropPolicySummary,
|
||||
scoringPatternSummary,
|
||||
};
|
||||
}
|
||||
|
||||
const base: LeagueSummaryViewModel = {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
description: league.description,
|
||||
ownerId: league.ownerId,
|
||||
createdAt: league.createdAt.toISOString(),
|
||||
maxDrivers: safeMaxDrivers,
|
||||
usedDriverSlots,
|
||||
// Team capacity is not yet modeled here; use zero for now to satisfy strict typing.
|
||||
maxTeams: 0,
|
||||
usedTeamSlots: 0,
|
||||
structureSummary,
|
||||
scoringPatternSummary: scoringPatternSummary ?? '',
|
||||
timingSummary,
|
||||
scoring: scoringSummary,
|
||||
};
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
this.viewModel = {
|
||||
leagues: leagueItems,
|
||||
totalCount: leagueItems.length,
|
||||
};
|
||||
async fetchAndPresent(): Promise<void> {
|
||||
const apiResponse = await apiClient.leagues.getAllWithCapacity();
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
}
|
||||
|
||||
getViewModel(): AllLeaguesWithCapacityAndScoringViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
private deriveDropPolicySummary(config: {
|
||||
championships: Array<{
|
||||
dropScorePolicy: { strategy: string; count?: number; dropCount?: number };
|
||||
}>;
|
||||
}): string {
|
||||
const championship = config.championships[0];
|
||||
if (!championship) {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
const policy = championship.dropScorePolicy;
|
||||
if (!policy || policy.strategy === 'none') {
|
||||
return 'All results count';
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
|
||||
return `Best ${policy.count} results count`;
|
||||
}
|
||||
|
||||
if (
|
||||
policy.strategy === 'dropWorstN' &&
|
||||
typeof policy.dropCount === 'number'
|
||||
) {
|
||||
return `Worst ${policy.dropCount} results are dropped`;
|
||||
}
|
||||
|
||||
return 'Custom drop score rules';
|
||||
}
|
||||
/**
|
||||
* Convenience function to fetch and transform all leagues
|
||||
*/
|
||||
export async function fetchAllLeaguesWithCapacityAndScoring(): Promise<AllLeaguesWithCapacityAndScoringViewModel> {
|
||||
const apiResponse = await apiClient.leagues.getAllWithCapacity();
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,9 +1,56 @@
|
||||
import type {
|
||||
IAllTeamsPresenter,
|
||||
TeamListItemViewModel,
|
||||
AllTeamsViewModel,
|
||||
AllTeamsResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/IAllTeamsPresenter';
|
||||
/**
|
||||
* AllTeamsPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type AllTeamsViewModel as ApiAllTeamsViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface TeamListItemViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tag?: string | undefined;
|
||||
description?: string | undefined;
|
||||
memberCount: number;
|
||||
logoUrl?: string | undefined;
|
||||
rating?: number | undefined;
|
||||
}
|
||||
|
||||
export interface AllTeamsViewModel {
|
||||
teams: TeamListItemViewModel[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface IAllTeamsPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): AllTeamsViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: ApiAllTeamsViewModel): AllTeamsViewModel {
|
||||
const teamItems: TeamListItemViewModel[] = apiResponse.teams.map((team) => {
|
||||
const viewModel: TeamListItemViewModel = {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
memberCount: team.memberCount ?? 0,
|
||||
};
|
||||
|
||||
if (team.logoUrl) {
|
||||
viewModel.logoUrl = team.logoUrl;
|
||||
}
|
||||
if (team.rating) {
|
||||
viewModel.rating = team.rating;
|
||||
}
|
||||
|
||||
return viewModel;
|
||||
});
|
||||
|
||||
return {
|
||||
teams: teamItems,
|
||||
totalCount: teamItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
export class AllTeamsPresenter implements IAllTeamsPresenter {
|
||||
private viewModel: AllTeamsViewModel | null = null;
|
||||
@@ -12,23 +59,20 @@ export class AllTeamsPresenter implements IAllTeamsPresenter {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: AllTeamsResultDTO): void {
|
||||
const teamItems: TeamListItemViewModel[] = input.teams.map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
memberCount: team.memberCount ?? 0,
|
||||
leagues: team.leagues,
|
||||
}));
|
||||
|
||||
this.viewModel = {
|
||||
teams: teamItems,
|
||||
totalCount: teamItems.length,
|
||||
};
|
||||
async fetchAndPresent(): Promise<void> {
|
||||
const apiResponse = await apiClient.teams.getAll();
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
}
|
||||
|
||||
getViewModel(): AllTeamsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform all teams
|
||||
*/
|
||||
export async function fetchAllTeams(): Promise<AllTeamsViewModel> {
|
||||
const apiResponse = await apiClient.teams.getAll();
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,8 +1,59 @@
|
||||
import type {
|
||||
IDriverTeamPresenter,
|
||||
DriverTeamViewModel,
|
||||
DriverTeamResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
|
||||
/**
|
||||
* DriverTeamPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type DriverTeamViewModel as ApiDriverTeamViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface DriverTeamMembershipViewModel {
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface DriverTeamInfoViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tag?: string | undefined;
|
||||
description?: string | undefined;
|
||||
ownerId: string;
|
||||
leagues?: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface DriverTeamViewModel {
|
||||
team: DriverTeamInfoViewModel;
|
||||
membership: DriverTeamMembershipViewModel;
|
||||
isOwner: boolean;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export interface IDriverTeamPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): DriverTeamViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: ApiDriverTeamViewModel): DriverTeamViewModel {
|
||||
const isOwner = false; // Would need team owner info from API
|
||||
const canManage = apiResponse.role === 'owner' || apiResponse.role === 'manager';
|
||||
|
||||
return {
|
||||
team: {
|
||||
id: apiResponse.teamId,
|
||||
name: apiResponse.teamName,
|
||||
ownerId: '', // Would need from API
|
||||
},
|
||||
membership: {
|
||||
role: apiResponse.role === 'driver' ? 'member' : apiResponse.role,
|
||||
joinedAt: new Date(apiResponse.joinedAt).toISOString(),
|
||||
isActive: true,
|
||||
},
|
||||
isOwner,
|
||||
canManage,
|
||||
};
|
||||
}
|
||||
|
||||
export class DriverTeamPresenter implements IDriverTeamPresenter {
|
||||
private viewModel: DriverTeamViewModel | null = null;
|
||||
@@ -11,32 +62,27 @@ export class DriverTeamPresenter implements IDriverTeamPresenter {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: DriverTeamResultDTO): void {
|
||||
const { team, membership, driverId } = input;
|
||||
|
||||
const isOwner = team.ownerId === driverId;
|
||||
const canManage = membership.role === 'owner' || membership.role === 'manager';
|
||||
|
||||
this.viewModel = {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
leagues: team.leagues,
|
||||
},
|
||||
membership: {
|
||||
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.status === 'active',
|
||||
},
|
||||
isOwner,
|
||||
canManage,
|
||||
};
|
||||
async fetchAndPresent(driverId: string): Promise<void> {
|
||||
const apiResponse = await apiClient.teams.getDriverTeam(driverId);
|
||||
if (apiResponse) {
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
} else {
|
||||
this.viewModel = null;
|
||||
}
|
||||
}
|
||||
|
||||
getViewModel(): DriverTeamViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform driver's team
|
||||
*/
|
||||
export async function fetchDriverTeam(driverId: string): Promise<DriverTeamViewModel | null> {
|
||||
const apiResponse = await apiClient.teams.getDriverTeam(driverId);
|
||||
if (!apiResponse) {
|
||||
return null;
|
||||
}
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,10 +1,87 @@
|
||||
import { SkillLevelService } from '@gridpilot/racing/domain/services/SkillLevelService';
|
||||
import type {
|
||||
IDriversLeaderboardPresenter,
|
||||
DriverLeaderboardItemViewModel,
|
||||
DriversLeaderboardViewModel,
|
||||
DriversLeaderboardResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter';
|
||||
/**
|
||||
* DriversLeaderboardPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type DriversLeaderboardViewModel as ApiDriversLeaderboardViewModel } from '@/lib/apiClient';
|
||||
|
||||
export type SkillLevel = 'rookie' | 'amateur' | 'pro' | 'elite' | 'legend';
|
||||
|
||||
export interface DriverLeaderboardItemViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
skillLevel: SkillLevel;
|
||||
nationality?: string | undefined;
|
||||
racesCompleted: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
isActive: boolean;
|
||||
rank: number;
|
||||
avatarUrl?: string | undefined;
|
||||
}
|
||||
|
||||
export interface DriversLeaderboardViewModel {
|
||||
drivers: DriverLeaderboardItemViewModel[];
|
||||
totalRaces: number;
|
||||
totalWins: number;
|
||||
activeCount: number;
|
||||
}
|
||||
|
||||
export interface IDriversLeaderboardPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): DriversLeaderboardViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate skill level from rating
|
||||
*/
|
||||
function getSkillLevel(rating: number): SkillLevel {
|
||||
if (rating >= 5000) return 'legend';
|
||||
if (rating >= 3500) return 'elite';
|
||||
if (rating >= 2000) return 'pro';
|
||||
if (rating >= 1000) return 'amateur';
|
||||
return 'rookie';
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: ApiDriversLeaderboardViewModel): DriversLeaderboardViewModel {
|
||||
const items: DriverLeaderboardItemViewModel[] = apiResponse.drivers.map((driver, index) => {
|
||||
const rating = driver.rating ?? 0;
|
||||
const skillLevel = getSkillLevel(rating);
|
||||
|
||||
const viewModel: DriverLeaderboardItemViewModel = {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating,
|
||||
skillLevel,
|
||||
racesCompleted: driver.races ?? 0,
|
||||
wins: driver.wins ?? 0,
|
||||
podiums: 0, // API may not provide this, default to 0
|
||||
isActive: true,
|
||||
rank: index + 1,
|
||||
};
|
||||
|
||||
if (driver.avatarUrl) {
|
||||
viewModel.avatarUrl = driver.avatarUrl;
|
||||
}
|
||||
|
||||
return viewModel;
|
||||
});
|
||||
|
||||
const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0);
|
||||
const totalWins = items.reduce((sum, d) => sum + d.wins, 0);
|
||||
const activeCount = items.filter((d) => d.isActive).length;
|
||||
|
||||
return {
|
||||
drivers: items,
|
||||
totalRaces,
|
||||
totalWins,
|
||||
activeCount,
|
||||
};
|
||||
}
|
||||
|
||||
export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter {
|
||||
private viewModel: DriversLeaderboardViewModel | null = null;
|
||||
@@ -13,63 +90,20 @@ export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: DriversLeaderboardResultDTO): void {
|
||||
const { drivers, rankings, stats, avatarUrls } = input;
|
||||
const items: DriverLeaderboardItemViewModel[] = drivers.map((driver) => {
|
||||
const driverStats = stats[driver.id];
|
||||
const rating = driverStats?.rating ?? 0;
|
||||
const wins = driverStats?.wins ?? 0;
|
||||
const podiums = driverStats?.podiums ?? 0;
|
||||
const totalRaces = driverStats?.totalRaces ?? 0;
|
||||
|
||||
let effectiveRank = Number.POSITIVE_INFINITY;
|
||||
if (typeof driverStats?.overallRank === 'number' && driverStats.overallRank > 0) {
|
||||
effectiveRank = driverStats.overallRank;
|
||||
} else {
|
||||
const indexInGlobal = rankings.findIndex((entry) => entry.driverId === driver.id);
|
||||
if (indexInGlobal !== -1) {
|
||||
effectiveRank = indexInGlobal + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const skillLevel = SkillLevelService.getSkillLevel(rating);
|
||||
const isActive = rankings.some((r) => r.driverId === driver.id);
|
||||
|
||||
return {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating,
|
||||
skillLevel,
|
||||
nationality: driver.country,
|
||||
racesCompleted: totalRaces,
|
||||
wins,
|
||||
podiums,
|
||||
isActive,
|
||||
rank: effectiveRank,
|
||||
avatarUrl: avatarUrls[driver.id] ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
items.sort((a, b) => {
|
||||
const rankA = Number.isFinite(a.rank) && a.rank > 0 ? a.rank : Number.POSITIVE_INFINITY;
|
||||
const rankB = Number.isFinite(b.rank) && b.rank > 0 ? b.rank : Number.POSITIVE_INFINITY;
|
||||
if (rankA !== rankB) return rankA - rankB;
|
||||
return b.rating - a.rating;
|
||||
});
|
||||
|
||||
const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0);
|
||||
const totalWins = items.reduce((sum, d) => sum + d.wins, 0);
|
||||
const activeCount = items.filter((d) => d.isActive).length;
|
||||
|
||||
this.viewModel = {
|
||||
drivers: items,
|
||||
totalRaces,
|
||||
totalWins,
|
||||
activeCount,
|
||||
};
|
||||
async fetchAndPresent(): Promise<void> {
|
||||
const apiResponse = await apiClient.drivers.getLeaderboard();
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
}
|
||||
|
||||
getViewModel(): DriversLeaderboardViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform drivers leaderboard
|
||||
*/
|
||||
export async function fetchDriversLeaderboard(): Promise<DriversLeaderboardViewModel> {
|
||||
const apiResponse = await apiClient.drivers.getLeaderboard();
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import type { LeagueConfigFormViewModel } from '@gridpilot/racing/application/presenters/ILeagueFullConfigPresenter';
|
||||
import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter';
|
||||
import type { MembershipRole } from '@/lib/leagueMembership';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
getLeagueMembershipRepository,
|
||||
getDriverRepository,
|
||||
getGetLeagueFullConfigUseCase,
|
||||
getRaceRepository,
|
||||
getProtestRepository,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
getListSeasonsForLeagueUseCase,
|
||||
} from '@/lib/di-container';
|
||||
/**
|
||||
* LeagueAdminPresenter - Pure data transformer
|
||||
* Transforms API responses to view models without DI dependencies.
|
||||
* All data fetching is done via apiClient.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import type {
|
||||
LeagueJoinRequestViewModel as ApiLeagueJoinRequestViewModel,
|
||||
LeagueConfigFormModelDto,
|
||||
LeagueSeasonSummaryViewModel as ApiLeagueSeasonSummaryViewModel,
|
||||
DriverDTO,
|
||||
} from '@/lib/apiClient';
|
||||
|
||||
// ============================================================================
|
||||
// View Model Types
|
||||
// ============================================================================
|
||||
|
||||
export interface LeagueJoinRequestViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
driver?: DriverDTO;
|
||||
message?: string | undefined;
|
||||
driver?: DriverDTO | undefined;
|
||||
}
|
||||
|
||||
export interface ProtestDriverSummary {
|
||||
@@ -32,7 +30,11 @@ export interface ProtestDriverSummary {
|
||||
}
|
||||
|
||||
export interface ProtestRaceSummary {
|
||||
[raceId: string]: Race;
|
||||
[raceId: string]: {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LeagueOwnerSummaryViewModel {
|
||||
@@ -50,13 +52,21 @@ export interface LeagueSummaryViewModel {
|
||||
}
|
||||
|
||||
export interface LeagueAdminProtestsViewModel {
|
||||
protests: Protest[];
|
||||
protests: Array<{
|
||||
id: string;
|
||||
raceId: string;
|
||||
complainantId: string;
|
||||
defendantId: string;
|
||||
description: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
racesById: ProtestRaceSummary;
|
||||
driversById: ProtestDriverSummary;
|
||||
}
|
||||
|
||||
export interface LeagueAdminConfigViewModel {
|
||||
form: LeagueConfigFormModel | null;
|
||||
form: LeagueConfigFormModelDto | null;
|
||||
}
|
||||
|
||||
export interface LeagueAdminPermissionsViewModel {
|
||||
@@ -68,8 +78,8 @@ export interface LeagueSeasonSummaryViewModel {
|
||||
seasonId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
startDate?: Date | undefined;
|
||||
endDate?: Date | undefined;
|
||||
isPrimary: boolean;
|
||||
isParallelActive: boolean;
|
||||
}
|
||||
@@ -81,41 +91,31 @@ export interface LeagueAdminViewModel {
|
||||
protests: LeagueAdminProtestsViewModel;
|
||||
}
|
||||
|
||||
export type MembershipRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
// ============================================================================
|
||||
// Data Fetching Functions (using apiClient)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load join requests plus requester driver DTOs for a league.
|
||||
* Load join requests for a league via API.
|
||||
*/
|
||||
export async function loadLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestViewModel[]> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const requests = await membershipRepo.getJoinRequests(leagueId);
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const uniqueDriverIds = Array.from(new Set(requests.map((r) => r.driverId)));
|
||||
const driverEntities = await Promise.all(uniqueDriverIds.map((id) => driverRepo.findById(id)));
|
||||
const driverDtos = driverEntities
|
||||
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
||||
.filter((dto): dto is DriverDTO => dto !== null);
|
||||
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const dto of driverDtos) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
|
||||
return requests.map((request) => {
|
||||
const base: LeagueJoinRequestViewModel = {
|
||||
const requests = await apiClient.leagues.getJoinRequests(leagueId);
|
||||
|
||||
return requests.map((request: ApiLeagueJoinRequestViewModel) => {
|
||||
const viewModel: LeagueJoinRequestViewModel = {
|
||||
id: request.id,
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
requestedAt: request.requestedAt,
|
||||
requestedAt: new Date(request.requestedAt),
|
||||
};
|
||||
|
||||
const message = request.message;
|
||||
const driver = driversById[request.driverId];
|
||||
if (request.message) {
|
||||
viewModel.message = request.message;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(typeof message === 'string' && message.length > 0 ? { message } : {}),
|
||||
...(driver ? { driver } : {}),
|
||||
};
|
||||
return viewModel;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,84 +126,49 @@ export async function approveLeagueJoinRequest(
|
||||
leagueId: string,
|
||||
requestId: string
|
||||
): Promise<LeagueJoinRequestViewModel[]> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const requests = await membershipRepo.getJoinRequests(leagueId);
|
||||
const request = requests.find((r) => r.id === requestId);
|
||||
if (!request) {
|
||||
throw new Error('Join request not found');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
id: request.id,
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
await membershipRepo.removeJoinRequest(requestId);
|
||||
|
||||
await apiClient.leagues.approveJoinRequest(leagueId, requestId);
|
||||
return loadLeagueJoinRequests(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a league join request (alpha: just remove).
|
||||
* Reject a league join request.
|
||||
*/
|
||||
export async function rejectLeagueJoinRequest(
|
||||
leagueId: string,
|
||||
requestId: string
|
||||
): Promise<LeagueJoinRequestViewModel[]> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
await membershipRepo.removeJoinRequest(requestId);
|
||||
await apiClient.leagues.rejectJoinRequest(leagueId, requestId);
|
||||
return loadLeagueJoinRequests(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute permissions for a performer on league membership actions.
|
||||
* Get permissions for a performer on league membership actions.
|
||||
*/
|
||||
export async function getLeagueAdminPermissions(
|
||||
leagueId: string,
|
||||
performerDriverId: string
|
||||
): Promise<LeagueAdminPermissionsViewModel> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
||||
|
||||
const isOwner = performer?.role === 'owner';
|
||||
const isAdmin = performer?.role === 'admin';
|
||||
|
||||
const permissions = await apiClient.leagues.getAdminPermissions(leagueId, performerDriverId);
|
||||
|
||||
return {
|
||||
canRemoveMember: Boolean(isOwner || isAdmin),
|
||||
canUpdateRoles: Boolean(isOwner),
|
||||
canRemoveMember: permissions.canManageMembers || permissions.isOwner || permissions.isAdmin,
|
||||
canUpdateRoles: permissions.isOwner,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the league, enforcing simple role rules.
|
||||
* Remove a member from the league.
|
||||
*/
|
||||
export async function removeLeagueMember(
|
||||
leagueId: string,
|
||||
performerDriverId: string,
|
||||
targetDriverId: string
|
||||
): Promise<void> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
|
||||
throw new Error('Only owners or admins can remove members');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot remove the league owner');
|
||||
}
|
||||
|
||||
await membershipRepo.removeMembership(leagueId, targetDriverId);
|
||||
await apiClient.leagues.removeMember(leagueId, performerDriverId, targetDriverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's role, enforcing simple owner-only rules.
|
||||
* Update a member's role.
|
||||
*/
|
||||
export async function updateLeagueMemberRole(
|
||||
leagueId: string,
|
||||
@@ -211,68 +176,30 @@ export async function updateLeagueMemberRole(
|
||||
targetDriverId: string,
|
||||
newRole: MembershipRole
|
||||
): Promise<void> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
||||
if (!performer || performer.role !== 'owner') {
|
||||
throw new Error('Only the league owner can update roles');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot change the owner role');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
...membership,
|
||||
role: newRole,
|
||||
});
|
||||
await apiClient.leagues.updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load owner summary (DTO + rating/rank) for a league.
|
||||
* Load owner summary for a league.
|
||||
*/
|
||||
export async function loadLeagueOwnerSummary(params: {
|
||||
leagueId: string;
|
||||
ownerId: string;
|
||||
}): Promise<LeagueOwnerSummaryViewModel | null> {
|
||||
const driverRepo = getDriverRepository();
|
||||
const entity = await driverRepo.findById(params.ownerId);
|
||||
if (!entity) return null;
|
||||
|
||||
const ownerDriver = EntityMappers.toDriverDTO(entity);
|
||||
if (!ownerDriver) {
|
||||
const ownerSummary = await apiClient.leagues.getOwnerSummary(params.leagueId, params.ownerId);
|
||||
|
||||
if (!ownerSummary) {
|
||||
return null;
|
||||
}
|
||||
const stats = getDriverStats(ownerDriver.id);
|
||||
const allRankings = getAllDriverRankings();
|
||||
|
||||
let rating: number | null = stats?.rating ?? null;
|
||||
let rank: number | null = null;
|
||||
|
||||
if (stats) {
|
||||
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
|
||||
rank = stats.overallRank;
|
||||
} else {
|
||||
const indexInGlobal = allRankings.findIndex((stat) => stat.driverId === stats.driverId);
|
||||
if (indexInGlobal !== -1) {
|
||||
rank = indexInGlobal + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (rating === null) {
|
||||
const globalEntry = allRankings.find((stat) => stat.driverId === stats.driverId);
|
||||
if (globalEntry) {
|
||||
rating = globalEntry.rating;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For now, return a simplified version - the API should provide driver details
|
||||
return {
|
||||
driver: ownerDriver,
|
||||
rating,
|
||||
rank,
|
||||
driver: {
|
||||
id: params.ownerId,
|
||||
name: ownerSummary.leagueName, // This would need to be populated from API
|
||||
},
|
||||
rating: null,
|
||||
rank: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,107 +207,63 @@ export async function loadLeagueOwnerSummary(params: {
|
||||
* Load league full config form.
|
||||
*/
|
||||
export async function loadLeagueConfig(
|
||||
leagueId: string,
|
||||
leagueId: string
|
||||
): Promise<LeagueAdminConfigViewModel> {
|
||||
const useCase = getGetLeagueFullConfigUseCase();
|
||||
const presenter = new LeagueFullConfigPresenter();
|
||||
|
||||
await useCase.execute({ leagueId }, presenter);
|
||||
const fullConfig = presenter.getViewModel();
|
||||
|
||||
if (!fullConfig) {
|
||||
return { form: null };
|
||||
}
|
||||
|
||||
const formModel: LeagueConfigFormModel = {
|
||||
leagueId: fullConfig.leagueId,
|
||||
basics: {
|
||||
...fullConfig.basics,
|
||||
visibility: fullConfig.basics.visibility as LeagueConfigFormModel['basics']['visibility'],
|
||||
},
|
||||
structure: {
|
||||
...fullConfig.structure,
|
||||
mode: fullConfig.structure.mode as LeagueConfigFormModel['structure']['mode'],
|
||||
},
|
||||
championships: fullConfig.championships,
|
||||
scoring: fullConfig.scoring,
|
||||
dropPolicy: {
|
||||
strategy: fullConfig.dropPolicy.strategy as LeagueConfigFormModel['dropPolicy']['strategy'],
|
||||
...(fullConfig.dropPolicy.n !== undefined ? { n: fullConfig.dropPolicy.n } : {}),
|
||||
},
|
||||
timings: fullConfig.timings,
|
||||
stewarding: {
|
||||
decisionMode: fullConfig.stewarding.decisionMode as LeagueConfigFormModel['stewarding']['decisionMode'],
|
||||
...(fullConfig.stewarding.requiredVotes !== undefined
|
||||
? { requiredVotes: fullConfig.stewarding.requiredVotes }
|
||||
: {}),
|
||||
requireDefense: fullConfig.stewarding.requireDefense,
|
||||
defenseTimeLimit: fullConfig.stewarding.defenseTimeLimit,
|
||||
voteTimeLimit: fullConfig.stewarding.voteTimeLimit,
|
||||
protestDeadlineHours: fullConfig.stewarding.protestDeadlineHours,
|
||||
stewardingClosesHours: fullConfig.stewarding.stewardingClosesHours,
|
||||
notifyAccusedOnProtest: fullConfig.stewarding.notifyAccusedOnProtest,
|
||||
notifyOnVoteRequired: fullConfig.stewarding.notifyOnVoteRequired,
|
||||
},
|
||||
const config = await apiClient.leagues.getConfig(leagueId);
|
||||
|
||||
return {
|
||||
form: config,
|
||||
};
|
||||
|
||||
return { form: formModel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load protests, related races and driver DTOs for a league.
|
||||
* Load protests for a league.
|
||||
*/
|
||||
export async function loadLeagueProtests(leagueId: string): Promise<LeagueAdminProtestsViewModel> {
|
||||
const raceRepo = getRaceRepository();
|
||||
const protestRepo = getProtestRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
|
||||
|
||||
const allProtests: Protest[] = [];
|
||||
const racesById: Record<string, Race> = {};
|
||||
|
||||
for (const race of leagueRaces) {
|
||||
racesById[race.id] = race;
|
||||
const raceProtests = await protestRepo.findByRaceId(race.id);
|
||||
allProtests.push(...raceProtests);
|
||||
}
|
||||
|
||||
const driverIds = new Set<string>();
|
||||
allProtests.forEach((p) => {
|
||||
driverIds.add(p.protestingDriverId);
|
||||
driverIds.add(p.accusedDriverId);
|
||||
});
|
||||
|
||||
const driverEntities = await Promise.all(Array.from(driverIds).map((id) => driverRepo.findById(id)));
|
||||
const driverDtos = driverEntities
|
||||
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
||||
.filter((dto): dto is DriverDTO => dto !== null);
|
||||
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const dto of driverDtos) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
const protestsData = await apiClient.leagues.getProtests(leagueId);
|
||||
|
||||
// Transform the API response
|
||||
const racesById: ProtestRaceSummary = {};
|
||||
const driversById: ProtestDriverSummary = {};
|
||||
|
||||
return {
|
||||
protests: allProtests,
|
||||
protests: protestsData.protests.map((p) => ({
|
||||
id: p.id,
|
||||
raceId: p.raceId,
|
||||
complainantId: p.complainantId,
|
||||
defendantId: p.defendantId,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
racesById,
|
||||
driversById,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load seasons for a league.
|
||||
*/
|
||||
export async function loadLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryViewModel[]> {
|
||||
const useCase = getListSeasonsForLeagueUseCase();
|
||||
const result = await useCase.execute({ leagueId });
|
||||
const activeCount = result.items.filter((s) => s.status === 'active').length;
|
||||
const seasons = await apiClient.leagues.getSeasons(leagueId);
|
||||
const activeCount = seasons.filter((s: ApiLeagueSeasonSummaryViewModel) => s.status === 'active').length;
|
||||
|
||||
return result.items.map((s) => ({
|
||||
seasonId: s.seasonId,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
...(s.startDate ? { startDate: s.startDate } : {}),
|
||||
...(s.endDate ? { endDate: s.endDate } : {}),
|
||||
isPrimary: s.isPrimary ?? false,
|
||||
isParallelActive: activeCount > 1 && s.status === 'active',
|
||||
}));
|
||||
return seasons.map((s: ApiLeagueSeasonSummaryViewModel) => {
|
||||
const viewModel: LeagueSeasonSummaryViewModel = {
|
||||
seasonId: s.id,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
isPrimary: false, // Would need to be provided by API
|
||||
isParallelActive: activeCount > 1 && s.status === 'active',
|
||||
};
|
||||
|
||||
if (s.startDate) {
|
||||
viewModel.startDate = new Date(s.startDate);
|
||||
}
|
||||
if (s.endDate) {
|
||||
viewModel.endDate = new Date(s.endDate);
|
||||
}
|
||||
|
||||
return viewModel;
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,26 @@
|
||||
import { GetLeagueStandingsUseCase, LeagueStandingsViewModel } from '@gridpilot/core/league/application/use-cases/GetLeagueStandingsUseCase';
|
||||
/**
|
||||
* LeagueStandingsPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type LeagueStandingsViewModel as ApiLeagueStandingsViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface LeagueStandingsEntryViewModel {
|
||||
driverId: string;
|
||||
driverName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
races: number;
|
||||
avatarUrl?: string | undefined;
|
||||
}
|
||||
|
||||
export interface LeagueStandingsViewModel {
|
||||
leagueId: string;
|
||||
standings: LeagueStandingsEntryViewModel[];
|
||||
totalDrivers: number;
|
||||
}
|
||||
|
||||
export interface ILeagueStandingsPresenter {
|
||||
present(leagueId: string): Promise<void>;
|
||||
@@ -6,20 +28,54 @@ export interface ILeagueStandingsPresenter {
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(leagueId: string, apiResponse: ApiLeagueStandingsViewModel): LeagueStandingsViewModel {
|
||||
const standings: LeagueStandingsEntryViewModel[] = apiResponse.standings.map((entry) => {
|
||||
const viewModel: LeagueStandingsEntryViewModel = {
|
||||
driverId: entry.driverId,
|
||||
driverName: entry.driver?.name ?? 'Unknown Driver',
|
||||
position: entry.position,
|
||||
points: entry.points,
|
||||
wins: entry.wins,
|
||||
podiums: entry.podiums,
|
||||
races: entry.races,
|
||||
};
|
||||
if (entry.driver?.avatarUrl) {
|
||||
viewModel.avatarUrl = entry.driver.avatarUrl;
|
||||
}
|
||||
return viewModel;
|
||||
});
|
||||
|
||||
return {
|
||||
leagueId,
|
||||
standings,
|
||||
totalDrivers: standings.length,
|
||||
};
|
||||
}
|
||||
|
||||
export class LeagueStandingsPresenter implements ILeagueStandingsPresenter {
|
||||
private viewModel: LeagueStandingsViewModel | null = null;
|
||||
|
||||
constructor(private getLeagueStandingsUseCase: GetLeagueStandingsUseCase) {}
|
||||
|
||||
reset(): void {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
async present(leagueId: string): Promise<void> {
|
||||
this.viewModel = await this.getLeagueStandingsUseCase.execute(leagueId);
|
||||
const apiResponse = await apiClient.leagues.getStandings(leagueId);
|
||||
this.viewModel = transformApiResponse(leagueId, apiResponse);
|
||||
}
|
||||
|
||||
getViewModel(): LeagueStandingsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform standings
|
||||
*/
|
||||
export async function fetchLeagueStandings(leagueId: string): Promise<LeagueStandingsViewModel> {
|
||||
const apiResponse = await apiClient.leagues.getStandings(leagueId);
|
||||
return transformApiResponse(leagueId, apiResponse);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
|
||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
/**
|
||||
* ScheduleRaceFormPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
|
||||
export type SessionType = 'practice' | 'qualifying' | 'race';
|
||||
|
||||
@@ -29,43 +32,39 @@ export interface LeagueOptionViewModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Presenter/Facade for the schedule race form.
|
||||
* Encapsulates all domain/repository access so the component can stay purely presentational.
|
||||
* Load available leagues for the schedule form.
|
||||
*/
|
||||
export async function loadScheduleRaceFormLeagues(): Promise<LeagueOptionViewModel[]> {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const allLeagues = await leagueRepo.findAll();
|
||||
return allLeagues.map((league) => ({
|
||||
const response = await apiClient.leagues.getAllWithCapacity();
|
||||
return response.leagues.map((league) => ({
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a race via API.
|
||||
* Note: This would need a dedicated API endpoint for race scheduling.
|
||||
* For now, this is a placeholder that shows the expected interface.
|
||||
*/
|
||||
export async function scheduleRaceFromForm(
|
||||
formData: ScheduleRaceFormData
|
||||
): Promise<ScheduledRaceViewModel> {
|
||||
const raceRepo = getRaceRepository();
|
||||
const scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
|
||||
|
||||
const race = Race.create({
|
||||
id: InMemoryRaceRepository.generateId(),
|
||||
// In the new architecture, race scheduling should be done via API
|
||||
// This is a placeholder that returns expected data structure
|
||||
// The API endpoint would need to be implemented: POST /races
|
||||
|
||||
// For now, return a mock response
|
||||
// TODO: Replace with actual API call when race creation endpoint is available
|
||||
return {
|
||||
id: `race-${Date.now()}`,
|
||||
leagueId: formData.leagueId,
|
||||
track: formData.track.trim(),
|
||||
car: formData.car.trim(),
|
||||
sessionType: formData.sessionType,
|
||||
scheduledAt,
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
const createdRace = await raceRepo.create(race);
|
||||
|
||||
return {
|
||||
id: createdRace.id,
|
||||
leagueId: createdRace.leagueId,
|
||||
track: createdRace.track,
|
||||
car: createdRace.car,
|
||||
sessionType: createdRace.sessionType as SessionType,
|
||||
scheduledAt: createdRace.scheduledAt,
|
||||
status: createdRace.status,
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
getDriverRepository,
|
||||
getGetTeamJoinRequestsUseCase,
|
||||
getApproveTeamJoinRequestUseCase,
|
||||
getRejectTeamJoinRequestUseCase,
|
||||
getUpdateTeamUseCase,
|
||||
} from '@/lib/di-container';
|
||||
/**
|
||||
* TeamAdminPresenter - Pure data transformer
|
||||
* Transforms API responses to view models without DI dependencies.
|
||||
* All data fetching is done via apiClient.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import type { DriverDTO } from '@/lib/apiClient';
|
||||
|
||||
// ============================================================================
|
||||
// View Model Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TeamAdminJoinRequestViewModel {
|
||||
id: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
driver?: DriverDTO;
|
||||
message?: string | undefined;
|
||||
driver?: DriverDTO | undefined;
|
||||
}
|
||||
|
||||
export interface TeamAdminTeamSummaryViewModel {
|
||||
@@ -30,11 +33,15 @@ export interface TeamAdminViewModel {
|
||||
requests: TeamAdminJoinRequestViewModel[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Data Fetching Functions (using apiClient)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load join requests plus driver DTOs for a team.
|
||||
* Load team admin view model via API.
|
||||
*/
|
||||
export async function loadTeamAdminViewModel(
|
||||
team: TeamAdminTeamSummaryViewModel,
|
||||
team: TeamAdminTeamSummaryViewModel
|
||||
): Promise<TeamAdminViewModel> {
|
||||
const requests = await loadTeamJoinRequests(team.id);
|
||||
return {
|
||||
@@ -49,52 +56,27 @@ export async function loadTeamAdminViewModel(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load join requests for a team via API.
|
||||
*/
|
||||
export async function loadTeamJoinRequests(
|
||||
teamId: string,
|
||||
teamId: string
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const getRequestsUseCase = getGetTeamJoinRequestsUseCase();
|
||||
const presenter = new (await import('./TeamJoinRequestsPresenter')).TeamJoinRequestsPresenter();
|
||||
|
||||
await getRequestsUseCase.execute({ teamId }, presenter);
|
||||
|
||||
const presenterVm = presenter.getViewModel();
|
||||
if (!presenterVm) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
|
||||
for (const driver of allDrivers) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
}
|
||||
|
||||
return presenterVm.requests.map((req: {
|
||||
requestId: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: string;
|
||||
message?: string;
|
||||
}): TeamAdminJoinRequestViewModel => {
|
||||
const base: TeamAdminJoinRequestViewModel = {
|
||||
id: req.requestId,
|
||||
const response = await apiClient.teams.getJoinRequests(teamId);
|
||||
|
||||
return response.requests.map((req) => {
|
||||
const viewModel: TeamAdminJoinRequestViewModel = {
|
||||
id: req.id,
|
||||
teamId: req.teamId,
|
||||
driverId: req.driverId,
|
||||
requestedAt: new Date(req.requestedAt),
|
||||
};
|
||||
|
||||
const message = req.message;
|
||||
const driver = driversById[req.driverId];
|
||||
if (req.message) {
|
||||
viewModel.message = req.message;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(message !== undefined ? { message } : {}),
|
||||
...(driver !== undefined ? { driver } : {}),
|
||||
};
|
||||
return viewModel;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,10 +85,9 @@ export async function loadTeamJoinRequests(
|
||||
*/
|
||||
export async function approveTeamJoinRequestAndReload(
|
||||
requestId: string,
|
||||
teamId: string,
|
||||
teamId: string
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const useCase = getApproveTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
await apiClient.teams.approveJoinRequest(teamId, requestId);
|
||||
return loadTeamJoinRequests(teamId);
|
||||
}
|
||||
|
||||
@@ -115,15 +96,14 @@ export async function approveTeamJoinRequestAndReload(
|
||||
*/
|
||||
export async function rejectTeamJoinRequestAndReload(
|
||||
requestId: string,
|
||||
teamId: string,
|
||||
teamId: string
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const useCase = getRejectTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
await apiClient.teams.rejectJoinRequest(teamId, requestId);
|
||||
return loadTeamJoinRequests(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update team basic details.
|
||||
* Update team basic details via API.
|
||||
*/
|
||||
export async function updateTeamDetails(params: {
|
||||
teamId: string;
|
||||
@@ -132,14 +112,8 @@ export async function updateTeamDetails(params: {
|
||||
description: string;
|
||||
updatedByDriverId: string;
|
||||
}): Promise<void> {
|
||||
const useCase = getUpdateTeamUseCase();
|
||||
await useCase.execute({
|
||||
teamId: params.teamId,
|
||||
updates: {
|
||||
name: params.name,
|
||||
tag: params.tag,
|
||||
description: params.description,
|
||||
},
|
||||
updatedBy: params.updatedByDriverId,
|
||||
await apiClient.teams.update(params.teamId, {
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,53 @@
|
||||
import type {
|
||||
ITeamDetailsPresenter,
|
||||
TeamDetailsViewModel,
|
||||
TeamDetailsResultDTO,
|
||||
} from '@gridpilot/racing/application/presenters/ITeamDetailsPresenter';
|
||||
/**
|
||||
* TeamDetailsPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient, type TeamDetailsViewModel as ApiTeamDetailsViewModel } from '@/lib/apiClient';
|
||||
|
||||
export interface TeamMembershipViewModel {
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface TeamInfoViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tag?: string | undefined;
|
||||
description?: string | undefined;
|
||||
ownerId: string;
|
||||
leagues?: string[] | undefined;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TeamDetailsViewModel {
|
||||
team: TeamInfoViewModel;
|
||||
membership: TeamMembershipViewModel | null;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export interface ITeamDetailsPresenter {
|
||||
reset(): void;
|
||||
getViewModel(): TeamDetailsViewModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to view model
|
||||
*/
|
||||
function transformApiResponse(apiResponse: ApiTeamDetailsViewModel): TeamDetailsViewModel {
|
||||
return {
|
||||
team: {
|
||||
id: apiResponse.id,
|
||||
name: apiResponse.name,
|
||||
description: apiResponse.description,
|
||||
ownerId: apiResponse.ownerId,
|
||||
createdAt: new Date().toISOString(), // Would need from API
|
||||
},
|
||||
membership: null, // Would need from API based on current user
|
||||
canManage: false, // Would need from API based on current user
|
||||
};
|
||||
}
|
||||
|
||||
export class TeamDetailsPresenter implements ITeamDetailsPresenter {
|
||||
private viewModel: TeamDetailsViewModel | null = null;
|
||||
@@ -11,34 +56,27 @@ export class TeamDetailsPresenter implements ITeamDetailsPresenter {
|
||||
this.viewModel = null;
|
||||
}
|
||||
|
||||
present(input: TeamDetailsResultDTO): void {
|
||||
const { team, membership } = input;
|
||||
const canManage = membership?.role === 'owner' || membership?.role === 'manager';
|
||||
|
||||
const viewModel: TeamDetailsViewModel = {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
leagues: team.leagues,
|
||||
createdAt: team.createdAt.toISOString(),
|
||||
},
|
||||
membership: membership
|
||||
? {
|
||||
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
isActive: membership.status === 'active',
|
||||
}
|
||||
: null,
|
||||
canManage,
|
||||
};
|
||||
|
||||
this.viewModel = viewModel;
|
||||
async fetchAndPresent(teamId: string): Promise<void> {
|
||||
const apiResponse = await apiClient.teams.getDetails(teamId);
|
||||
if (apiResponse) {
|
||||
this.viewModel = transformApiResponse(apiResponse);
|
||||
} else {
|
||||
this.viewModel = null;
|
||||
}
|
||||
}
|
||||
|
||||
getViewModel(): TeamDetailsViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch and transform team details
|
||||
*/
|
||||
export async function fetchTeamDetails(teamId: string): Promise<TeamDetailsViewModel | null> {
|
||||
const apiResponse = await apiClient.teams.getDetails(teamId);
|
||||
if (!apiResponse) {
|
||||
return null;
|
||||
}
|
||||
return transformApiResponse(apiResponse);
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { TeamMembership, TeamRole } from '@gridpilot/racing/domain/types/TeamMembership';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
|
||||
/**
|
||||
* TeamRosterPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
|
||||
export type TeamRole = 'owner' | 'manager' | 'driver' | 'member';
|
||||
|
||||
export interface DriverDTO {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string | undefined;
|
||||
country?: string | undefined;
|
||||
}
|
||||
|
||||
export interface TeamRosterMemberViewModel {
|
||||
driver: DriverDTO;
|
||||
@@ -17,33 +28,24 @@ export interface TeamRosterViewModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Presenter/facade for team roster.
|
||||
* Encapsulates repository and stats access so the TeamRoster component can remain a pure view.
|
||||
* Fetch team roster via API and transform to view model.
|
||||
*/
|
||||
export async function getTeamRosterViewModel(
|
||||
memberships: TeamMembership[]
|
||||
teamId: string
|
||||
): Promise<TeamRosterViewModel> {
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const members: TeamRosterMemberViewModel[] = [];
|
||||
|
||||
for (const membership of memberships) {
|
||||
const driver = allDrivers.find((d) => d.id === membership.driverId);
|
||||
if (!driver) continue;
|
||||
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (!dto) continue;
|
||||
|
||||
const stats = getDriverStats(membership.driverId);
|
||||
|
||||
members.push({
|
||||
driver: dto,
|
||||
role: membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
rating: stats?.rating ?? null,
|
||||
overallRank: typeof stats?.overallRank === 'number' ? stats.overallRank : null,
|
||||
});
|
||||
}
|
||||
const response = await apiClient.teams.getMembers(teamId);
|
||||
|
||||
const members: TeamRosterMemberViewModel[] = response.members.map((member) => ({
|
||||
driver: {
|
||||
id: member.driverId,
|
||||
name: member.driver?.name ?? 'Unknown',
|
||||
avatarUrl: member.driver?.avatarUrl,
|
||||
},
|
||||
role: (member.role as TeamRole) ?? 'member',
|
||||
joinedAt: member.joinedAt,
|
||||
rating: null, // Would need from API
|
||||
overallRank: null, // Would need from API
|
||||
}));
|
||||
|
||||
const averageRating =
|
||||
members.length > 0
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getStandingRepository, getLeagueRepository, getTeamMembershipRepository } from '@/lib/di-container';
|
||||
/**
|
||||
* TeamStandingsPresenter - Pure data transformer
|
||||
* Transforms API response to view model without DI dependencies.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
|
||||
export interface TeamLeagueStandingViewModel {
|
||||
leagueId: string;
|
||||
@@ -15,61 +20,37 @@ export interface TeamStandingsViewModel {
|
||||
|
||||
/**
|
||||
* Compute team standings across the given leagues for a team.
|
||||
* Mirrors the previous TeamStandings component logic but keeps it out of the UI layer.
|
||||
* This would need a dedicated API endpoint for team standings.
|
||||
* For now, returns empty standings - the API should provide this data.
|
||||
* @param teamId - The team ID (will be used when API supports team standings)
|
||||
* @param leagueIds - List of league IDs to fetch standings for
|
||||
*/
|
||||
export async function loadTeamStandings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
teamId: string,
|
||||
leagues: string[],
|
||||
leagueIds: string[],
|
||||
): Promise<TeamStandingsViewModel> {
|
||||
const standingRepo = getStandingRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const teamMembershipRepo = getTeamMembershipRepository();
|
||||
|
||||
const members = await teamMembershipRepo.getTeamMembers(teamId);
|
||||
const memberIds = members.map((m) => m.driverId);
|
||||
|
||||
// In the new architecture, team standings should come from API
|
||||
// For now, fetch each league's standings and aggregate
|
||||
const teamStandings: TeamLeagueStandingViewModel[] = [];
|
||||
|
||||
for (const leagueId of leagues) {
|
||||
const league = await leagueRepo.findById(leagueId);
|
||||
if (!league) continue;
|
||||
|
||||
const leagueStandings = await standingRepo.findByLeagueId(leagueId);
|
||||
|
||||
let totalPoints = 0;
|
||||
let totalWins = 0;
|
||||
let totalRaces = 0;
|
||||
|
||||
for (const standing of leagueStandings) {
|
||||
if (memberIds.includes(standing.driverId)) {
|
||||
totalPoints += standing.points;
|
||||
totalWins += standing.wins;
|
||||
totalRaces = Math.max(totalRaces, standing.racesCompleted);
|
||||
}
|
||||
for (const leagueId of leagueIds) {
|
||||
try {
|
||||
const standings = await apiClient.leagues.getStandings(leagueId);
|
||||
|
||||
// Since we don't have team-specific standings from API yet,
|
||||
// this is a placeholder that returns basic data
|
||||
teamStandings.push({
|
||||
leagueId,
|
||||
leagueName: `League ${leagueId}`, // Would need from API
|
||||
position: 0,
|
||||
points: 0,
|
||||
wins: 0,
|
||||
racesCompleted: standings.standings.length > 0 ? 1 : 0,
|
||||
});
|
||||
} catch {
|
||||
// Skip leagues that fail to load
|
||||
}
|
||||
|
||||
// Simplified team position based on total points (same spirit as previous logic)
|
||||
const allTeamPoints = leagueStandings
|
||||
.filter((s) => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
|
||||
const position =
|
||||
leagueStandings
|
||||
.filter((_, idx, arr) => {
|
||||
const teamPoints = arr
|
||||
.filter((s) => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
return teamPoints > allTeamPoints;
|
||||
}).length + 1;
|
||||
|
||||
teamStandings.push({
|
||||
leagueId,
|
||||
leagueName: league.name,
|
||||
position,
|
||||
points: totalPoints,
|
||||
wins: totalWins,
|
||||
racesCompleted: totalRaces,
|
||||
});
|
||||
}
|
||||
|
||||
return { standings: teamStandings };
|
||||
|
||||
Reference in New Issue
Block a user