diff --git a/apps/website/lib/view-models/AnalyticsDashboardViewModel.ts b/apps/website/lib/view-models/AnalyticsDashboardViewModel.ts index 364cb33f1..8d20aadfa 100644 --- a/apps/website/lib/view-models/AnalyticsDashboardViewModel.ts +++ b/apps/website/lib/view-models/AnalyticsDashboardViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/AnalyticsMetricsViewModel.ts b/apps/website/lib/view-models/AnalyticsMetricsViewModel.ts index 207a024da..5e7719dea 100644 --- a/apps/website/lib/view-models/AnalyticsMetricsViewModel.ts +++ b/apps/website/lib/view-models/AnalyticsMetricsViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/AvatarViewModel.test.ts b/apps/website/lib/view-models/AvatarViewModel.test.ts new file mode 100644 index 000000000..a2862a203 --- /dev/null +++ b/apps/website/lib/view-models/AvatarViewModel.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/AvatarViewModel.ts b/apps/website/lib/view-models/AvatarViewModel.ts index e6769ecb1..42adba55c 100644 --- a/apps/website/lib/view-models/AvatarViewModel.ts +++ b/apps/website/lib/view-models/AvatarViewModel.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/CompleteOnboardingViewModel.test.ts b/apps/website/lib/view-models/CompleteOnboardingViewModel.test.ts new file mode 100644 index 000000000..3b4e3f999 --- /dev/null +++ b/apps/website/lib/view-models/CompleteOnboardingViewModel.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/CompleteOnboardingViewModel.ts b/apps/website/lib/view-models/CompleteOnboardingViewModel.ts index 10e349108..0a86d9ee1 100644 --- a/apps/website/lib/view-models/CompleteOnboardingViewModel.ts +++ b/apps/website/lib/view-models/CompleteOnboardingViewModel.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/DeleteMediaViewModel.test.ts b/apps/website/lib/view-models/DeleteMediaViewModel.test.ts new file mode 100644 index 000000000..b19a765dd --- /dev/null +++ b/apps/website/lib/view-models/DeleteMediaViewModel.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/DeleteMediaViewModel.ts b/apps/website/lib/view-models/DeleteMediaViewModel.ts index f66e95a82..456899f01 100644 --- a/apps/website/lib/view-models/DeleteMediaViewModel.ts +++ b/apps/website/lib/view-models/DeleteMediaViewModel.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverLeaderboardItemViewModel.test.ts b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.test.ts new file mode 100644 index 000000000..7c9cd1840 --- /dev/null +++ b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts index 6decb39c0..7ea380007 100644 --- a/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts +++ b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/DriverLeaderboardViewModel.ts b/apps/website/lib/view-models/DriverLeaderboardViewModel.ts index 2157cf506..0c1f53d58 100644 --- a/apps/website/lib/view-models/DriverLeaderboardViewModel.ts +++ b/apps/website/lib/view-models/DriverLeaderboardViewModel.ts @@ -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); diff --git a/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts index 18b48cecb..c13cb0f57 100644 --- a/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts +++ b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts @@ -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); } diff --git a/apps/website/lib/view-models/DriverViewModel.test.ts b/apps/website/lib/view-models/DriverViewModel.test.ts new file mode 100644 index 000000000..0c6132437 --- /dev/null +++ b/apps/website/lib/view-models/DriverViewModel.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverViewModel.ts b/apps/website/lib/view-models/DriverViewModel.ts index 60fd30ba0..3c405e43a 100644 --- a/apps/website/lib/view-models/DriverViewModel.ts +++ b/apps/website/lib/view-models/DriverViewModel.ts @@ -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'; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueAdminViewModel.test.ts b/apps/website/lib/view-models/LeagueAdminViewModel.test.ts new file mode 100644 index 000000000..b0b95142e --- /dev/null +++ b/apps/website/lib/view-models/LeagueAdminViewModel.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueAdminViewModel.ts b/apps/website/lib/view-models/LeagueAdminViewModel.ts index 61308d9fa..49f95c678 100644 --- a/apps/website/lib/view-models/LeagueAdminViewModel.ts +++ b/apps/website/lib/view-models/LeagueAdminViewModel.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueJoinRequestViewModel.ts b/apps/website/lib/view-models/LeagueJoinRequestViewModel.ts index 95466cabf..c4face713 100644 --- a/apps/website/lib/view-models/LeagueJoinRequestViewModel.ts +++ b/apps/website/lib/view-models/LeagueJoinRequestViewModel.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueMemberViewModel.ts b/apps/website/lib/view-models/LeagueMemberViewModel.ts index 72c88af21..b2831fd42 100644 --- a/apps/website/lib/view-models/LeagueMemberViewModel.ts +++ b/apps/website/lib/view-models/LeagueMemberViewModel.ts @@ -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(); diff --git a/apps/website/lib/view-models/LeagueStandingsViewModel.test.ts b/apps/website/lib/view-models/LeagueStandingsViewModel.test.ts new file mode 100644 index 000000000..4c25e2ea1 --- /dev/null +++ b/apps/website/lib/view-models/LeagueStandingsViewModel.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueStandingsViewModel.ts b/apps/website/lib/view-models/LeagueStandingsViewModel.ts index 78200d39a..5a5de1724 100644 --- a/apps/website/lib/view-models/LeagueStandingsViewModel.ts +++ b/apps/website/lib/view-models/LeagueStandingsViewModel.ts @@ -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[] = []; } \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueSummaryViewModel.ts b/apps/website/lib/view-models/LeagueSummaryViewModel.ts index 36a1be029..bba48d557 100644 --- a/apps/website/lib/view-models/LeagueSummaryViewModel.ts +++ b/apps/website/lib/view-models/LeagueSummaryViewModel.ts @@ -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}`; diff --git a/apps/website/lib/view-models/MediaViewModel.test.ts b/apps/website/lib/view-models/MediaViewModel.test.ts new file mode 100644 index 000000000..09cb74d54 --- /dev/null +++ b/apps/website/lib/view-models/MediaViewModel.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/MediaViewModel.ts b/apps/website/lib/view-models/MediaViewModel.ts index 47486f6a3..ba5c27662 100644 --- a/apps/website/lib/view-models/MediaViewModel.ts +++ b/apps/website/lib/view-models/MediaViewModel.ts @@ -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`; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/MembershipFeeViewModel.ts b/apps/website/lib/view-models/MembershipFeeViewModel.ts index 535f456fc..d808e6281 100644 --- a/apps/website/lib/view-models/MembershipFeeViewModel.ts +++ b/apps/website/lib/view-models/MembershipFeeViewModel.ts @@ -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)}`; diff --git a/apps/website/lib/view-models/PaymentViewModel.ts b/apps/website/lib/view-models/PaymentViewModel.ts index 7e7618b92..6cedac993 100644 --- a/apps/website/lib/view-models/PaymentViewModel.ts +++ b/apps/website/lib/view-models/PaymentViewModel.ts @@ -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); } diff --git a/apps/website/lib/view-models/PrizeViewModel.ts b/apps/website/lib/view-models/PrizeViewModel.ts index 4176f7638..d220ac414 100644 --- a/apps/website/lib/view-models/PrizeViewModel.ts +++ b/apps/website/lib/view-models/PrizeViewModel.ts @@ -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'; diff --git a/apps/website/lib/view-models/ProtestViewModel.test.ts b/apps/website/lib/view-models/ProtestViewModel.test.ts new file mode 100644 index 000000000..16edce038 --- /dev/null +++ b/apps/website/lib/view-models/ProtestViewModel.test.ts @@ -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(''); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/ProtestViewModel.ts b/apps/website/lib/view-models/ProtestViewModel.ts index b32d607a2..266bad404 100644 --- a/apps/website/lib/view-models/ProtestViewModel.ts +++ b/apps/website/lib/view-models/ProtestViewModel.ts @@ -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); + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceDetailViewModel.test.ts b/apps/website/lib/view-models/RaceDetailViewModel.test.ts new file mode 100644 index 000000000..761f9ef5f --- /dev/null +++ b/apps/website/lib/view-models/RaceDetailViewModel.test.ts @@ -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 => ({ + 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 => ({ + 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'); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceDetailViewModel.ts b/apps/website/lib/view-models/RaceDetailViewModel.ts index 261fbb13a..82570cfe9 100644 --- a/apps/website/lib/view-models/RaceDetailViewModel.ts +++ b/apps/website/lib/view-models/RaceDetailViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/RaceListItemViewModel.ts b/apps/website/lib/view-models/RaceListItemViewModel.ts index d7e978ab2..88d11d03d 100644 --- a/apps/website/lib/view-models/RaceListItemViewModel.ts +++ b/apps/website/lib/view-models/RaceListItemViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/RaceResultViewModel.test.ts b/apps/website/lib/view-models/RaceResultViewModel.test.ts new file mode 100644 index 000000000..bc122f8c6 --- /dev/null +++ b/apps/website/lib/view-models/RaceResultViewModel.test.ts @@ -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('--:--.---'); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceResultViewModel.ts b/apps/website/lib/view-models/RaceResultViewModel.ts index 3caea06bf..0428dac0a 100644 --- a/apps/website/lib/view-models/RaceResultViewModel.ts +++ b/apps/website/lib/view-models/RaceResultViewModel.ts @@ -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 = ''; } \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceResultsDetailViewModel.ts b/apps/website/lib/view-models/RaceResultsDetailViewModel.ts index 148e7298d..7e87aaa72 100644 --- a/apps/website/lib/view-models/RaceResultsDetailViewModel.ts +++ b/apps/website/lib/view-models/RaceResultsDetailViewModel.ts @@ -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; - 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 = {}; + 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); diff --git a/apps/website/lib/view-models/RacesPageViewModel.test.ts b/apps/website/lib/view-models/RacesPageViewModel.test.ts new file mode 100644 index 000000000..523e6d2dc --- /dev/null +++ b/apps/website/lib/view-models/RacesPageViewModel.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/RacesPageViewModel.ts b/apps/website/lib/view-models/RacesPageViewModel.ts index f4918a312..bb2e934b2 100644 --- a/apps/website/lib/view-models/RacesPageViewModel.ts +++ b/apps/website/lib/view-models/RacesPageViewModel.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts index ac6920fbe..a1b9f9075 100644 --- a/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts +++ b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/SessionViewModel.ts b/apps/website/lib/view-models/SessionViewModel.ts index 317b43d03..88d75685b 100644 --- a/apps/website/lib/view-models/SessionViewModel.ts +++ b/apps/website/lib/view-models/SessionViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/SponsorDashboardViewModel.ts b/apps/website/lib/view-models/SponsorDashboardViewModel.ts index e93fdbb53..cd36dc687 100644 --- a/apps/website/lib/view-models/SponsorDashboardViewModel.ts +++ b/apps/website/lib/view-models/SponsorDashboardViewModel.ts @@ -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()}`; diff --git a/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts b/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts index 58f2ffda5..c41675d56 100644 --- a/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts +++ b/apps/website/lib/view-models/SponsorSponsorshipsViewModel.ts @@ -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; diff --git a/apps/website/lib/view-models/SponsorViewModel.ts b/apps/website/lib/view-models/SponsorViewModel.ts index 4fb50ba94..cb8ed7c22 100644 --- a/apps/website/lib/view-models/SponsorViewModel.ts +++ b/apps/website/lib/view-models/SponsorViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/SponsorshipDetailViewModel.ts b/apps/website/lib/view-models/SponsorshipDetailViewModel.ts index aba2cf8d1..7ba106fb3 100644 --- a/apps/website/lib/view-models/SponsorshipDetailViewModel.ts +++ b/apps/website/lib/view-models/SponsorshipDetailViewModel.ts @@ -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()}`; diff --git a/apps/website/lib/view-models/SponsorshipPricingViewModel.ts b/apps/website/lib/view-models/SponsorshipPricingViewModel.ts index c62f1fa01..a35e483e0 100644 --- a/apps/website/lib/view-models/SponsorshipPricingViewModel.ts +++ b/apps/website/lib/view-models/SponsorshipPricingViewModel.ts @@ -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; diff --git a/apps/website/lib/view-models/StandingEntryViewModel.test.ts b/apps/website/lib/view-models/StandingEntryViewModel.test.ts new file mode 100644 index 000000000..31777cdb5 --- /dev/null +++ b/apps/website/lib/view-models/StandingEntryViewModel.test.ts @@ -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 => ({ + 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); + }); +}); \ No newline at end of file diff --git a/apps/website/lib/view-models/StandingEntryViewModel.ts b/apps/website/lib/view-models/StandingEntryViewModel.ts index 3ddbeb8dc..103ec22ac 100644 --- a/apps/website/lib/view-models/StandingEntryViewModel.ts +++ b/apps/website/lib/view-models/StandingEntryViewModel.ts @@ -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; diff --git a/apps/website/lib/view-models/TeamDetailsViewModel.ts b/apps/website/lib/view-models/TeamDetailsViewModel.ts index 16c6091a7..eb0b6bd63 100644 --- a/apps/website/lib/view-models/TeamDetailsViewModel.ts +++ b/apps/website/lib/view-models/TeamDetailsViewModel.ts @@ -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; diff --git a/apps/website/lib/view-models/TeamJoinRequestViewModel.ts b/apps/website/lib/view-models/TeamJoinRequestViewModel.ts index 1ee6909cc..76773b769 100644 --- a/apps/website/lib/view-models/TeamJoinRequestViewModel.ts +++ b/apps/website/lib/view-models/TeamJoinRequestViewModel.ts @@ -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; diff --git a/apps/website/lib/view-models/TeamMemberViewModel.ts b/apps/website/lib/view-models/TeamMemberViewModel.ts index bdc4464e3..c0db92702 100644 --- a/apps/website/lib/view-models/TeamMemberViewModel.ts +++ b/apps/website/lib/view-models/TeamMemberViewModel.ts @@ -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; diff --git a/apps/website/lib/view-models/TeamSummaryViewModel.ts b/apps/website/lib/view-models/TeamSummaryViewModel.ts index 1bab1e2a1..3a692091a 100644 --- a/apps/website/lib/view-models/TeamSummaryViewModel.ts +++ b/apps/website/lib/view-models/TeamSummaryViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/UpdateAvatarViewModel.ts b/apps/website/lib/view-models/UpdateAvatarViewModel.ts index c14dc6e22..8e5ed4ead 100644 --- a/apps/website/lib/view-models/UpdateAvatarViewModel.ts +++ b/apps/website/lib/view-models/UpdateAvatarViewModel.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/UploadMediaViewModel.ts b/apps/website/lib/view-models/UploadMediaViewModel.ts index 42d9ff1be..aa8e1270c 100644 --- a/apps/website/lib/view-models/UploadMediaViewModel.ts +++ b/apps/website/lib/view-models/UploadMediaViewModel.ts @@ -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; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/UserProfileViewModel.ts b/apps/website/lib/view-models/UserProfileViewModel.ts index c0ab3e6a8..7524fb419 100644 --- a/apps/website/lib/view-models/UserProfileViewModel.ts +++ b/apps/website/lib/view-models/UserProfileViewModel.ts @@ -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 */ diff --git a/apps/website/lib/view-models/WalletTransactionViewModel.ts b/apps/website/lib/view-models/WalletTransactionViewModel.ts index 98d712159..55c1d3259 100644 --- a/apps/website/lib/view-models/WalletTransactionViewModel.ts +++ b/apps/website/lib/view-models/WalletTransactionViewModel.ts @@ -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(); diff --git a/apps/website/lib/view-models/WalletViewModel.ts b/apps/website/lib/view-models/WalletViewModel.ts index 40e5a367a..4a4136ce8 100644 --- a/apps/website/lib/view-models/WalletViewModel.ts +++ b/apps/website/lib/view-models/WalletViewModel.ts @@ -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)}`; diff --git a/docs/architecture/FORM_MODELS.md b/docs/architecture/FORM_MODELS.md new file mode 100644 index 000000000..de52e701a --- /dev/null +++ b/docs/architecture/FORM_MODELS.md @@ -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. \ No newline at end of file diff --git a/docs/architecture/FORM_SUBMISSION.md b/docs/architecture/FORM_SUBMISSION.md new file mode 100644 index 000000000..6bd3d4694 --- /dev/null +++ b/docs/architecture/FORM_SUBMISSION.md @@ -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. \ No newline at end of file