view models

This commit is contained in:
2025-12-18 01:20:23 +01:00
parent 7c449af311
commit cc2553876a
216 changed files with 485 additions and 10179 deletions

View File

@@ -1,111 +0,0 @@
/**
* 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;
reset(): void {
this.viewModel = null;
}
async fetchAndPresent(): Promise<void> {
const apiResponse = await apiClient.leagues.getAllWithCapacity();
this.viewModel = transformApiResponse(apiResponse);
}
getViewModel(): AllLeaguesWithCapacityAndScoringViewModel | null {
return this.viewModel;
}
}
/**
* 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,73 +0,0 @@
import type {
IAllLeaguesWithCapacityPresenter,
LeagueWithCapacityViewModel,
AllLeaguesWithCapacityViewModel,
AllLeaguesWithCapacityResultDTO,
} from '@core/racing/application/presenters/IAllLeaguesWithCapacityPresenter';
export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityPresenter {
private viewModel: AllLeaguesWithCapacityViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: AllLeaguesWithCapacityResultDTO): void {
const { leagues, memberCounts } = input;
const leagueItems: LeagueWithCapacityViewModel[] = leagues.map((league) => {
const usedSlots = memberCounts.get(league.id) ?? 0;
// Ensure we never expose an impossible state like 26/24:
// clamp maxDrivers to at least usedSlots at the application boundary.
const configuredMax = league.settings.maxDrivers ?? usedSlots;
const safeMaxDrivers = Math.max(configuredMax, usedSlots);
const base: LeagueWithCapacityViewModel = {
id: league.id,
name: league.name,
description: league.description,
ownerId: league.ownerId,
settings: {
...league.settings,
maxDrivers: safeMaxDrivers,
},
createdAt: league.createdAt.toISOString(),
usedSlots,
};
if (!league.socialLinks) {
return base;
}
const socialLinks: NonNullable<LeagueWithCapacityViewModel['socialLinks']> = {};
if (league.socialLinks.discordUrl) {
socialLinks.discordUrl = league.socialLinks.discordUrl;
}
if (league.socialLinks.youtubeUrl) {
socialLinks.youtubeUrl = league.socialLinks.youtubeUrl;
}
if (league.socialLinks.websiteUrl) {
socialLinks.websiteUrl = league.socialLinks.websiteUrl;
}
if (Object.keys(socialLinks).length === 0) {
return base;
}
return {
...base,
socialLinks,
};
});
this.viewModel = {
leagues: leagueItems,
totalCount: leagueItems.length,
};
}
getViewModel(): AllLeaguesWithCapacityViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,21 +0,0 @@
import type {
IAllRacesPagePresenter,
AllRacesPageResultDTO,
AllRacesPageViewModel,
} from '@core/racing/application/presenters/IAllRacesPagePresenter';
export class AllRacesPagePresenter implements IAllRacesPagePresenter {
private viewModel: AllRacesPageViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(dto: AllRacesPageResultDTO): void {
this.viewModel = dto;
}
getViewModel(): AllRacesPageViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,78 +0,0 @@
/**
* 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;
reset(): void {
this.viewModel = null;
}
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 +0,0 @@
import { AnalyticsDashboardDto } from '../dtos';
import { AnalyticsDashboardViewModel } from '../view-models';
export class AnalyticsDashboardPresenter {
present(dto: AnalyticsDashboardDto): AnalyticsDashboardViewModel {
return new AnalyticsDashboardViewModel(dto);
}
}

View File

@@ -1,8 +0,0 @@
import { AnalyticsMetricsDto } from '../dtos';
import { AnalyticsMetricsViewModel } from '../view-models';
export class AnalyticsMetricsPresenter {
present(dto: AnalyticsMetricsDto): AnalyticsMetricsViewModel {
return new AnalyticsMetricsViewModel(dto);
}
}

View File

@@ -1,39 +0,0 @@
import type {
GetAvatarOutputDto,
RequestAvatarGenerationOutputDto,
UpdateAvatarOutputDto
} from '../dtos';
import type {
AvatarViewModel,
RequestAvatarGenerationViewModel,
UpdateAvatarViewModel
} from '../view-models';
/**
* Avatar Presenter
* Transforms avatar DTOs to ViewModels
*/
export class AvatarPresenter {
presentAvatar(dto: GetAvatarOutputDto): AvatarViewModel {
return {
driverId: dto.driverId,
avatarUrl: dto.avatarUrl,
hasAvatar: dto.hasAvatar,
};
}
presentRequestGeneration(dto: RequestAvatarGenerationOutputDto): RequestAvatarGenerationViewModel {
return {
success: dto.success,
avatarUrl: dto.avatarUrl,
error: dto.error,
};
}
presentUpdate(dto: UpdateAvatarOutputDto): UpdateAvatarViewModel {
return {
success: dto.success,
error: dto.error,
};
}
}

View File

@@ -1,15 +0,0 @@
import type { CompleteOnboardingOutputDto } from '../dtos';
import type { CompleteOnboardingViewModel } from '../view-models/CompleteOnboardingViewModel';
/**
* Complete Onboarding Presenter
* Transforms CompleteOnboardingOutputDto to CompleteOnboardingViewModel
*/
export class CompleteOnboardingPresenter {
present(dto: CompleteOnboardingOutputDto): CompleteOnboardingViewModel {
return {
driverId: dto.driverId,
success: dto.success,
};
}
}

View File

