This commit is contained in:
2025-12-16 10:50:15 +01:00
parent 775d41e055
commit 8ed6ba1fd1
144 changed files with 5763 additions and 1985 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
});
}

View File

@@ -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);
}

View File

@@ -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,
};
}

View File

@@ -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,
});
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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 };