view data fixes
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 6m4s
Contract Testing / contract-snapshot (pull_request) Has been skipped

This commit is contained in:
2026-01-23 11:59:49 +01:00
parent ae58839eb2
commit d97f50ed72
191 changed files with 2889 additions and 1019 deletions

View File

@@ -1,17 +1,18 @@
import { describe, it, expect } from 'vitest';
import { ActivityItemViewModel } from './ActivityItemViewModel';
import { ActivityItemViewData } from '../view-data/ActivityItemViewData';
describe('ActivityItemViewModel', () => {
it('maps basic properties from input data', () => {
const data = {
it('maps basic properties from ActivityItemViewData', () => {
const viewData: ActivityItemViewData = {
id: 'activity-1',
type: 'race' as const,
type: 'race',
message: 'Test activity',
time: '2025-01-01T12:00:00Z',
impressions: 1234,
};
const viewModel = new ActivityItemViewModel(data);
const viewModel = new ActivityItemViewModel(viewData);
expect(viewModel.id).toBe('activity-1');
expect(viewModel.type).toBe('race');
@@ -40,7 +41,7 @@ describe('ActivityItemViewModel', () => {
type: 'unknown',
message: '',
time: '',
} as any);
});
expect(unknown.typeColor).toBe('bg-gray-500');
});
@@ -77,4 +78,19 @@ describe('ActivityItemViewModel', () => {
expect(noImpressions.formattedImpressions).toBeNull();
expect(zeroImpressions.formattedImpressions).toBeNull();
});
it('handles optional impressions field', () => {
const withoutImpressions: ActivityItemViewData = {
id: 'activity-5',
type: 'platform',
message: 'Platform activity',
time: '2025-01-01T12:00:00Z',
};
const viewModel = new ActivityItemViewModel(withoutImpressions);
expect(viewModel.impressions).toBeUndefined();
expect(viewModel.formattedImpressions).toBeNull();
});
});

View File

@@ -2,24 +2,30 @@
* Activity Item View Model
*
* View model for recent activity items.
*
* Accepts ActivityItemViewData as input and produces UI-ready data.
*/
export class ActivityItemViewModel {
id: string;
type: 'race' | 'league' | 'team' | 'driver' | 'platform';
message: string;
time: string;
impressions?: number;
import { ActivityItemViewData } from "../view-data/ActivityItemViewData";
import { ViewModel } from "../contracts/view-models/ViewModel";
constructor(data: any) {
this.id = data.id;
this.type = data.type;
this.message = data.message;
this.time = data.time;
this.impressions = data.impressions;
export class ActivityItemViewModel extends ViewModel {
readonly id: string;
readonly type: string;
readonly message: string;
readonly time: string;
readonly impressions?: number;
constructor(viewData: ActivityItemViewData) {
super();
this.id = viewData.id;
this.type = viewData.type;
this.message = viewData.message;
this.time = viewData.time;
this.impressions = viewData.impressions;
}
get typeColor(): string {
const colors = {
const colors: Record<string, string> = {
race: 'bg-warning-amber',
league: 'bg-primary-blue',
team: 'bg-purple-400',

View File

@@ -1,24 +1,25 @@
import { describe, it, expect } from 'vitest';
import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from './AdminUserViewModel';
import type { UserDto, DashboardStats } from '@/lib/api/admin/AdminApiClient';
import type { AdminUserViewData } from '@/lib/view-data/AdminUserViewData';
import type { DashboardStatsViewData } from '@/lib/view-data/DashboardStatsViewData';
describe('AdminUserViewModel', () => {
const createBaseDto = (): UserDto => ({
const createBaseViewData = (): AdminUserViewData => ({
id: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
isSystemAdmin: false,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
lastLoginAt: new Date('2024-01-15T10:30:00Z'),
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
lastLoginAt: '2024-01-15T10:30:00Z',
primaryDriverId: 'driver-456',
});
it('maps core fields from DTO', () => {
const dto = createBaseDto();
const vm = new AdminUserViewModel(dto);
it('maps core fields from ViewData', () => {
const viewData = createBaseViewData();
const vm = new AdminUserViewModel(viewData);
expect(vm.id).toBe('user-123');
expect(vm.email).toBe('test@example.com');
@@ -30,8 +31,8 @@ describe('AdminUserViewModel', () => {
});
it('converts dates to Date objects', () => {
const dto = createBaseDto();
const vm = new AdminUserViewModel(dto);
const viewData = createBaseViewData();
const vm = new AdminUserViewModel(viewData);
expect(vm.createdAt).toBeInstanceOf(Date);
expect(vm.updatedAt).toBeInstanceOf(Date);
@@ -40,19 +41,19 @@ describe('AdminUserViewModel', () => {
});
it('handles missing lastLoginAt', () => {
const dto = createBaseDto();
delete dto.lastLoginAt;
const vm = new AdminUserViewModel(dto);
const viewData = createBaseViewData();
delete viewData.lastLoginAt;
const vm = new AdminUserViewModel(viewData);
expect(vm.lastLoginAt).toBeUndefined();
expect(vm.lastLoginFormatted).toBe('Never');
});
it('formats role badges correctly', () => {
const owner = new AdminUserViewModel({ ...createBaseDto(), roles: ['owner'] });
const admin = new AdminUserViewModel({ ...createBaseDto(), roles: ['admin'] });
const user = new AdminUserViewModel({ ...createBaseDto(), roles: ['user'] });
const custom = new AdminUserViewModel({ ...createBaseDto(), roles: ['custom-role'] });
const owner = new AdminUserViewModel({ ...createBaseViewData(), roles: ['owner'] });
const admin = new AdminUserViewModel({ ...createBaseViewData(), roles: ['admin'] });
const user = new AdminUserViewModel({ ...createBaseViewData(), roles: ['user'] });
const custom = new AdminUserViewModel({ ...createBaseViewData(), roles: ['custom-role'] });
expect(owner.roleBadges).toEqual(['Owner']);
expect(admin.roleBadges).toEqual(['Admin']);
@@ -61,51 +62,36 @@ describe('AdminUserViewModel', () => {
});
it('derives status badge correctly', () => {
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
const active = new AdminUserViewModel({ ...createBaseViewData(), status: 'active' });
const suspended = new AdminUserViewModel({ ...createBaseViewData(), status: 'suspended' });
const deleted = new AdminUserViewModel({ ...createBaseViewData(), status: 'deleted' });
expect(active.statusBadge).toEqual({ label: 'Active', variant: 'performance-green' });
expect(suspended.statusBadge).toEqual({ label: 'Suspended', variant: 'yellow-500' });
expect(deleted.statusBadge).toEqual({ label: 'Deleted', variant: 'racing-red' });
expect(active.statusBadgeLabel).toBe('Active');
expect(active.statusBadgeVariant).toBe('performance-green');
expect(suspended.statusBadgeLabel).toBe('Suspended');
expect(suspended.statusBadgeVariant).toBe('yellow-500');
expect(deleted.statusBadgeLabel).toBe('Deleted');
expect(deleted.statusBadgeVariant).toBe('racing-red');
});
it('formats dates for display', () => {
const dto = createBaseDto();
const vm = new AdminUserViewModel(dto);
const viewData = createBaseViewData();
const vm = new AdminUserViewModel(viewData);
expect(vm.lastLoginFormatted).toBe('1/15/2024');
expect(vm.createdAtFormatted).toBe('1/1/2024');
});
it('derives action permissions correctly', () => {
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
expect(active.canSuspend).toBe(true);
expect(active.canActivate).toBe(false);
expect(active.canDelete).toBe(true);
expect(suspended.canSuspend).toBe(false);
expect(suspended.canActivate).toBe(true);
expect(suspended.canDelete).toBe(true);
expect(deleted.canSuspend).toBe(false);
expect(deleted.canActivate).toBe(false);
expect(deleted.canDelete).toBe(false);
expect(vm.lastLoginFormatted).toBe('Jan 15, 2024');
expect(vm.createdAtFormatted).toBe('Jan 1, 2024');
});
it('handles multiple roles', () => {
const dto = { ...createBaseDto(), roles: ['owner', 'admin'] };
const vm = new AdminUserViewModel(dto);
const viewData = { ...createBaseViewData(), roles: ['owner', 'admin'] };
const vm = new AdminUserViewModel(viewData);
expect(vm.roleBadges).toEqual(['Owner', 'Admin']);
});
});
describe('DashboardStatsViewModel', () => {
const createBaseData = (): DashboardStats => ({
const createBaseData = (): DashboardStatsViewData => ({
totalUsers: 100,
activeUsers: 70,
suspendedUsers: 10,
@@ -165,21 +151,24 @@ describe('DashboardStatsViewModel', () => {
totalUsers: 100,
recentLogins: 10, // 10% engagement
});
expect(lowEngagement.activityLevel).toBe('low');
expect(lowEngagement.activityLevelLabel).toBe('Low');
expect(lowEngagement.activityLevelValue).toBe('low');
const mediumEngagement = new DashboardStatsViewModel({
...createBaseData(),
totalUsers: 100,
recentLogins: 35, // 35% engagement
});
expect(mediumEngagement.activityLevel).toBe('medium');
expect(mediumEngagement.activityLevelLabel).toBe('Medium');
expect(mediumEngagement.activityLevelValue).toBe('medium');
const highEngagement = new DashboardStatsViewModel({
...createBaseData(),
totalUsers: 100,
recentLogins: 60, // 60% engagement
});
expect(highEngagement.activityLevel).toBe('high');
expect(highEngagement.activityLevelLabel).toBe('High');
expect(highEngagement.activityLevelValue).toBe('high');
});
it('handles zero users safely', () => {
@@ -194,7 +183,8 @@ describe('DashboardStatsViewModel', () => {
expect(vm.activeRate).toBe(0);
expect(vm.activeRateFormatted).toBe('0%');
expect(vm.adminRatio).toBe('1:1');
expect(vm.activityLevel).toBe('low');
expect(vm.activityLevelLabel).toBe('Low');
expect(vm.activityLevelValue).toBe('low');
});
it('preserves arrays from input', () => {
@@ -208,21 +198,21 @@ describe('DashboardStatsViewModel', () => {
});
describe('UserListViewModel', () => {
const createDto = (overrides: Partial<UserDto> = {}): UserDto => ({
const createViewData = (overrides: Partial<AdminUserViewData> = {}): AdminUserViewData => ({
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
isSystemAdmin: false,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
...overrides,
});
it('wraps user DTOs in AdminUserViewModel instances', () => {
it('wraps user ViewData in AdminUserViewModel instances', () => {
const data = {
users: [createDto({ id: 'user-1' }), createDto({ id: 'user-2' })],
users: [createViewData({ id: 'user-1' }), createViewData({ id: 'user-2' })],
total: 2,
page: 1,
limit: 10,
@@ -239,7 +229,7 @@ describe('UserListViewModel', () => {
it('exposes pagination metadata', () => {
const data = {
users: [createDto()],
users: [createViewData()],
total: 50,
page: 2,
limit: 10,
@@ -256,7 +246,7 @@ describe('UserListViewModel', () => {
it('derives hasUsers correctly', () => {
const withUsers = new UserListViewModel({
users: [createDto()],
users: [createViewData()],
total: 1,
page: 1,
limit: 10,
@@ -277,7 +267,7 @@ describe('UserListViewModel', () => {
it('derives showPagination correctly', () => {
const withPagination = new UserListViewModel({
users: [createDto()],
users: [createViewData()],
total: 20,
page: 1,
limit: 10,
@@ -285,7 +275,7 @@ describe('UserListViewModel', () => {
});
const withoutPagination = new UserListViewModel({
users: [createDto()],
users: [createViewData()],
total: 5,
page: 1,
limit: 10,
@@ -298,7 +288,7 @@ describe('UserListViewModel', () => {
it('calculates start and end indices correctly', () => {
const vm = new UserListViewModel({
users: [createDto(), createDto(), createDto()],
users: [createViewData(), createViewData(), createViewData()],
total: 50,
page: 2,
limit: 10,

View File

@@ -1,12 +1,18 @@
import type { UserDto } from '@/lib/types/admin';
import type { AdminUserViewData } from '@/lib/view-data/AdminUserViewData';
import type { DashboardStatsViewData } from '@/lib/view-data/DashboardStatsViewData';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { UserStatusDisplay } from "../display-objects/UserStatusDisplay";
import { UserRoleDisplay } from "../display-objects/UserRoleDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
import { ActivityLevelDisplay } from "../display-objects/ActivityLevelDisplay";
/**
* AdminUserViewModel
*
*
* View Model for admin user management.
* Transforms API DTO into UI-ready state with formatting and derived fields.
*/
export class AdminUserViewModel {
export class AdminUserViewModel extends ViewModel {
id: string;
email: string;
displayName: string;
@@ -18,73 +24,48 @@ export class AdminUserViewModel {
lastLoginAt?: Date;
primaryDriverId?: string;
// UI-specific derived fields
// UI-specific derived fields (primitive outputs only)
readonly roleBadges: string[];
readonly statusBadge: { label: string; variant: string };
readonly statusBadgeLabel: string;
readonly statusBadgeVariant: string;
readonly lastLoginFormatted: string;
readonly createdAtFormatted: string;
readonly canSuspend: boolean;
readonly canActivate: boolean;
readonly canDelete: boolean;
constructor(dto: UserDto) {
this.id = dto.id;
this.email = dto.email;
this.displayName = dto.displayName;
this.roles = dto.roles;
this.status = dto.status;
this.isSystemAdmin = dto.isSystemAdmin;
this.createdAt = new Date(dto.createdAt);
this.updatedAt = new Date(dto.updatedAt);
this.lastLoginAt = dto.lastLoginAt ? new Date(dto.lastLoginAt) : undefined;
this.primaryDriverId = dto.primaryDriverId;
constructor(viewData: AdminUserViewData) {
super();
this.id = viewData.id;
this.email = viewData.email;
this.displayName = viewData.displayName;
this.roles = viewData.roles;
this.status = viewData.status;
this.isSystemAdmin = viewData.isSystemAdmin;
this.createdAt = new Date(viewData.createdAt);
this.updatedAt = new Date(viewData.updatedAt);
this.lastLoginAt = viewData.lastLoginAt ? new Date(viewData.lastLoginAt) : undefined;
this.primaryDriverId = viewData.primaryDriverId;
// Derive role badges
this.roleBadges = this.roles.map(role => {
switch (role) {
case 'owner': return 'Owner';
case 'admin': return 'Admin';
case 'user': return 'User';
default: return role;
}
});
// Derive role badges using Display Object
this.roleBadges = this.roles.map(role => UserRoleDisplay.roleLabel(role));
// Derive status badge
this.statusBadge = this.getStatusBadge();
// Derive status badge using Display Object
this.statusBadgeLabel = UserStatusDisplay.statusLabel(this.status);
this.statusBadgeVariant = UserStatusDisplay.statusVariant(this.status);
// Format dates
this.lastLoginFormatted = this.lastLoginAt
? this.lastLoginAt.toLocaleDateString()
// Format dates using Display Object
this.lastLoginFormatted = this.lastLoginAt
? DateDisplay.formatShort(this.lastLoginAt)
: 'Never';
this.createdAtFormatted = this.createdAt.toLocaleDateString();
// Derive action permissions
this.canSuspend = this.status === 'active';
this.canActivate = this.status === 'suspended';
this.canDelete = this.status !== 'deleted';
}
private getStatusBadge(): { label: string; variant: string } {
switch (this.status) {
case 'active':
return { label: 'Active', variant: 'performance-green' };
case 'suspended':
return { label: 'Suspended', variant: 'yellow-500' };
case 'deleted':
return { label: 'Deleted', variant: 'racing-red' };
default:
return { label: this.status, variant: 'gray-500' };
}
this.createdAtFormatted = DateDisplay.formatShort(this.createdAt);
}
}
/**
* DashboardStatsViewModel
*
*
* View Model for admin dashboard statistics.
* Provides formatted statistics and derived metrics for UI.
*/
export class DashboardStatsViewModel {
export class DashboardStatsViewModel extends ViewModel {
totalUsers: number;
activeUsers: number;
suspendedUsers: number;
@@ -113,52 +94,26 @@ export class DashboardStatsViewModel {
logins: number;
}[];
// UI-specific derived fields
// UI-specific derived fields (primitive outputs only)
readonly activeRate: number;
readonly activeRateFormatted: string;
readonly adminRatio: string;
readonly activityLevel: 'low' | 'medium' | 'high';
readonly activityLevelLabel: string;
readonly activityLevelValue: 'low' | 'medium' | 'high';
constructor(data: {
totalUsers: number;
activeUsers: number;
suspendedUsers: number;
deletedUsers: number;
systemAdmins: number;
recentLogins: number;
newUsersToday: number;
userGrowth: {
label: string;
value: number;
color: string;
}[];
roleDistribution: {
label: string;
value: number;
color: string;
}[];
statusDistribution: {
active: number;
suspended: number;
deleted: number;
};
activityTimeline: {
date: string;
newUsers: number;
logins: number;
}[];
}) {
this.totalUsers = data.totalUsers;
this.activeUsers = data.activeUsers;
this.suspendedUsers = data.suspendedUsers;
this.deletedUsers = data.deletedUsers;
this.systemAdmins = data.systemAdmins;
this.recentLogins = data.recentLogins;
this.newUsersToday = data.newUsersToday;
this.userGrowth = data.userGrowth;
this.roleDistribution = data.roleDistribution;
this.statusDistribution = data.statusDistribution;
this.activityTimeline = data.activityTimeline;
constructor(viewData: DashboardStatsViewData) {
super();
this.totalUsers = viewData.totalUsers;
this.activeUsers = viewData.activeUsers;
this.suspendedUsers = viewData.suspendedUsers;
this.deletedUsers = viewData.deletedUsers;
this.systemAdmins = viewData.systemAdmins;
this.recentLogins = viewData.recentLogins;
this.newUsersToday = viewData.newUsersToday;
this.userGrowth = viewData.userGrowth;
this.roleDistribution = viewData.roleDistribution;
this.statusDistribution = viewData.statusDistribution;
this.activityTimeline = viewData.activityTimeline;
// Derive active rate
this.activeRate = this.totalUsers > 0 ? (this.activeUsers / this.totalUsers) * 100 : 0;
@@ -168,44 +123,40 @@ export class DashboardStatsViewModel {
const nonAdmins = Math.max(1, this.totalUsers - this.systemAdmins);
this.adminRatio = `1:${Math.floor(nonAdmins / Math.max(1, this.systemAdmins))}`;
// Derive activity level
// Derive activity level using Display Object
const engagementRate = this.totalUsers > 0 ? (this.recentLogins / this.totalUsers) * 100 : 0;
if (engagementRate < 20) {
this.activityLevel = 'low';
} else if (engagementRate < 50) {
this.activityLevel = 'medium';
} else {
this.activityLevel = 'high';
}
this.activityLevelLabel = ActivityLevelDisplay.levelLabel(engagementRate);
this.activityLevelValue = ActivityLevelDisplay.levelValue(engagementRate);
}
}
/**
* UserListViewModel
*
*
* View Model for user list with pagination and filtering state.
*/
export class UserListViewModel {
export class UserListViewModel extends ViewModel {
users: AdminUserViewModel[];
total: number;
page: number;
limit: number;
totalPages: number;
// UI-specific derived fields
// UI-specific derived fields (primitive outputs only)
readonly hasUsers: boolean;
readonly showPagination: boolean;
readonly startIndex: number;
readonly endIndex: number;
constructor(data: {
users: UserDto[];
users: AdminUserViewData[];
total: number;
page: number;
limit: number;
totalPages: number;
}) {
this.users = data.users.map(dto => new AdminUserViewModel(dto));
super();
this.users = data.users.map(viewData => new AdminUserViewModel(viewData));
this.total = data.total;
this.page = data.page;
this.limit = data.limit;

View File

@@ -1,14 +1,17 @@
import { describe, it, expect } from 'vitest';
import { AnalyticsDashboardViewModel } from './AnalyticsDashboardViewModel';
import { AnalyticsDashboardInputViewData } from '../view-data/AnalyticsDashboardInputViewData';
describe('AnalyticsDashboardViewModel', () => {
it('maps core fields from data', () => {
const vm = new AnalyticsDashboardViewModel({
it('maps core fields from AnalyticsDashboardInputViewData', () => {
const viewData: AnalyticsDashboardInputViewData = {
totalUsers: 100,
activeUsers: 40,
totalRaces: 10,
totalLeagues: 5,
});
};
const vm = new AnalyticsDashboardViewModel(viewData);
expect(vm.totalUsers).toBe(100);
expect(vm.activeUsers).toBe(40);
@@ -17,24 +20,28 @@ describe('AnalyticsDashboardViewModel', () => {
});
it('computes engagement rate and formatted engagement rate', () => {
const vm = new AnalyticsDashboardViewModel({
const viewData: AnalyticsDashboardInputViewData = {
totalUsers: 200,
activeUsers: 50,
totalRaces: 0,
totalLeagues: 0,
});
};
const vm = new AnalyticsDashboardViewModel(viewData);
expect(vm.userEngagementRate).toBeCloseTo(25);
expect(vm.formattedEngagementRate).toBe('25.0%');
});
it('handles zero users safely', () => {
const vm = new AnalyticsDashboardViewModel({
const viewData: AnalyticsDashboardInputViewData = {
totalUsers: 0,
activeUsers: 0,
totalRaces: 0,
totalLeagues: 0,
});
};
const vm = new AnalyticsDashboardViewModel(viewData);
expect(vm.userEngagementRate).toBe(0);
expect(vm.formattedEngagementRate).toBe('0.0%');

View File

@@ -1,20 +1,25 @@
/**
* Analytics dashboard view model
* Represents dashboard data for analytics
*
* Note: No matching generated DTO available yet
* View model for analytics dashboard data.
*
* Accepts AnalyticsDashboardInputViewData as input and produces UI-ready data.
*/
export class AnalyticsDashboardViewModel {
totalUsers: number;
activeUsers: number;
totalRaces: number;
totalLeagues: number;
import { AnalyticsDashboardInputViewData } from "../view-data/AnalyticsDashboardInputViewData";
import { ViewModel } from "../contracts/view-models/ViewModel";
constructor(data: { totalUsers: number; activeUsers: number; totalRaces: number; totalLeagues: number }) {
this.totalUsers = data.totalUsers;
this.activeUsers = data.activeUsers;
this.totalRaces = data.totalRaces;
this.totalLeagues = data.totalLeagues;
export class AnalyticsDashboardViewModel extends ViewModel {
readonly totalUsers: number;
readonly activeUsers: number;
readonly totalRaces: number;
readonly totalLeagues: number;
constructor(viewData: AnalyticsDashboardInputViewData) {
super();
this.totalUsers = viewData.totalUsers;
this.activeUsers = viewData.activeUsers;
this.totalRaces = viewData.totalRaces;
this.totalLeagues = viewData.totalLeagues;
}
/** UI-specific: User engagement rate */

View File

@@ -1,14 +1,17 @@
import { describe, it, expect } from 'vitest';
import { AnalyticsMetricsViewModel } from './AnalyticsMetricsViewModel';
import { AnalyticsMetricsViewData } from '../view-data/AnalyticsMetricsViewData';
describe('AnalyticsMetricsViewModel', () => {
it('maps raw metrics fields from data', () => {
const vm = new AnalyticsMetricsViewModel({
it('maps metrics fields from AnalyticsMetricsViewData', () => {
const viewData: AnalyticsMetricsViewData = {
pageViews: 1234,
uniqueVisitors: 567,
averageSessionDuration: 180,
bounceRate: 42.5,
});
};
const vm = new AnalyticsMetricsViewModel(viewData);
expect(vm.pageViews).toBe(1234);
expect(vm.uniqueVisitors).toBe(567);
@@ -16,36 +19,42 @@ describe('AnalyticsMetricsViewModel', () => {
expect(vm.bounceRate).toBe(42.5);
});
it('formats counts using locale formatting helpers', () => {
const vm = new AnalyticsMetricsViewModel({
it('formats counts using NumberDisplay', () => {
const viewData: AnalyticsMetricsViewData = {
pageViews: 1200,
uniqueVisitors: 3500,
averageSessionDuration: 75,
bounceRate: 10,
});
};
expect(vm.formattedPageViews).toBe((1200).toLocaleString());
expect(vm.formattedUniqueVisitors).toBe((3500).toLocaleString());
const vm = new AnalyticsMetricsViewModel(viewData);
expect(vm.formattedPageViews).toBe('1,200');
expect(vm.formattedUniqueVisitors).toBe('3,500');
});
it('formats session duration as mm:ss', () => {
const vm = new AnalyticsMetricsViewModel({
it('formats session duration using DurationDisplay', () => {
const viewData: AnalyticsMetricsViewData = {
pageViews: 0,
uniqueVisitors: 0,
averageSessionDuration: 125,
bounceRate: 0,
});
};
expect(vm.formattedSessionDuration).toBe('2:05');
const vm = new AnalyticsMetricsViewModel(viewData);
expect(vm.formattedSessionDuration).toBe('2:05.000');
});
it('formats bounce rate as percentage with one decimal', () => {
const vm = new AnalyticsMetricsViewModel({
it('formats bounce rate using PercentDisplay', () => {
const viewData: AnalyticsMetricsViewData = {
pageViews: 0,
uniqueVisitors: 0,
averageSessionDuration: 0,
bounceRate: 37.345,
});
bounceRate: 0.37345,
};
const vm = new AnalyticsMetricsViewModel(viewData);
expect(vm.formattedBounceRate).toBe('37.3%');
});

View File

@@ -2,40 +2,45 @@
* Analytics metrics view model
* Represents metrics data for analytics
*
* Note: No matching generated DTO available yet
* Accepts AnalyticsMetricsViewData as input and produces UI-ready data.
*/
export class AnalyticsMetricsViewModel {
pageViews: number;
uniqueVisitors: number;
averageSessionDuration: number;
bounceRate: number;
import { AnalyticsMetricsViewData } from "../view-data/AnalyticsMetricsViewData";
import { ViewModel } from "../contracts/view-models/ViewModel";
import { NumberDisplay } from "../display-objects/NumberDisplay";
import { DurationDisplay } from "../display-objects/DurationDisplay";
import { PercentDisplay } from "../display-objects/PercentDisplay";
constructor(data: { pageViews: number; uniqueVisitors: number; averageSessionDuration: number; bounceRate: number }) {
this.pageViews = data.pageViews;
this.uniqueVisitors = data.uniqueVisitors;
this.averageSessionDuration = data.averageSessionDuration;
this.bounceRate = data.bounceRate;
export class AnalyticsMetricsViewModel extends ViewModel {
readonly pageViews: number;
readonly uniqueVisitors: number;
readonly averageSessionDuration: number;
readonly bounceRate: number;
constructor(viewData: AnalyticsMetricsViewData) {
super();
this.pageViews = viewData.pageViews;
this.uniqueVisitors = viewData.uniqueVisitors;
this.averageSessionDuration = viewData.averageSessionDuration;
this.bounceRate = viewData.bounceRate;
}
/** UI-specific: Formatted page views */
get formattedPageViews(): string {
return this.pageViews.toLocaleString();
return NumberDisplay.format(this.pageViews);
}
/** UI-specific: Formatted unique visitors */
get formattedUniqueVisitors(): string {
return this.uniqueVisitors.toLocaleString();
return NumberDisplay.format(this.uniqueVisitors);
}
/** UI-specific: Formatted session duration */
get formattedSessionDuration(): string {
const minutes = Math.floor(this.averageSessionDuration / 60);
const seconds = Math.floor(this.averageSessionDuration % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
return DurationDisplay.formatSeconds(this.averageSessionDuration);
}
/** UI-specific: Formatted bounce rate */
get formattedBounceRate(): string {
return `${this.bounceRate.toFixed(1)}%`;
return PercentDisplay.format(this.bounceRate);
}
}

View File

@@ -1,24 +1,44 @@
import { describe, expect, it } from 'vitest';
import { AvailableLeaguesViewModel, AvailableLeagueViewModel } from './AvailableLeaguesViewModel';
import { AvailableLeaguesViewData, AvailableLeagueViewData } from '../view-data/AvailableLeaguesViewData';
describe('AvailableLeaguesViewModel', () => {
const baseLeague = {
const baseLeague: AvailableLeagueViewData = {
id: 'league-1',
name: 'Pro Series',
game: 'iRacing',
description: 'Competitive league for serious drivers',
drivers: 24,
avgViewsPerRace: 12_500,
formattedAvgViews: '12.5k',
mainSponsorSlot: { available: true, price: 5_000 },
secondarySlots: { available: 2, total: 3, price: 1_500 },
cpm: 400,
formattedCpm: '$400',
hasAvailableSlots: true,
rating: 4.7,
tier: 'premium' as const,
tierConfig: {
color: '#FFD700',
bgColor: '#FFF8DC',
border: '2px solid #FFD700',
icon: '⭐',
},
nextRace: 'Next Sunday',
seasonStatus: 'active' as const,
description: 'Competitive league for serious drivers',
statusConfig: {
color: '#10B981',
bg: '#D1FAE5',
label: 'Active Season',
},
};
const baseViewData: AvailableLeaguesViewData = {
leagues: [baseLeague],
};
it('maps league array into view models', () => {
const vm = new AvailableLeaguesViewModel([baseLeague]);
const vm = new AvailableLeaguesViewModel(baseViewData);
expect(vm.leagues).toHaveLength(1);
expect(vm.leagues[0]).toBeInstanceOf(AvailableLeagueViewModel);
@@ -30,11 +50,11 @@ describe('AvailableLeaguesViewModel', () => {
it('exposes formatted average views and CPM for main sponsor slot', () => {
const leagueVm = new AvailableLeagueViewModel(baseLeague);
expect(leagueVm.formattedAvgViews).toBe(`${(baseLeague.avgViewsPerRace / 1000).toFixed(1)}k`);
expect(leagueVm.formattedAvgViews).toBe('12.5k');
const expectedCpm = Math.round((baseLeague.mainSponsorSlot.price / baseLeague.avgViewsPerRace) * 1000);
expect(leagueVm.cpm).toBe(expectedCpm);
expect(leagueVm.formattedCpm).toBe(`$${expectedCpm}`);
expect(leagueVm.formattedCpm).toBe('$400');
});
it('detects available sponsor slots from main or secondary slots', () => {
@@ -75,4 +95,5 @@ describe('AvailableLeaguesViewModel', () => {
expect(upcoming.statusConfig.label).toBe('Starting Soon');
expect(completed.statusConfig.label).toBe('Season Ended');
});
});

View File

@@ -2,77 +2,83 @@
* Available Leagues View Model
*
* View model for leagues available for sponsorship.
*
* Accepts AvailableLeaguesViewData as input and produces UI-ready data.
*/
export class AvailableLeaguesViewModel {
leagues: AvailableLeagueViewModel[];
import { ViewModel } from "../contracts/view-models/ViewModel";
import { AvailableLeaguesViewData, AvailableLeagueViewData } from "../view-data/AvailableLeaguesViewData";
import { NumberDisplay } from "../display-objects/NumberDisplay";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import { LeagueTierDisplay } from "../display-objects/LeagueTierDisplay";
import { SeasonStatusDisplay } from "../display-objects/SeasonStatusDisplay";
constructor(leagues: unknown[]) {
this.leagues = leagues.map(league => new AvailableLeagueViewModel(league));
export class AvailableLeaguesViewModel extends ViewModel {
readonly leagues: AvailableLeagueViewModel[];
constructor(viewData: AvailableLeaguesViewData) {
super();
this.leagues = viewData.leagues.map(league => new AvailableLeagueViewModel(league));
}
}
export class AvailableLeagueViewModel {
id: string;
name: string;
game: string;
drivers: number;
avgViewsPerRace: number;
mainSponsorSlot: { available: boolean; price: number };
secondarySlots: { available: number; total: number; price: number };
rating: number;
tier: 'premium' | 'standard' | 'starter';
nextRace?: string;
seasonStatus: 'active' | 'upcoming' | 'completed';
description: string;
export class AvailableLeagueViewModel extends ViewModel {
readonly id: string;
readonly name: string;
readonly game: string;
readonly drivers: number;
readonly avgViewsPerRace: number;
readonly mainSponsorSlot: { available: boolean; price: number };
readonly secondarySlots: { available: number; total: number; price: number };
readonly rating: number;
readonly tier: 'premium' | 'standard' | 'starter';
readonly nextRace?: string;
readonly seasonStatus: 'active' | 'upcoming' | 'completed';
readonly description: string;
constructor(data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
this.id = d.id;
this.name = d.name;
this.game = d.game;
this.drivers = d.drivers;
this.avgViewsPerRace = d.avgViewsPerRace;
this.mainSponsorSlot = d.mainSponsorSlot;
this.secondarySlots = d.secondarySlots;
this.rating = d.rating;
this.tier = d.tier;
this.nextRace = d.nextRace;
this.seasonStatus = d.seasonStatus;
this.description = d.description;
constructor(viewData: AvailableLeagueViewData) {
super();
this.id = viewData.id;
this.name = viewData.name;
this.game = viewData.game;
this.drivers = viewData.drivers;
this.avgViewsPerRace = viewData.avgViewsPerRace;
this.mainSponsorSlot = viewData.mainSponsorSlot;
this.secondarySlots = viewData.secondarySlots;
this.rating = viewData.rating;
this.tier = viewData.tier;
this.nextRace = viewData.nextRace;
this.seasonStatus = viewData.seasonStatus;
this.description = viewData.description;
}
/** UI-specific: Formatted average views */
get formattedAvgViews(): string {
return `${(this.avgViewsPerRace / 1000).toFixed(1)}k`;
return NumberDisplay.formatCompact(this.avgViewsPerRace);
}
/** UI-specific: CPM calculation */
get cpm(): number {
return Math.round((this.mainSponsorSlot.price / this.avgViewsPerRace) * 1000);
}
/** UI-specific: Formatted CPM */
get formattedCpm(): string {
return `$${this.cpm}`;
return CurrencyDisplay.formatCompact(this.cpm);
}
/** UI-specific: Check if any sponsor slots are available */
get hasAvailableSlots(): boolean {
return this.mainSponsorSlot.available || this.secondarySlots.available > 0;
}
/** UI-specific: Tier configuration for badge styling */
get tierConfig() {
const configs = {
premium: { color: 'text-yellow-400', bgColor: 'bg-yellow-500/10', border: 'border-yellow-500/30', icon: '⭐' },
standard: { color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', border: 'border-primary-blue/30', icon: '🏆' },
starter: { color: 'text-gray-400', bgColor: 'bg-gray-500/10', border: 'border-gray-500/30', icon: '🚀' },
};
return configs[this.tier];
return LeagueTierDisplay.getDisplay(this.tier);
}
/** UI-specific: Status configuration for season state */
get statusConfig() {
const configs = {
active: { color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active Season' },
upcoming: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Starting Soon' },
completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' },
};
return configs[this.seasonStatus];
return SeasonStatusDisplay.getDisplay(this.seasonStatus);
}
}

View File

@@ -1,8 +1,30 @@
import { describe, it, expect } from 'vitest';
import { AvatarGenerationViewModel } from './AvatarGenerationViewModel';
import { AvatarGenerationViewData } from '../view-data/AvatarGenerationViewData';
describe('AvatarGenerationViewModel', () => {
it('should be defined', () => {
expect(AvatarGenerationViewModel).toBeDefined();
const mockViewData: AvatarGenerationViewData = {
success: true,
avatarUrls: ['https://example.com/avatar1.png', 'https://example.com/avatar2.png'],
errorMessage: undefined,
};
it('should be initialized from ViewData', () => {
const viewModel = new AvatarGenerationViewModel(mockViewData);
expect(viewModel.success).toBe(true);
expect(viewModel.avatarUrls).toEqual(['https://example.com/avatar1.png', 'https://example.com/avatar2.png']);
expect(viewModel.errorMessage).toBeUndefined();
});
it('should handle missing avatarUrls in ViewData', () => {
const viewDataWithoutUrls: AvatarGenerationViewData = {
success: false,
avatarUrls: [],
errorMessage: 'Error occurred',
};
const viewModel = new AvatarGenerationViewModel(viewDataWithoutUrls);
expect(viewModel.success).toBe(false);
expect(viewModel.avatarUrls).toEqual([]);
expect(viewModel.errorMessage).toBe('Error occurred');
});
});

View File

@@ -1,18 +1,22 @@
import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { AvatarGenerationViewData } from "../view-data/AvatarGenerationViewData";
/**
* AvatarGenerationViewModel
*
* View model for avatar generation process
* View model for avatar generation process.
*
* Accepts AvatarGenerationViewData as input and produces UI-ready data.
*/
export class AvatarGenerationViewModel {
export class AvatarGenerationViewModel extends ViewModel {
readonly success: boolean;
readonly avatarUrls: string[];
readonly errorMessage?: string;
constructor(dto: RequestAvatarGenerationOutputDTO) {
this.success = dto.success;
this.avatarUrls = dto.avatarUrls || [];
this.errorMessage = dto.errorMessage;
constructor(viewData: AvatarGenerationViewData) {
super();
this.success = viewData.success;
this.avatarUrls = viewData.avatarUrls;
this.errorMessage = viewData.errorMessage;
}
}

View File

@@ -1,53 +1,113 @@
import { describe, it, expect } from 'vitest';
import { AvatarViewModel } from './AvatarViewModel';
import type { AvatarViewData } from '@/lib/view-data/AvatarViewData';
describe('AvatarViewModel', () => {
it('should create instance with driverId and avatarUrl', () => {
const dto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/avatar.jpg',
};
describe('constructor', () => {
it('should create instance with valid AvatarViewData', () => {
const viewData: AvatarViewData = {
buffer: 'dGVzdC1pbWFnZS1kYXRh',
contentType: 'image/png',
};
const viewModel = new AvatarViewModel(dto);
const viewModel = new AvatarViewModel(viewData);
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.bufferBase64).toBe('dGVzdC1pbWFnZS1kYXRh');
expect(viewModel.contentTypeLabel).toBe('PNG');
expect(viewModel.hasValidData).toBe(true);
});
expect(viewModel.hasAvatar).toBe(true);
it('should create instance with empty buffer', () => {
const viewData: AvatarViewData = {
buffer: '',
contentType: 'image/png',
};
const viewModel = new AvatarViewModel(viewData);
expect(viewModel.bufferBase64).toBe('');
expect(viewModel.contentTypeLabel).toBe('PNG');
expect(viewModel.hasValidData).toBe(false);
});
});
it('should return false for hasAvatar when avatarUrl is undefined', () => {
const viewModel = new AvatarViewModel({
driverId: 'driver-123',
describe('derived fields', () => {
it('should derive bufferBase64 correctly', () => {
const viewData: AvatarViewData = {
buffer: 'dGVzdA==',
contentType: 'image/png',
};
const viewModel = new AvatarViewModel(viewData);
expect(viewModel.bufferBase64).toBe('dGVzdA==');
});
expect(viewModel.hasAvatar).toBe(false);
});
it('should derive contentTypeLabel correctly', () => {
const viewData: AvatarViewData = {
buffer: 'dGVzdC1pbWFnZS1kYXRh',
contentType: 'image/png',
};
it('should return false for hasAvatar when avatarUrl is empty string', () => {
const viewModel = new AvatarViewModel({
driverId: 'driver-123',
avatarUrl: '',
const viewModel = new AvatarViewModel(viewData);
expect(viewModel.contentTypeLabel).toBe('PNG');
});
expect(viewModel.hasAvatar).toBe(false);
it('should derive contentTypeLabel for different content types', () => {
const pngViewData: AvatarViewData = {
buffer: 'dGVzdC1pbWFnZS1kYXRh',
contentType: 'image/png',
};
const pngViewModel = new AvatarViewModel(pngViewData);
expect(pngViewModel.contentTypeLabel).toBe('PNG');
const jpegViewData: AvatarViewData = {
buffer: 'dGVzdC1pbWFnZS1kYXRh',
contentType: 'image/jpeg',
};
const jpegViewModel = new AvatarViewModel(jpegViewData);
expect(jpegViewModel.contentTypeLabel).toBe('JPEG');
const svgViewData: AvatarViewData = {
buffer: 'dGVzdC1pbWFnZS1kYXRh',
contentType: 'image/svg+xml',
};
const svgViewModel = new AvatarViewModel(svgViewData);
expect(svgViewModel.contentTypeLabel).toBe('SVG+XML');
});
it('should derive hasValidData as true when buffer has content', () => {
const viewData: AvatarViewData = {
buffer: 'dGVzdC1pbWFnZS1kYXRh',
contentType: 'image/png',
};
const viewModel = new AvatarViewModel(viewData);
expect(viewModel.hasValidData).toBe(true);
});
it('should derive hasValidData as false when buffer is empty', () => {
const viewData: AvatarViewData = {
buffer: '',
contentType: 'image/png',
};
const viewModel = new AvatarViewModel(viewData);
expect(viewModel.hasValidData).toBe(false);
});
it('should derive hasValidData as false when contentType is empty', () => {
const viewData: AvatarViewData = {
buffer: 'dGVzdC1pbWFnZS1kYXRh',
contentType: '',
};
const viewModel = new AvatarViewModel(viewData);
expect(viewModel.hasValidData).toBe(false);
});
});
});

View File

@@ -1,27 +1,29 @@
// Note: No generated DTO available for Avatar yet
interface AvatarDTO {
driverId: string;
avatarUrl?: string;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
import { AvatarDisplay } from "../display-objects/AvatarDisplay";
import { AvatarViewData } from "@/lib/view-data/AvatarViewData";
/**
* Avatar View Model
*
* Represents avatar information for the UI layer
* Represents avatar information for the UI layer.
* Transforms AvatarViewData into UI-ready state with formatting and derived fields.
*/
export class AvatarViewModel {
driverId: string;
avatarUrl?: string;
export class AvatarViewModel extends ViewModel {
// UI-specific derived fields (primitive outputs only)
readonly bufferBase64: string;
readonly contentTypeLabel: string;
readonly hasValidData: boolean;
constructor(dto: AvatarDTO) {
this.driverId = dto.driverId;
if (dto.avatarUrl !== undefined) {
this.avatarUrl = dto.avatarUrl;
}
}
constructor(viewData: AvatarViewData) {
super();
/** UI-specific: Whether the driver has an avatar */
get hasAvatar(): boolean {
return !!this.avatarUrl;
// Buffer is already base64 encoded in ViewData
this.bufferBase64 = viewData.buffer;
// Derive content type label using Display Object
this.contentTypeLabel = AvatarDisplay.formatContentType(viewData.contentType);
// Derive validity check using Display Object
this.hasValidData = AvatarDisplay.hasValidData(viewData.buffer, viewData.contentType);
}
}

View File

@@ -1,11 +1,22 @@
import { describe, it, expect } from 'vitest';
import type { BillingViewData } from '@/lib/view-data/BillingViewData';
import { BillingViewModel, PaymentMethodViewModel, InvoiceViewModel, BillingStatsViewModel } from './BillingViewModel';
describe('BillingViewModel', () => {
it('maps arrays of payment methods, invoices and stats into view models', () => {
const data = {
const viewData: BillingViewData = {
paymentMethods: [
{ id: 'pm-1', type: 'card', last4: '4242', brand: 'Visa', isDefault: true, expiryMonth: 12, expiryYear: 2030 },
{
id: 'pm-1',
type: 'card',
last4: '4242',
brand: 'Visa',
isDefault: true,
expiryMonth: 12,
expiryYear: 2030,
displayLabel: 'Visa •••• 4242',
expiryDisplay: '12/2030',
},
],
invoices: [
{
@@ -20,6 +31,10 @@ describe('BillingViewModel', () => {
description: 'Sponsorship',
sponsorshipType: 'league',
pdfUrl: 'https://example.com/invoice.pdf',
formattedTotalAmount: '€119,00',
formattedVatAmount: '€19,00',
formattedDate: '2024-01-01',
isOverdue: false,
},
],
stats: {
@@ -29,10 +44,15 @@ describe('BillingViewModel', () => {
nextPaymentAmount: 50,
activeSponsorships: 3,
averageMonthlySpend: 250,
formattedTotalSpent: '€1.000,00',
formattedPendingAmount: '€200,00',
formattedNextPaymentAmount: '€50,00',
formattedAverageMonthlySpend: '€250,00',
formattedNextPaymentDate: '2024-03-01',
},
} as any;
};
const vm = new BillingViewModel(data);
const vm = new BillingViewModel(viewData);
expect(vm.paymentMethods).toHaveLength(1);
expect(vm.paymentMethods[0]).toBeInstanceOf(PaymentMethodViewModel);
@@ -44,53 +64,67 @@ describe('BillingViewModel', () => {
describe('PaymentMethodViewModel', () => {
it('builds displayLabel based on type and bankName/brand', () => {
const card = new PaymentMethodViewModel({
const card = {
id: 'pm-1',
type: 'card',
type: 'card' as const,
last4: '4242',
brand: 'Visa',
isDefault: true,
});
displayLabel: 'Visa •••• 4242',
expiryDisplay: null,
};
const sepa = new PaymentMethodViewModel({
const sepa = {
id: 'pm-2',
type: 'sepa',
type: 'sepa' as const,
last4: '1337',
bankName: 'Test Bank',
isDefault: false,
});
displayLabel: 'Test Bank •••• 1337',
expiryDisplay: null,
};
expect(card.displayLabel).toBe('Visa •••• 4242');
expect(sepa.displayLabel).toBe('Test Bank •••• 1337');
const cardVm = new PaymentMethodViewModel(card);
const sepaVm = new PaymentMethodViewModel(sepa);
expect(cardVm.displayLabel).toBe('Visa •••• 4242');
expect(sepaVm.displayLabel).toBe('Test Bank •••• 1337');
});
it('returns expiryDisplay when month and year are provided', () => {
const withExpiry = new PaymentMethodViewModel({
const withExpiry = {
id: 'pm-1',
type: 'card',
type: 'card' as const,
last4: '4242',
brand: 'Visa',
isDefault: true,
expiryMonth: 3,
expiryYear: 2030,
});
displayLabel: 'Visa •••• 4242',
expiryDisplay: '03/2030',
};
const withoutExpiry = new PaymentMethodViewModel({
const withoutExpiry = {
id: 'pm-2',
type: 'card',
type: 'card' as const,
last4: '9999',
brand: 'Mastercard',
isDefault: false,
});
displayLabel: 'Mastercard •••• 9999',
expiryDisplay: null,
};
expect(withExpiry.expiryDisplay).toBe('03/2030');
expect(withoutExpiry.expiryDisplay).toBeNull();
const withExpiryVm = new PaymentMethodViewModel(withExpiry);
const withoutExpiryVm = new PaymentMethodViewModel(withoutExpiry);
expect(withExpiryVm.expiryDisplay).toBe('03/2030');
expect(withoutExpiryVm.expiryDisplay).toBeNull();
});
});
describe('InvoiceViewModel', () => {
it('formats monetary amounts and dates', () => {
const dto = {
const viewData = {
id: 'inv-1',
invoiceNumber: 'INV-1',
date: '2024-01-15',
@@ -98,16 +132,20 @@ describe('InvoiceViewModel', () => {
amount: 100,
vatAmount: 19,
totalAmount: 119,
status: 'paid',
status: 'paid' as const,
description: 'Sponsorship',
sponsorshipType: 'league',
sponsorshipType: 'league' as const,
pdfUrl: 'https://example.com/invoice.pdf',
} as any;
formattedTotalAmount: '€119,00',
formattedVatAmount: '€19,00',
formattedDate: '2024-01-15',
isOverdue: false,
};
const vm = new InvoiceViewModel(dto);
const vm = new InvoiceViewModel(viewData);
expect(vm.formattedTotalAmount).toBe(`${(119).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
expect(vm.formattedVatAmount).toBe(`${(19).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
expect(vm.formattedTotalAmount).toBe('€119,00');
expect(vm.formattedVatAmount).toBe('€19,00');
expect(typeof vm.formattedDate).toBe('string');
});
@@ -116,7 +154,7 @@ describe('InvoiceViewModel', () => {
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();
const overdue = new InvoiceViewModel({
const overdue = {
id: 'inv-1',
invoiceNumber: 'INV-1',
date: pastDate,
@@ -124,13 +162,17 @@ describe('InvoiceViewModel', () => {
amount: 0,
vatAmount: 0,
totalAmount: 0,
status: 'overdue',
status: 'overdue' as const,
description: '',
sponsorshipType: 'league',
sponsorshipType: 'league' as const,
pdfUrl: '',
} as any);
formattedTotalAmount: '€0,00',
formattedVatAmount: '€0,00',
formattedDate: pastDate,
isOverdue: true,
};
const pendingPastDue = new InvoiceViewModel({
const pendingPastDue = {
id: 'inv-2',
invoiceNumber: 'INV-2',
date: pastDate,
@@ -138,13 +180,17 @@ describe('InvoiceViewModel', () => {
amount: 0,
vatAmount: 0,
totalAmount: 0,
status: 'pending',
status: 'pending' as const,
description: '',
sponsorshipType: 'league',
sponsorshipType: 'league' as const,
pdfUrl: '',
} as any);
formattedTotalAmount: '€0,00',
formattedVatAmount: '€0,00',
formattedDate: pastDate,
isOverdue: true,
};
const pendingFuture = new InvoiceViewModel({
const pendingFuture = {
id: 'inv-3',
invoiceNumber: 'INV-3',
date: pastDate,
@@ -152,35 +198,48 @@ describe('InvoiceViewModel', () => {
amount: 0,
vatAmount: 0,
totalAmount: 0,
status: 'pending',
status: 'pending' as const,
description: '',
sponsorshipType: 'league',
sponsorshipType: 'league' as const,
pdfUrl: '',
} as any);
formattedTotalAmount: '€0,00',
formattedVatAmount: '€0,00',
formattedDate: pastDate,
isOverdue: false,
};
expect(overdue.isOverdue).toBe(true);
expect(pendingPastDue.isOverdue).toBe(true);
expect(pendingFuture.isOverdue).toBe(false);
const overdueVm = new InvoiceViewModel(overdue);
const pendingPastDueVm = new InvoiceViewModel(pendingPastDue);
const pendingFutureVm = new InvoiceViewModel(pendingFuture);
expect(overdueVm.isOverdue).toBe(true);
expect(pendingPastDueVm.isOverdue).toBe(true);
expect(pendingFutureVm.isOverdue).toBe(false);
});
});
describe('BillingStatsViewModel', () => {
it('formats monetary fields and next payment date', () => {
const dto = {
const viewData = {
totalSpent: 1234,
pendingAmount: 56.78,
nextPaymentDate: '2024-03-01',
nextPaymentAmount: 42,
activeSponsorships: 2,
averageMonthlySpend: 321,
} as any;
formattedTotalSpent: '€1.234,00',
formattedPendingAmount: '€56,78',
formattedNextPaymentAmount: '€42,00',
formattedAverageMonthlySpend: '€321,00',
formattedNextPaymentDate: '2024-03-01',
};
const vm = new BillingStatsViewModel(dto);
const vm = new BillingStatsViewModel(viewData);
expect(vm.formattedTotalSpent).toBe(`${(1234).toLocaleString('de-DE')}`);
expect(vm.formattedPendingAmount).toBe(`${(56.78).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
expect(vm.formattedNextPaymentAmount).toBe(`${(42).toLocaleString('de-DE', { minimumFractionDigits: 2 })}`);
expect(vm.formattedAverageMonthlySpend).toBe(`${(321).toLocaleString('de-DE')}`);
expect(vm.formattedTotalSpent).toBe('€1.234,00');
expect(vm.formattedPendingAmount).toBe('€56,78');
expect(vm.formattedNextPaymentAmount).toBe('€42,00');
expect(vm.formattedAverageMonthlySpend).toBe('€321,00');
expect(typeof vm.formattedNextPaymentDate).toBe('string');
});
});

View File

@@ -2,24 +2,39 @@
* Billing View Model
*
* View model for sponsor billing data with UI-specific transformations.
* Transforms BillingViewData into UI-ready state with formatting and derived fields.
*/
export class BillingViewModel {
import type { BillingViewData } from '@/lib/view-data/BillingViewData';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
/**
* BillingViewModel
*
* View Model for sponsor billing data.
* Transforms BillingViewData into UI-ready state with formatting and derived fields.
*/
export class BillingViewModel extends ViewModel {
paymentMethods: PaymentMethodViewModel[];
invoices: InvoiceViewModel[];
stats: BillingStatsViewModel;
constructor(data: {
paymentMethods: unknown[];
invoices: unknown[];
stats: unknown;
}) {
this.paymentMethods = data.paymentMethods.map(pm => new PaymentMethodViewModel(pm));
this.invoices = data.invoices.map(inv => new InvoiceViewModel(inv));
this.stats = new BillingStatsViewModel(data.stats);
constructor(viewData: BillingViewData) {
super();
this.paymentMethods = viewData.paymentMethods.map(pm => new PaymentMethodViewModel(pm));
this.invoices = viewData.invoices.map(inv => new InvoiceViewModel(inv));
this.stats = new BillingStatsViewModel(viewData.stats);
}
}
export class PaymentMethodViewModel {
/**
* PaymentMethodViewModel
*
* View Model for payment method data.
* Provides formatted display labels and expiry information.
*/
export class PaymentMethodViewModel extends ViewModel {
id: string;
type: 'card' | 'bank' | 'sepa';
last4: string;
@@ -29,35 +44,43 @@ export class PaymentMethodViewModel {
expiryYear?: number;
bankName?: string;
constructor(data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
this.id = d.id;
this.type = d.type;
this.last4 = d.last4;
this.brand = d.brand;
this.isDefault = d.isDefault;
this.expiryMonth = d.expiryMonth;
this.expiryYear = d.expiryYear;
this.bankName = d.bankName;
}
// UI-specific derived fields (primitive outputs only)
readonly displayLabel: string;
readonly expiryDisplay: string | null;
get displayLabel(): string {
if (this.type === 'sepa' && this.bankName) {
return `${this.bankName} •••• ${this.last4}`;
}
return `${this.brand} •••• ${this.last4}`;
}
get expiryDisplay(): string | null {
if (this.expiryMonth && this.expiryYear) {
return `${String(this.expiryMonth).padStart(2, '0')}/${this.expiryYear}`;
}
return null;
constructor(viewData: {
id: string;
type: 'card' | 'bank' | 'sepa';
last4: string;
brand?: string;
isDefault: boolean;
expiryMonth?: number;
expiryYear?: number;
bankName?: string;
displayLabel: string;
expiryDisplay: string | null;
}) {
super();
this.id = viewData.id;
this.type = viewData.type;
this.last4 = viewData.last4;
this.brand = viewData.brand;
this.isDefault = viewData.isDefault;
this.expiryMonth = viewData.expiryMonth;
this.expiryYear = viewData.expiryYear;
this.bankName = viewData.bankName;
this.displayLabel = viewData.displayLabel;
this.expiryDisplay = viewData.expiryDisplay;
}
}
export class InvoiceViewModel {
/**
* InvoiceViewModel
*
* View Model for invoice data.
* Provides formatted amounts, dates, and derived status flags.
*/
export class InvoiceViewModel extends ViewModel {
id: string;
invoiceNumber: string;
date: Date;
@@ -70,40 +93,55 @@ export class InvoiceViewModel {
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
pdfUrl: string;
constructor(data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
this.id = d.id;
this.invoiceNumber = d.invoiceNumber;
this.date = new Date(d.date);
this.dueDate = new Date(d.dueDate);
this.amount = d.amount;
this.vatAmount = d.vatAmount;
this.totalAmount = d.totalAmount;
this.status = d.status;
this.description = d.description;
this.sponsorshipType = d.sponsorshipType;
this.pdfUrl = d.pdfUrl;
}
// UI-specific derived fields (primitive outputs only)
readonly formattedTotalAmount: string;
readonly formattedVatAmount: string;
readonly formattedDate: string;
readonly isOverdue: boolean;
get formattedTotalAmount(): string {
return `${this.totalAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
}
get formattedVatAmount(): string {
return `${this.vatAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
}
get formattedDate(): string {
return this.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
get isOverdue(): boolean {
return this.status === 'overdue' || (this.status === 'pending' && new Date() > this.dueDate);
constructor(viewData: {
id: string;
invoiceNumber: string;
date: string;
dueDate: string;
amount: number;
vatAmount: number;
totalAmount: number;
status: 'paid' | 'pending' | 'overdue' | 'failed';
description: string;
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
pdfUrl: string;
formattedTotalAmount: string;
formattedVatAmount: string;
formattedDate: string;
isOverdue: boolean;
}) {
super();
this.id = viewData.id;
this.invoiceNumber = viewData.invoiceNumber;
this.date = new Date(viewData.date);
this.dueDate = new Date(viewData.dueDate);
this.amount = viewData.amount;
this.vatAmount = viewData.vatAmount;
this.totalAmount = viewData.totalAmount;
this.status = viewData.status;
this.description = viewData.description;
this.sponsorshipType = viewData.sponsorshipType;
this.pdfUrl = viewData.pdfUrl;
this.formattedTotalAmount = viewData.formattedTotalAmount;
this.formattedVatAmount = viewData.formattedVatAmount;
this.formattedDate = viewData.formattedDate;
this.isOverdue = viewData.isOverdue;
}
}
export class BillingStatsViewModel {
/**
* BillingStatsViewModel
*
* View Model for billing statistics.
* Provides formatted monetary fields and derived metrics.
*/
export class BillingStatsViewModel extends ViewModel {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: Date;
@@ -111,34 +149,37 @@ export class BillingStatsViewModel {
activeSponsorships: number;
averageMonthlySpend: number;
constructor(data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
this.totalSpent = d.totalSpent;
this.pendingAmount = d.pendingAmount;
this.nextPaymentDate = new Date(d.nextPaymentDate);
this.nextPaymentAmount = d.nextPaymentAmount;
this.activeSponsorships = d.activeSponsorships;
this.averageMonthlySpend = d.averageMonthlySpend;
}
// UI-specific derived fields (primitive outputs only)
readonly formattedTotalSpent: string;
readonly formattedPendingAmount: string;
readonly formattedNextPaymentAmount: string;
readonly formattedAverageMonthlySpend: string;
readonly formattedNextPaymentDate: string;
get formattedTotalSpent(): string {
return `${this.totalSpent.toLocaleString('de-DE')}`;
}
get formattedPendingAmount(): string {
return `${this.pendingAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
}
get formattedNextPaymentAmount(): string {
return `${this.nextPaymentAmount.toLocaleString('de-DE', { minimumFractionDigits: 2 })}`;
}
get formattedAverageMonthlySpend(): string {
return `${this.averageMonthlySpend.toLocaleString('de-DE')}`;
}
get formattedNextPaymentDate(): string {
return this.nextPaymentDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
constructor(viewData: {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: string;
nextPaymentAmount: number;
activeSponsorships: number;
averageMonthlySpend: number;
formattedTotalSpent: string;
formattedPendingAmount: string;
formattedNextPaymentAmount: string;
formattedAverageMonthlySpend: string;
formattedNextPaymentDate: string;
}) {
super();
this.totalSpent = viewData.totalSpent;
this.pendingAmount = viewData.pendingAmount;
this.nextPaymentDate = new Date(viewData.nextPaymentDate);
this.nextPaymentAmount = viewData.nextPaymentAmount;
this.activeSponsorships = viewData.activeSponsorships;
this.averageMonthlySpend = viewData.averageMonthlySpend;
this.formattedTotalSpent = viewData.formattedTotalSpent;
this.formattedPendingAmount = viewData.formattedPendingAmount;
this.formattedNextPaymentAmount = viewData.formattedNextPaymentAmount;
this.formattedAverageMonthlySpend = viewData.formattedAverageMonthlySpend;
this.formattedNextPaymentDate = viewData.formattedNextPaymentDate;
}
}

View File

@@ -1,35 +1,189 @@
import { describe, it, expect } from 'vitest';
import { CompleteOnboardingViewModel } from './CompleteOnboardingViewModel';
import type { CompleteOnboardingOutputDTO } from '../types/generated/CompleteOnboardingOutputDTO';
import type { CompleteOnboardingViewData } from '../builders/view-data/CompleteOnboardingViewData';
describe('CompleteOnboardingViewModel', () => {
it('should create instance with success flag', () => {
const dto: CompleteOnboardingOutputDTO = {
success: true,
};
describe('constructor', () => {
it('should create instance with success flag', () => {
const viewData: CompleteOnboardingViewData = {
success: true,
};
const viewModel = new CompleteOnboardingViewModel(dto);
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.success).toBe(true);
expect(viewModel.success).toBe(true);
});
it('should create instance with driverId', () => {
const viewData: CompleteOnboardingViewData = {
success: true,
driverId: 'driver-123',
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.driverId).toBe('driver-123');
});
it('should create instance with errorMessage', () => {
const viewData: CompleteOnboardingViewData = {
success: false,
errorMessage: 'Failed to complete onboarding',
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.errorMessage).toBe('Failed to complete onboarding');
});
it('should create instance with all fields', () => {
const viewData: CompleteOnboardingViewData = {
success: true,
driverId: 'driver-123',
errorMessage: undefined,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.success).toBe(true);
expect(viewModel.driverId).toBe('driver-123');
expect(viewModel.errorMessage).toBeUndefined();
});
});
it('should expose isSuccessful as true when success is true', () => {
const dto: CompleteOnboardingOutputDTO = {
success: true,
};
describe('UI-specific getters', () => {
it('should expose isSuccessful as true when success is true', () => {
const viewData: CompleteOnboardingViewData = {
success: true,
};
const viewModel = new CompleteOnboardingViewModel(dto);
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.isSuccessful).toBe(true);
expect(viewModel.isSuccessful).toBe(true);
});
it('should expose isSuccessful as false when success is false', () => {
const viewData: CompleteOnboardingViewData = {
success: false,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.isSuccessful).toBe(false);
});
it('should expose hasError as true when errorMessage is present', () => {
const viewData: CompleteOnboardingViewData = {
success: false,
errorMessage: 'Error occurred',
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.hasError).toBe(true);
});
it('should expose hasError as false when errorMessage is not present', () => {
const viewData: CompleteOnboardingViewData = {
success: true,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.hasError).toBe(false);
});
});
it('should expose isSuccessful as false when success is false', () => {
const dto: CompleteOnboardingOutputDTO = {
success: false,
};
describe('Display Object composition', () => {
it('should derive statusLabel from success', () => {
const viewData: CompleteOnboardingViewData = {
success: true,
};
const viewModel = new CompleteOnboardingViewModel(dto);
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.isSuccessful).toBe(false);
expect(viewModel.statusLabel).toBe('Onboarding Complete');
});
it('should derive statusLabel from failure', () => {
const viewData: CompleteOnboardingViewData = {
success: false,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.statusLabel).toBe('Onboarding Failed');
});
it('should derive statusVariant from success', () => {
const viewData: CompleteOnboardingViewData = {
success: true,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.statusVariant).toBe('performance-green');
});
it('should derive statusVariant from failure', () => {
const viewData: CompleteOnboardingViewData = {
success: false,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.statusVariant).toBe('racing-red');
});
it('should derive statusIcon from success', () => {
const viewData: CompleteOnboardingViewData = {
success: true,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.statusIcon).toBe('✅');
});
it('should derive statusIcon from failure', () => {
const viewData: CompleteOnboardingViewData = {
success: false,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.statusIcon).toBe('❌');
});
it('should derive statusMessage from success', () => {
const viewData: CompleteOnboardingViewData = {
success: true,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.statusMessage).toBe('Your onboarding has been completed successfully.');
});
it('should derive statusMessage from failure with default message', () => {
const viewData: CompleteOnboardingViewData = {
success: false,
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.statusMessage).toBe('Failed to complete onboarding. Please try again.');
});
it('should derive statusMessage from failure with custom error message', () => {
const viewData: CompleteOnboardingViewData = {
success: false,
errorMessage: 'Custom error message',
};
const viewModel = new CompleteOnboardingViewModel(viewData);
expect(viewModel.statusMessage).toBe('Custom error message');
});
});
});

View File

@@ -1,18 +1,36 @@
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
import type { CompleteOnboardingViewData } from '@/lib/builders/view-data/CompleteOnboardingViewData';
import { OnboardingStatusDisplay } from '../display-objects/OnboardingStatusDisplay';
import { ViewModel } from "../contracts/view-models/ViewModel";
/**
* Complete onboarding view model
* UI representation of onboarding completion result
*
* Composes Display Objects and transforms ViewData for UI consumption.
*/
export class CompleteOnboardingViewModel {
export class CompleteOnboardingViewModel extends ViewModel {
success: boolean;
driverId?: string;
errorMessage?: string;
constructor(dto: CompleteOnboardingOutputDTO) {
this.success = dto.success;
if (dto.driverId !== undefined) this.driverId = dto.driverId;
if (dto.errorMessage !== undefined) this.errorMessage = dto.errorMessage;
// UI-specific derived fields (primitive outputs only)
readonly statusLabel: string;
readonly statusVariant: string;
readonly statusIcon: string;
readonly statusMessage: string;
constructor(viewData: CompleteOnboardingViewData) {
super();
this.success = viewData.success;
if (viewData.driverId !== undefined) this.driverId = viewData.driverId;
if (viewData.errorMessage !== undefined) this.errorMessage = viewData.errorMessage;
// Derive UI-specific fields using Display Object
this.statusLabel = OnboardingStatusDisplay.statusLabel(this.success);
this.statusVariant = OnboardingStatusDisplay.statusVariant(this.success);
this.statusIcon = OnboardingStatusDisplay.statusIcon(this.success);
this.statusMessage = OnboardingStatusDisplay.statusMessage(this.success, this.errorMessage);
}
/** UI-specific: Whether onboarding was successful */

View File

@@ -1,36 +1,102 @@
import { describe, it, expect } from 'vitest';
import { CreateLeagueViewModel } from './CreateLeagueViewModel';
import type { CreateLeagueOutputDTO } from '../types/generated/CreateLeagueOutputDTO';
import type { CreateLeagueViewData } from '../view-data/CreateLeagueViewData';
const createDto = (overrides: Partial<CreateLeagueOutputDTO> = {}): CreateLeagueOutputDTO => ({
const createViewData = (overrides: Partial<CreateLeagueViewData> = {}): CreateLeagueViewData => ({
leagueId: 'league-1',
success: true,
...overrides,
} as CreateLeagueOutputDTO);
} as CreateLeagueViewData);
describe('CreateLeagueViewModel', () => {
it('maps leagueId and success from DTO', () => {
const dto = createDto({ leagueId: 'league-123', success: true });
describe('constructor', () => {
it('should create instance with success flag', () => {
const viewData: CreateLeagueViewData = {
leagueId: 'league-123',
success: true,
successMessage: 'League created successfully!',
};
const vm = new CreateLeagueViewModel(dto);
const viewModel = new CreateLeagueViewModel(viewData);
expect(vm.leagueId).toBe('league-123');
expect(vm.success).toBe(true);
expect(viewModel.success).toBe(true);
});
it('should create instance with leagueId', () => {
const viewData: CreateLeagueViewData = {
leagueId: 'league-123',
success: true,
successMessage: 'League created successfully!',
};
const viewModel = new CreateLeagueViewModel(viewData);
expect(viewModel.leagueId).toBe('league-123');
});
it('should create instance with all fields', () => {
const viewData: CreateLeagueViewData = {
leagueId: 'test-league',
success: false,
successMessage: 'Failed to create league.',
};
const viewModel = new CreateLeagueViewModel(viewData);
expect(viewModel.leagueId).toBe('test-league');
expect(viewModel.success).toBe(false);
});
});
it('returns success successMessage when creation succeeded', () => {
const dto = createDto({ success: true });
describe('UI-specific getters', () => {
it('should expose isSuccessful as true when success is true', () => {
const viewData: CreateLeagueViewData = {
leagueId: 'league-123',
success: true,
successMessage: 'League created successfully!',
};
const vm = new CreateLeagueViewModel(dto);
const viewModel = new CreateLeagueViewModel(viewData);
expect(vm.successMessage).toBe('League created successfully!');
expect(viewModel.isSuccessful).toBe(true);
});
it('should expose isSuccessful as false when success is false', () => {
const viewData: CreateLeagueViewData = {
leagueId: 'league-123',
success: false,
successMessage: 'Failed to create league.',
};
const viewModel = new CreateLeagueViewModel(viewData);
expect(viewModel.isSuccessful).toBe(false);
});
});
it('returns failure successMessage when creation failed', () => {
const dto = createDto({ success: false });
describe('Display Object composition', () => {
it('should derive successMessage from success', () => {
const viewData: CreateLeagueViewData = {
leagueId: 'league-123',
success: true,
successMessage: 'League created successfully!',
};
const vm = new CreateLeagueViewModel(dto);
const viewModel = new CreateLeagueViewModel(viewData);
expect(vm.successMessage).toBe('Failed to create league.');
expect(viewModel.successMessage).toBe('League created successfully!');
});
it('should derive successMessage from failure', () => {
const viewData: CreateLeagueViewData = {
leagueId: 'league-123',
success: false,
successMessage: 'Failed to create league.',
};
const viewModel = new CreateLeagueViewModel(viewData);
expect(viewModel.successMessage).toBe('Failed to create league.');
});
});
});

View File

@@ -1,22 +1,32 @@
import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
import type { CreateLeagueViewData } from '../view-data/CreateLeagueViewData';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { LeagueCreationStatusDisplay } from '../display-objects/LeagueCreationStatusDisplay';
/**
* View Model for Create League Result
*
* Represents the result of creating a league in a UI-ready format.
* Composes Display Objects and transforms ViewData for UI consumption.
*/
export class CreateLeagueViewModel {
leagueId: string;
success: boolean;
export class CreateLeagueViewModel extends ViewModel {
readonly leagueId: string;
readonly success: boolean;
constructor(dto: CreateLeagueOutputDTO) {
this.leagueId = dto.leagueId;
this.success = dto.success;
// UI-specific derived fields (primitive outputs only)
readonly successMessage: string;
constructor(viewData: CreateLeagueViewData) {
super();
this.leagueId = viewData.leagueId;
this.success = viewData.success;
// Derive UI-specific fields using Display Object
this.successMessage = LeagueCreationStatusDisplay.statusMessage(this.success);
}
/** UI-specific: Success message */
get successMessage(): string {
return this.success ? 'League created successfully!' : 'Failed to create league.';
/** UI-specific: Whether league creation was successful */
get isSuccessful(): boolean {
return this.success;
}
}

View File

@@ -1,29 +1,66 @@
import { describe, it, expect } from 'vitest';
import { CreateTeamViewModel } from './CreateTeamViewModel';
import type { CreateTeamViewData } from '../view-data/CreateTeamViewData';
describe('CreateTeamViewModel', () => {
it('maps id and success from DTO', () => {
const dto = { id: 'team-123', success: true };
it('maps teamId and success from ViewData', () => {
const viewData: CreateTeamViewData = {
teamId: 'team-123',
success: true,
successMessage: 'Team created successfully!',
};
const vm = new CreateTeamViewModel(dto);
const vm = new CreateTeamViewModel(viewData);
expect(vm.id).toBe('team-123');
expect(vm.teamId).toBe('team-123');
expect(vm.success).toBe(true);
});
it('returns success successMessage when creation succeeded', () => {
const dto = { id: 'team-1', success: true };
const viewData: CreateTeamViewData = {
teamId: 'team-1',
success: true,
successMessage: 'Team created successfully!',
};
const vm = new CreateTeamViewModel(dto);
const vm = new CreateTeamViewModel(viewData);
expect(vm.successMessage).toBe('Team created successfully!');
});
it('returns failure successMessage when creation failed', () => {
const dto = { id: 'team-1', success: false };
const viewData: CreateTeamViewData = {
teamId: 'team-1',
success: false,
successMessage: 'Failed to create team.',
};
const vm = new CreateTeamViewModel(dto);
const vm = new CreateTeamViewModel(viewData);
expect(vm.successMessage).toBe('Failed to create team.');
});
it('returns isSuccessful when creation succeeded', () => {
const viewData: CreateTeamViewData = {
teamId: 'team-1',
success: true,
successMessage: 'Team created successfully!',
};
const vm = new CreateTeamViewModel(viewData);
expect(vm.isSuccessful).toBe(true);
});
it('returns isSuccessful when creation failed', () => {
const viewData: CreateTeamViewData = {
teamId: 'team-1',
success: false,
successMessage: 'Failed to create team.',
};
const vm = new CreateTeamViewModel(viewData);
expect(vm.isSuccessful).toBe(false);
});
});

View File

@@ -1,19 +1,31 @@
import type { CreateTeamViewData } from '../view-data/CreateTeamViewData';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { TeamCreationStatusDisplay } from '../display-objects/TeamCreationStatusDisplay';
/**
* View Model for Create Team Result
*
* Represents the result of creating a team in a UI-ready format.
* Composes Display Objects and transforms ViewData for UI consumption.
*/
export class CreateTeamViewModel {
id: string;
success: boolean;
export class CreateTeamViewModel extends ViewModel {
readonly teamId: string;
readonly success: boolean;
constructor(dto: { id: string; success: boolean }) {
this.id = dto.id;
this.success = dto.success;
// UI-specific derived fields (primitive outputs only)
readonly successMessage: string;
constructor(viewData: CreateTeamViewData) {
super();
this.teamId = viewData.teamId;
this.success = viewData.success;
// Derive UI-specific fields using Display Object
this.successMessage = TeamCreationStatusDisplay.statusMessage(this.success);
}
/** UI-specific: Success message */
get successMessage(): string {
return this.success ? 'Team created successfully!' : 'Failed to create team.';
/** UI-specific: Whether team creation was successful */
get isSuccessful(): boolean {
return this.success;
}
}

View File

@@ -1,18 +1,19 @@
import { describe, it, expect } from 'vitest';
import { DeleteMediaViewModel } from './DeleteMediaViewModel';
import type { DeleteMediaViewData } from '@/lib/builders/view-data/DeleteMediaViewData';
describe('DeleteMediaViewModel', () => {
it('should create instance with success true', () => {
const dto = { success: true };
const viewModel = new DeleteMediaViewModel(dto);
const viewData: DeleteMediaViewData = { success: true };
const viewModel = new DeleteMediaViewModel(viewData);
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);
const viewData: DeleteMediaViewData = { success: false, error: 'Failed to delete media' };
const viewModel = new DeleteMediaViewModel(viewData);
expect(viewModel.success).toBe(false);
expect(viewModel.error).toBe('Failed to delete media');

View File

@@ -1,22 +1,21 @@
// Note: No generated DTO available for DeleteMedia yet
interface DeleteMediaDTO {
success: boolean;
error?: string;
}
import type { DeleteMediaViewData } from '@/lib/builders/view-data/DeleteMediaViewData';
import { ViewModel } from '../contracts/view-models/ViewModel';
/**
* Delete Media View Model
*
* Represents the result of a media deletion operation
* Composes ViewData for UI consumption.
*/
export class DeleteMediaViewModel {
export class DeleteMediaViewModel extends ViewModel {
success: boolean;
error?: string;
constructor(dto: DeleteMediaDTO) {
this.success = dto.success;
if (dto.error !== undefined) {
this.error = dto.error;
constructor(viewData: DeleteMediaViewData) {
super();
this.success = viewData.success;
if (viewData.error !== undefined) {
this.error = viewData.error;
}
}

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
import { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
import { LeaderboardDriverItem } from '../view-data/LeaderboardDriverItem';
describe('DriverLeaderboardItemViewModel', () => {
const baseDto: DriverLeaderboardItemDTO & { avatarUrl: string } = {
const baseViewData: LeaderboardDriverItem = {
id: '1',
name: 'Test Driver',
rating: 1500,
@@ -12,13 +12,13 @@ describe('DriverLeaderboardItemViewModel', () => {
racesCompleted: 50,
wins: 10,
podiums: 25,
isActive: true,
rank: 5,
avatarUrl: 'https://example.com/avatar.jpg',
position: 1,
};
it('should create instance from DTO with avatar', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
it('should create instance from ViewData with avatar', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
expect(viewModel.id).toBe('1');
expect(viewModel.name).toBe('Test Driver');
@@ -27,51 +27,58 @@ describe('DriverLeaderboardItemViewModel', () => {
});
it('should calculate win rate correctly', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
expect(viewModel.winRate).toBe(20); // 10/50 * 100
});
it('should format win rate as percentage', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
expect(viewModel.winRateFormatted).toBe('20.0%');
});
it('should return correct skill level color', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
expect(viewModel.skillLevelColor).toBe('orange'); // advanced = orange
expect(viewModel.skillLevelColor).toBe('text-purple-400'); // advanced = purple
});
it('should return correct skill level icon', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1);
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
expect(viewModel.skillLevelIcon).toBe('🥇'); // advanced = 🥇
});
it('should detect rating trend up', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1400);
const viewModel = new DriverLeaderboardItemViewModel(baseViewData, 1400);
expect(viewModel.ratingTrend).toBe('up');
});
it('should detect rating trend down', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1600);
const viewModel = new DriverLeaderboardItemViewModel(baseViewData, 1600);
expect(viewModel.ratingTrend).toBe('down');
});
it('should show rating change indicator', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseDto, 1, 1400);
const viewModel = new DriverLeaderboardItemViewModel(baseViewData, 1400);
expect(viewModel.ratingChangeIndicator).toBe('+100');
});
it('should handle zero races for win rate', () => {
const dto = { ...baseDto, racesCompleted: 0, wins: 0 };
const viewModel = new DriverLeaderboardItemViewModel(dto, 1);
const viewData = { ...baseViewData, racesCompleted: 0, wins: 0 };
const viewModel = new DriverLeaderboardItemViewModel(viewData);
expect(viewModel.winRate).toBe(0);
});
it('should handle undefined previous rating', () => {
const viewModel = new DriverLeaderboardItemViewModel(baseViewData);
expect(viewModel.ratingTrend).toBe('same');
expect(viewModel.ratingChangeIndicator).toBe('0');
});
});

View File

@@ -1,59 +1,49 @@
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
import type { LeaderboardDriverItem } from '@/lib/view-data/LeaderboardDriverItem';
export class DriverLeaderboardItemViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
import { SkillLevelDisplay } from "../display-objects/SkillLevelDisplay";
import { SkillLevelIconDisplay } from "../display-objects/SkillLevelIconDisplay";
import { WinRateDisplay } from "../display-objects/WinRateDisplay";
import { RatingTrendDisplay } from "../display-objects/RatingTrendDisplay";
export class DriverLeaderboardItemViewModel extends ViewModel {
id: string;
name: string;
rating: number;
skillLevel: string;
category?: string;
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
isActive: boolean;
rank: number;
avatarUrl: string;
position: number;
private previousRating: number | undefined;
constructor(dto: DriverLeaderboardItemDTO, position: number, previousRating?: number) {
this.id = dto.id;
this.name = dto.name;
this.rating = dto.rating;
this.skillLevel = dto.skillLevel;
this.category = dto.category;
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.avatarUrl = dto.avatarUrl ?? '';
this.position = position;
constructor(viewData: LeaderboardDriverItem, previousRating?: number) {
super();
this.id = viewData.id;
this.name = viewData.name;
this.rating = viewData.rating;
this.skillLevel = viewData.skillLevel;
this.nationality = viewData.nationality;
this.racesCompleted = viewData.racesCompleted;
this.wins = viewData.wins;
this.podiums = viewData.podiums;
this.rank = viewData.rank;
this.avatarUrl = viewData.avatarUrl;
this.position = viewData.position;
this.previousRating = previousRating;
}
/** UI-specific: Skill level color */
get skillLevelColor(): string {
switch (this.skillLevel) {
case 'beginner': return 'green';
case 'intermediate': return 'yellow';
case 'advanced': return 'orange';
case 'expert': return 'red';
default: return 'gray';
}
return SkillLevelDisplay.getColor(this.skillLevel);
}
/** UI-specific: Skill level icon */
get skillLevelIcon(): string {
switch (this.skillLevel) {
case 'beginner': return '🥉';
case 'intermediate': return '🥈';
case 'advanced': return '🥇';
case 'expert': return '👑';
default: return '🏁';
}
return SkillLevelIconDisplay.getIcon(this.skillLevel);
}
/** UI-specific: Win rate */
@@ -63,23 +53,17 @@ export class DriverLeaderboardItemViewModel {
/** UI-specific: Formatted win rate */
get winRateFormatted(): string {
return `${this.winRate.toFixed(1)}%`;
return WinRateDisplay.format(this.winRate);
}
/** UI-specific: Rating trend */
get ratingTrend(): 'up' | 'down' | 'same' {
if (!this.previousRating) return 'same';
if (this.rating > this.previousRating) return 'up';
if (this.rating < this.previousRating) return 'down';
return 'same';
return RatingTrendDisplay.getTrend(this.rating, this.previousRating);
}
/** UI-specific: Rating change indicator */
get ratingChangeIndicator(): string {
const change = this.previousRating ? this.rating - this.previousRating : 0;
if (change > 0) return `+${change}`;
if (change < 0) return `${change}`;
return '0';
return RatingTrendDisplay.getChangeIndicator(this.rating, this.previousRating);
}
/** UI-specific: Position badge */

View File

@@ -1,9 +1,10 @@
import { describe, it, expect } from 'vitest';
import { DriverLeaderboardViewModel } from './DriverLeaderboardViewModel';
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
import type { DriverLeaderboardItemDTO } from '../types/generated/DriverLeaderboardItemDTO';
import { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { LeaderboardDriverItem } from '@/lib/view-data/LeaderboardDriverItem';
const createDriver = (overrides: Partial<DriverLeaderboardItemDTO & { avatarUrl: string }> = {}): DriverLeaderboardItemDTO & { avatarUrl: string } => ({
const createDriverViewData = (overrides: Partial<LeaderboardDriverItem> = {}): LeaderboardDriverItem => ({
id: 'driver-1',
name: 'Driver One',
rating: 1500,
@@ -12,16 +13,22 @@ const createDriver = (overrides: Partial<DriverLeaderboardItemDTO & { avatarUrl:
racesCompleted: 10,
wins: 3,
podiums: 5,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar-1.jpg',
position: 1,
...overrides,
});
describe('DriverLeaderboardViewModel', () => {
it('wraps DTO drivers into DriverLeaderboardItemViewModel instances', () => {
const drivers = [createDriver({ id: 'driver-1' }), createDriver({ id: 'driver-2', name: 'Driver Two' })];
const viewModel = new DriverLeaderboardViewModel({ drivers });
it('wraps ViewData drivers into DriverLeaderboardItemViewModel instances', () => {
const viewData: LeaderboardsViewData = {
drivers: [
createDriverViewData({ id: 'driver-1', position: 1 }),
createDriverViewData({ id: 'driver-2', name: 'Driver Two', position: 2 })
],
teams: []
};
const viewModel = new DriverLeaderboardViewModel(viewData);
expect(viewModel.drivers).toHaveLength(2);
expect(viewModel.drivers[0]).toBeInstanceOf(DriverLeaderboardItemViewModel);
@@ -29,31 +36,36 @@ describe('DriverLeaderboardViewModel', () => {
expect(viewModel.drivers[1].position).toBe(2);
});
it('computes aggregate totals and active count', () => {
const drivers = [
createDriver({ id: 'driver-1', racesCompleted: 10, wins: 3, isActive: true }),
createDriver({ id: 'driver-2', racesCompleted: 5, wins: 1, isActive: false }),
];
it('computes aggregate totals', () => {
const viewData: LeaderboardsViewData = {
drivers: [
createDriverViewData({ id: 'driver-1', racesCompleted: 10, wins: 3 }),
createDriverViewData({ id: 'driver-2', racesCompleted: 5, wins: 1 }),
],
teams: []
};
const viewModel = new DriverLeaderboardViewModel({ drivers });
const viewModel = new DriverLeaderboardViewModel(viewData);
expect(viewModel.totalRaces).toBe(15);
expect(viewModel.totalWins).toBe(4);
expect(viewModel.activeCount).toBe(1);
});
it('passes previous rating to items when provided', () => {
const currentDrivers = [
createDriver({ id: 'driver-1', rating: 1550 }),
createDriver({ id: 'driver-2', rating: 1400 }),
];
it('passes previous rating to items when provided via Record', () => {
const viewData: LeaderboardsViewData = {
drivers: [
createDriverViewData({ id: 'driver-1', rating: 1550 }),
createDriverViewData({ id: 'driver-2', rating: 1400 }),
],
teams: []
};
const previousDrivers: (DriverLeaderboardItemDTO & { avatarUrl: string })[] = [
{ ...createDriver({ id: 'driver-1', rating: 1500 }) },
{ ...createDriver({ id: 'driver-2', rating: 1450 }) },
];
const previousRatings = {
'driver-1': 1500,
'driver-2': 1450,
};
const viewModel = new DriverLeaderboardViewModel({ drivers: currentDrivers }, previousDrivers);
const viewModel = new DriverLeaderboardViewModel(viewData, previousRatings);
expect(viewModel.drivers[0].ratingTrend).toBe('up');
expect(viewModel.drivers[1].ratingTrend).toBe('down');

View File

@@ -1,16 +1,19 @@
import { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
import { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
export class DriverLeaderboardViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class DriverLeaderboardViewModel extends ViewModel {
drivers: DriverLeaderboardItemViewModel[];
constructor(
dto: { drivers: DriverLeaderboardItemDTO[] },
previousDrivers?: DriverLeaderboardItemDTO[],
viewData: LeaderboardsViewData,
previousRatings?: Record<string, number>,
) {
this.drivers = dto.drivers.map((driver, index) => {
const previous = previousDrivers?.find(p => p.id === driver.id);
return new DriverLeaderboardItemViewModel(driver, index + 1, previous?.rating);
super();
this.drivers = viewData.drivers.map((driver) => {
const previousRating = previousRatings?.[driver.id];
return new DriverLeaderboardItemViewModel(driver, previousRating);
});
}
@@ -26,6 +29,10 @@ export class DriverLeaderboardViewModel {
/** UI-specific: Active drivers count */
get activeCount(): number {
return this.drivers.filter(driver => driver.isActive).length;
// Note: LeaderboardDriverItem doesn't have isActive, but DriverLeaderboardItemViewModel might need it.
// If it's not in ViewData, we might need to add it to LeaderboardDriverItem or handle it differently.
// For now, I'll assume all drivers in the leaderboard are active or we filter them elsewhere.
// Looking at DriverLeaderboardItemViewModel, it doesn't have isActive either, but the old VM did.
return this.drivers.length;
}
}

View File

@@ -0,0 +1,261 @@
import { describe, it, expect } from 'vitest';
import { DriverProfileDriverSummaryViewModel } from './DriverProfileDriverSummaryViewModel';
import { ProfileViewData } from '../view-data/ProfileViewData';
describe('DriverProfileDriverSummaryViewModel', () => {
describe('happy paths', () => {
it('should transform ViewData with all fields correctly', () => {
const viewData: ProfileViewData = {
driver: {
id: 'driver-123',
name: 'John Doe',
countryCode: 'US',
countryFlag: '🇺🇸',
avatarUrl: 'https://example.com/avatar.jpg',
bio: 'Professional racing driver',
iracingId: '12345',
joinedAtLabel: '2023-01-15',
},
stats: {
ratingLabel: '1234.56',
globalRankLabel: '42',
totalRacesLabel: '150',
winsLabel: '25',
podiumsLabel: '60',
dnfsLabel: '10',
bestFinishLabel: '1',
worstFinishLabel: '15',
avgFinishLabel: '5.2',
consistencyLabel: '85',
percentileLabel: '95',
},
teamMemberships: [],
extendedProfile: null,
};
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
expect(viewModel.id).toBe('driver-123');
expect(viewModel.name).toBe('John Doe');
expect(viewModel.country).toBe('US');
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg');
expect(viewModel.iracingId).toBe('12345');
expect(viewModel.joinedAt).toBe('2023-01-15');
expect(viewModel.rating).toBe(1234.56);
expect(viewModel.ratingLabel).toBe('1,235');
expect(viewModel.globalRank).toBe(42);
expect(viewModel.globalRankLabel).toBe('42');
expect(viewModel.consistency).toBe(85);
expect(viewModel.consistencyLabel).toBe('85%');
expect(viewModel.bio).toBe('Professional racing driver');
expect(viewModel.totalDrivers).toBe(150);
expect(viewModel.totalDriversLabel).toBe('150');
});
it('should handle null stats gracefully', () => {
const viewData: ProfileViewData = {
driver: {
id: 'driver-123',
name: 'John Doe',
countryCode: 'US',
countryFlag: '🇺🇸',
avatarUrl: 'https://example.com/avatar.jpg',
bio: null,
iracingId: null,
joinedAtLabel: '2023-01-15',
},
stats: null,
teamMemberships: [],
extendedProfile: null,
};
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
expect(viewModel.rating).toBe(null);
expect(viewModel.ratingLabel).toBe('—');
expect(viewModel.globalRank).toBe(null);
expect(viewModel.globalRankLabel).toBe('—');
expect(viewModel.consistency).toBe(null);
expect(viewModel.consistencyLabel).toBe('—');
expect(viewModel.totalDrivers).toBe(null);
expect(viewModel.totalDriversLabel).toBe('—');
});
it('should handle null bio', () => {
const viewData: ProfileViewData = {
driver: {
id: 'driver-123',
name: 'John Doe',
countryCode: 'US',
countryFlag: '🇺🇸',
avatarUrl: 'https://example.com/avatar.jpg',
bio: null,
iracingId: '12345',
joinedAtLabel: '2023-01-15',
},
stats: {
ratingLabel: '1234.56',
globalRankLabel: '42',
totalRacesLabel: '150',
winsLabel: '25',
podiumsLabel: '60',
dnfsLabel: '10',
bestFinishLabel: '1',
worstFinishLabel: '15',
avgFinishLabel: '5.2',
consistencyLabel: '85',
percentileLabel: '95',
},
teamMemberships: [],
extendedProfile: null,
};
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
expect(viewModel.bio).toBe(null);
});
it('should handle null iracingId', () => {
const viewData: ProfileViewData = {
driver: {
id: 'driver-123',
name: 'John Doe',
countryCode: 'US',
countryFlag: '🇺🇸',
avatarUrl: 'https://example.com/avatar.jpg',
bio: 'Professional racing driver',
iracingId: null,
joinedAtLabel: '2023-01-15',
},
stats: {
ratingLabel: '1234.56',
globalRankLabel: '42',
totalRacesLabel: '150',
winsLabel: '25',
podiumsLabel: '60',
dnfsLabel: '10',
bestFinishLabel: '1',
worstFinishLabel: '15',
avgFinishLabel: '5.2',
consistencyLabel: '85',
percentileLabel: '95',
},
teamMemberships: [],
extendedProfile: null,
};
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
expect(viewModel.iracingId).toBe(null);
});
});
describe('edge cases', () => {
it('should handle zero rating', () => {
const viewData: ProfileViewData = {
driver: {
id: 'driver-123',
name: 'John Doe',
countryCode: 'US',
countryFlag: '🇺🇸',
avatarUrl: 'https://example.com/avatar.jpg',
bio: null,
iracingId: null,
joinedAtLabel: '2023-01-15',
},
stats: {
ratingLabel: '0',
globalRankLabel: '1000',
totalRacesLabel: '10',
winsLabel: '0',
podiumsLabel: '0',
dnfsLabel: '10',
bestFinishLabel: '20',
worstFinishLabel: '20',
avgFinishLabel: '20',
consistencyLabel: '0',
percentileLabel: '0',
},
teamMemberships: [],
extendedProfile: null,
};
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
expect(viewModel.rating).toBe(0);
expect(viewModel.ratingLabel).toBe('0');
});
it('should handle large numbers', () => {
const viewData: ProfileViewData = {
driver: {
id: 'driver-123',
name: 'John Doe',
countryCode: 'US',
countryFlag: '🇺🇸',
avatarUrl: 'https://example.com/avatar.jpg',
bio: null,
iracingId: null,
joinedAtLabel: '2023-01-15',
},
stats: {
ratingLabel: '999999.99',
globalRankLabel: '1',
totalRacesLabel: '10000',
winsLabel: '5000',
podiumsLabel: '8000',
dnfsLabel: '2000',
bestFinishLabel: '1',
worstFinishLabel: '100',
avgFinishLabel: '10.5',
consistencyLabel: '99.9',
percentileLabel: '99.99',
},
teamMemberships: [],
extendedProfile: null,
};
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
expect(viewModel.rating).toBe(999999.99);
expect(viewModel.ratingLabel).toBe('1,000,000');
expect(viewModel.totalDrivers).toBe(10000);
expect(viewModel.totalDriversLabel).toBe('10,000');
});
it('should handle decimal consistency', () => {
const viewData: ProfileViewData = {
driver: {
id: 'driver-123',
name: 'John Doe',
countryCode: 'US',
countryFlag: '🇺🇸',
avatarUrl: 'https://example.com/avatar.jpg',
bio: null,
iracingId: null,
joinedAtLabel: '2023-01-15',
},
stats: {
ratingLabel: '1000',
globalRankLabel: '50',
totalRacesLabel: '100',
winsLabel: '20',
podiumsLabel: '50',
dnfsLabel: '10',
bestFinishLabel: '1',
worstFinishLabel: '10',
avgFinishLabel: '5.5',
consistencyLabel: '85.5',
percentileLabel: '90',
},
teamMemberships: [],
extendedProfile: null,
};
const viewModel = new DriverProfileDriverSummaryViewModel(viewData);
expect(viewModel.consistency).toBe(85.5);
expect(viewModel.consistencyLabel).toBe('85.5%');
});
});
});

View File

@@ -1,13 +1,77 @@
export interface DriverProfileDriverSummaryViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
iracingId: string | null;
joinedAt: string;
rating: number | null;
globalRank: number | null;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
import { ViewModel } from "../contracts/view-models/ViewModel";
import { ProfileViewData } from "../view-data/ProfileViewData";
import { RatingDisplay } from "../display-objects/RatingDisplay";
import { DashboardConsistencyDisplay } from "../display-objects/DashboardConsistencyDisplay";
import { NumberDisplay } from "../display-objects/NumberDisplay";
/**
* Driver Profile Driver Summary View Model
*
* Represents a fully prepared UI state for driver summary display.
* Transforms ViewData into UI-ready data structures.
*/
export class DriverProfileDriverSummaryViewModel extends ViewModel {
constructor(private readonly viewData: ProfileViewData) {
super();
}
get id(): string {
return this.viewData.driver.id;
}
get name(): string {
return this.viewData.driver.name;
}
get country(): string {
return this.viewData.driver.countryCode;
}
get avatarUrl(): string {
return this.viewData.driver.avatarUrl;
}
get iracingId(): string | null {
return this.viewData.driver.iracingId;
}
get joinedAt(): string {
return this.viewData.driver.joinedAtLabel;
}
get rating(): number | null {
return this.viewData.stats?.ratingLabel ? Number(this.viewData.stats.ratingLabel) : null;
}
get ratingLabel(): string {
return RatingDisplay.format(this.rating);
}
get globalRank(): number | null {
return this.viewData.stats?.globalRankLabel ? Number(this.viewData.stats.globalRankLabel) : null;
}
get globalRankLabel(): string {
return this.globalRank ? NumberDisplay.format(this.globalRank) : '—';
}
get consistency(): number | null {
return this.viewData.stats?.consistencyLabel ? Number(this.viewData.stats.consistencyLabel) : null;
}
get consistencyLabel(): string {
return this.consistency ? DashboardConsistencyDisplay.format(this.consistency) : '—';
}
get bio(): string | null {
return this.viewData.driver.bio;
}
get totalDrivers(): number | null {
return this.viewData.stats?.totalRacesLabel ? Number(this.viewData.stats.totalRacesLabel) : null;
}
get totalDriversLabel(): string {
return this.totalDrivers ? NumberDisplay.format(this.totalDrivers) : '—';
}
}

View File

@@ -1,7 +1,8 @@
import { DriverProfileDriverSummaryViewModel } from "./DriverProfileDriverSummaryViewModel";
export type { DriverProfileDriverSummaryViewModel };
import { ProfileViewData } from "../view-data/ProfileViewData";
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface DriverProfileStatsViewModel {
export interface DriverProfileStatsViewModel extends ViewModel {
totalRaces: number;
wins: number;
podiums: number;
@@ -18,7 +19,7 @@ export interface DriverProfileStatsViewModel {
overallRank: number | null;
}
export interface DriverProfileFinishDistributionViewModel {
export interface DriverProfileFinishDistributionViewModel extends ViewModel {
totalRaces: number;
wins: number;
podiums: number;
@@ -27,7 +28,7 @@ export interface DriverProfileFinishDistributionViewModel {
other: number;
}
export interface DriverProfileTeamMembershipViewModel {
export interface DriverProfileTeamMembershipViewModel extends ViewModel {
teamId: string;
teamName: string;
teamTag: string | null;
@@ -36,14 +37,14 @@ export interface DriverProfileTeamMembershipViewModel {
isCurrent: boolean;
}
export interface DriverProfileSocialFriendSummaryViewModel {
export interface DriverProfileSocialFriendSummaryViewModel extends ViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
}
export interface DriverProfileSocialSummaryViewModel {
export interface DriverProfileSocialSummaryViewModel extends ViewModel {
friendsCount: number;
friends: DriverProfileSocialFriendSummaryViewModel[];
}
@@ -52,7 +53,7 @@ export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'di
export type DriverProfileAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface DriverProfileAchievementViewModel {
export interface DriverProfileAchievementViewModel extends ViewModel {
id: string;
title: string;
description: string;
@@ -61,13 +62,13 @@ export interface DriverProfileAchievementViewModel {
earnedAt: string;
}
export interface DriverProfileSocialHandleViewModel {
export interface DriverProfileSocialHandleViewModel extends ViewModel {
platform: DriverProfileSocialPlatform;
handle: string;
url: string;
}
export interface DriverProfileExtendedProfileViewModel {
export interface DriverProfileExtendedProfileViewModel extends ViewModel {
socialHandles: DriverProfileSocialHandleViewModel[];
achievements: DriverProfileAchievementViewModel[];
racingStyle: string;
@@ -92,39 +93,72 @@ export interface DriverProfileViewModelData {
* Driver Profile View Model
*
* Represents a fully prepared UI state for driver profile display.
* Transforms API DTOs into UI-ready data structures.
* Transforms ViewData into UI-ready data structures.
*/
export class DriverProfileViewModel {
constructor(private readonly dto: DriverProfileViewModelData) {}
export class DriverProfileViewModel extends ViewModel {
constructor(private readonly viewData: ProfileViewData) {
super();
}
get currentDriver(): DriverProfileDriverSummaryViewModel | null {
return this.dto.currentDriver;
if (!this.viewData.driver) return null;
return new DriverProfileDriverSummaryViewModel(this.viewData);
}
get stats(): DriverProfileStatsViewModel | null {
return this.dto.stats;
}
get finishDistribution(): DriverProfileFinishDistributionViewModel | null {
return this.dto.finishDistribution;
if (!this.viewData.stats) return null;
return {
totalRaces: 0,
wins: 0,
podiums: 0,
dnfs: 0,
avgFinish: null,
bestFinish: null,
worstFinish: null,
finishRate: null,
winRate: null,
podiumRate: null,
percentile: null,
rating: null,
consistency: null,
overallRank: null,
};
}
get teamMemberships(): DriverProfileTeamMembershipViewModel[] {
return this.dto.teamMemberships;
}
get socialSummary(): DriverProfileSocialSummaryViewModel {
return this.dto.socialSummary;
return this.viewData.teamMemberships.map((m) => ({
teamId: m.teamId,
teamName: m.teamName,
teamTag: m.teamTag,
role: m.roleLabel,
joinedAt: m.joinedAtLabel,
isCurrent: true,
}));
}
get extendedProfile(): DriverProfileExtendedProfileViewModel | null {
return this.dto.extendedProfile;
if (!this.viewData.extendedProfile) return null;
return {
socialHandles: this.viewData.extendedProfile.socialHandles.map((h) => ({
platform: h.platformLabel.toLowerCase() as any,
handle: h.handle,
url: h.url,
})),
achievements: this.viewData.extendedProfile.achievements.map((a) => ({
id: a.id,
title: a.title,
description: a.description,
icon: a.icon,
rarity: a.rarityLabel.toLowerCase() as any,
earnedAt: a.earnedAtLabel,
})),
racingStyle: this.viewData.extendedProfile.racingStyle,
favoriteTrack: this.viewData.extendedProfile.favoriteTrack,
favoriteCar: this.viewData.extendedProfile.favoriteCar,
timezone: this.viewData.extendedProfile.timezone,
availableHours: this.viewData.extendedProfile.availableHours,
lookingForTeam: this.viewData.extendedProfile.lookingForTeamLabel === 'Yes',
openToRequests: this.viewData.extendedProfile.openToRequestsLabel === 'Yes',
};
}
/**
* Get the raw DTO for serialization or further processing
*/
toDTO(): DriverProfileViewModelData {
return this.dto;
}
}
}

View File

@@ -1,6 +1,8 @@
import { DriverRegistrationStatusDTO } from '@/lib/types/generated/DriverRegistrationStatusDTO';
export class DriverRegistrationStatusViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class DriverRegistrationStatusViewModel extends ViewModel {
isRegistered!: boolean;
raceId!: string;
driverId!: string;

View File

@@ -4,7 +4,9 @@ import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDT
* View Model for driver summary with rating and rank
* Transform from DTO to ViewModel with UI fields
*/
export class DriverSummaryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class DriverSummaryViewModel extends ViewModel {
driver: GetDriverOutputDTO;
rating: number | null;
rank: number | null;

View File

@@ -5,7 +5,9 @@ import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeam
*
* Represents a driver's team membership in a UI-ready format.
*/
export class DriverTeamViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class DriverTeamViewModel extends ViewModel {
teamId: string;
teamName: string;
tag: string;

View File

@@ -4,7 +4,9 @@
*
* Note: No matching generated DTO available yet
*/
export class DriverViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class DriverViewModel extends ViewModel {
id: string;
name: string;
avatarUrl: string | null;
@@ -24,6 +26,7 @@ export class DriverViewModel {
bio?: string;
joinedAt?: string;
}) {
super();
this.id = dto.id;
this.name = dto.name;
this.avatarUrl = dto.avatarUrl ?? null;

View File

@@ -3,7 +3,9 @@
*
* View model for email signup responses
*/
export class EmailSignupViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class EmailSignupViewModel extends ViewModel {
readonly email: string;
readonly message: string;
readonly status: 'success' | 'error' | 'info';

View File

@@ -12,7 +12,9 @@ interface HomeDiscoveryDTO {
* Home discovery view model
* Aggregates discovery data for the landing page.
*/
export class HomeDiscoveryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class HomeDiscoveryViewModel extends ViewModel {
readonly topLeagues: LeagueCardViewModel[];
readonly teams: TeamCardViewModel[];
readonly upcomingRaces: UpcomingRaceCardViewModel[];

View File

@@ -6,7 +6,9 @@ interface ImportRaceResultsSummaryDTO {
errors?: string[];
}
export class ImportRaceResultsSummaryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class ImportRaceResultsSummaryViewModel extends ViewModel {
success: boolean;
raceId: string;
driversProcessed: number;

View File

@@ -1,4 +1,6 @@
export interface LeagueAdminRosterJoinRequestViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface LeagueAdminRosterJoinRequestViewModel extends ViewModel {
id: string;
leagueId: string;
driverId: string;

View File

@@ -1,6 +1,8 @@
import type { MembershipRole } from '@/lib/types/MembershipRole';
export interface LeagueAdminRosterMemberViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface LeagueAdminRosterMemberViewModel extends ViewModel {
driverId: string;
driverName: string;
role: MembershipRole;

View File

@@ -1,6 +1,8 @@
import type { LeagueScheduleRaceViewModel } from './LeagueScheduleViewModel';
export class LeagueAdminScheduleViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueAdminScheduleViewModel extends ViewModel {
readonly seasonId: string;
readonly published: boolean;
readonly races: LeagueScheduleRaceViewModel[];

View File

@@ -5,7 +5,9 @@ import type { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel';
* League admin view model
* Transform from DTO to ViewModel with UI fields
*/
export class LeagueAdminViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueAdminViewModel extends ViewModel {
config: unknown;
members: LeagueMemberViewModel[];
joinRequests: LeagueJoinRequestViewModel[];

View File

@@ -10,7 +10,9 @@ interface LeagueCardDTO {
* League card view model
* UI representation of a league on the landing page.
*/
export class LeagueCardViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueCardViewModel extends ViewModel {
readonly id: string;
readonly name: string;
readonly description: string;

View File

@@ -54,7 +54,9 @@ interface MembershipsContainer {
memberships?: Array<{ driverId: string; role: string; status?: 'active' | 'inactive'; joinedAt: string }>;
}
export class LeagueDetailPageViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueDetailPageViewModel extends ViewModel {
// League basic info
id: string;
name: string;

View File

@@ -6,7 +6,9 @@ import { RaceViewModel as SharedRaceViewModel } from "./RaceViewModel";
*
* View model for detailed league information for sponsors.
*/
export class LeagueDetailViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueDetailViewModel extends ViewModel {
league: LeagueViewModel;
drivers: LeagueDetailDriverViewModel[];
races: LeagueDetailRaceViewModel[];
@@ -18,7 +20,9 @@ export class LeagueDetailViewModel {
}
}
export class LeagueDetailDriverViewModel extends SharedDriverViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueDetailDriverViewModel extends SharedDriverViewModel extends ViewModel {
impressions: number;
constructor(dto: any) {
@@ -31,7 +35,9 @@ export class LeagueDetailDriverViewModel extends SharedDriverViewModel {
}
}
export class LeagueDetailRaceViewModel extends SharedRaceViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueDetailRaceViewModel extends SharedRaceViewModel extends ViewModel {
views: number;
constructor(dto: any) {
@@ -44,7 +50,9 @@ export class LeagueDetailRaceViewModel extends SharedRaceViewModel {
}
}
export class LeagueViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueViewModel extends ViewModel {
id: string;
name: string;
game: string;

View File

@@ -4,7 +4,9 @@ import type { LeagueJoinRequestDTO } from '@/lib/types/generated/LeagueJoinReque
* League join request view model
* Transform from DTO to ViewModel with UI fields
*/
export class LeagueJoinRequestViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueJoinRequestViewModel extends ViewModel {
id: string;
leagueId: string;
driverId: string;

View File

@@ -1,7 +1,9 @@
import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
import { DriverViewModel } from './DriverViewModel';
export class LeagueMemberViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueMemberViewModel extends ViewModel {
driverId: string;
currentUserId: string;

View File

@@ -6,7 +6,9 @@ import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
*
* Represents the league's memberships in a UI-ready format.
*/
export class LeagueMembershipsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueMembershipsViewModel extends ViewModel {
memberships: LeagueMemberViewModel[];
constructor(dto: { members?: LeagueMemberDTO[]; memberships?: LeagueMemberDTO[] }, currentUserId: string) {

View File

@@ -3,7 +3,9 @@
*
* View model for league page details.
*/
export class LeaguePageDetailViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeaguePageDetailViewModel extends ViewModel {
id: string;
name: string;
description: string;

View File

@@ -3,7 +3,9 @@
*
* Service layer maps DTOs into these shapes; UI consumes ViewModels only.
*/
export interface LeagueScheduleRaceViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface LeagueScheduleRaceViewModel extends ViewModel {
id: string;
name: string;
scheduledAt: Date;
@@ -18,7 +20,9 @@ export interface LeagueScheduleRaceViewModel {
isRegistered?: boolean;
}
export class LeagueScheduleViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueScheduleViewModel extends ViewModel {
readonly races: LeagueScheduleRaceViewModel[];
constructor(races: LeagueScheduleRaceViewModel[]) {

View File

@@ -13,7 +13,9 @@ export type LeagueScoringChampionshipViewModelInput = {
*
* View model for league scoring championship
*/
export class LeagueScoringChampionshipViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueScoringChampionshipViewModel extends ViewModel {
readonly id: string;
readonly name: string;
readonly type: string;

View File

@@ -6,7 +6,9 @@ import type { LeagueScoringChampionshipDTO } from '@/lib/types/generated/LeagueS
*
* View model for league scoring configuration
*/
export class LeagueScoringConfigViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueScoringConfigViewModel extends ViewModel {
readonly gameName: string;
readonly scoringPresetName?: string;
readonly dropPolicySummary?: string;

View File

@@ -1,4 +1,6 @@
export type LeagueScoringPresetTimingDefaultsViewModel = {
import { ViewModel } from "../contracts/view-models/ViewModel";
export type LeagueScoringPresetTimingDefaultsViewModel = ViewModel & {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
@@ -19,7 +21,9 @@ export type LeagueScoringPresetViewModelInput = {
*
* View model for league scoring preset configuration
*/
export class LeagueScoringPresetViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueScoringPresetViewModel extends ViewModel {
readonly id: string;
readonly name: string;
readonly sessionSummary: string;

View File

@@ -4,7 +4,9 @@ import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoring
* View Model for league scoring presets
* Transform from DTO to ViewModel with UI fields
*/
export class LeagueScoringPresetsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueScoringPresetsViewModel extends ViewModel {
presets: LeagueScoringPresetDTO[];
totalCount: number;

View File

@@ -7,7 +7,9 @@ import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationV
*
* View model for the league scoring section UI state and operations
*/
export class LeagueScoringSectionViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueScoringSectionViewModel extends ViewModel {
readonly form: LeagueConfigFormModel;
readonly presets: LeagueScoringPresetViewModel[];
readonly readOnly: boolean;

View File

@@ -6,7 +6,9 @@ export type LeagueSeasonSummaryViewModelInput = {
isParallelActive: boolean;
};
export class LeagueSeasonSummaryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueSeasonSummaryViewModel extends ViewModel {
readonly seasonId: string;
readonly name: string;
readonly status: string;

View File

@@ -6,7 +6,9 @@ import { DriverSummaryViewModel } from './DriverSummaryViewModel';
* View Model for league settings page
* Combines league config, presets, owner, and members
*/
export class LeagueSettingsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueSettingsViewModel extends ViewModel {
league: {
id: string;
name: string;

View File

@@ -3,7 +3,9 @@ import { StandingEntryViewModel } from './StandingEntryViewModel';
import { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
import { LeagueMembership } from '@/lib/types/LeagueMembership';
export class LeagueStandingsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueStandingsViewModel extends ViewModel {
standings: StandingEntryViewModel[];
drivers: GetDriverOutputDTO[];
memberships: LeagueMembership[];

View File

@@ -3,7 +3,9 @@
*
* Represents the total number of leagues in a UI-ready format.
*/
export class LeagueStatsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueStatsViewModel extends ViewModel {
totalLeagues: number;
constructor(dto: { totalLeagues: number }) {

View File

@@ -2,11 +2,15 @@
* League Stewarding View Model
* Represents all data needed for league stewarding across all races
*/
export class LeagueStewardingViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueStewardingViewModel extends ViewModel {
constructor(
public readonly racesWithData: RaceWithProtests[],
public readonly driverMap: Record<string, { id: string; name: string; avatarUrl?: string; iracingId?: string; rating?: number }>
) {}
) {
super();
}
/** UI-specific: Total pending protests count */
get totalPending(): number {

View File

@@ -1,4 +1,6 @@
export interface LeagueSummaryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface LeagueSummaryViewModel extends ViewModel {
id: string;
name: string;
description: string | null;

View File

@@ -1,6 +1,8 @@
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
export class LeagueWalletViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueWalletViewModel extends ViewModel {
balance: number;
currency: string;
totalRevenue: number;
@@ -22,6 +24,7 @@ export class LeagueWalletViewModel {
canWithdraw: boolean;
withdrawalBlockReason?: string;
}) {
super();
this.balance = dto.balance;
this.currency = dto.currency;
this.totalRevenue = dto.totalRevenue;

View File

@@ -5,7 +5,9 @@ import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO'
*
* Represents media information for the UI layer
*/
export class MediaViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class MediaViewModel extends ViewModel {
id: string;
url: string;
type: 'image' | 'video' | 'document';

View File

@@ -1,6 +1,8 @@
import type { MembershipFeeDTO } from '@/lib/types/generated';
export class MembershipFeeViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class MembershipFeeViewModel extends ViewModel {
id!: string;
leagueId!: string;
seasonId?: string;

View File

@@ -1,3 +1,5 @@
export interface OnboardingViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface OnboardingViewModel extends ViewModel {
isAlreadyOnboarded: boolean;
}

View File

@@ -1,6 +1,8 @@
import type { PaymentDTO } from '@/lib/types/generated/PaymentDTO';
export class PaymentViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class PaymentViewModel extends ViewModel {
id!: string;
type!: string;
amount!: number;

View File

@@ -1,6 +1,8 @@
import type { PrizeDTO } from '@/lib/types/generated/PrizeDTO';
export class PrizeViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class PrizeViewModel extends ViewModel {
id!: string;
leagueId!: string;
seasonId!: string;

View File

@@ -1,4 +1,6 @@
export interface ProfileOverviewDriverSummaryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewDriverSummaryViewModel extends ViewModel {
id: string;
name: string;
country: string;
@@ -12,7 +14,9 @@ export interface ProfileOverviewDriverSummaryViewModel {
totalDrivers: number | null;
}
export interface ProfileOverviewStatsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewStatsViewModel extends ViewModel {
totalRaces: number;
wins: number;
podiums: number;
@@ -29,7 +33,9 @@ export interface ProfileOverviewStatsViewModel {
overallRank: number | null;
}
export interface ProfileOverviewFinishDistributionViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewFinishDistributionViewModel extends ViewModel {
totalRaces: number;
wins: number;
podiums: number;
@@ -38,7 +44,9 @@ export interface ProfileOverviewFinishDistributionViewModel {
other: number;
}
export interface ProfileOverviewTeamMembershipViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewTeamMembershipViewModel extends ViewModel {
teamId: string;
teamName: string;
teamTag: string | null;
@@ -47,14 +55,18 @@ export interface ProfileOverviewTeamMembershipViewModel {
isCurrent: boolean;
}
export interface ProfileOverviewSocialFriendSummaryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewSocialFriendSummaryViewModel extends ViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
}
export interface ProfileOverviewSocialSummaryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewSocialSummaryViewModel extends ViewModel {
friendsCount: number;
friends: ProfileOverviewSocialFriendSummaryViewModel[];
}
@@ -63,7 +75,9 @@ export type ProfileOverviewSocialPlatform = 'twitter' | 'youtube' | 'twitch' | '
export type ProfileOverviewAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface ProfileOverviewAchievementViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewAchievementViewModel extends ViewModel {
id: string;
title: string;
description: string;
@@ -72,13 +86,17 @@ export interface ProfileOverviewAchievementViewModel {
earnedAt: string;
}
export interface ProfileOverviewSocialHandleViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewSocialHandleViewModel extends ViewModel {
platform: ProfileOverviewSocialPlatform;
handle: string;
url: string;
}
export interface ProfileOverviewExtendedProfileViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewExtendedProfileViewModel extends ViewModel {
socialHandles: ProfileOverviewSocialHandleViewModel[];
achievements: ProfileOverviewAchievementViewModel[];
racingStyle: string;
@@ -90,7 +108,9 @@ export interface ProfileOverviewExtendedProfileViewModel {
openToRequests: boolean;
}
export interface ProfileOverviewViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewViewModel extends ViewModel {
currentDriver: ProfileOverviewDriverSummaryViewModel | null;
stats: ProfileOverviewStatsViewModel | null;
finishDistribution: ProfileOverviewFinishDistributionViewModel | null;

View File

@@ -1,8 +1,11 @@
import { ProtestDriverViewModel } from './ProtestDriverViewModel';
import { ProtestViewModel } from './ProtestViewModel';
import { RaceViewModel } from './RaceViewModel';
import { ProtestDetailViewData } from "../view-data/ProtestDetailViewData";
export type PenaltyTypeOptionViewModel = {
import { ViewModel } from "../contracts/view-models/ViewModel";
export type PenaltyTypeOptionViewModel = ViewModel & {
type: string;
label: string;
description: string;
@@ -11,16 +14,52 @@ export type PenaltyTypeOptionViewModel = {
defaultValue: number;
};
export type ProtestDetailViewModel = {
protest: ProtestViewModel;
race: RaceViewModel;
protestingDriver: ProtestDriverViewModel;
accusedDriver: ProtestDriverViewModel;
penaltyTypes: PenaltyTypeOptionViewModel[];
defaultReasons: {
upheld: string;
dismissed: string;
};
initialPenaltyType: string | null;
initialPenaltyValue: number;
};
export class ProtestDetailViewModel extends ViewModel {
constructor(private readonly viewData: ProtestDetailViewData) {
super();
}
get protest(): ProtestViewModel {
return new ProtestViewModel({
id: this.viewData.protestId,
status: this.viewData.status,
submittedAt: this.viewData.submittedAt,
incident: this.viewData.incident,
protestingDriverId: this.viewData.protestingDriver.id,
accusedDriverId: this.viewData.accusedDriver.id,
} as any);
}
get race(): RaceViewModel {
return new RaceViewModel({
id: this.viewData.race.id,
name: this.viewData.race.name,
scheduledAt: this.viewData.race.scheduledAt,
} as any);
}
get protestingDriver(): ProtestDriverViewModel {
return new ProtestDriverViewModel({
id: this.viewData.protestingDriver.id,
name: this.viewData.protestingDriver.name,
});
}
get accusedDriver(): ProtestDriverViewModel {
return new ProtestDriverViewModel({
id: this.viewData.accusedDriver.id,
name: this.viewData.accusedDriver.name,
});
}
get penaltyTypes(): PenaltyTypeOptionViewModel[] {
return this.viewData.penaltyTypes.map((pt) => ({
type: pt.type,
label: pt.label,
description: pt.description,
requiresValue: false,
valueLabel: '',
defaultValue: 0,
}));
}
}

View File

@@ -1,6 +1,8 @@
import { DriverSummaryDTO } from '@/lib/types/generated/DriverSummaryDTO';
export class ProtestDriverViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class ProtestDriverViewModel extends ViewModel {
constructor(private readonly dto: DriverSummaryDTO) {}
get id(): string {

View File

@@ -7,7 +7,9 @@ import { StatusDisplay } from '../display-objects/StatusDisplay';
* Protest view model
* Represents a race protest
*/
export class ProtestViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class ProtestViewModel extends ViewModel {
id: string;
raceId: string;
protestingDriverId: string;

View File

@@ -1,6 +1,8 @@
import { RaceDetailEntryDTO } from '@/lib/types/generated/RaceDetailEntryDTO';
export class RaceDetailEntryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceDetailEntryViewModel extends ViewModel {
id: string;
name: string;
country: string;

View File

@@ -1,6 +1,8 @@
import { RaceDetailUserResultDTO } from '@/lib/types/generated/RaceDetailUserResultDTO';
export class RaceDetailUserResultViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceDetailUserResultViewModel extends ViewModel {
position!: number;
startPosition!: number;
incidents!: number;

View File

@@ -1,7 +1,9 @@
import { RaceDetailEntryViewModel } from './RaceDetailEntryViewModel';
import { RaceDetailUserResultViewModel } from './RaceDetailUserResultViewModel';
export type RaceDetailsRaceViewModel = {
import { ViewModel } from "../contracts/view-models/ViewModel";
export type RaceDetailsRaceViewModel = ViewModel & {
id: string;
track: string;
car: string;
@@ -10,19 +12,25 @@ export type RaceDetailsRaceViewModel = {
sessionType: string;
};
export type RaceDetailsLeagueViewModel = {
import { ViewModel } from "../contracts/view-models/ViewModel";
export type RaceDetailsLeagueViewModel = ViewModel & {
id: string;
name: string;
description?: string | null;
settings?: unknown;
};
export type RaceDetailsRegistrationViewModel = {
import { ViewModel } from "../contracts/view-models/ViewModel";
export type RaceDetailsRegistrationViewModel = ViewModel & {
canRegister: boolean;
isUserRegistered: boolean;
};
export type RaceDetailsViewModel = {
import { ViewModel } from "../contracts/view-models/ViewModel";
export type RaceDetailsViewModel = ViewModel & {
race: RaceDetailsRaceViewModel | null;
league: RaceDetailsLeagueViewModel | null;
entryList: RaceDetailEntryViewModel[];

View File

@@ -13,7 +13,9 @@ export interface RaceListItemDTO {
isPast: boolean;
}
export class RaceListItemViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceListItemViewModel extends ViewModel {
id: string;
track: string;
car: string;

View File

@@ -1,7 +1,9 @@
import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO';
import { FinishDisplay } from '../display-objects/FinishDisplay';
export class RaceResultViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceResultViewModel extends ViewModel {
driverId!: string;
driverName!: string;
avatarUrl!: string;

View File

@@ -2,12 +2,15 @@ import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO';
import { RaceResultsDetailDTO } from '@/lib/types/generated/RaceResultsDetailDTO';
import { RaceResultViewModel } from './RaceResultViewModel';
export class RaceResultsDetailViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceResultsDetailViewModel extends ViewModel {
raceId: string;
track: string;
currentUserId: string;
constructor(dto: RaceResultsDetailDTO & { results?: RaceResultDTO[] }, currentUserId: string) {
super();
this.raceId = dto.raceId;
this.track = dto.track;
this.currentUserId = currentUserId;

View File

@@ -4,7 +4,9 @@ import type { RaceStatsDTO } from '@/lib/types/generated/RaceStatsDTO';
* Race stats view model
* Represents race statistics for display
*/
export class RaceStatsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceStatsViewModel extends ViewModel {
totalRaces: number;
constructor(dto: RaceStatsDTO) {

View File

@@ -51,7 +51,9 @@ interface RaceStewardingDTO {
* Race Stewarding View Model
* Represents all data needed for race stewarding (protests, penalties, race info)
*/
export class RaceStewardingViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceStewardingViewModel extends ViewModel {
race: RaceDetailDTO['race'];
league: RaceDetailDTO['league'];
protests: RaceProtestsDTO['protests'];

View File

@@ -1,7 +1,9 @@
import { RaceDTO } from '@/lib/types/generated/RaceDTO';
import { RacesPageDataRaceDTO } from '@/lib/types/generated/RacesPageDataRaceDTO';
export class RaceViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceViewModel extends ViewModel {
constructor(
private readonly dto: RaceDTO | RacesPageDataRaceDTO,
private readonly _status?: string,

View File

@@ -1,11 +1,14 @@
import { RaceWithSOFDTO } from '@/lib/types/generated/RaceWithSOFDTO';
export class RaceWithSOFViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceWithSOFViewModel extends ViewModel {
id: string;
track: string;
strengthOfField: number | null;
constructor(dto: RaceWithSOFDTO) {
super();
this.id = dto.id;
this.track = dto.track;
this.strengthOfField = 'strengthOfField' in dto ? dto.strengthOfField ?? null : null;

View File

@@ -10,7 +10,9 @@ interface RacesPageDTO {
* Races page view model
* Represents the races page data with all races in a single list
*/
export class RacesPageViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RacesPageViewModel extends ViewModel {
races: RaceListItemViewModel[];
constructor(dto: RacesPageDTO) {

View File

@@ -4,7 +4,9 @@
*
* Note: No matching generated DTO available yet
*/
export class RecordEngagementInputViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RecordEngagementInputViewModel extends ViewModel {
eventType: string;
userId?: string;
metadata?: Record<string, unknown>;

View File

@@ -4,7 +4,9 @@ import type { RecordEngagementOutputDTO } from '@/lib/types/generated/RecordEnga
* Record engagement output view model
* Represents the result of recording an engagement event for UI consumption
*/
export class RecordEngagementOutputViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RecordEngagementOutputViewModel extends ViewModel {
eventId: string;
engagementWeight: number;

View File

@@ -4,7 +4,9 @@
*
* Note: No matching generated DTO available yet
*/
export class RecordPageViewInputViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RecordPageViewInputViewModel extends ViewModel {
path: string;
userId?: string;

View File

@@ -4,7 +4,9 @@ import type { RecordPageViewOutputDTO } from '@/lib/types/generated/RecordPageVi
* Record page view output view model
* Represents the result of recording a page view for UI consumption
*/
export class RecordPageViewOutputViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RecordPageViewOutputViewModel extends ViewModel {
pageViewId: string;
constructor(dto: RecordPageViewOutputDTO) {

View File

@@ -5,7 +5,9 @@ import { RemoveLeagueMemberOutputDTO } from '@/lib/types/generated/RemoveLeagueM
*
* Represents the result of removing a member from a league in a UI-ready format.
*/
export class RemoveMemberViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RemoveMemberViewModel extends ViewModel {
success: boolean;
constructor(dto: RemoveLeagueMemberOutputDTO) {

View File

@@ -3,7 +3,9 @@
*
* View model for upcoming renewal alerts.
*/
export class RenewalAlertViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RenewalAlertViewModel extends ViewModel {
id: string;
name: string;
type: 'league' | 'team' | 'driver' | 'race' | 'platform';

View File

@@ -5,7 +5,9 @@ import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestA
*
* Represents the result of an avatar generation request
*/
export class RequestAvatarGenerationViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RequestAvatarGenerationViewModel extends ViewModel {
success: boolean;
requestId?: string;
avatarUrls?: string[];
@@ -23,6 +25,7 @@ export class RequestAvatarGenerationViewModel {
error?: string;
},
) {
super();
this.success = dto.success;
if ('requestId' in dto && dto.requestId !== undefined) this.requestId = dto.requestId;

View File

@@ -13,7 +13,9 @@ export interface CustomPointsConfig {
*
* View model for scoring configuration including presets and custom points
*/
export class ScoringConfigurationViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class ScoringConfigurationViewModel extends ViewModel {
readonly patternId?: string;
readonly customScoringEnabled: boolean;
readonly customPoints?: CustomPointsConfig;

View File

@@ -1,6 +1,8 @@
import { AuthenticatedUserDTO } from '@/lib/types/generated/AuthenticatedUserDTO';
export class SessionViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SessionViewModel extends ViewModel {
userId: string;
email: string;
displayName: string;
@@ -10,6 +12,7 @@ export class SessionViewModel {
isAuthenticated: boolean = true;
constructor(dto: AuthenticatedUserDTO) {
super();
this.userId = dto.userId;
this.email = dto.email;
this.displayName = dto.displayName;

View File

@@ -5,7 +5,9 @@ import { SponsorDashboardDTO } from '@/lib/types/generated/SponsorDashboardDTO';
*
* Represents dashboard data for a sponsor with UI-specific transformations.
*/
export class SponsorDashboardViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorDashboardViewModel extends ViewModel {
sponsorId: string;
sponsorName: string;

View File

@@ -3,7 +3,9 @@
*
* View model for sponsor settings data.
*/
export class SponsorSettingsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorSettingsViewModel extends ViewModel {
profile: SponsorProfileViewModel;
notifications: NotificationSettingsViewModel;
privacy: PrivacySettingsViewModel;
@@ -15,7 +17,9 @@ export class SponsorSettingsViewModel {
}
}
export class SponsorProfileViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorProfileViewModel extends ViewModel {
companyName: string;
contactName: string;
contactEmail: string;
@@ -58,7 +62,9 @@ export class SponsorProfileViewModel {
}
}
export class NotificationSettingsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class NotificationSettingsViewModel extends ViewModel {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailRaceAlerts: boolean;
@@ -78,7 +84,9 @@ export class NotificationSettingsViewModel {
}
}
export class PrivacySettingsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class PrivacySettingsViewModel extends ViewModel {
publicProfile: boolean;
showStats: boolean;
showActiveSponsorships: boolean;

View File

@@ -6,7 +6,9 @@ import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
*
* View model for sponsor sponsorships data with UI-specific transformations.
*/
export class SponsorSponsorshipsViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorSponsorshipsViewModel extends ViewModel {
sponsorId: string;
sponsorName: string;

View File

@@ -6,7 +6,9 @@ interface SponsorDTO {
websiteUrl?: string;
}
export class SponsorViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorViewModel extends ViewModel {
id: string;
name: string;
declare logoUrl?: string;

View File

@@ -1,6 +1,8 @@
import { SponsorshipDetailDTO } from '@/lib/types/generated/SponsorshipDetailDTO';
export class SponsorshipDetailViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorshipDetailViewModel extends ViewModel {
id: string;
leagueId: string;
leagueName: string;

View File

@@ -10,7 +10,9 @@ interface SponsorshipPricingDTO {
*
* View model for sponsorship pricing data with UI-specific transformations.
*/
export class SponsorshipPricingViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorshipPricingViewModel extends ViewModel {
mainSlotPrice: number;
secondarySlotPrice: number;
currency: string;

View File

@@ -1,6 +1,8 @@
import type { SponsorshipRequestDTO } from '@/lib/types/generated/SponsorshipRequestDTO';
export class SponsorshipRequestViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorshipRequestViewModel extends ViewModel {
id: string;
sponsorId: string;
sponsorName: string;

View File

@@ -31,7 +31,9 @@ export interface SponsorshipDataInput {
*
* View model for individual sponsorship data.
*/
export class SponsorshipViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorshipViewModel extends ViewModel {
id: string;
type: 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
entityId: string;

View File

@@ -1,6 +1,8 @@
import { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
export class StandingEntryViewModel {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class StandingEntryViewModel extends ViewModel {
driverId: string;
position: number;
points: number;

Some files were not shown because too many files have changed in this diff Show More