@@ -1,21 +0,0 @@
import type {
IDashboardOverviewPresenter,
DashboardOverviewResultDTO,
DashboardOverviewViewModel,
} from '@core/racing/application/presenters/IDashboardOverviewPresenter';
export class DashboardOverviewPresenter implements IDashboardOverviewPresenter {
private viewModel: DashboardOverviewViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(dto: DashboardOverviewResultDTO): void {
this.viewModel = dto;
}
getViewModel(): DashboardOverviewViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,18 +0,0 @@
import type { DriverDto } from '../dtos';
import type { DriverViewModel } from '../view-models/DriverViewModel';
/**
* Driver Presenter
* Transforms DriverDto to DriverViewModel
*/
export class DriverPresenter {
present(dto: DriverDto): DriverViewModel {
return {
id: dto.id,
name: dto.name,
avatarUrl: dto.avatarUrl,
iracingId: dto.iracingId,
rating: dto.rating,
};
}
}

View File

@@ -1,19 +0,0 @@
import type { DriverRegistrationStatusDto } from '../dtos';
import type { DriverRegistrationStatusViewModel } from '../view-models';
import { DriverRegistrationStatusViewModel as ViewModel } from '../view-models';
/**
* Driver Registration Status Presenter
* Transforms DriverRegistrationStatusDto to DriverRegistrationStatusViewModel
*/
export class DriverRegistrationStatusPresenter {
present(dto: DriverRegistrationStatusDto): DriverRegistrationStatusViewModel {
return new ViewModel(dto);
}
}
// Legacy functional export for backward compatibility
export const presentDriverRegistrationStatus = (dto: DriverRegistrationStatusDto): DriverRegistrationStatusViewModel => {
const presenter = new DriverRegistrationStatusPresenter();
return presenter.present(dto);
};

View File

@@ -1,88 +0,0 @@
/**
* 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;
reset(): void {
this.viewModel = null;
}
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,18 +0,0 @@
import type { DriversLeaderboardDto } from '../dtos';
import type { DriverLeaderboardViewModel } from '../view-models';
/**
* Drivers Leaderboard Presenter
* Transforms DriversLeaderboardDto to DriverLeaderboardViewModel
*/
export class DriversLeaderboardPresenter {
present(dto: DriversLeaderboardDto, previousDrivers?: any): DriverLeaderboardViewModel {
return new DriverLeaderboardViewModel(dto as any, previousDrivers);
}
}
// Legacy functional export for backward compatibility
export const presentDriversLeaderboard = (dto: DriversLeaderboardDto, previousDrivers?: any): DriverLeaderboardViewModel => {
const presenter = new DriversLeaderboardPresenter();
return presenter.present(dto, previousDrivers);
};

View File

@@ -1,14 +0,0 @@
import type { IEntitySponsorshipPricingPresenter } from '@core/racing/application/presenters/IEntitySponsorshipPricingPresenter';
import type { GetEntitySponsorshipPricingResultDTO } from '@core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
export class EntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter {
private data: GetEntitySponsorshipPricingResultDTO | null = null;
present(data: GetEntitySponsorshipPricingResultDTO | null): void {
this.data = data;
}
getData(): GetEntitySponsorshipPricingResultDTO | null {
return this.data;
}
}

View File

@@ -1,21 +0,0 @@
import type { ImportRaceResultsSummaryDto } from '../dtos/ImportRaceResultsSummaryDto';
export interface ImportRaceResultsSummaryViewModel {
success: boolean;
raceId: string;
driversProcessed: number;
resultsRecorded: number;
errors?: string[];
}
export class ImportRaceResultsPresenter {
present(dto: ImportRaceResultsSummaryDto): ImportRaceResultsSummaryViewModel {
return {
success: dto.success,
raceId: dto.raceId,
driversProcessed: dto.driversProcessed,
resultsRecorded: dto.resultsRecorded,
errors: dto.errors,
};
}
}

View File

@@ -1,269 +0,0 @@
/**
* 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 | undefined;
driver?: DriverDTO | undefined;
}
export interface ProtestDriverSummary {
[driverId: string]: DriverDTO;
}
export interface ProtestRaceSummary {
[raceId: string]: {
id: string;
name: string;
scheduledTime: string;
};
}
export interface LeagueOwnerSummaryViewModel {
driver: DriverDTO;
rating: number | null;
rank: number | null;
}
export interface LeagueSummaryViewModel {
id: string;
ownerId: string;
settings: {
pointsSystem: string;
};
}
export interface LeagueAdminProtestsViewModel {
protests: Array<{
id: string;
raceId: string;
complainantId: string;
defendantId: string;
description: string;
status: string;
createdAt: string;
}>;
racesById: ProtestRaceSummary;
driversById: ProtestDriverSummary;
}
export interface LeagueAdminConfigViewModel {
form: LeagueConfigFormModelDto | null;
}
export interface LeagueAdminPermissionsViewModel {
canRemoveMember: boolean;
canUpdateRoles: boolean;
}
export interface LeagueSeasonSummaryViewModel {
seasonId: string;
name: string;
status: string;
startDate?: Date | undefined;
endDate?: Date | undefined;
isPrimary: boolean;
isParallelActive: boolean;
}
export interface LeagueAdminViewModel {
joinRequests: LeagueJoinRequestViewModel[];
ownerSummary: LeagueOwnerSummaryViewModel | null;
config: LeagueAdminConfigViewModel;
protests: LeagueAdminProtestsViewModel;
}
export type MembershipRole = 'owner' | 'admin' | 'member';
// ============================================================================
// Data Fetching Functions (using apiClient)
// ============================================================================
/**
* Load join requests for a league via API.
*/
export async function loadLeagueJoinRequests(leagueId: string): Promise<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: new Date(request.requestedAt),
};
if (request.message) {
viewModel.message = request.message;
}
return viewModel;
});
}
/**
* Approve a league join request and return updated join requests.
*/
export async function approveLeagueJoinRequest(
leagueId: string,
requestId: string
): Promise<LeagueJoinRequestViewModel[]> {
await apiClient.leagues.approveJoinRequest(leagueId, requestId);
return loadLeagueJoinRequests(leagueId);
}
/**
* Reject a league join request.
*/
export async function rejectLeagueJoinRequest(
leagueId: string,
requestId: string
): Promise<LeagueJoinRequestViewModel[]> {
await apiClient.leagues.rejectJoinRequest(leagueId, requestId);
return loadLeagueJoinRequests(leagueId);
}
/**
* Get permissions for a performer on league membership actions.
*/
export async function getLeagueAdminPermissions(
leagueId: string,
performerDriverId: string
): Promise<LeagueAdminPermissionsViewModel> {
const permissions = await apiClient.leagues.getAdminPermissions(leagueId, performerDriverId);
return {
canRemoveMember: permissions.canManageMembers || permissions.isOwner || permissions.isAdmin,
canUpdateRoles: permissions.isOwner,
};
}
/**
* Remove a member from the league.
*/
export async function removeLeagueMember(
leagueId: string,
performerDriverId: string,
targetDriverId: string
): Promise<void> {
await apiClient.leagues.removeMember(leagueId, performerDriverId, targetDriverId);
}
/**
* Update a member's role.
*/
export async function updateLeagueMemberRole(
leagueId: string,
performerDriverId: string,
targetDriverId: string,
newRole: MembershipRole
): Promise<void> {
await apiClient.leagues.updateMemberRole(leagueId, performerDriverId, targetDriverId, newRole);
}
/**
* Load owner summary for a league.
*/
export async function loadLeagueOwnerSummary(params: {
leagueId: string;
ownerId: string;
}): Promise<LeagueOwnerSummaryViewModel | null> {
const ownerSummary = await apiClient.leagues.getOwnerSummary(params.leagueId, params.ownerId);
if (!ownerSummary) {
return null;
}
// For now, return a simplified version - the API should provide driver details
return {
driver: {
id: params.ownerId,
name: ownerSummary.leagueName, // This would need to be populated from API
},
rating: null,
rank: null,
};
}
/**
* Load league full config form.
*/
export async function loadLeagueConfig(
leagueId: string
): Promise<LeagueAdminConfigViewModel> {
const config = await apiClient.leagues.getConfig(leagueId);
return {
form: config,
};
}
/**
* Load protests for a league.
*/
export async function loadLeagueProtests(leagueId: string): Promise<LeagueAdminProtestsViewModel> {
const protestsData = await apiClient.leagues.getProtests(leagueId);
// Transform the API response
const racesById: ProtestRaceSummary = {};
const driversById: ProtestDriverSummary = {};
return {
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 seasons = await apiClient.leagues.getSeasons(leagueId);
const activeCount = seasons.filter((s: ApiLeagueSeasonSummaryViewModel) => s.status === 'active').length;
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,72 +0,0 @@
import type {
ILeagueDriverSeasonStatsPresenter,
LeagueDriverSeasonStatsItemViewModel,
LeagueDriverSeasonStatsViewModel,
LeagueDriverSeasonStatsResultDTO,
} from '@core/racing/application/presenters/ILeagueDriverSeasonStatsPresenter';
export class LeagueDriverSeasonStatsPresenter implements ILeagueDriverSeasonStatsPresenter {
private viewModel: LeagueDriverSeasonStatsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(dto: LeagueDriverSeasonStatsResultDTO): void {
const { leagueId, standings, penalties, driverResults, driverRatings } = dto;
const stats: LeagueDriverSeasonStatsItemViewModel[] = standings.map((standing) => {
const penalty = penalties.get(standing.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
const totalPenaltyPoints = penalty.baseDelta;
const bonusPoints = penalty.bonusDelta;
const racesCompleted = standing.racesCompleted;
const pointsPerRace = racesCompleted > 0 ? standing.points / racesCompleted : 0;
const ratingInfo = driverRatings.get(standing.driverId) ?? { rating: null, ratingChange: null };
const results = driverResults.get(standing.driverId) ?? [];
let avgFinish: number | null = null;
if (results.length > 0) {
const totalPositions = results.reduce((sum, r) => sum + r.position, 0);
const avg = totalPositions / results.length;
avgFinish = Number.isFinite(avg) ? Number(avg.toFixed(2)) : null;
}
return {
leagueId,
driverId: standing.driverId,
position: standing.position,
driverName: '',
teamId: '',
teamName: '',
totalPoints: standing.points + totalPenaltyPoints + bonusPoints,
basePoints: standing.points,
penaltyPoints: Math.abs(totalPenaltyPoints),
bonusPoints,
pointsPerRace,
racesStarted: results.length,
racesFinished: results.length,
dnfs: 0,
noShows: 0,
avgFinish,
rating: ratingInfo.rating,
ratingChange: ratingInfo.ratingChange,
};
});
stats.sort((a, b) => a.position - b.position);
this.viewModel = {
leagueId,
stats,
};
}
getViewModel(): LeagueDriverSeasonStatsViewModel {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel;
}
}

View File

@@ -1,117 +0,0 @@
import type { DropScorePolicy } from '@core/racing/domain/types/DropScorePolicy';
import type {
ILeagueFullConfigPresenter,
LeagueFullConfigData,
LeagueConfigFormViewModel,
} from '@core/racing/application/presenters/ILeagueFullConfigPresenter';
export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
private viewModel: LeagueConfigFormViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(data: LeagueFullConfigData): void {
const { league, activeSeason, scoringConfig, game } = data;
const patternId = scoringConfig?.scoringPresetId;
const primaryChampionship =
scoringConfig && scoringConfig.championships && scoringConfig.championships.length > 0
? scoringConfig.championships[0]
: undefined;
const dropPolicy = primaryChampionship?.dropScorePolicy ?? undefined;
const dropPolicyForm = this.mapDropPolicy(dropPolicy);
const defaultQualifyingMinutes = 30;
const defaultMainRaceMinutes = 40;
const mainRaceMinutes =
typeof league.settings.sessionDuration === 'number'
? league.settings.sessionDuration
: defaultMainRaceMinutes;
const qualifyingMinutes = defaultQualifyingMinutes;
const roundsPlanned = 8;
let sessionCount = 2;
if (primaryChampionship && Array.isArray(primaryChampionship.sessionTypes)) {
sessionCount = primaryChampionship.sessionTypes.length;
}
const practiceMinutes = 20;
const sprintRaceMinutes = patternId === 'sprint-main-driver' ? 20 : undefined;
this.viewModel = {
leagueId: league.id,
basics: {
name: league.name,
description: league.description,
visibility: 'public',
gameId: game?.id ?? 'iracing',
},
structure: {
mode: 'solo',
maxDrivers: league.settings.maxDrivers ?? 32,
multiClassEnabled: false,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
},
scoring: {
customScoringEnabled: !patternId,
...(patternId ? { patternId } : {}),
},
dropPolicy: dropPolicyForm,
timings: {
practiceMinutes,
qualifyingMinutes,
mainRaceMinutes,
sessionCount,
roundsPlanned,
...(typeof sprintRaceMinutes === 'number'
? { sprintRaceMinutes }
: {}),
},
stewarding: {
decisionMode: 'admin_only',
requireDefense: true,
defenseTimeLimit: 48,
voteTimeLimit: 72,
protestDeadlineHours: 72,
stewardingClosesHours: 168,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
},
};
}
getViewModel(): LeagueConfigFormViewModel | null {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel;
}
private mapDropPolicy(policy: DropScorePolicy | undefined): { strategy: string; n?: number } {
if (!policy || policy.strategy === 'none') {
return { strategy: 'none' };
}
if (policy.strategy === 'bestNResults') {
const n = typeof policy.count === 'number' ? policy.count : undefined;
return n !== undefined ? { strategy: 'bestNResults', n } : { strategy: 'none' };
}
if (policy.strategy === 'dropWorstN') {
const n = typeof policy.dropCount === 'number' ? policy.dropCount : undefined;
return n !== undefined ? { strategy: 'dropWorstN', n } : { strategy: 'none' };
}
return { strategy: 'none' };
}
}

View File

@@ -1,6 +0,0 @@
import { LeagueMemberDto } from '../dtos';
import { LeagueMemberViewModel } from '../view-models';
export const presentLeagueMember = (dto: LeagueMemberDto, currentUserId: string): LeagueMemberViewModel => {
return new LeagueMemberViewModel(dto, currentUserId);
};

View File

@@ -1,16 +0,0 @@
import type { LeagueMembershipsDto } from '../dtos';
import { LeagueMemberViewModel } from '../view-models';
/**
* League Members Presenter
*
* Transforms league memberships DTO to view models for the UI.
*/
export class LeagueMembersPresenter {
/**
* Present league memberships with current user context
*/
present(dto: LeagueMembershipsDto, currentUserId: string): LeagueMemberViewModel[] {
return dto.members.map(member => new LeagueMemberViewModel(member, currentUserId));
}
}

View File

@@ -1,115 +0,0 @@
import type { Race } from '@core/racing/domain/entities/Race';
import type { IRaceRepository } from '@core/racing/application/ports/IRaceRepository';
import type { IIsDriverRegisteredForRaceQuery } from '@core/racing/application/queries/IIsDriverRegisteredForRaceQuery';
import type { IRegisterForRaceUseCase } from '@core/racing/application/use-cases/IRegisterForRaceUseCase';
import type { IWithdrawFromRaceUseCase } from '@core/racing/application/use-cases/IWithdrawFromRaceUseCase';
export interface LeagueScheduleRaceItemViewModel {
id: string;
leagueId: string;
track: string;
car: string;
sessionType: string;
scheduledAt: Date;
status: Race['status'];
isUpcoming: boolean;
isPast: boolean;
isRegistered: boolean;
}
export interface LeagueScheduleViewModel {
races: LeagueScheduleRaceItemViewModel[];
}
export interface ILeagueSchedulePresenter {
loadLeagueSchedule(leagueId: string, driverId: string): Promise<LeagueScheduleViewModel>;
registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void>;
withdrawFromRace(raceId: string, driverId: string): Promise<void>;
}
export class LeagueSchedulePresenter implements ILeagueSchedulePresenter {
constructor(
private raceRepository: IRaceRepository,
private isDriverRegisteredForRaceQuery: IIsDriverRegisteredForRaceQuery,
private registerForRaceUseCase: IRegisterForRaceUseCase,
private withdrawFromRaceUseCase: IWithdrawFromRaceUseCase,
) {}
/**
* Load league schedule with registration status for a given driver.
*/
async loadLeagueSchedule(
leagueId: string,
driverId: string,
): Promise<LeagueScheduleViewModel> {
const allRaces = await this.raceRepository.findAll();
const leagueRaces = allRaces
.filter((race) => race.leagueId === leagueId)
.sort(
(a, b) =>
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
);
const now = new Date();
const registrationStates: Record<string, boolean> = {};
await Promise.all(
leagueRaces.map(async (race) => {
const registered = await this.isDriverRegisteredForRaceQuery.execute({
raceId: race.id,
driverId,
});
registrationStates[race.id] = registered;
}),
);
const races: LeagueScheduleRaceItemViewModel[] = leagueRaces.map((race) => {
const raceDate = new Date(race.scheduledAt);
const isPast = race.status === 'completed' || raceDate <= now;
const isUpcoming = race.status === 'scheduled' && raceDate > now;
return {
id: race.id,
leagueId: race.leagueId,
track: race.track,
car: race.car,
sessionType: race.sessionType,
scheduledAt: raceDate,
status: race.status,
isUpcoming,
isPast,
isRegistered: registrationStates[race.id] ?? false,
};
});
return { races };
}
/**
* Register the driver for a race.
*/
async registerForRace(
raceId: string,
leagueId: string,
driverId: string,
): Promise<void> {
await this.registerForRaceUseCase.execute({
raceId,
leagueId,
driverId,
});
}
/**
* Withdraw the driver from a race.
*/
async withdrawFromRace(
raceId: string,
driverId: string,
): Promise<void> {
await this.withdrawFromRaceUseCase.execute({
raceId,
driverId,
});
}
}

View File

@@ -1,14 +0,0 @@
import type { ILeagueSchedulePreviewPresenter } from '@core/racing/application/presenters/ILeagueSchedulePreviewPresenter';
import type { LeagueSchedulePreviewDTO } from '@core/racing/application/dto/LeagueScheduleDTO';
export class LeagueSchedulePreviewPresenter implements ILeagueSchedulePreviewPresenter {
private data: LeagueSchedulePreviewDTO | null = null;
present(data: LeagueSchedulePreviewDTO): void {
this.data = data;
}
getData(): LeagueSchedulePreviewDTO | null {
return this.data;
}
}

View File

@@ -1,153 +0,0 @@
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
import type { BonusRule } from '@core/racing/domain/types/BonusRule';
import type {
ILeagueScoringConfigPresenter,
LeagueScoringConfigData,
LeagueScoringConfigViewModel,
LeagueScoringChampionshipViewModel,
} from '@core/racing/application/presenters/ILeagueScoringConfigPresenter';
export class LeagueScoringConfigPresenter implements ILeagueScoringConfigPresenter {
private viewModel: LeagueScoringConfigViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(data: LeagueScoringConfigData): LeagueScoringConfigViewModel {
const championships: LeagueScoringChampionshipViewModel[] =
data.championships.map((champ) => this.mapChampionship(champ));
const dropPolicySummary =
data.preset?.dropPolicySummary ??
this.deriveDropPolicyDescriptionFromChampionships(data.championships);
this.viewModel = {
leagueId: data.leagueId,
seasonId: data.seasonId,
gameId: data.gameId,
gameName: data.gameName,
scoringPresetId: data.scoringPresetId ?? 'custom',
scoringPresetName: data.preset?.name ?? 'Custom',
dropPolicySummary,
championships,
};
return this.viewModel;
}
getViewModel(): LeagueScoringConfigViewModel {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel;
}
private mapChampionship(championship: ChampionshipConfig): LeagueScoringChampionshipViewModel {
const sessionTypes = championship.sessionTypes.map((s) => s.toString());
const pointsPreview = this.buildPointsPreview(championship.pointsTableBySessionType);
const bonusSummary = this.buildBonusSummary(
championship.bonusRulesBySessionType ?? {},
);
const dropPolicyDescription = this.deriveDropPolicyDescription(
championship.dropScorePolicy,
);
return {
id: championship.id,
name: championship.name,
type: championship.type,
sessionTypes,
pointsPreview,
bonusSummary,
dropPolicyDescription,
};
}
private buildPointsPreview(
tables: Record<string, { getPointsForPosition: (position: number) => number }>,
): Array<{ sessionType: string; position: number; points: number }> {
const preview: Array<{
sessionType: string;
position: number;
points: number;
}> = [];
const maxPositions = 10;
for (const [sessionType, table] of Object.entries(tables)) {
for (let pos = 1; pos <= maxPositions; pos++) {
const points = table.getPointsForPosition(pos);
if (points && points !== 0) {
preview.push({
sessionType,
position: pos,
points,
});
}
}
}
return preview;
}
private buildBonusSummary(
bonusRulesBySessionType: Record<string, BonusRule[]>,
): string[] {
const summaries: string[] = [];
for (const [sessionType, rules] of Object.entries(bonusRulesBySessionType)) {
for (const rule of rules) {
if (rule.type === 'fastestLap') {
const base = `Fastest lap in ${sessionType}`;
if (rule.requiresFinishInTopN) {
summaries.push(
`${base} +${rule.points} points if finishing P${rule.requiresFinishInTopN} or better`,
);
} else {
summaries.push(`${base} +${rule.points} points`);
}
} else {
summaries.push(
`${rule.type} bonus in ${sessionType} worth ${rule.points} points`,
);
}
}
}
return summaries;
}
private deriveDropPolicyDescriptionFromChampionships(
championships: ChampionshipConfig[],
): string {
const first = championships[0];
if (!first) {
return 'All results count';
}
return this.deriveDropPolicyDescription(first.dropScorePolicy);
}
private deriveDropPolicyDescription(policy: {
strategy: string;
count?: number;
dropCount?: number;
}): string {
if (!policy || policy.strategy === 'none') {
return 'All results count';
}
if (policy.strategy === 'bestNResults' && typeof policy.count === 'number') {
return `Best ${policy.count} results count towards the championship`;
}
if (
policy.strategy === 'dropWorstN' &&
typeof policy.dropCount === 'number'
) {
return `Worst ${policy.dropCount} results are dropped from the championship total`;
}
return 'Custom drop score rules apply';
}
}

View File

@@ -1,29 +0,0 @@
import type {
ILeagueScoringPresetsPresenter,
LeagueScoringPresetsViewModel,
LeagueScoringPresetsResultDTO,
} from '@core/racing/application/presenters/ILeagueScoringPresetsPresenter';
export class LeagueScoringPresetsPresenter implements ILeagueScoringPresetsPresenter {
private viewModel: LeagueScoringPresetsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(dto: LeagueScoringPresetsResultDTO): void {
const { presets } = dto;
this.viewModel = {
presets,
totalCount: presets.length,
};
}
getViewModel(): LeagueScoringPresetsViewModel {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel;
}
}

View File

@@ -1,18 +0,0 @@
import type { LeagueStandingsDto, StandingEntryDto } from '../dtos';
import { LeagueStandingsViewModel } from '../view-models';
/**
* League Standings Presenter
* Transforms LeagueStandingsDto to LeagueStandingsViewModel
*/
export class LeagueStandingsPresenter {
present(dto: LeagueStandingsDto, currentUserId: string, previousStandings?: StandingEntryDto[]): LeagueStandingsViewModel {
return new LeagueStandingsViewModel(dto, currentUserId, previousStandings);
}
}
// Legacy functional export for backward compatibility
export const presentLeagueStandings = (dto: LeagueStandingsDto, currentUserId: string, previousStandings?: StandingEntryDto[]): LeagueStandingsViewModel => {
const presenter = new LeagueStandingsPresenter();
return presenter.present(dto, currentUserId, previousStandings);
};

View File

@@ -1,42 +0,0 @@
import type {
ILeagueStatsPresenter,
LeagueStatsViewModel,
} from '@core/racing/application/presenters/ILeagueStatsPresenter';
export class LeagueStatsPresenter implements ILeagueStatsPresenter {
private viewModel: LeagueStatsViewModel | null = null;
present(
leagueId: string,
totalRaces: number,
completedRaces: number,
scheduledRaces: number,
sofValues: number[]
): LeagueStatsViewModel {
const averageSOF = sofValues.length > 0
? Math.round(sofValues.reduce((a, b) => a + b, 0) / sofValues.length)
: null;
const highestSOF = sofValues.length > 0 ? Math.max(...sofValues) : null;
const lowestSOF = sofValues.length > 0 ? Math.min(...sofValues) : null;
this.viewModel = {
leagueId,
totalRaces,
completedRaces,
scheduledRaces,
averageSOF,
highestSOF,
lowestSOF,
};
return this.viewModel;
}
getViewModel(): LeagueStatsViewModel {
if (!this.viewModel) {
throw new Error('Presenter has not been called yet');
}
return this.viewModel;
}
}

View File

@@ -1,21 +0,0 @@
import type { AllLeaguesWithCapacityDto } from '../dtos';
import { LeagueSummaryViewModel } from '../view-models';
/**
* League Summary Presenter
* Transforms AllLeaguesWithCapacityDto to array of LeagueSummaryViewModel
*/
export class LeagueSummaryPresenter {
present(dto: AllLeaguesWithCapacityDto): LeagueSummaryViewModel[] {
return dto.leagues.map(league => new LeagueSummaryViewModel(league));
}
}
// Legacy functional exports for backward compatibility
export const presentLeagueSummary = (dto: any): LeagueSummaryViewModel => {
return new LeagueSummaryViewModel(dto);
};
export const presentLeagueSummaries = (dtos: any[]): LeagueSummaryViewModel[] => {
return dtos.map(presentLeagueSummary);
};

View File

@@ -1,35 +0,0 @@
import type { GetMediaOutputDto, UploadMediaOutputDto, DeleteMediaOutputDto } from '../dtos';
import type { MediaViewModel, UploadMediaViewModel, DeleteMediaViewModel } from '../view-models';
/**
* Media Presenter
* Transforms media DTOs to ViewModels
*/
export class MediaPresenter {
presentMedia(dto: GetMediaOutputDto): MediaViewModel {
return {
id: dto.id,
url: dto.url,
type: dto.type,
category: dto.category,
uploadedAt: new Date(dto.uploadedAt),
size: dto.size,
};
}
presentUpload(dto: UploadMediaOutputDto): UploadMediaViewModel {
return {
success: dto.success,
mediaId: dto.mediaId,
url: dto.url,
error: dto.error,
};
}
presentDelete(dto: DeleteMediaOutputDto): DeleteMediaViewModel {
return {
success: dto.success,
error: dto.error,
};
}
}

View File

@@ -1,6 +0,0 @@
import { MembershipFeeDto } from '../dtos';
import { MembershipFeeViewModel } from '../view-models';
export const presentMembershipFee = (dto: MembershipFeeDto): MembershipFeeViewModel => {
return new MembershipFeeViewModel(dto);
};

View File

@@ -1,17 +0,0 @@
import type { GetPaymentsOutputDto } from '../dtos';
import { PaymentViewModel } from '../view-models';
import { presentPayment } from './PaymentPresenter';
/**
* Payment List Presenter
*
* Transforms payment list DTOs into ViewModels for UI consumption.
*/
export class PaymentListPresenter {
/**
* Transform payment list DTO to ViewModels
*/
present(dto: GetPaymentsOutputDto): PaymentViewModel[] {
return dto.payments.map(payment => presentPayment(payment));
}
}

View File

@@ -1,6 +0,0 @@
import { PaymentDto } from '../dtos';
import { PaymentViewModel } from '../view-models';
export const presentPayment = (dto: PaymentDto): PaymentViewModel => {
return new PaymentViewModel(dto);
};

View File

@@ -1,21 +0,0 @@
import type {
IPendingSponsorshipRequestsPresenter,
PendingSponsorshipRequestsViewModel,
} from '@core/racing/application/presenters/IPendingSponsorshipRequestsPresenter';
import type { GetPendingSponsorshipRequestsResultDTO } from '@core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
export class PendingSponsorshipRequestsPresenter implements IPendingSponsorshipRequestsPresenter {
private viewModel: PendingSponsorshipRequestsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(data: GetPendingSponsorshipRequestsResultDTO): void {
this.viewModel = data;
}
getViewModel(): PendingSponsorshipRequestsViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,6 +0,0 @@
import { PrizeDto } from '../dtos';
import { PrizeViewModel } from '../view-models';
export const presentPrize = (dto: PrizeDto): PrizeViewModel => {
return new PrizeViewModel(dto);
};

View File

@@ -1,16 +0,0 @@
import type {
IProfileOverviewPresenter,
ProfileOverviewViewModel,
} from '@core/racing/application/presenters/IProfileOverviewPresenter';
export class ProfileOverviewPresenter implements IProfileOverviewPresenter {
private viewModel: ProfileOverviewViewModel | null = null;
present(viewModel: ProfileOverviewViewModel): void {
this.viewModel = viewModel;
}
getViewModel(): ProfileOverviewViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,13 +0,0 @@
import { RaceDetailDto } from '../dtos';
import { RaceDetailViewModel } from '../view-models';
export class RaceDetailPresenter {
present(dto: RaceDetailDto): RaceDetailViewModel {
return new RaceDetailViewModel(dto);
}
}
export const presentRaceDetail = (dto: RaceDetailDto): RaceDetailViewModel => {
const presenter = new RaceDetailPresenter();
return presenter.present(dto);
};

View File

@@ -1,6 +0,0 @@
import { RaceListItemDto } from '../dtos';
import { RaceListItemViewModel } from '../view-models';
export const presentRaceListItem = (dto: RaceListItemDto): RaceListItemViewModel => {
return new RaceListItemViewModel(dto);
};

View File

@@ -1,55 +0,0 @@
import type {
IRacePenaltiesPresenter,
RacePenaltyViewModel,
RacePenaltiesResultDTO,
RacePenaltiesViewModel,
} from '@core/racing/application/presenters/IRacePenaltiesPresenter';
export class RacePenaltiesPresenter implements IRacePenaltiesPresenter {
private viewModel: RacePenaltiesViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(dto: RacePenaltiesResultDTO): void {
const { penalties, driverMap } = dto;
const penaltyViewModels: RacePenaltyViewModel[] = penalties.map((penalty) => {
const value = typeof penalty.value === 'number' ? penalty.value : 0;
const protestId = penalty.protestId;
const appliedAt = penalty.appliedAt ? penalty.appliedAt.toISOString() : undefined;
const notes = penalty.notes;
const base: RacePenaltyViewModel = {
id: penalty.id,
raceId: penalty.raceId,
driverId: penalty.driverId,
driverName: driverMap.get(penalty.driverId) || 'Unknown',
type: penalty.type,
value,
reason: penalty.reason,
issuedBy: penalty.issuedBy,
issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown',
status: penalty.status,
description: penalty.getDescription(),
issuedAt: penalty.issuedAt.toISOString(),
};
return {
...base,
...(protestId ? { protestId } : {}),
...(appliedAt ? { appliedAt } : {}),
...(typeof notes === 'string' && notes.length > 0 ? { notes } : {}),
};
});
this.viewModel = {
penalties: penaltyViewModels,
};
}
getViewModel(): RacePenaltiesViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,43 +0,0 @@
import { RaceDetailViewModel } from '../view-models/RaceDetailViewModel';
import type { RaceDetailDto } from '../dtos/RaceDetailDto';
import type { RacesPageDataDto } from '../dtos/RacesPageDataDto';
import type { RacesPageViewModel } from '../view-models/RacesPageViewModel';
/**
* Race Presenter
*
* Stateless presenter that transforms race DTOs into view models.
* All methods are pure functions with no side effects.
*/
export class RacePresenter {
presentRaceDetail(dto: RaceDetailDto): RaceDetailViewModel {
return new RaceDetailViewModel(dto);
}
presentRacesPage(dto: RacesPageDataDto): RacesPageViewModel {
return {
upcomingRaces: dto.races.filter(r => r.isUpcoming).map(r => this.presentRaceCard(r)),
completedRaces: dto.races.filter(r => r.status === 'completed').map(r => this.presentRaceCard(r)),
totalCount: dto.races.length,
};
}
private presentRaceCard(race: any): any {
return {
id: race.id,
title: race.title || race.track,
scheduledTime: race.scheduledTime || race.scheduledAt,
status: this.formatStatus(race.status),
};
}
private formatStatus(status: string): string {
const statusMap: Record<string, string> = {
scheduled: 'Scheduled',
running: 'Live',
completed: 'Finished',
cancelled: 'Cancelled',
};
return statusMap[status] || status;
}
}

View File

@@ -1,60 +0,0 @@
import type {
IRaceProtestsPresenter,
RaceProtestViewModel,
RaceProtestsResultDTO,
RaceProtestsViewModel,
} from '@core/racing/application/presenters/IRaceProtestsPresenter';
export class RaceProtestsPresenter implements IRaceProtestsPresenter {
private viewModel: RaceProtestsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(dto: RaceProtestsResultDTO): void {
const { protests, driverMap } = dto;
const protestViewModels: RaceProtestViewModel[] = protests.map((protest) => {
const base: RaceProtestViewModel = {
id: protest.id,
raceId: protest.raceId,
protestingDriverId: protest.protestingDriverId,
protestingDriverName: driverMap.get(protest.protestingDriverId) || 'Unknown',
accusedDriverId: protest.accusedDriverId,
accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown',
incident: protest.incident,
filedAt: protest.filedAt.toISOString(),
status: protest.status,
};
const comment = protest.comment;
const proofVideoUrl = protest.proofVideoUrl;
const reviewedBy = protest.reviewedBy;
const reviewedByName =
protest.reviewedBy !== undefined
? driverMap.get(protest.reviewedBy) ?? 'Unknown'
: undefined;
const decisionNotes = protest.decisionNotes;
const reviewedAt = protest.reviewedAt?.toISOString();
return {
...base,
...(comment !== undefined ? { comment } : {}),
...(proofVideoUrl !== undefined ? { proofVideoUrl } : {}),
...(reviewedBy !== undefined ? { reviewedBy } : {}),
...(reviewedByName !== undefined ? { reviewedByName } : {}),
...(decisionNotes !== undefined ? { decisionNotes } : {}),
...(reviewedAt !== undefined ? { reviewedAt } : {}),
};
});
this.viewModel = {
protests: protestViewModels,
};
}
getViewModel(): RaceProtestsViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,25 +0,0 @@
import type {
IRaceRegistrationsPresenter,
RaceRegistrationsViewModel,
RaceRegistrationsResultDTO,
} from '@core/racing/application/presenters/IRaceRegistrationsPresenter';
export class RaceRegistrationsPresenter implements IRaceRegistrationsPresenter {
private viewModel: RaceRegistrationsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: RaceRegistrationsResultDTO): void {
const { registeredDriverIds } = input;
this.viewModel = {
registeredDriverIds,
count: registeredDriverIds.length,
};
}
getViewModel(): RaceRegistrationsViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,13 +0,0 @@
import { RaceResultsDetailDto } from '../dtos';
import { RaceResultsDetailViewModel } from '../view-models';
export class RaceResultsDetailPresenter {
present(dto: RaceResultsDetailDto, currentUserId?: string): RaceResultsDetailViewModel {
return new RaceResultsDetailViewModel(dto, currentUserId);
}
}
export const presentRaceResultsDetail = (dto: RaceResultsDetailDto, currentUserId?: string): RaceResultsDetailViewModel => {
const presenter = new RaceResultsDetailPresenter();
return presenter.present(dto, currentUserId);
};

View File

@@ -1,52 +0,0 @@
import { describe, it, expect } from 'vitest';
import { presentRaceResult } from './RaceResultsPresenter';
import { RaceResultViewModel } from '../view-models';
import type { RaceResultRowDto } from '../dtos';
describe('RaceResultsPresenter', () => {
describe('presentRaceResult', () => {
it('should transform RaceResultRowDto into RaceResultViewModel', () => {
const dto: RaceResultRowDto = {
id: '1',
raceId: 'race-1',
driverId: 'driver-1',
position: 1,
fastestLap: 85.5,
incidents: 0,
startPosition: 2,
};
const result = presentRaceResult(dto);
expect(result).toBeInstanceOf(RaceResultViewModel);
expect(result.id).toBe('1');
expect(result.raceId).toBe('race-1');
expect(result.driverId).toBe('driver-1');
expect(result.position).toBe(1);
expect(result.fastestLap).toBe(85.5);
expect(result.incidents).toBe(0);
expect(result.startPosition).toBe(2);
});
it('should handle zero values correctly', () => {
const dto: RaceResultRowDto = {
id: '2',
raceId: 'race-2',
driverId: 'driver-2',
position: 5,
fastestLap: 0,
incidents: 3,
startPosition: 5,
};
const result = presentRaceResult(dto);
expect(result).toBeInstanceOf(RaceResultViewModel);
expect(result.id).toBe('2');
expect(result.position).toBe(5);
expect(result.fastestLap).toBe(0);
expect(result.incidents).toBe(3);
expect(result.startPosition).toBe(5);
});
});
});

View File

@@ -1,6 +0,0 @@
import { RaceResultRowDto } from '../dtos';
import { RaceResultViewModel } from '../view-models';
export const presentRaceResult = (dto: RaceResultRowDto): RaceResultViewModel => {
return new RaceResultViewModel(dto);
};

View File

@@ -1,17 +0,0 @@
import type { RaceWithSOFDto } from '../dtos/RaceWithSOFDto';
export interface RaceWithSOFViewModel {
id: string;
track: string;
strengthOfField: number | null;
}
export class RaceWithSOFPresenter {
present(dto: RaceWithSOFDto): RaceWithSOFViewModel {
return {
id: dto.id,
track: dto.track,
strengthOfField: dto.strengthOfField,
};
}
}

View File

@@ -1,89 +0,0 @@
import type {
IRacesPagePresenter,
RacesPageViewModel,
RaceListItemViewModel,
RacesPageResultDTO,
} from '@core/racing/application/presenters/IRacesPagePresenter';
export class RacesPagePresenter implements IRacesPagePresenter {
private viewModel: RacesPageViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: RacesPageResultDTO): void {
const { races } = input;
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const raceViewModels: RaceListItemViewModel[] = races.map((race) => {
const scheduledAt =
typeof race.scheduledAt === 'string'
? race.scheduledAt
: race.scheduledAt.toISOString();
const allowedStatuses: RaceListItemViewModel['status'][] = [
'scheduled',
'running',
'completed',
'cancelled',
];
const status: RaceListItemViewModel['status'] =
allowedStatuses.includes(race.status as RaceListItemViewModel['status'])
? (race.status as RaceListItemViewModel['status'])
: 'scheduled';
return {
id: race.id,
track: race.track,
car: race.car,
scheduledAt,
status,
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
};
});
const stats = {
total: raceViewModels.length,
scheduled: raceViewModels.filter(r => r.status === 'scheduled').length,
running: raceViewModels.filter(r => r.status === 'running').length,
completed: raceViewModels.filter(r => r.status === 'completed').length,
};
const liveRaces = raceViewModels.filter(r => r.isLive);
const upcomingThisWeek = raceViewModels
.filter(r => {
const scheduledDate = new Date(r.scheduledAt);
return r.isUpcoming && scheduledDate >= now && scheduledDate <= nextWeek;
})
.slice(0, 5);
const recentResults = raceViewModels
.filter(r => r.status === 'completed')
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
.slice(0, 3);
this.viewModel = {
races: raceViewModels,
stats,
liveRaces,
upcomingThisWeek,
recentResults,
};
}
getViewModel(): RacesPageViewModel {
if (!this.viewModel) {
throw new Error('ViewModel not yet generated. Call present() first.');
}
return this.viewModel;
}
}

View File

@@ -1,70 +0,0 @@
/**
* ScheduleRaceFormPresenter - Pure data transformer
* Transforms API response to view model without DI dependencies.
*/
import { apiClient } from '@/lib/apiClient';
export type SessionType = 'practice' | 'qualifying' | 'race';
export interface ScheduleRaceFormData {
leagueId: string;
track: string;
car: string;
sessionType: SessionType;
scheduledDate: string;
scheduledTime: string;
}
export interface ScheduledRaceViewModel {
id: string;
leagueId: string;
track: string;
car: string;
sessionType: SessionType;
scheduledAt: Date;
status: string;
}
export interface LeagueOptionViewModel {
id: string;
name: string;
}
/**
* Load available leagues for the schedule form.
*/
export async function loadScheduleRaceFormLeagues(): Promise<LeagueOptionViewModel[]> {
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 scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
// 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',
};
}

View File

@@ -1,13 +0,0 @@
import type { SessionDataDto } from '../dtos';
import { SessionViewModel } from '../view-models';
/**
* Session Presenter
* Transforms session DTOs to ViewModels
*/
export class SessionPresenter {
presentSession(dto: SessionDataDto | null): SessionViewModel | null {
if (!dto) return null;
return new SessionViewModel(dto);
}
}

View File

@@ -1,13 +0,0 @@
import type { SponsorDashboardDto } from '../dtos';
import { SponsorDashboardViewModel } from '../view-models/SponsorDashboardViewModel';
/**
* Sponsor Dashboard Presenter
*
* Transforms sponsor dashboard DTOs into view models.
*/
export class SponsorDashboardPresenter {
present(dto: SponsorDashboardDto): SponsorDashboardViewModel {
return new SponsorDashboardViewModel(dto);
}
}

View File

@@ -1,13 +0,0 @@
import type { GetSponsorsOutputDto } from '../dtos';
import { SponsorViewModel } from '../view-models';
/**
* Sponsor List Presenter
*
* Transforms sponsor list DTOs into view models.
*/
export class SponsorListPresenter {
present(dto: GetSponsorsOutputDto): SponsorViewModel[] {
return dto.sponsors.map(sponsor => new SponsorViewModel(sponsor));
}
}

View File

@@ -1,13 +0,0 @@
import type { SponsorDto } from '../dtos';
import { SponsorViewModel } from '../view-models';
/**
* Sponsor Presenter
*
* Transforms sponsor DTOs into view models.
*/
export class SponsorPresenter {
present(dto: SponsorDto): SponsorViewModel {
return new SponsorViewModel(dto);
}
}

View File

@@ -1,13 +0,0 @@
import type { SponsorSponsorshipsDto } from '../dtos';
import { SponsorSponsorshipsViewModel } from '../view-models/SponsorSponsorshipsViewModel';
/**
* Sponsor Sponsorships Presenter
*
* Transforms sponsor sponsorships DTOs into view models.
*/
export class SponsorSponsorshipsPresenter {
present(dto: SponsorSponsorshipsDto): SponsorSponsorshipsViewModel {
return new SponsorSponsorshipsViewModel(dto);
}
}

View File

@@ -1,6 +0,0 @@
import { SponsorshipDetailDto } from '../dtos';
import { SponsorshipDetailViewModel } from '../view-models';
export const presentSponsorshipDetail = (dto: SponsorshipDetailDto): SponsorshipDetailViewModel => {
return new SponsorshipDetailViewModel(dto);
};

View File

@@ -1,13 +0,0 @@
import type { GetEntitySponsorshipPricingResultDto } from '../dtos';
import { SponsorshipPricingViewModel } from '../view-models/SponsorshipPricingViewModel';
/**
* Sponsorship Pricing Presenter
*
* Transforms sponsorship pricing DTOs into view models.
*/
export class SponsorshipPricingPresenter {
present(dto: GetEntitySponsorshipPricingResultDto): SponsorshipPricingViewModel {
return new SponsorshipPricingViewModel(dto);
}
}

View File

@@ -1,119 +0,0 @@
/**
* 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 | undefined;
driver?: DriverDTO | undefined;
}
export interface TeamAdminTeamSummaryViewModel {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
}
export interface TeamAdminViewModel {
team: TeamAdminTeamSummaryViewModel;
requests: TeamAdminJoinRequestViewModel[];
}
// ============================================================================
// Data Fetching Functions (using apiClient)
// ============================================================================
/**
* Load team admin view model via API.
*/
export async function loadTeamAdminViewModel(
team: TeamAdminTeamSummaryViewModel
): Promise<TeamAdminViewModel> {
const requests = await loadTeamJoinRequests(team.id);
return {
team: {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: team.ownerId,
},
requests,
};
}
/**
* Load join requests for a team via API.
*/
export async function loadTeamJoinRequests(
teamId: string
): Promise<TeamAdminJoinRequestViewModel[]> {
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),
};
if (req.message) {
viewModel.message = req.message;
}
return viewModel;
});
}
/**
* Approve a team join request and return updated request view models.
*/
export async function approveTeamJoinRequestAndReload(
requestId: string,
teamId: string
): Promise<TeamAdminJoinRequestViewModel[]> {
await apiClient.teams.approveJoinRequest(teamId, requestId);
return loadTeamJoinRequests(teamId);
}
/**
* Reject a team join request and return updated request view models.
*/
export async function rejectTeamJoinRequestAndReload(
requestId: string,
teamId: string
): Promise<TeamAdminJoinRequestViewModel[]> {
await apiClient.teams.rejectJoinRequest(teamId, requestId);
return loadTeamJoinRequests(teamId);
}
/**
* Update team basic details via API.
*/
export async function updateTeamDetails(params: {
teamId: string;
name: string;
tag: string;
description: string;
updatedByDriverId: string;
}): Promise<void> {
await apiClient.teams.update(params.teamId, {
name: params.name,
description: params.description,
});
}

