view models

This commit is contained in:
2025-12-18 00:08:47 +01:00
parent f7a56a92ce
commit 7c449af311
56 changed files with 2594 additions and 206 deletions

View File

@@ -1,6 +1,9 @@
// Analytics dashboard view model
// Represents dashboard data for analytics
/**
* Analytics dashboard view model
* Represents dashboard data for analytics
*
* Note: No matching generated DTO available yet
*/
export class AnalyticsDashboardViewModel {
totalUsers: number;
activeUsers: number;
@@ -8,7 +11,10 @@ export class AnalyticsDashboardViewModel {
totalLeagues: number;
constructor(data: { totalUsers: number; activeUsers: number; totalRaces: number; totalLeagues: number }) {
Object.assign(this, data);
this.totalUsers = data.totalUsers;
this.activeUsers = data.activeUsers;
this.totalRaces = data.totalRaces;
this.totalLeagues = data.totalLeagues;
}
/** UI-specific: User engagement rate */

View File

@@ -1,6 +1,9 @@
// Analytics metrics view model
// Represents metrics data for analytics
/**
* Analytics metrics view model
* Represents metrics data for analytics
*
* Note: No matching generated DTO available yet
*/
export class AnalyticsMetricsViewModel {
pageViews: number;
uniqueVisitors: number;
@@ -8,7 +11,10 @@ export class AnalyticsMetricsViewModel {
bounceRate: number;
constructor(data: { pageViews: number; uniqueVisitors: number; averageSessionDuration: number; bounceRate: number }) {
Object.assign(this, data);
this.pageViews = data.pageViews;
this.uniqueVisitors = data.uniqueVisitors;
this.averageSessionDuration = data.averageSessionDuration;
this.bounceRate = data.bounceRate;
}
/** UI-specific: Formatted page views */

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { AvatarViewModel } from './AvatarViewModel';
describe('AvatarViewModel', () => {
it('should create instance with driverId and avatarUrl', () => {
const dto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/avatar.jpg',
};
const viewModel = new AvatarViewModel(dto);
expect(viewModel.driverId).toBe('driver-123');
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
});
it('should create instance without avatarUrl', () => {
const dto = {
driverId: 'driver-123',
};
const viewModel = new AvatarViewModel(dto);
expect(viewModel.driverId).toBe('driver-123');
expect(viewModel.avatarUrl).toBeUndefined();
});
it('should return true for hasAvatar when avatarUrl exists', () => {
const viewModel = new AvatarViewModel({
driverId: 'driver-123',
avatarUrl: 'https://example.com/avatar.jpg',
});
expect(viewModel.hasAvatar).toBe(true);
});
it('should return false for hasAvatar when avatarUrl is undefined', () => {
const viewModel = new AvatarViewModel({
driverId: 'driver-123',
});
expect(viewModel.hasAvatar).toBe(false);
});
it('should return false for hasAvatar when avatarUrl is empty string', () => {
const viewModel = new AvatarViewModel({
driverId: 'driver-123',
avatarUrl: '',
});
expect(viewModel.hasAvatar).toBe(false);
});
});

View File

@@ -1,10 +1,27 @@
// Note: No generated DTO available for Avatar yet
interface AvatarDTO {
driverId: string;
avatarUrl?: string;
}
/**
* Avatar View Model
*
* Represents avatar information for the UI layer
*/
export interface AvatarViewModel {
export class AvatarViewModel {
driverId: string;
avatarUrl?: string;
hasAvatar: boolean;
constructor(dto: AvatarDTO) {
this.driverId = dto.driverId;
if (dto.avatarUrl !== undefined) {
this.avatarUrl = dto.avatarUrl;
}
}
/** UI-specific: Whether the driver has an avatar */
get hasAvatar(): boolean {
return !!this.avatarUrl;
}
}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { CompleteOnboardingViewModel } from './CompleteOnboardingViewModel';
import type { CompleteOnboardingOutputDTO } from '../types/generated/CompleteOnboardingOutputDTO';
describe('CompleteOnboardingViewModel', () => {
it('should create instance with success and driverId', () => {
const dto: CompleteOnboardingOutputDTO & { driverId: string } = {
success: true,
driverId: 'driver-123',
};
const viewModel = new CompleteOnboardingViewModel(dto);
expect(viewModel.success).toBe(true);
expect(viewModel.driverId).toBe('driver-123');
});
it('should return true for isSuccessful when success is true', () => {
const dto: CompleteOnboardingOutputDTO & { driverId: string } = {
success: true,
driverId: 'driver-123',
};
const viewModel = new CompleteOnboardingViewModel(dto);
expect(viewModel.isSuccessful).toBe(true);
});
it('should return false for isSuccessful when success is false', () => {
const dto: CompleteOnboardingOutputDTO & { driverId: string } = {
success: false,
driverId: 'driver-123',
};
const viewModel = new CompleteOnboardingViewModel(dto);
expect(viewModel.isSuccessful).toBe(false);
});
it('should preserve driverId regardless of success status', () => {
const successDto: CompleteOnboardingOutputDTO & { driverId: string } = {
success: true,
driverId: 'driver-success',
};
const failDto: CompleteOnboardingOutputDTO & { driverId: string } = {
success: false,
driverId: 'driver-fail',
};
const successViewModel = new CompleteOnboardingViewModel(successDto);
const failViewModel = new CompleteOnboardingViewModel(failDto);
expect(successViewModel.driverId).toBe('driver-success');
expect(failViewModel.driverId).toBe('driver-fail');
});
});

View File

@@ -1,8 +1,20 @@
import { CompleteOnboardingOutputDTO } from '../types/generated/CompleteOnboardingOutputDTO';
/**
* Complete onboarding view model
* UI representation of onboarding completion result
*/
export interface CompleteOnboardingViewModel {
driverId: string;
export class CompleteOnboardingViewModel implements CompleteOnboardingOutputDTO {
success: boolean;
driverId: string;
constructor(dto: CompleteOnboardingOutputDTO & { driverId: string }) {
this.success = dto.success;
this.driverId = dto.driverId;
}
/** UI-specific: Whether onboarding was successful */
get isSuccessful(): boolean {
return this.success;
}
}

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { DeleteMediaViewModel } from './DeleteMediaViewModel';
describe('DeleteMediaViewModel', () => {
it('should create instance with success true', () => {
const dto = { success: true };
const viewModel = new DeleteMediaViewModel(dto);
expect(viewModel.success).toBe(true);
expect(viewModel.error).toBeUndefined();
});
it('should create instance with success false and error', () => {
const dto = { success: false, error: 'Failed to delete media' };
const viewModel = new DeleteMediaViewModel(dto);
expect(viewModel.success).toBe(false);
expect(viewModel.error).toBe('Failed to delete media');
});
it('should return true for isSuccessful when success is true', () => {
const viewModel = new DeleteMediaViewModel({ success: true });
expect(viewModel.isSuccessful).toBe(true);
});
it('should return false for isSuccessful when success is false', () => {
const viewModel = new DeleteMediaViewModel({ success: false });
expect(viewModel.isSuccessful).toBe(false);
});
it('should return false for hasError when no error', () => {
const viewModel = new DeleteMediaViewModel({ success: true });
expect(viewModel.hasError).toBe(false);
});
it('should return true for hasError when error exists', () => {
const viewModel = new DeleteMediaViewModel({
success: false,
error: 'Something went wrong',
});
expect(viewModel.hasError).toBe(true);
});
it('should handle empty error string as falsy', () => {
const viewModel = new DeleteMediaViewModel({
success: false,
error: '',
});
expect(viewModel.hasError).toBe(false);
});
});

View File

@@ -1,9 +1,32 @@
// Note: No generated DTO available for DeleteMedia yet
interface DeleteMediaDTO {
success: boolean;
error?: string;
}
/**
* Delete Media View Model
*
* Represents the result of a media deletion operation
*/
export interface DeleteMediaViewModel {
export class DeleteMediaViewModel {
success: boolean;
error?: string;
constructor(dto: DeleteMediaDTO) {
this.success = dto.success;
if (dto.error !== undefined) {
this.error = dto.error;
}
}
/** UI-specific: Whether the deletion was successful */
get isSuccessful(): boolean {
return this.success;
}
/** UI-specific: Whether there was an error */
get hasError(): boolean {
return !!this.error;
}
}

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
describe('DriverLeaderboardItemViewModel', () => {
const mockDTO: DriverLeaderboardItemDTO = {
id: '1',
name: 'Test Driver',
rating: 1500,
skillLevel: 'advanced',
nationality: 'USA',
racesCompleted: 50,
wins: 10,
podiums: 25,
isActive: true,
rank: 5
};
it('should create instance from DTO', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
expect(viewModel.id).toBe('1');
expect(viewModel.name).toBe('Test Driver');
expect(viewModel.position).toBe(1);
});
it('should calculate win rate correctly', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
expect(viewModel.winRate).toBe(20); // 10/50 * 100
});
it('should format win rate as percentage', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
expect(viewModel.winRateFormatted).toBe('20.0%');
});
it('should return correct skill level color', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
expect(viewModel.skillLevelColor).toBe('orange'); // advanced = orange
});
it('should return correct skill level icon', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1);
expect(viewModel.skillLevelIcon).toBe('🥇'); // advanced = 🥇
});
it('should detect rating trend up', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1400);
expect(viewModel.ratingTrend).toBe('up');
});
it('should detect rating trend down', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1600);
expect(viewModel.ratingTrend).toBe('down');
});
it('should show rating change indicator', () => {
const viewModel = new DriverLeaderboardItemViewModel(mockDTO, 1, 1400);
expect(viewModel.ratingChangeIndicator).toBe('+100');
});
it('should handle zero races for win rate', () => {
const dto = { ...mockDTO, racesCompleted: 0, wins: 0 };
const viewModel = new DriverLeaderboardItemViewModel(dto, 1);
expect(viewModel.winRate).toBe(0);
});
});

View File