View File

@@ -1,12 +0,0 @@
import type { TeamDetailsDto } from '../dtos';
import { TeamDetailsViewModel } from '../view-models';
/**
* Team Details Presenter
* Transforms TeamDetailsDto to TeamDetailsViewModel
*/
export class TeamDetailsPresenter {
present(dto: TeamDetailsDto, currentUserId: string): TeamDetailsViewModel {
return new TeamDetailsViewModel(dto, currentUserId);
}
}

View File

@@ -1,18 +0,0 @@
import type { TeamJoinRequestItemDto } from '../dtos';
import { TeamJoinRequestViewModel } from '../view-models';
/**
* Team Join Request Presenter
* Transforms TeamJoinRequestItemDto to TeamJoinRequestViewModel
*/
export class TeamJoinRequestPresenter {
present(dto: TeamJoinRequestItemDto, currentUserId: string, isOwner: boolean): TeamJoinRequestViewModel {
return new TeamJoinRequestViewModel(dto, currentUserId, isOwner);
}
}
// Backward compatibility export (deprecated)
export const presentTeamJoinRequest = (dto: TeamJoinRequestItemDto, currentUserId: string, isOwner: boolean): TeamJoinRequestViewModel => {
const presenter = new TeamJoinRequestPresenter();
return presenter.present(dto, currentUserId, isOwner);
};

View File

@@ -1,38 +0,0 @@
import type {
ITeamJoinRequestsPresenter,
TeamJoinRequestViewModel,
TeamJoinRequestsViewModel,
TeamJoinRequestsResultDTO,
} from '@core/racing/application/presenters/ITeamJoinRequestsPresenter';
export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
private viewModel: TeamJoinRequestsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: TeamJoinRequestsResultDTO): void {
const requestItems: TeamJoinRequestViewModel[] = input.requests.map((request) => ({
requestId: request.id,
driverId: request.driverId,
driverName: input.driverNames[request.driverId] ?? 'Unknown Driver',
teamId: request.teamId,
status: 'pending',
requestedAt: request.requestedAt.toISOString(),
avatarUrl: input.avatarUrls[request.driverId] ?? '',
}));
const pendingCount = requestItems.filter((r) => r.status === 'pending').length;
this.viewModel = {
requests: requestItems,
pendingCount,
totalCount: requestItems.length,
};
}
getViewModel(): TeamJoinRequestsViewModel | null {
return this.viewModel;
}
}

View File

@@ -1,12 +0,0 @@
import type { AllTeamsDto } from '../dtos';
import { TeamSummaryViewModel } from '../view-models';
/**
* Team List Presenter
* Transforms AllTeamsDto to array of TeamSummaryViewModel
*/
export class TeamListPresenter {
present(dto: AllTeamsDto): TeamSummaryViewModel[] {
return dto.teams.map(team => new TeamSummaryViewModel(team));
}
}