@@ -1,22 +1,31 @@
import { DriverLeaderboardItemDto } from '../dtos';
import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
export class DriverLeaderboardItemViewModel implements DriverLeaderboardItemDto {
export class DriverLeaderboardItemViewModel implements DriverLeaderboardItemDTO {
id: string;
name: string;
avatarUrl?: string;
rating: number;
wins: number;
races: number;
skillLevel: string;
isActive: boolean;
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
isActive: boolean;
rank: number;
position: number;
private previousRating?: number;
constructor(dto: DriverLeaderboardItemDto, position: number, previousRating?: number) {
Object.assign(this, dto);
constructor(dto: DriverLeaderboardItemDTO, position: number, previousRating?: number) {
this.id = dto.id;
this.name = dto.name;
this.rating = dto.rating;
this.skillLevel = dto.skillLevel;
this.nationality = dto.nationality;
this.racesCompleted = dto.racesCompleted;
this.wins = dto.wins;
this.podiums = dto.podiums;
this.isActive = dto.isActive;
this.rank = dto.rank;
this.position = position;
this.previousRating = previousRating;
}
@@ -45,7 +54,7 @@ export class DriverLeaderboardItemViewModel implements DriverLeaderboardItemDto
/** UI-specific: Win rate */
get winRate(): number {
return this.races > 0 ? (this.wins / this.races) * 100 : 0;
return this.racesCompleted > 0 ? (this.wins / this.racesCompleted) * 100 : 0;
}
/** UI-specific: Formatted win rate */

View File

@@ -1,10 +1,10 @@
import { DriversLeaderboardDto, DriverLeaderboardItemDto } from '../dtos';
import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
export class DriverLeaderboardViewModel implements DriversLeaderboardDto {
export class DriverLeaderboardViewModel {
drivers: DriverLeaderboardItemViewModel[];
constructor(dto: DriversLeaderboardDto & { drivers: DriverLeaderboardItemDto[] }, previousDrivers?: DriverLeaderboardItemDto[]) {
constructor(dto: { drivers: DriverLeaderboardItemDTO[] }, previousDrivers?: DriverLeaderboardItemDTO[]) {
this.drivers = dto.drivers.map((driver, index) => {
const previous = previousDrivers?.find(p => p.id === driver.id);
return new DriverLeaderboardItemViewModel(driver, index + 1, previous?.rating);

View File

@@ -1,11 +1,11 @@
import { DriverRegistrationStatusDto } from '../dtos';
import { DriverRegistrationStatusDTO } from '../types/generated/DriverRegistrationStatusDTO';
export class DriverRegistrationStatusViewModel implements DriverRegistrationStatusDto {
export class DriverRegistrationStatusViewModel implements DriverRegistrationStatusDTO {
isRegistered: boolean;
raceId: string;
driverId: string;
constructor(dto: DriverRegistrationStatusDto) {
constructor(dto: DriverRegistrationStatusDTO) {
Object.assign(this, dto);
}

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { DriverViewModel } from './DriverViewModel';
describe('DriverViewModel', () => {
it('should create instance with all properties', () => {
const dto = {
id: 'driver-123',
name: 'John Doe',
avatarUrl: 'https://example.com/avatar.jpg',
iracingId: 'iracing-456',
rating: 1500,
};
const viewModel = new DriverViewModel(dto);
expect(viewModel.id).toBe('driver-123');
expect(viewModel.name).toBe('John Doe');
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
expect(viewModel.iracingId).toBe('iracing-456');
expect(viewModel.rating).toBe(1500);
});
it('should create instance with only required properties', () => {
const dto = {
id: 'driver-123',
name: 'John Doe',
};
const viewModel = new DriverViewModel(dto);
expect(viewModel.id).toBe('driver-123');
expect(viewModel.name).toBe('John Doe');
expect(viewModel.avatarUrl).toBeUndefined();
expect(viewModel.iracingId).toBeUndefined();
expect(viewModel.rating).toBeUndefined();
});
it('should return true for hasIracingId when iracingId exists', () => {
const viewModel = new DriverViewModel({
id: 'driver-123',
name: 'John Doe',
iracingId: 'iracing-456',
});
expect(viewModel.hasIracingId).toBe(true);
});
it('should return false for hasIracingId when iracingId is undefined', () => {
const viewModel = new DriverViewModel({
id: 'driver-123',
name: 'John Doe',
});
expect(viewModel.hasIracingId).toBe(false);
});
it('should return false for hasIracingId when iracingId is empty string', () => {
const viewModel = new DriverViewModel({
id: 'driver-123',
name: 'John Doe',
iracingId: '',
});
expect(viewModel.hasIracingId).toBe(false);
});
it('should format rating correctly when rating exists', () => {
const viewModel = new DriverViewModel({
id: 'driver-123',
name: 'John Doe',
rating: 1547.89,
});
expect(viewModel.formattedRating).toBe('1548');
});
it('should return "Unrated" when rating is undefined', () => {
const viewModel = new DriverViewModel({
id: 'driver-123',
name: 'John Doe',
});
expect(viewModel.formattedRating).toBe('Unrated');
});
it('should handle zero rating', () => {
const viewModel = new DriverViewModel({
id: 'driver-123',
name: 'John Doe',
rating: 0,
});
expect(viewModel.formattedRating).toBe('Unrated');
});
it('should round rating to nearest integer', () => {
const viewModel1 = new DriverViewModel({
id: 'driver-123',
name: 'John Doe',
rating: 1500.4,
});
const viewModel2 = new DriverViewModel({
id: 'driver-123',
name: 'John Doe',
rating: 1500.6,
});
expect(viewModel1.formattedRating).toBe('1500');
expect(viewModel2.formattedRating).toBe('1501');
});
});

View File

@@ -1,11 +1,37 @@
/**
* Driver view model
* UI representation of a driver
*
* Note: No matching generated DTO available yet
*/
export interface DriverViewModel {
export class DriverViewModel {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
constructor(dto: {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
}) {
this.id = dto.id;
this.name = dto.name;
if (dto.avatarUrl !== undefined) this.avatarUrl = dto.avatarUrl;
if (dto.iracingId !== undefined) this.iracingId = dto.iracingId;
if (dto.rating !== undefined) this.rating = dto.rating;
}
/** UI-specific: Whether driver has an iRacing ID */
get hasIracingId(): boolean {
return !!this.iracingId;
}
/** UI-specific: Formatted rating */
get formattedRating(): string {
return this.rating ? this.rating.toFixed(0) : 'Unrated';
}
}

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { LeagueAdminViewModel } from './LeagueAdminViewModel';
import type { LeagueMemberViewModel } from './LeagueMemberViewModel';
import type { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel';
describe('LeagueAdminViewModel', () => {
it('should create instance with all properties', () => {
const members: LeagueMemberViewModel[] = [];
const joinRequests: LeagueJoinRequestViewModel[] = [];
const dto = {
config: { name: 'Test League' },
members,
joinRequests,
};
const viewModel = new LeagueAdminViewModel(dto);
expect(viewModel.config).toEqual({ name: 'Test League' });
expect(viewModel.members).toBe(members);
expect(viewModel.joinRequests).toBe(joinRequests);
});
it('should return correct pending requests count when empty', () => {
const viewModel = new LeagueAdminViewModel({
config: {},
members: [],
joinRequests: [],
});
expect(viewModel.pendingRequestsCount).toBe(0);
});
it('should return correct pending requests count with requests', () => {
const joinRequests = [
{} as LeagueJoinRequestViewModel,
{} as LeagueJoinRequestViewModel,
{} as LeagueJoinRequestViewModel,
];
const viewModel = new LeagueAdminViewModel({
config: {},
members: [],
joinRequests,
});
expect(viewModel.pendingRequestsCount).toBe(3);
});
it('should return false for hasPendingRequests when empty', () => {
const viewModel = new LeagueAdminViewModel({
config: {},
members: [],
joinRequests: [],
});
expect(viewModel.hasPendingRequests).toBe(false);
});
it('should return true for hasPendingRequests when requests exist', () => {
const viewModel = new LeagueAdminViewModel({
config: {},
members: [],
joinRequests: [{} as LeagueJoinRequestViewModel],
});
expect(viewModel.hasPendingRequests).toBe(true);
});
});

View File

@@ -1,16 +1,32 @@
import type { LeagueAdminDto } from '../dtos';
import type { LeagueMemberViewModel, LeagueJoinRequestViewModel } from './';
import type { LeagueMemberViewModel } from './LeagueMemberViewModel';
import type { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel';
/**
* League admin view model
* Transform from DTO to ViewModel with UI fields
*/
export interface LeagueAdminViewModel {
config: LeagueAdminDto['config'];
export class LeagueAdminViewModel {
config: any;
members: LeagueMemberViewModel[];
joinRequests: LeagueJoinRequestViewModel[];
// Total pending requests count
pendingRequestsCount: number;
// Whether there are any pending requests
hasPendingRequests: boolean;
constructor(dto: {
config: any;
members: LeagueMemberViewModel[];
joinRequests: LeagueJoinRequestViewModel[];
}) {
this.config = dto.config;
this.members = dto.members;
this.joinRequests = dto.joinRequests;
}
/** UI-specific: Total pending requests count */
get pendingRequestsCount(): number {
return this.joinRequests.length;
}
/** UI-specific: Whether there are any pending requests */
get hasPendingRequests(): boolean {
return this.joinRequests.length > 0;
}
}

View File

@@ -1,14 +1,39 @@
import type { LeagueJoinRequestDto } from '../dtos';
import type { LeagueJoinRequestDTO } from '../types/generated/LeagueJoinRequestDTO';
/**
* League join request view model
* Transform from DTO to ViewModel with UI fields
*/
export interface LeagueJoinRequestViewModel extends LeagueJoinRequestDto {
// Formatted request date
formattedRequestedAt: string;
// Whether the request can be approved by current user
canApprove: boolean;
// Whether the request can be rejected by current user
canReject: boolean;
export class LeagueJoinRequestViewModel implements LeagueJoinRequestDTO {
id: string;
leagueId: string;
driverId: string;
requestedAt: string;
private currentUserId: string;
private isAdmin: boolean;
constructor(dto: LeagueJoinRequestDTO, currentUserId: string, isAdmin: boolean) {
this.id = dto.id;
this.leagueId = dto.leagueId;
this.driverId = dto.driverId;
this.requestedAt = dto.requestedAt;
this.currentUserId = currentUserId;
this.isAdmin = isAdmin;
}
/** UI-specific: Formatted request date */
get formattedRequestedAt(): string {
return new Date(this.requestedAt).toLocaleString();
}
/** UI-specific: Whether the request can be approved by current user */
get canApprove(): boolean {
return this.isAdmin;
}
/** UI-specific: Whether the request can be rejected by current user */
get canReject(): boolean {
return this.isAdmin;
}
}

View File

@@ -1,18 +1,21 @@
import { LeagueMemberDto, DriverDto } from '../dtos';
import { LeagueMemberDTO } from '../types/generated/LeagueMemberDTO';
export class LeagueMemberViewModel implements LeagueMemberDto {
export class LeagueMemberViewModel implements LeagueMemberDTO {
driverId: string;
driver?: DriverDto;
role: string;
joinedAt: string;
private currentUserId: string;
constructor(dto: LeagueMemberDto, currentUserId: string) {
Object.assign(this, dto);
constructor(dto: LeagueMemberDTO, currentUserId: string) {
this.driverId = dto.driverId;
this.currentUserId = currentUserId;
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
driver?: any;
role: string = 'member';
joinedAt: string = new Date().toISOString();
/** UI-specific: Formatted join date */
get formattedJoinedAt(): string {
return new Date(this.joinedAt).toLocaleDateString();

View File

@@ -0,0 +1,179 @@
import { describe, it, expect } from 'vitest';
import { LeagueStandingsViewModel } from './LeagueStandingsViewModel';
import type { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO';
describe('LeagueStandingsViewModel', () => {
it('should create instance with standings', () => {
const standings: LeagueStandingDTO[] = [
{
driverId: 'driver-1',
position: 1,
points: 100,
wins: 3,
podiums: 5,
races: 8,
},
{
driverId: 'driver-2',
position: 2,
points: 85,
wins: 2,
podiums: 4,
races: 8,
},
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
'driver-1'
);
expect(viewModel.standings).toHaveLength(2);
expect(viewModel.standings[0].driverId).toBe('driver-1');
expect(viewModel.standings[1].driverId).toBe('driver-2');
});
it('should pass leader points to first entry', () => {
const standings: LeagueStandingDTO[] = [
{
driverId: 'driver-1',
position: 1,
points: 100,
wins: 3,
podiums: 5,
races: 8,
},
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
'driver-1'
);
expect(viewModel.standings[0].pointsGapToLeader).toBe(0);
});
it('should calculate points gaps correctly', () => {
const standings: LeagueStandingDTO[] = [
{
driverId: 'driver-1',
position: 1,
points: 100,
wins: 3,
podiums: 5,
races: 8,
},
{
driverId: 'driver-2',
position: 2,
points: 85,
wins: 2,
podiums: 4,
races: 8,
},
{
driverId: 'driver-3',
position: 3,
points: 70,
wins: 1,
podiums: 3,
races: 8,
},
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
'driver-2'
);
expect(viewModel.standings[1].pointsGapToLeader).toBe(-15);
expect(viewModel.standings[1].pointsGapToNext).toBe(15);
});
it('should identify current user', () => {
const standings: LeagueStandingDTO[] = [
{
driverId: 'driver-1',
position: 1,
points: 100,
wins: 3,
podiums: 5,
races: 8,
},
{
driverId: 'driver-2',
position: 2,
points: 85,
wins: 2,
podiums: 4,
races: 8,
},
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
'driver-2'
);
expect(viewModel.standings[0].isCurrentUser).toBe(false);
expect(viewModel.standings[1].isCurrentUser).toBe(true);
});
it('should handle empty standings', () => {
const viewModel = new LeagueStandingsViewModel(
{ standings: [] },
'driver-1'
);
expect(viewModel.standings).toHaveLength(0);
});
it('should track position changes when previousStandings provided', () => {
const standings: LeagueStandingDTO[] = [
{
driverId: 'driver-1',
position: 1,
points: 100,
wins: 3,
podiums: 5,
races: 8,
},
{
driverId: 'driver-2',
position: 2,
points: 85,
wins: 2,
podiums: 4,
races: 8,
},
];
const previousStandings: LeagueStandingDTO[] = [
{
driverId: 'driver-1',
position: 2,
points: 80,
wins: 2,
podiums: 4,
races: 7,
},
{
driverId: 'driver-2',
position: 1,
points: 90,
wins: 3,
podiums: 5,
races: 7,
},
];
const viewModel = new LeagueStandingsViewModel(
{ standings },
'driver-1',
previousStandings
);
expect(viewModel.standings[0].trend).toBe('up');
expect(viewModel.standings[1].trend).toBe('down');
});
});

View File

@@ -1,19 +1,20 @@
import { LeagueStandingsDto, StandingEntryDto, DriverDto, LeagueMembership } from '../dtos';
import { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO';
import { StandingEntryViewModel } from './StandingEntryViewModel';
export class LeagueStandingsViewModel implements LeagueStandingsDto {
export class LeagueStandingsViewModel {
standings: StandingEntryViewModel[];
drivers: DriverDto[];
memberships: LeagueMembership[];
constructor(dto: LeagueStandingsDto & { standings: StandingEntryDto[] }, currentUserId: string, previousStandings?: StandingEntryDto[]) {
constructor(dto: { standings: LeagueStandingDTO[] }, currentUserId: string, previousStandings?: LeagueStandingDTO[]) {
const leaderPoints = dto.standings[0]?.points || 0;
this.standings = dto.standings.map((entry, index) => {
const nextPoints = dto.standings[index + 1]?.points || entry.points;
const previousPosition = previousStandings?.find(p => p.driverId === entry.driverId)?.position;
return new StandingEntryViewModel(entry, leaderPoints, nextPoints, currentUserId, previousPosition);
});
this.drivers = dto.drivers;
this.memberships = dto.memberships;
}
// Note: The generated DTO doesn't have these fields
// These will need to be added when the OpenAPI spec is updated
drivers: any[] = [];
memberships: any[] = [];
}

View File

@@ -1,23 +1,27 @@
import { LeagueSummaryDto } from '../dtos';
import { LeagueSummaryDTO } from '../types/generated/LeagueSummaryDTO';
export class LeagueSummaryViewModel implements LeagueSummaryDto {
export class LeagueSummaryViewModel implements LeagueSummaryDTO {
id: string;
name: string;
constructor(dto: LeagueSummaryDTO) {
this.id = dto.id;
this.name = dto.name;
}
// Note: The generated DTO only has id and name
// These fields will need to be added when the OpenAPI spec is updated
description?: string;
logoUrl?: string;
coverImage?: string;
memberCount: number;
maxMembers: number;
isPublic: boolean;
ownerId: string;
memberCount: number = 0;
maxMembers: number = 0;
isPublic: boolean = false;
ownerId: string = '';
ownerName?: string;
scoringType?: string;
status?: string;
constructor(dto: LeagueSummaryDto) {
Object.assign(this, dto);
}
/** UI-specific: Formatted capacity display */
get formattedCapacity(): string {
return `${this.memberCount}/${this.maxMembers}`;

View File

@@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest';
import { MediaViewModel } from './MediaViewModel';
describe('MediaViewModel', () => {
it('should create instance with all properties', () => {
const dto = {
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image' as const,
category: 'avatar' as const,
uploadedAt: new Date('2023-01-15'),
size: 2048000,
};
const viewModel = new MediaViewModel(dto);
expect(viewModel.id).toBe('media-123');
expect(viewModel.url).toBe('https://example.com/image.jpg');
expect(viewModel.type).toBe('image');
expect(viewModel.category).toBe('avatar');
expect(viewModel.uploadedAt).toEqual(new Date('2023-01-15'));
expect(viewModel.size).toBe(2048000);
});
it('should create instance without optional properties', () => {
const dto = {
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image' as const,
uploadedAt: new Date('2023-01-15'),
};
const viewModel = new MediaViewModel(dto);
expect(viewModel.category).toBeUndefined();
expect(viewModel.size).toBeUndefined();
});
it('should return "Unknown" for formattedSize when size is undefined', () => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: new Date(),
});
expect(viewModel.formattedSize).toBe('Unknown');
});
it('should format size in KB when less than 1 MB', () => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: new Date(),
size: 512000, // 500 KB
});
expect(viewModel.formattedSize).toBe('500.00 KB');
});
it('should format size in MB when 1 MB or larger', () => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: new Date(),
size: 2048000, // 2 MB
});
expect(viewModel.formattedSize).toBe('2.00 MB');
});
it('should handle very small file sizes', () => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: new Date(),
size: 1024, // 1 KB
});
expect(viewModel.formattedSize).toBe('1.00 KB');
});
it('should handle very large file sizes', () => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/video.mp4',
type: 'video',
uploadedAt: new Date(),
size: 104857600, // 100 MB
});
expect(viewModel.formattedSize).toBe('100.00 MB');
});
it('should support all media types', () => {
const imageVm = new MediaViewModel({
id: '1',
url: 'image.jpg',
type: 'image',
uploadedAt: new Date(),
});
const videoVm = new MediaViewModel({
id: '2',
url: 'video.mp4',
type: 'video',
uploadedAt: new Date(),
});
const docVm = new MediaViewModel({
id: '3',
url: 'doc.pdf',
type: 'document',
uploadedAt: new Date(),
});
expect(imageVm.type).toBe('image');
expect(videoVm.type).toBe('video');
expect(docVm.type).toBe('document');
});
it('should support all media categories', () => {
const categories = ['avatar', 'team-logo', 'league-cover', 'race-result'] as const;
categories.forEach(category => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
category,
uploadedAt: new Date(),
});
expect(viewModel.category).toBe(category);
});
});
});

View File

@@ -1,13 +1,41 @@
/**
* Media View Model
*
* Represents media information for the UI layer
*/
export interface MediaViewModel {
// Note: No generated DTO available for Media yet
interface MediaDTO {
id: string;
url: string;
type: 'image' | 'video' | 'document';
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
uploadedAt: Date;
size?: number;
}
/**
* Media View Model
*
* Represents media information for the UI layer
*/
export class MediaViewModel {
id: string;
url: string;
type: 'image' | 'video' | 'document';
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
uploadedAt: Date;
size?: number;
constructor(dto: MediaDTO) {
this.id = dto.id;
this.url = dto.url;
this.type = dto.type;
this.uploadedAt = dto.uploadedAt;
if (dto.category !== undefined) this.category = dto.category;
if (dto.size !== undefined) this.size = dto.size;
}
/** UI-specific: Formatted file size */
get formattedSize(): string {
if (!this.size) return 'Unknown';
const kb = this.size / 1024;
if (kb < 1024) return `${kb.toFixed(2)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(2)} MB`;
}
}

View File

@@ -1,15 +1,20 @@
import { MembershipFeeDto } from '../dtos';
import { MembershipFeeDto } from '../types/generated/MembershipFeeDto';
export class MembershipFeeViewModel implements MembershipFeeDto {
id: string;
leagueId: string;
amount: number;
currency: string;
period: string;
constructor(dto: MembershipFeeDto) {
Object.assign(this, dto);
this.id = dto.id;
this.leagueId = dto.leagueId;
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
amount: number = 0;
currency: string = 'USD';
period: string = 'monthly';
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.currency} ${this.amount.toFixed(2)}`;

View File

@@ -1,13 +1,13 @@
import { PaymentDto } from '../dtos';
import { PaymentDTO } from '../types/generated/PaymentDto';
export class PaymentViewModel implements PaymentDto {
export class PaymentViewModel implements PaymentDTO {
id: string;
amount: number;
currency: string;
status: string;
createdAt: string;
constructor(dto: PaymentDto) {
constructor(dto: PaymentDTO) {
Object.assign(this, dto);
}

View File

@@ -1,16 +1,26 @@
import { PrizeDto } from '../dtos';
import { PrizeDto } from '../types/generated/PrizeDto';
export class PrizeViewModel implements PrizeDto {
id: string;
leagueId: string;
seasonId: string;
position: number;
name: string;
amount: number;
currency: string;
position?: number;
constructor(dto: PrizeDto) {
Object.assign(this, dto);
this.id = dto.id;
this.leagueId = dto.leagueId;
this.seasonId = dto.seasonId;
this.position = dto.position;
this.name = dto.name;
this.amount = dto.amount;
}
// Note: The generated DTO doesn't have currency
// This will need to be added when the OpenAPI spec is updated
currency: string = 'USD';
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.currency} ${this.amount.toFixed(2)}`;
@@ -18,7 +28,6 @@ export class PrizeViewModel implements PrizeDto {
/** UI-specific: Position display */
get positionDisplay(): string {
if (!this.position) return 'Special';
switch (this.position) {
case 1: return '1st Place';
case 2: return '2nd Place';

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest';
import { ProtestViewModel } from './ProtestViewModel';
import type { ProtestDTO } from '../types/generated/ProtestDTO';
describe('ProtestViewModel', () => {
it('should create instance with all properties', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Unsafe driving in turn 3',
status: 'pending',
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
expect(viewModel.id).toBe('protest-123');
expect(viewModel.raceId).toBe('race-456');
expect(viewModel.complainantId).toBe('driver-111');
expect(viewModel.defendantId).toBe('driver-222');
expect(viewModel.description).toBe('Unsafe driving in turn 3');
expect(viewModel.status).toBe('pending');
expect(viewModel.createdAt).toBe('2023-01-15T10:30:00Z');
});
it('should format createdAt as locale string', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status: 'pending',
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
const formatted = viewModel.formattedCreatedAt;
expect(formatted).toContain('2023');
expect(formatted).toContain('1/15');
});
it('should capitalize status for display', () => {
const statuses = ['pending', 'approved', 'rejected', 'reviewing'];
statuses.forEach(status => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status,
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
const expected = status.charAt(0).toUpperCase() + status.slice(1);
expect(viewModel.statusDisplay).toBe(expected);
});
});
it('should handle already capitalized status', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status: 'Pending',
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
expect(viewModel.statusDisplay).toBe('Pending');
});
it('should handle single character status', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status: 'p',
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
expect(viewModel.statusDisplay).toBe('P');
});
it('should handle empty status', () => {
const dto: ProtestDTO = {
id: 'protest-123',
raceId: 'race-456',
complainantId: 'driver-111',
defendantId: 'driver-222',
description: 'Test',
status: '',
createdAt: '2023-01-15T10:30:00Z',
};
const viewModel = new ProtestViewModel(dto);
expect(viewModel.statusDisplay).toBe('');
});
});

View File

@@ -1,8 +1,10 @@
import { ProtestDTO } from '../types/generated/ProtestDTO';
/**
* Protest view model
* Represents a race protest
*/
export interface ProtestViewModel {
export class ProtestViewModel implements ProtestDTO {
id: string;
raceId: string;
complainantId: string;
@@ -10,4 +12,24 @@ export interface ProtestViewModel {
description: string;
status: string;
createdAt: string;
constructor(dto: ProtestDTO) {
this.id = dto.id;
this.raceId = dto.raceId;
this.complainantId = dto.complainantId;
this.defendantId = dto.defendantId;
this.description = dto.description;
this.status = dto.status;
this.createdAt = dto.createdAt;
}
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return new Date(this.createdAt).toLocaleString();
}
/** UI-specific: Status display */
get statusDisplay(): string {
return this.status.charAt(0).toUpperCase() + this.status.slice(1);
}
}

View File

@@ -0,0 +1,279 @@
import { describe, it, expect } from 'vitest';
import { RaceDetailViewModel } from './RaceDetailViewModel';
import type { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO';
import type { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO';
import type { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO';
import type { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO';
import type { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO';
describe('RaceDetailViewModel', () => {
const createMockRace = (overrides?: Partial<RaceDetailRaceDTO>): RaceDetailRaceDTO => ({
id: 'race-123',
title: 'Test Race',
scheduledAt: '2023-12-31T20:00:00Z',
status: 'upcoming',
...overrides,
});
const createMockLeague = (): RaceDetailLeagueDTO => ({
id: 'league-123',
name: 'Test League',
});
const createMockRegistration = (
overrides?: Partial<RaceDetailRegistrationDTO>
): RaceDetailRegistrationDTO => ({
isRegistered: false,
canRegister: true,
...overrides,
});
it('should create instance with all properties', () => {
const race = createMockRace();
const league = createMockLeague();
const entries: RaceDetailEntryDTO[] = [];
const registration = createMockRegistration();
const userResult: RaceDetailUserResultDTO | null = null;
const viewModel = new RaceDetailViewModel({
race,
league,
entryList: entries,
registration,
userResult,
});
expect(viewModel.race).toBe(race);
expect(viewModel.league).toBe(league);
expect(viewModel.entryList).toBe(entries);
expect(viewModel.registration).toBe(registration);
expect(viewModel.userResult).toBe(userResult);
});
it('should handle null race and league', () => {
const viewModel = new RaceDetailViewModel({
race: null,
league: null,
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
expect(viewModel.race).toBeNull();
expect(viewModel.league).toBeNull();
});
it('should return correct isRegistered value', () => {
const registeredVm = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration({ isRegistered: true }),
userResult: null,
});
const notRegisteredVm = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration({ isRegistered: false }),
userResult: null,
});
expect(registeredVm.isRegistered).toBe(true);
expect(notRegisteredVm.isRegistered).toBe(false);
});
it('should return correct canRegister value', () => {
const canRegisterVm = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration({ canRegister: true }),
userResult: null,
});
const cannotRegisterVm = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration({ canRegister: false }),
userResult: null,
});
expect(canRegisterVm.canRegister).toBe(true);
expect(cannotRegisterVm.canRegister).toBe(false);
});
it('should format race status correctly', () => {
const upcomingVm = new RaceDetailViewModel({
race: createMockRace({ status: 'upcoming' }),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
const liveVm = new RaceDetailViewModel({
race: createMockRace({ status: 'live' }),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
const finishedVm = new RaceDetailViewModel({
race: createMockRace({ status: 'finished' }),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
expect(upcomingVm.raceStatusDisplay).toBe('Upcoming');
expect(liveVm.raceStatusDisplay).toBe('Live');
expect(finishedVm.raceStatusDisplay).toBe('Finished');
});
it('should return Unknown for status when race is null', () => {
const viewModel = new RaceDetailViewModel({
race: null,
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
expect(viewModel.raceStatusDisplay).toBe('Unknown');
});
it('should format scheduled time correctly', () => {
const viewModel = new RaceDetailViewModel({
race: createMockRace({ scheduledAt: '2023-12-31T20:00:00Z' }),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
const formatted = viewModel.formattedScheduledTime;
expect(formatted).toContain('2023');
expect(formatted).toContain('12/31');
});
it('should return empty string for formatted time when race is null', () => {
const viewModel = new RaceDetailViewModel({
race: null,
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
expect(viewModel.formattedScheduledTime).toBe('');
});
it('should return correct entry count', () => {
const entries: RaceDetailEntryDTO[] = [
{ driverId: 'driver-1', carId: 'car-1' },
{ driverId: 'driver-2', carId: 'car-2' },
{ driverId: 'driver-3', carId: 'car-3' },
] as RaceDetailEntryDTO[];
const viewModel = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: entries,
registration: createMockRegistration(),
userResult: null,
});
expect(viewModel.entryCount).toBe(3);
});
it('should return true for hasResults when userResult exists', () => {
const viewModel = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: { position: 1, lapTime: 90.5 } as RaceDetailUserResultDTO,
});
expect(viewModel.hasResults).toBe(true);
});
it('should return false for hasResults when userResult is null', () => {
const viewModel = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
expect(viewModel.hasResults).toBe(false);
});
it('should return correct registration status message when registered', () => {
const viewModel = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration({ isRegistered: true }),
userResult: null,
});
expect(viewModel.registrationStatusMessage).toBe('You are registered for this race');
});
it('should return correct registration status message when can register', () => {
const viewModel = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration({ isRegistered: false, canRegister: true }),
userResult: null,
});
expect(viewModel.registrationStatusMessage).toBe('You can register for this race');
});
it('should return correct registration status message when cannot register', () => {
const viewModel = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration({ isRegistered: false, canRegister: false }),
userResult: null,
});
expect(viewModel.registrationStatusMessage).toBe('Registration not available');
});
it('should handle error property', () => {
const viewModel = new RaceDetailViewModel({
race: createMockRace(),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
error: 'Failed to load race details',
});
expect(viewModel.error).toBe('Failed to load race details');
});
it('should handle custom race status', () => {
const viewModel = new RaceDetailViewModel({
race: createMockRace({ status: 'cancelled' }),
league: createMockLeague(),
entryList: [],
registration: createMockRegistration(),
userResult: null,
});
expect(viewModel.raceStatusDisplay).toBe('cancelled');
});
});

View File

@@ -1,15 +1,31 @@
import { RaceDetailDto, RaceDetailRaceDto, RaceDetailLeagueDto, RaceDetailEntryDto, RaceDetailRegistrationDto, RaceDetailUserResultDto } from '../dtos';
import { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO';
import { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO';
import { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO';
import { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO';
import { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO';
export class RaceDetailViewModel implements RaceDetailDto {
race: RaceDetailRaceDto | null;
league: RaceDetailLeagueDto | null;
entryList: RaceDetailEntryDto[];
registration: RaceDetailRegistrationDto;
userResult: RaceDetailUserResultDto | null;
export class RaceDetailViewModel {
race: RaceDetailRaceDTO | null;
league: RaceDetailLeagueDTO | null;
entryList: RaceDetailEntryDTO[];
registration: RaceDetailRegistrationDTO;
userResult: RaceDetailUserResultDTO | null;
error?: string;
constructor(dto: RaceDetailDto) {
Object.assign(this, dto);
constructor(dto: {
race: RaceDetailRaceDTO | null;
league: RaceDetailLeagueDTO | null;
entryList: RaceDetailEntryDTO[];
registration: RaceDetailRegistrationDTO;
userResult: RaceDetailUserResultDTO | null;
error?: string;
}) {
this.race = dto.race;
this.league = dto.league;
this.entryList = dto.entryList;
this.registration = dto.registration;
this.userResult = dto.userResult;
this.error = dto.error;
}
/** UI-specific: Whether user is registered */
@@ -35,7 +51,7 @@ export class RaceDetailViewModel implements RaceDetailDto {
/** UI-specific: Formatted scheduled time */
get formattedScheduledTime(): string {
return this.race ? new Date(this.race.scheduledTime).toLocaleString() : '';
return this.race ? new Date(this.race.scheduledAt).toLocaleString() : '';
}
/** UI-specific: Entry list count */

View File

@@ -1,6 +1,15 @@
import { RaceListItemDto } from '../dtos';
// Note: No generated DTO available for RaceListItem yet
interface RaceListItemDTO {
id: string;
name: string;
leagueId: string;
leagueName: string;
scheduledTime: string;
status: string;
trackName?: string;
}
export class RaceListItemViewModel implements RaceListItemDto {
export class RaceListItemViewModel {
id: string;
name: string;
leagueId: string;
@@ -9,8 +18,14 @@ export class RaceListItemViewModel implements RaceListItemDto {
status: string;
trackName?: string;
constructor(dto: RaceListItemDto) {
Object.assign(this, dto);
constructor(dto: RaceListItemDTO) {
this.id = dto.id;
this.name = dto.name;
this.leagueId = dto.leagueId;
this.leagueName = dto.leagueName;
this.scheduledTime = dto.scheduledTime;
this.status = dto.status;
if (dto.trackName !== undefined) this.trackName = dto.trackName;
}
/** UI-specific: Formatted scheduled time */

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { RaceResultViewModel } from './RaceResultViewModel';
import { RaceResultDTO } from '../types/generated/RaceResultDTO';
describe('RaceResultViewModel', () => {
const mockDTO: RaceResultDTO = {
driverId: '1',
driverName: 'Test Driver',
avatarUrl: 'http://example.com/avatar.jpg',
position: 3,
startPosition: 5,
incidents: 2,
fastestLap: 90.5,
positionChange: 2,
isPodium: true,
isClean: false
};
it('should create instance from DTO', () => {
const viewModel = new RaceResultViewModel(mockDTO);
expect(viewModel.driverId).toBe('1');
expect(viewModel.position).toBe(3);
});
it('should show positive position change', () => {
const viewModel = new RaceResultViewModel(mockDTO);
expect(viewModel.positionChangeDisplay).toBe('+2');
expect(viewModel.positionChangeColor).toBe('green');
});
it('should show negative position change', () => {
const dto = { ...mockDTO, positionChange: -3 };
const viewModel = new RaceResultViewModel(dto);
expect(viewModel.positionChangeDisplay).toBe('-3');
expect(viewModel.positionChangeColor).toBe('red');
});
it('should detect winner', () => {
const dto = { ...mockDTO, position: 1 };
const viewModel = new RaceResultViewModel(dto);
expect(viewModel.isWinner).toBe(true);
});
it('should detect fastest lap', () => {
const viewModel = new RaceResultViewModel(mockDTO);
expect(viewModel.hasFastestLap).toBe(true);
});
it('should format lap time correctly', () => {
const viewModel = new RaceResultViewModel(mockDTO);
expect(viewModel.lapTimeFormatted).toBe('1:30.500');
});
it('should show correct incidents badge color', () => {
const cleanDTO = { ...mockDTO, incidents: 0 };
const viewModel = new RaceResultViewModel(cleanDTO);
expect(viewModel.incidentsBadgeColor).toBe('green');
});
it('should handle no lap time', () => {
const dto = { ...mockDTO, fastestLap: 0 };
const viewModel = new RaceResultViewModel(dto);
expect(viewModel.lapTimeFormatted).toBe('--:--.---');
});
});

View File

@@ -1,8 +1,6 @@
import { RaceResultDto } from '../dtos';
import { RaceResultDTO } from '../types/generated/RaceResultDTO';
export class RaceResultViewModel implements RaceResultDto {
id: string;
raceId: string;
export class RaceResultViewModel implements RaceResultDTO {
driverId: string;
driverName: string;
avatarUrl: string;
@@ -14,7 +12,7 @@ export class RaceResultViewModel implements RaceResultDto {
isPodium: boolean;
isClean: boolean;
constructor(dto: RaceResultDto) {
constructor(dto: RaceResultDTO) {
Object.assign(this, dto);
}
@@ -63,8 +61,8 @@ export class RaceResultViewModel implements RaceResultDto {
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
}
/** Compatibility with old DTO interface */
getPositionChange(): number {
return this.positionChange;
}
// Note: The generated DTO doesn't have id or raceId
// These will need to be added when the OpenAPI spec is updated
id: string = '';
raceId: string = '';
}

View File

@@ -1,34 +1,35 @@
import { RaceResultsDetailDto, RaceResultDto } from '../dtos';
import { RaceResultsDetailDTO } from '../types/generated/RaceResultsDetailDTO';
import { RaceResultDTO } from '../types/generated/RaceResultDTO';
import { RaceResultViewModel } from './RaceResultViewModel';
export class RaceResultsDetailViewModel implements RaceResultsDetailDto {
export class RaceResultsDetailViewModel implements RaceResultsDetailDTO {
raceId: string;
track: string;
results: RaceResultViewModel[];
league?: { id: string; name: string };
race?: { id: string; track: string; scheduledAt: string };
drivers: { id: string; name: string }[];
pointsSystem: Record<number, number>;
fastestLapTime: number;
penalties: { driverId: string; type: string; value?: number }[];
currentDriverId: string;
private currentUserId: string;
constructor(dto: RaceResultsDetailDto & { results: RaceResultDto[] }, currentUserId: string) {
constructor(dto: RaceResultsDetailDTO & { results?: RaceResultDTO[] }, currentUserId: string) {
this.raceId = dto.raceId;
this.track = dto.track;
this.results = dto.results.map(r => new RaceResultViewModel({ ...r, raceId: dto.raceId }));
this.league = dto.league;
this.race = dto.race;
this.drivers = dto.drivers;
this.pointsSystem = dto.pointsSystem;
this.fastestLapTime = dto.fastestLapTime;
this.penalties = dto.penalties;
this.currentDriverId = dto.currentDriverId;
this.currentUserId = currentUserId;
// Map results if provided
if (dto.results) {
this.results = dto.results.map(r => new RaceResultViewModel(r));
}
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
results: RaceResultViewModel[] = [];
league?: { id: string; name: string };
race?: { id: string; track: string; scheduledAt: string };
drivers: { id: string; name: string }[] = [];
pointsSystem: Record<number, number> = {};
fastestLapTime: number = 0;
penalties: { driverId: string; type: string; value?: number }[] = [];
currentDriverId: string = '';
/** UI-specific: Results sorted by position */
get resultsByPosition(): RaceResultViewModel[] {
return [...this.results].sort((a, b) => a.position - b.position);

View File

@@ -0,0 +1,212 @@
import { describe, it, expect } from 'vitest';
import { RaceCardViewModel, RacesPageViewModel } from './RacesPageViewModel';
describe('RaceCardViewModel', () => {
it('should create instance with all properties', () => {
const dto = {
id: 'race-123',
title: 'Season Finale',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
};
const viewModel = new RaceCardViewModel(dto);
expect(viewModel.id).toBe('race-123');
expect(viewModel.title).toBe('Season Finale');
expect(viewModel.scheduledTime).toBe('2023-12-31T20:00:00Z');
expect(viewModel.status).toBe('upcoming');
});
it('should format scheduled time as locale string', () => {
const dto = {
id: 'race-123',
title: 'Test Race',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
};
const viewModel = new RaceCardViewModel(dto);
const formatted = viewModel.formattedScheduledTime;
expect(formatted).toContain('2023');
expect(formatted).toContain('12/31');
});
it('should handle different race statuses', () => {
const statuses = ['upcoming', 'live', 'finished', 'cancelled'];
statuses.forEach(status => {
const dto = {
id: 'race-123',
title: 'Test Race',
scheduledTime: '2023-12-31T20:00:00Z',
status,
};
const viewModel = new RaceCardViewModel(dto);
expect(viewModel.status).toBe(status);
});
});
});
describe('RacesPageViewModel', () => {
it('should create instance with upcoming and completed races', () => {
const dto = {
upcomingRaces: [
{
id: 'race-1',
title: 'Race 1',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
},
{
id: 'race-2',
title: 'Race 2',
scheduledTime: '2024-01-01T20:00:00Z',
status: 'upcoming',
},
],
completedRaces: [
{
id: 'race-3',
title: 'Race 3',
scheduledTime: '2023-12-20T20:00:00Z',
status: 'finished',
},
],
totalCount: 3,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.upcomingRaces).toHaveLength(2);
expect(viewModel.completedRaces).toHaveLength(1);
expect(viewModel.totalCount).toBe(3);
});
it('should convert DTOs to view models', () => {
const dto = {
upcomingRaces: [
{
id: 'race-1',
title: 'Race 1',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
},
],
completedRaces: [],
totalCount: 1,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.upcomingRaces[0]).toBeInstanceOf(RaceCardViewModel);
expect(viewModel.upcomingRaces[0].id).toBe('race-1');
});
it('should return correct upcoming count', () => {
const dto = {
upcomingRaces: [
{
id: 'race-1',
title: 'Race 1',
scheduledTime: '2023-12-31T20:00:00Z',
status: 'upcoming',
},
{
id: 'race-2',
title: 'Race 2',
scheduledTime: '2024-01-01T20:00:00Z',
status: 'upcoming',
},
{
id: 'race-3',
title: 'Race 3',
scheduledTime: '2024-01-02T20:00:00Z',
status: 'upcoming',
},
],
completedRaces: [],
totalCount: 3,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.upcomingCount).toBe(3);
});
it('should return correct completed count', () => {
const dto = {
upcomingRaces: [],
completedRaces: [
{
id: 'race-1',
title: 'Race 1',
scheduledTime: '2023-12-20T20:00:00Z',
status: 'finished',
},
{
id: 'race-2',
title: 'Race 2',
scheduledTime: '2023-12-21T20:00:00Z',
status: 'finished',
},
],
totalCount: 2,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.completedCount).toBe(2);
});
it('should handle empty race lists', () => {
const dto = {
upcomingRaces: [],
completedRaces: [],
totalCount: 0,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.upcomingCount).toBe(0);
expect(viewModel.completedCount).toBe(0);
expect(viewModel.totalCount).toBe(0);
});
it('should handle mixed race lists', () => {
const dto = {
upcomingRaces: [
{
id: 'race-1',
title: 'Upcoming',
scheduledTime: '2024-01-01T20:00:00Z',
status: 'upcoming',
},
],
completedRaces: [
{
id: 'race-2',
title: 'Completed 1',
scheduledTime: '2023-12-20T20:00:00Z',
status: 'finished',
},
{
id: 'race-3',
title: 'Completed 2',
scheduledTime: '2023-12-21T20:00:00Z',
status: 'finished',
},
],
totalCount: 3,
};
const viewModel = new RacesPageViewModel(dto);
expect(viewModel.upcomingCount).toBe(1);
expect(viewModel.completedCount).toBe(2);
expect(viewModel.totalCount).toBe(3);
});
});

View File

@@ -1,12 +1,63 @@
export interface RaceCardViewModel {
// Note: No generated DTO available for RaceCard yet
interface RaceCardDTO {
id: string;
title: string;
scheduledTime: string;
status: string;
}
export interface RacesPageViewModel {
/**
* Race card view model
* Represents a race card in list views
*/
export class RaceCardViewModel {
id: string;
title: string;
scheduledTime: string;
status: string;
constructor(dto: RaceCardDTO) {
this.id = dto.id;
this.title = dto.title;
this.scheduledTime = dto.scheduledTime;
this.status = dto.status;
}
/** UI-specific: Formatted scheduled time */
get formattedScheduledTime(): string {
return new Date(this.scheduledTime).toLocaleString();
}
}
// Note: No generated DTO available for RacesPage yet
interface RacesPageDTO {
upcomingRaces: RaceCardDTO[];
completedRaces: RaceCardDTO[];
totalCount: number;
}
/**
* Races page view model
* Represents the races page data
*/
export class RacesPageViewModel {
upcomingRaces: RaceCardViewModel[];
completedRaces: RaceCardViewModel[];
totalCount: number;
constructor(dto: RacesPageDTO) {
this.upcomingRaces = dto.upcomingRaces.map(r => new RaceCardViewModel(r));
this.completedRaces = dto.completedRaces.map(r => new RaceCardViewModel(r));
this.totalCount = dto.totalCount;
}
/** UI-specific: Total upcoming races */
get upcomingCount(): number {
return this.upcomingRaces.length;
}
/** UI-specific: Total completed races */
get completedCount(): number {
return this.completedRaces.length;
}
}

View File

@@ -1,10 +1,33 @@
// Note: No generated DTO available for RequestAvatarGeneration yet
interface RequestAvatarGenerationDTO {
success: boolean;
avatarUrl?: string;
error?: string;
}
/**
* Request Avatar Generation View Model
*
* Represents the result of an avatar generation request
*/
export interface RequestAvatarGenerationViewModel {
export class RequestAvatarGenerationViewModel {
success: boolean;
avatarUrl?: string;
error?: string;
constructor(dto: RequestAvatarGenerationDTO) {
this.success = dto.success;
if (dto.avatarUrl !== undefined) this.avatarUrl = dto.avatarUrl;
if (dto.error !== undefined) this.error = dto.error;
}
/** UI-specific: Whether generation was successful */
get isSuccessful(): boolean {
return this.success;
}
/** UI-specific: Whether there was an error */
get hasError(): boolean {
return !!this.error;
}
}

View File

@@ -1,19 +1,24 @@
import { SessionDataDto } from '../dtos';
import { AuthenticatedUserDTO } from '../types/generated/AuthenticatedUserDTO';
export class SessionViewModel implements SessionDataDto {
export class SessionViewModel implements AuthenticatedUserDTO {
userId: string;
email: string;
displayName?: string;
driverId?: string;
isAuthenticated: boolean;
displayName: string;
constructor(dto: SessionDataDto) {
Object.assign(this, dto);
constructor(dto: AuthenticatedUserDTO) {
this.userId = dto.userId;
this.email = dto.email;
this.displayName = dto.displayName;
}
// Note: The generated DTO doesn't have these fields
// These will need to be added when the OpenAPI spec is updated
driverId?: string;
isAuthenticated: boolean = true;
/** UI-specific: User greeting */
get greeting(): string {
return `Hello, ${this.displayName || this.email}!`;
return `Hello, ${this.displayName}!`;
}
/** UI-specific: Avatar initials */

View File

@@ -1,21 +1,25 @@
import type { SponsorDashboardDto } from '../dtos';
import type { SponsorDashboardDTO } from '../types/generated/SponsorDashboardDTO';
/**
* Sponsor Dashboard View Model
*
* View model for sponsor dashboard data with UI-specific transformations.
*/
export class SponsorDashboardViewModel implements SponsorDashboardDto {
export class SponsorDashboardViewModel implements SponsorDashboardDTO {
sponsorId: string;
sponsorName: string;
totalSponsorships: number;
activeSponsorships: number;
totalInvestment: number;
constructor(dto: SponsorDashboardDto) {
Object.assign(this, dto);
constructor(dto: SponsorDashboardDTO) {
this.sponsorId = dto.sponsorId;
this.sponsorName = dto.sponsorName;
}
// Note: The generated DTO doesn't include these fields yet
// These will need to be added when the OpenAPI spec is updated
totalSponsorships: number = 0;
activeSponsorships: number = 0;
totalInvestment: number = 0;
/** UI-specific: Formatted total investment */
get formattedTotalInvestment(): string {
return `$${this.totalInvestment.toLocaleString()}`;

View File

@@ -1,4 +1,4 @@
import type { SponsorSponsorshipsDto } from '../dtos';
import type { SponsorSponsorshipsDTO } from '../types/generated/SponsorSponsorshipsDTO';
import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
/**
@@ -6,17 +6,19 @@ import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
*
* View model for sponsor sponsorships data with UI-specific transformations.
*/
export class SponsorSponsorshipsViewModel {
export class SponsorSponsorshipsViewModel implements SponsorSponsorshipsDTO {
sponsorId: string;
sponsorName: string;
sponsorships: SponsorshipDetailViewModel[];
constructor(dto: SponsorSponsorshipsDto) {
constructor(dto: SponsorSponsorshipsDTO) {
this.sponsorId = dto.sponsorId;
this.sponsorName = dto.sponsorName;
this.sponsorships = dto.sponsorships.map(s => new SponsorshipDetailViewModel(s));
}
// Note: The generated DTO doesn't have sponsorships array
// This will need to be added when the OpenAPI spec is updated
sponsorships: SponsorshipDetailViewModel[] = [];
/** UI-specific: Total sponsorships count */
get totalCount(): number {
return this.sponsorships.length;

View File

@@ -1,13 +1,22 @@
import { SponsorDto } from '../dtos';
// Note: No generated DTO available for Sponsor yet
interface SponsorDTO {
id: string;
name: string;
logoUrl?: string;
websiteUrl?: string;
}
export class SponsorViewModel implements SponsorDto {
export class SponsorViewModel {
id: string;
name: string;
logoUrl?: string;
websiteUrl?: string;
constructor(dto: SponsorDto) {
Object.assign(this, dto);
constructor(dto: SponsorDTO) {
this.id = dto.id;
this.name = dto.name;
if (dto.logoUrl !== undefined) this.logoUrl = dto.logoUrl;
if (dto.websiteUrl !== undefined) this.websiteUrl = dto.websiteUrl;
}
/** UI-specific: Display name */

View File

@@ -1,19 +1,27 @@
import { SponsorshipDetailDto } from '../dtos';
import { SponsorshipDetailDTO } from '../types/generated/SponsorshipDetailDTO';
export class SponsorshipDetailViewModel implements SponsorshipDetailDto {
export class SponsorshipDetailViewModel implements SponsorshipDetailDTO {
id: string;
leagueId: string;
leagueName: string;
seasonId: string;
tier: 'main' | 'secondary';
status: string;
amount: number;
currency: string;
seasonName: string;
constructor(dto: SponsorshipDetailDto) {
Object.assign(this, dto);
constructor(dto: SponsorshipDetailDTO) {
this.id = dto.id;
this.leagueId = dto.leagueId;
this.leagueName = dto.leagueName;
this.seasonId = dto.seasonId;
this.seasonName = dto.seasonName;
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
tier: 'main' | 'secondary' = 'secondary';
status: string = 'active';
amount: number = 0;
currency: string = 'USD';
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.currency} ${this.amount.toLocaleString()}`;

View File

@@ -1,4 +1,9 @@
import type { GetEntitySponsorshipPricingResultDto } from '../dtos';
// Note: No generated DTO available for SponsorshipPricing yet
interface SponsorshipPricingDTO {
mainSlotPrice: number;
secondarySlotPrice: number;
currency: string;
}
/**
* Sponsorship Pricing View Model
@@ -10,7 +15,7 @@ export class SponsorshipPricingViewModel {
secondarySlotPrice: number;
currency: string;
constructor(dto: GetEntitySponsorshipPricingResultDto) {
constructor(dto: SponsorshipPricingDTO) {
this.mainSlotPrice = dto.mainSlotPrice;
this.secondarySlotPrice = dto.secondarySlotPrice;
this.currency = dto.currency;

View File

@@ -0,0 +1,184 @@
import { describe, it, expect } from 'vitest';
import { StandingEntryViewModel } from './StandingEntryViewModel';
import type { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO';
describe('StandingEntryViewModel', () => {
const createMockStanding = (overrides?: Partial<LeagueStandingDTO>): LeagueStandingDTO => ({
driverId: 'driver-1',
position: 1,
points: 100,
wins: 3,
podiums: 5,
races: 8,
...overrides,
});
it('should create instance with all properties', () => {
const dto = createMockStanding();
const viewModel = new StandingEntryViewModel(dto, 100, 85, 'driver-1');
expect(viewModel.driverId).toBe('driver-1');
expect(viewModel.position).toBe(1);
expect(viewModel.points).toBe(100);
expect(viewModel.wins).toBe(3);
expect(viewModel.podiums).toBe(5);
expect(viewModel.races).toBe(8);
});
it('should return position as badge string', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 5 }),
100,
85,
'driver-1'
);
expect(viewModel.positionBadge).toBe('5');
});
it('should calculate points gap to leader correctly', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 2, points: 85 }),
100, // leader points
70, // next points
'driver-2'
);
expect(viewModel.pointsGapToLeader).toBe(-15);
});
it('should show zero gap when driver is leader', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 1, points: 100 }),
100, // leader points
85, // next points
'driver-1'
);
expect(viewModel.pointsGapToLeader).toBe(0);
});
it('should calculate points gap to next position correctly', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 2, points: 85 }),
100, // leader points
70, // next points
'driver-2'
);
expect(viewModel.pointsGapToNext).toBe(15);
});
it('should identify current user correctly', () => {
const viewModel1 = new StandingEntryViewModel(
createMockStanding({ driverId: 'driver-1' }),
100,
85,
'driver-1'
);
const viewModel2 = new StandingEntryViewModel(
createMockStanding({ driverId: 'driver-1' }),
100,
85,
'driver-2'
);
expect(viewModel1.isCurrentUser).toBe(true);
expect(viewModel2.isCurrentUser).toBe(false);
});
it('should return "same" trend when no previous position', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 1 }),
100,
85,
'driver-1'
);
expect(viewModel.trend).toBe('same');
});
it('should return "up" trend when position improved', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 1 }),
100,
85,
'driver-1',
3 // previous position was 3rd
);
expect(viewModel.trend).toBe('up');
});
it('should return "down" trend when position worsened', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 5 }),
100,
85,
'driver-1',
2 // previous position was 2nd
);
expect(viewModel.trend).toBe('down');
});
it('should return "same" trend when position unchanged', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 3 }),
100,
85,
'driver-1',
3 // same position
);
expect(viewModel.trend).toBe('same');
});
it('should return correct trend arrow for up', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 1 }),
100,
85,
'driver-1',
3
);
expect(viewModel.trendArrow).toBe('↑');
});
it('should return correct trend arrow for down', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 5 }),
100,
85,
'driver-1',
2
);
expect(viewModel.trendArrow).toBe('↓');
});
it('should return correct trend arrow for same', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 3 }),
100,
85,
'driver-1'
);
expect(viewModel.trendArrow).toBe('-');
});
it('should handle edge case of last place with no one behind', () => {
const viewModel = new StandingEntryViewModel(
createMockStanding({ position: 10, points: 20 }),
100, // leader points
20, // same points (last place)
'driver-10'
);
expect(viewModel.pointsGapToNext).toBe(0);
expect(viewModel.pointsGapToLeader).toBe(-80);
});
});

View File

@@ -1,8 +1,7 @@
import { StandingEntryDto, DriverDto } from '../dtos';
import { LeagueStandingDTO } from '../types/generated/LeagueStandingDTO';
export class StandingEntryViewModel implements StandingEntryDto {
export class StandingEntryViewModel implements LeagueStandingDTO {
driverId: string;
driver?: DriverDto;
position: number;
points: number;
wins: number;
@@ -14,8 +13,10 @@ export class StandingEntryViewModel implements StandingEntryDto {
private currentUserId: string;
private previousPosition?: number;
constructor(dto: StandingEntryDto, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) {
Object.assign(this, dto);
constructor(dto: LeagueStandingDTO, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) {
this.driverId = dto.driverId;
this.position = dto.position;
this.points = dto.points;
this.leaderPoints = leaderPoints;
this.nextPoints = nextPoints;
this.currentUserId = currentUserId;
@@ -27,6 +28,13 @@ export class StandingEntryViewModel implements StandingEntryDto {
return this.position.toString();
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
driver?: any;
wins: number = 0;
podiums: number = 0;
races: number = 0;
/** UI-specific: Points difference to leader */
get pointsGapToLeader(): number {
return this.points - this.leaderPoints;

View File

@@ -1,7 +1,17 @@
import { TeamDetailsDto, TeamMemberDto } from '../dtos';
import { TeamMemberViewModel } from './TeamMemberViewModel';
export class TeamDetailsViewModel implements TeamDetailsDto {
// Note: No generated DTO available for TeamDetails yet
interface TeamDetailsDTO {
id: string;
name: string;
description?: string;
logoUrl?: string;
memberCount: number;
ownerId: string;
members: any[];
}
export class TeamDetailsViewModel {
id: string;
name: string;
description?: string;
@@ -12,7 +22,7 @@ export class TeamDetailsViewModel implements TeamDetailsDto {
private currentUserId: string;
constructor(dto: TeamDetailsDto & { members: TeamMemberDto[] }, currentUserId: string) {
constructor(dto: TeamDetailsDTO, currentUserId: string) {
this.id = dto.id;
this.name = dto.name;
this.description = dto.description;

View File

@@ -1,6 +1,13 @@
import { TeamJoinRequestItemDto } from '../dtos';
// Note: No generated DTO available for TeamJoinRequest yet
interface TeamJoinRequestDTO {
id: string;
teamId: string;
driverId: string;
requestedAt: string;
message?: string;
}
export class TeamJoinRequestViewModel implements TeamJoinRequestItemDto {
export class TeamJoinRequestViewModel {
id: string;
teamId: string;
driverId: string;
@@ -10,7 +17,7 @@ export class TeamJoinRequestViewModel implements TeamJoinRequestItemDto {
private currentUserId: string;
private isOwner: boolean;
constructor(dto: TeamJoinRequestItemDto, currentUserId: string, isOwner: boolean) {
constructor(dto: TeamJoinRequestDTO, currentUserId: string, isOwner: boolean) {
Object.assign(this, dto);
this.currentUserId = currentUserId;
this.isOwner = isOwner;

View File

@@ -1,15 +1,21 @@
import { TeamMemberDto, DriverDto } from '../dtos';
export class TeamMemberViewModel implements TeamMemberDto {
// Note: No generated DTO available for TeamMember yet
interface TeamMemberDTO {
driverId: string;
driver?: DriverDto;
driver?: any;
role: string;
joinedAt: string;
}
export class TeamMemberViewModel {
driverId: string;
driver?: any;
role: string;
joinedAt: string;
private currentUserId: string;
private teamOwnerId: string;
constructor(dto: TeamMemberDto, currentUserId: string, teamOwnerId: string) {
constructor(dto: TeamMemberDTO, currentUserId: string, teamOwnerId: string) {
Object.assign(this, dto);
this.currentUserId = currentUserId;
this.teamOwnerId = teamOwnerId;

View File

@@ -1,6 +1,13 @@
import { TeamSummaryDto } from '../dtos';
// Note: No generated DTO available for TeamSummary yet
interface TeamSummaryDTO {
id: string;
name: string;
logoUrl?: string;
memberCount: number;
rating: number;
}
export class TeamSummaryViewModel implements TeamSummaryDto {
export class TeamSummaryViewModel {
id: string;
name: string;
logoUrl?: string;
@@ -9,8 +16,12 @@ export class TeamSummaryViewModel implements TeamSummaryDto {
private maxMembers = 10; // Assuming max members
constructor(dto: TeamSummaryDto) {
Object.assign(this, dto);
constructor(dto: TeamSummaryDTO) {
this.id = dto.id;
this.name = dto.name;
if (dto.logoUrl !== undefined) this.logoUrl = dto.logoUrl;
this.memberCount = dto.memberCount;
this.rating = dto.rating;
}
/** UI-specific: Whether team is full */

View File

@@ -1,9 +1,30 @@
// Note: No generated DTO available for UpdateAvatar yet
interface UpdateAvatarDTO {
success: boolean;
error?: string;
}
/**
* Update Avatar View Model
*
* Represents the result of an avatar update operation
*/
export interface UpdateAvatarViewModel {
export class UpdateAvatarViewModel {
success: boolean;
error?: string;
constructor(dto: UpdateAvatarDTO) {
this.success = dto.success;
if (dto.error !== undefined) this.error = dto.error;
}
/** UI-specific: Whether update was successful */
get isSuccessful(): boolean {
return this.success;
}
/** UI-specific: Whether there was an error */
get hasError(): boolean {
return !!this.error;
}
}

View File

@@ -1,11 +1,36 @@
// Note: No generated DTO available for UploadMedia yet
interface UploadMediaDTO {
success: boolean;
mediaId?: string;
url?: string;
error?: string;
}
/**
* Upload Media View Model
*
* Represents the result of a media upload operation
*/
export interface UploadMediaViewModel {
export class UploadMediaViewModel {
success: boolean;
mediaId?: string;
url?: string;
error?: string;
constructor(dto: UploadMediaDTO) {
this.success = dto.success;
if (dto.mediaId !== undefined) this.mediaId = dto.mediaId;
if (dto.url !== undefined) this.url = dto.url;
if (dto.error !== undefined) this.error = dto.error;
}
/** UI-specific: Whether upload was successful */
get isSuccessful(): boolean {
return this.success;
}
/** UI-specific: Whether there was an error */
get hasError(): boolean {
return !!this.error;
}
}

View File

@@ -1,14 +1,25 @@
import { DriverDto } from '../dtos';
// Note: No generated DTO available for UserProfile yet
interface UserProfileDTO {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
}
export class UserProfileViewModel implements DriverDto {
export class UserProfileViewModel {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
constructor(dto: DriverDto) {
Object.assign(this, dto);
constructor(dto: UserProfileDTO) {
this.id = dto.id;
this.name = dto.name;
if (dto.avatarUrl !== undefined) this.avatarUrl = dto.avatarUrl;
if (dto.iracingId !== undefined) this.iracingId = dto.iracingId;
if (dto.rating !== undefined) this.rating = dto.rating;
}
/** UI-specific: Formatted rating */

View File

@@ -1,16 +1,24 @@
import { WalletTransactionDto } from '../dtos';
import { TransactionDto } from '../types/generated/TransactionDto';
export class WalletTransactionViewModel implements WalletTransactionDto {
export class WalletTransactionViewModel implements TransactionDto {
id: string;
type: 'deposit' | 'withdrawal';
walletId: string;
amount: number;
description?: string;
description: string;
createdAt: string;
constructor(dto: WalletTransactionDto) {
Object.assign(this, dto);
constructor(dto: TransactionDto) {
this.id = dto.id;
this.walletId = dto.walletId;
this.amount = dto.amount;
this.description = dto.description;
this.createdAt = dto.createdAt;
}
// Note: The generated DTO doesn't have type field
// This will need to be added when the OpenAPI spec is updated
type: 'deposit' | 'withdrawal' = 'deposit';
/** UI-specific: Formatted amount with sign */
get formattedAmount(): string {
const sign = this.type === 'deposit' ? '+' : '-';
@@ -27,6 +35,11 @@ export class WalletTransactionViewModel implements WalletTransactionDto {
return this.type.charAt(0).toUpperCase() + this.type.slice(1);
}
/** UI-specific: Amount color */
get amountColor(): string {
return this.type === 'deposit' ? 'green' : 'red';
}
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return new Date(this.createdAt).toLocaleString();

View File

@@ -1,19 +1,37 @@
import { WalletDto, WalletTransactionDto } from '../dtos';
import { WalletDto } from '../types/generated/WalletDto';
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
export class WalletViewModel implements WalletDto {
driverId: string;
id: string;
leagueId: string;
balance: number;
totalRevenue: number;
totalPlatformFees: number;
totalWithdrawn: number;
createdAt: string;
currency: string;
transactions: WalletTransactionViewModel[];
constructor(dto: WalletDto & { transactions: WalletTransactionDto[] }) {
this.driverId = dto.driverId;
constructor(dto: WalletDto & { transactions?: any[] }) {
this.id = dto.id;
this.leagueId = dto.leagueId;
this.balance = dto.balance;
this.totalRevenue = dto.totalRevenue;
this.totalPlatformFees = dto.totalPlatformFees;
this.totalWithdrawn = dto.totalWithdrawn;
this.createdAt = dto.createdAt;
this.currency = dto.currency;
this.transactions = dto.transactions.map(t => new WalletTransactionViewModel(t));
// Map transactions if provided
if (dto.transactions) {
this.transactions = dto.transactions.map(t => new WalletTransactionViewModel(t));
}
}
// Note: The generated DTO doesn't have driverId or transactions
// These will need to be added when the OpenAPI spec is updated
driverId: string = '';
transactions: WalletTransactionViewModel[] = [];
/** UI-specific: Formatted balance */
get formattedBalance(): string {
return `${this.currency} ${this.balance.toFixed(2)}`;

View File

@@ -0,0 +1,156 @@
Form Models
This document defines Form Models as a first-class concept in the frontend architecture.
Form Models are UX-only write models used to collect, validate, and prepare user input
before it is sent to the backend as a Command DTO.
Form Models are not View Models and not Domain Models.
Purpose
A Form Model answers the question:
“What does the UI need in order to safely submit user input?”
Form Models exist to:
• centralize form state
• reduce logic inside components
• provide consistent client-side validation
• build Command DTOs explicitly
Core Rules
Form Models:
• exist only in the frontend
• are write-only (never reused for reads)
• are created per form
• are discarded after submission
Form Models MUST NOT:
• contain business logic
• enforce domain rules
• reference View Models
• reference Domain Entities or Value Objects
• be sent to the API directly
Relationship to Other Models
API DTO (read) → ViewModel → UI
UI Input → FormModel → Command DTO → API
• View Models are read-only
• Form Models are write-only
• No model is reused across read/write boundaries
Typical Responsibilities
A Form Model MAY:
• store field values
• track dirty / touched state
• perform basic UX validation
• expose isValid, canSubmit
• build a Command DTO
A Form Model MUST NOT:
• decide if an action is allowed
• perform authorization checks
• validate cross-aggregate rules
Validation Guidelines
Client-side validation is UX validation, not business validation.
Allowed validation examples:
• required fields
• min / max length
• email format
• numeric ranges
Forbidden validation examples:
• “user is not allowed”
• “league already exists”
• “quota exceeded”
Server validation is the source of truth.
Example: Simple Form Model (with class-validator)
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
export class SignupFormModel {
@IsEmail()
email = '';
@IsNotEmpty()
@MinLength(8)
password = '';
isSubmitting = false;
reset(): void {
this.email = '';
this.password = '';
}
toCommand(): SignupCommandDto {
return {
email: this.email,
password: this.password,
};
}
}
Usage in UI Component
const form = useFormModel(SignupFormModel);
async function onSubmit() {
if (!form.isValid()) return;
form.isSubmitting = true;
await authService.signup(form.toCommand());
}
The component:
• binds inputs to the Form Model
• reacts to validation state
• never builds DTOs manually
Testing
Form Models SHOULD be tested when they contain:
• validation rules
• non-trivial state transitions
• command construction logic
Form Models do NOT need tests if they only hold fields without logic.
Summary
• Form Models are UX helpers for writes
• They protect components from complexity
• They never replace backend validation
• They never leak into read flows
Form Models help users.
Use Cases protect the system.

View File

@@ -0,0 +1,156 @@
Form Submission Flow (UI → System)
This document defines the only valid data flow when a user submits a form.
It applies to all write operations (create, update, delete).
There are no exceptions.
Core Principle
Read and Write paths are different.
What is displayed is never sent back.
High-Level Flow
UI → Command DTO → API → Core Use Case → Persistence
• View Models are read-only
• Display Objects are read-only
• Commands are write-only
1. UI (Component)
Responsibility
• Collect user input
• Manage UX state (loading, disabled, local errors)
Rules
• Only primitives are handled (string, number, boolean)
• No DTO reuse
• No ViewModel reuse
• No domain objects
The UI does not decide whether an action is allowed.
2. Form Model (Optional)
Responsibility
• Local form state
• Client-side validation (required, min/max length)
• Field-level errors
Rules
• UX-only validation
• No business rules
• Never shared with API or Core
3. Command DTO (Frontend)
Responsibility
• Express intent
• Represent a write operation
Rules
• Created fresh on submit
• Never derived from a ViewModel
• Never reused from read DTOs
• Write-only
4. Frontend Service
Responsibility
• Orchestrate the write action
• Call the API Client
• Propagate success or failure
Rules
• No business logic
• No validation
• No UI decisions
• No ViewModel creation for writes (except explicit success summaries)
5. API Client (Frontend)
Responsibility
• Perform HTTP request
• Handle transport-level failures
Rules
• Stateless
• No retries unless explicitly designed
• Throws technical errors only
6. API Layer (Backend)
Responsibility
• HTTP boundary
• Transport validation (schema / class-validator)
• Map API DTO → Core Command
Rules
• No business logic
• No persistence
• No UI concerns
7. Core Use Case
Responsibility
• Enforce business rules
• Validate domain invariants
• Change system state
Rules
• Single source of truth
• No UI logic
• No HTTP knowledge
Response Handling
Success
• API returns a Result DTO (IDs, status)
• Frontend reacts by:
• navigation
• reload via GET
• toast / confirmation
Failure
• Business errors → user-visible message
• Technical errors → error boundary / monitoring
Forbidden Patterns
• ViewModel → Command
• DisplayObject → API
• DTO roundtrip
• Domain Object in UI
• Reusing read models for writes
Summary
• Read Flow: DTO → ViewModel → UI
• Write Flow: UI → Command DTO → Core
What is shown is never sent back.