View File

@@ -1,6 +0,0 @@
import { TeamMemberDto } from '../dtos';
import { TeamMemberViewModel } from '../view-models';
export const presentTeamMember = (dto: TeamMemberDto, currentUserId: string, teamOwnerId: string): TeamMemberViewModel => {
return new TeamMemberViewModel(dto, currentUserId, teamOwnerId);
};

View File

@@ -1,12 +0,0 @@
import type { TeamMembersDto } from '../dtos';
import { TeamMemberViewModel } from '../view-models';
/**
* Team Members Presenter
* Transforms TeamMembersDto to array of TeamMemberViewModel
*/
export class TeamMembersPresenter {
present(dto: TeamMembersDto, currentUserId: string, teamOwnerId: string): TeamMemberViewModel[] {
return dto.members.map(member => new TeamMemberViewModel(member, currentUserId, teamOwnerId));
}
}

View File

@@ -1,61 +0,0 @@
/**
* 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;
role: TeamRole;
joinedAt: string;
rating: number | null;
overallRank: number | null;
}
export interface TeamRosterViewModel {
members: TeamRosterMemberViewModel[];
averageRating: number;
}
/**
* Fetch team roster via API and transform to view model.
*/
export async function getTeamRosterViewModel(
teamId: string
): Promise<TeamRosterViewModel> {
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
? Math.round(
members.reduce((sum, m) => sum + (m.rating ?? 0), 0) / members.length
)
: 0;
return {
members,
averageRating,
};
}

View File

@@ -1,57 +0,0 @@
/**
* TeamStandingsPresenter - Pure data transformer
* Transforms API response to view model without DI dependencies.
*/
import { apiClient } from '@/lib/apiClient';
export interface TeamLeagueStandingViewModel {
leagueId: string;
leagueName: string;
position: number;
points: number;
wins: number;
racesCompleted: number;
}
export interface TeamStandingsViewModel {
standings: TeamLeagueStandingViewModel[];
}
/**
* Compute team standings across the given leagues for a team.
* 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,
leagueIds: string[],
): Promise<TeamStandingsViewModel> {
// 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 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
}
}
return { standings: teamStandings };
}

View File

@@ -1,6 +0,0 @@
import { TeamSummaryDto } from '../dtos';
import { TeamSummaryViewModel } from '../view-models';
export const presentTeamSummary = (dto: TeamSummaryDto): TeamSummaryViewModel => {
return new TeamSummaryViewModel(dto);
};

View File

@@ -1,100 +0,0 @@
import type {
ITeamsLeaderboardPresenter,
TeamsLeaderboardViewModel,
TeamLeaderboardItemViewModel,
SkillLevel,
TeamsLeaderboardResultDTO,
} from '@core/racing/application/presenters/ITeamsLeaderboardPresenter';
interface TeamLeaderboardInput {
id: string;
name: string;
memberCount: number;
rating: number | null;
totalWins: number;
totalRaces: number;
performanceLevel: SkillLevel;
isRecruiting: boolean;
createdAt: Date;
description?: string | null;
specialization?: string | null;
region?: string | null;
languages?: string[];
}
export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
private viewModel: TeamsLeaderboardViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: TeamsLeaderboardResultDTO): void {
const teams = (input.teams ?? []) as TeamLeaderboardInput[];
const recruitingCount = input.recruitingCount ?? 0;
const transformedTeams = teams.map((team) => this.transformTeam(team));
const groupsBySkillLevel = transformedTeams.reduce<Record<SkillLevel, TeamLeaderboardItemViewModel[]>>(
(acc, team) => {
if (!acc[team.performanceLevel]) {
acc[team.performanceLevel] = [];
}
acc[team.performanceLevel]!.push(team);
return acc;
},
{
beginner: [],
intermediate: [],
advanced: [],
pro: [],
},
);
const topTeams = transformedTeams
.filter((t) => t.rating !== null)
.slice()
.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0))
.slice(0, 5);
this.viewModel = {
teams: transformedTeams,
recruitingCount,
groupsBySkillLevel,
topTeams,
};
}
getViewModel(): TeamsLeaderboardViewModel | null {
return this.viewModel;
}
private transformTeam(team: TeamLeaderboardInput): TeamLeaderboardItemViewModel {
let specialization: TeamLeaderboardItemViewModel['specialization'];
if (
team.specialization === 'endurance' ||
team.specialization === 'sprint' ||
team.specialization === 'mixed'
) {
specialization = team.specialization;
} else {
specialization = undefined;
}
return {
id: team.id,
name: team.name,
memberCount: team.memberCount,
rating: team.rating,
totalWins: team.totalWins,
totalRaces: team.totalRaces,
performanceLevel: team.performanceLevel,
isRecruiting: team.isRecruiting,
createdAt: team.createdAt,
description: team.description ?? '',
specialization: specialization ?? 'mixed',
region: team.region ?? '',
languages: team.languages ?? [],
};
}
}

View File

@@ -1,6 +0,0 @@
import { WalletDto, WalletTransactionDto } from '../dtos';
import { WalletViewModel } from '../view-models';
export const presentWallet = (dto: WalletDto): WalletViewModel => {
return new WalletViewModel(dto);
};

View File

@@ -1,6 +0,0 @@
import { WalletTransactionDto } from '../dtos';
import { WalletTransactionViewModel } from '../view-models';
export const presentWalletTransaction = (dto: WalletTransactionDto): WalletTransactionViewModel => {
return new WalletTransactionViewModel(dto);
};