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

This commit is contained in:
2026-01-23 13:04:05 +01:00
parent d97f50ed72
commit e22033be38
24 changed files with 605 additions and 455 deletions

View File

@@ -0,0 +1,20 @@
/**
* DriverRegistrationStatusDisplay
*
* Deterministic mapping of driver registration boolean state
* to UI labels and variants.
*/
export class DriverRegistrationStatusDisplay {
static statusMessage(isRegistered: boolean): string {
return isRegistered ? "Registered for this race" : "Not registered";
}
static statusBadgeVariant(isRegistered: boolean): string {
return isRegistered ? "success" : "warning";
}
static registrationButtonText(isRegistered: boolean): string {
return isRegistered ? "Withdraw" : "Register";
}
}

View File

@@ -165,6 +165,10 @@ export class ProfileDisplay {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
},
manager: {
text: 'Manager',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
admin: {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',

View File

@@ -23,7 +23,6 @@ describe('DriverRegistrationService', () => {
const mockDto = {
isRegistered: true,
raceId: 'race-456',
driverId: 'driver-123',
};
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
@@ -36,7 +35,6 @@ describe('DriverRegistrationService', () => {
expect(result.raceId).toBe('race-456');
expect(result.driverId).toBe('driver-123');
expect(result.statusMessage).toBe('Registered for this race');
expect(result.statusColor).toBe('green');
expect(result.statusBadgeVariant).toBe('success');
expect(result.registrationButtonText).toBe('Withdraw');
expect(result.canRegister).toBe(false);
@@ -49,7 +47,6 @@ describe('DriverRegistrationService', () => {
const mockDto = {
isRegistered: false,
raceId: 'race-456',
driverId: 'driver-123',
};
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
@@ -58,7 +55,6 @@ describe('DriverRegistrationService', () => {
expect(result.isRegistered).toBe(false);
expect(result.statusMessage).toBe('Not registered');
expect(result.statusColor).toBe('red');
expect(result.statusBadgeVariant).toBe('warning');
expect(result.registrationButtonText).toBe('Register');
expect(result.canRegister).toBe(true);

View File

@@ -5,6 +5,7 @@ import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { Service } from '@/lib/contracts/services/Service';
import type { DriverRegistrationStatusViewData } from '@/lib/view-data/DriverRegistrationStatusViewData';
@injectable()
export class DriverRegistrationService implements Service {
@@ -22,7 +23,15 @@ export class DriverRegistrationService implements Service {
}
async getDriverRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusViewModel> {
const data = await this.apiClient.getRegistrationStatus(driverId, raceId);
return new DriverRegistrationStatusViewModel(data);
const dto = await this.apiClient.getRegistrationStatus(driverId, raceId);
const viewData: DriverRegistrationStatusViewData = {
isRegistered: dto.isRegistered,
raceId: dto.raceId,
driverId,
canRegister: !dto.isRegistered,
};
return new DriverRegistrationStatusViewModel(viewData);
}
}

View File

@@ -0,0 +1,14 @@
/**
* Driver Registration Status View Data
*
* JSON-serializable, template-ready data structure.
*/
import { ViewData } from "../contracts/view-data/ViewData";
export interface DriverRegistrationStatusViewData extends ViewData {
isRegistered: boolean;
raceId: string;
driverId: string;
canRegister: boolean;
}

View File

@@ -0,0 +1,9 @@
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export type EmailSignupStatus = 'success' | 'error' | 'info';
export interface EmailSignupViewData extends ViewData {
email: string;
message: string;
status: EmailSignupStatus;
}

View File

@@ -0,0 +1,21 @@
import type { ViewData } from '@/lib/contracts/view-data/ViewData';
export interface HomeDiscoveryViewData extends ViewData {
topLeagues: Array<{
id: string;
name: string;
description: string;
}>;
teams: Array<{
id: string;
name: string;
description: string;
logoUrl?: string;
}>;
upcomingRaces: Array<{
id: string;
track: string;
car: string;
formattedDate: string;
}>;
}

View File

@@ -0,0 +1,9 @@
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export interface ImportRaceResultsSummaryViewData extends ViewData {
success: boolean;
raceId: string;
driversProcessed: number;
resultsRecorded: number;
errors: string[];
}

View File

@@ -1,39 +1,41 @@
import { describe, it, expect } from 'vitest';
import { DriverRegistrationStatusViewModel } from './DriverRegistrationStatusViewModel';
import type { DriverRegistrationStatusDTO } from '../types/generated/DriverRegistrationStatusDTO';
import type { DriverRegistrationStatusViewData } from '../view-data/DriverRegistrationStatusViewData';
const createStatusDto = (overrides: Partial<DriverRegistrationStatusDTO> = {}): DriverRegistrationStatusDTO => ({
const createViewData = (
overrides: Partial<DriverRegistrationStatusViewData> = {},
): DriverRegistrationStatusViewData => ({
isRegistered: true,
raceId: 'race-1',
driverId: 'driver-1',
canRegister: false,
...overrides,
});
describe('DriverRegistrationStatusViewModel', () => {
it('maps basic registration status fields from DTO', () => {
const dto = createStatusDto({ isRegistered: true });
const viewModel = new DriverRegistrationStatusViewModel(dto);
it('exposes basic registration status fields from ViewData', () => {
const viewModel = new DriverRegistrationStatusViewModel(createViewData({ isRegistered: true }));
expect(viewModel.isRegistered).toBe(true);
expect(viewModel.raceId).toBe('race-1');
expect(viewModel.driverId).toBe('driver-1');
});
it('derives UI fields when registered', () => {
const viewModel = new DriverRegistrationStatusViewModel(createStatusDto({ isRegistered: true }));
expect(viewModel.statusMessage).toBe('Registered for this race');
expect(viewModel.statusColor).toBe('green');
expect(viewModel.statusBadgeVariant).toBe('success');
expect(viewModel.registrationButtonText).toBe('Withdraw');
expect(viewModel.canRegister).toBe(false);
});
it('derives UI fields when registered', () => {
const viewModel = new DriverRegistrationStatusViewModel(createViewData({ isRegistered: true }));
expect(viewModel.statusMessage).toBe('Registered for this race');
expect(viewModel.statusBadgeVariant).toBe('success');
expect(viewModel.registrationButtonText).toBe('Withdraw');
});
it('derives UI fields when not registered', () => {
const viewModel = new DriverRegistrationStatusViewModel(createStatusDto({ isRegistered: false }));
const viewModel = new DriverRegistrationStatusViewModel(
createViewData({ isRegistered: false, canRegister: true }),
);
expect(viewModel.statusMessage).toBe('Not registered');
expect(viewModel.statusColor).toBe('red');
expect(viewModel.statusBadgeVariant).toBe('warning');
expect(viewModel.registrationButtonText).toBe('Register');
expect(viewModel.canRegister).toBe(true);

View File

@@ -1,38 +1,37 @@
import { DriverRegistrationStatusDTO } from '@/lib/types/generated/DriverRegistrationStatusDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { DriverRegistrationStatusViewData } from "../view-data/DriverRegistrationStatusViewData";
import { DriverRegistrationStatusDisplay } from "../display-objects/DriverRegistrationStatusDisplay";
export class DriverRegistrationStatusViewModel extends ViewModel {
isRegistered!: boolean;
raceId!: string;
driverId!: string;
constructor(dto: DriverRegistrationStatusDTO) {
Object.assign(this, dto);
constructor(private readonly viewData: DriverRegistrationStatusViewData) {
super();
}
/** UI-specific: Status message */
get statusMessage(): string {
return this.isRegistered ? 'Registered for this race' : 'Not registered';
get isRegistered(): boolean {
return this.viewData.isRegistered;
}
/** UI-specific: Status color */
get statusColor(): string {
return this.isRegistered ? 'green' : 'red';
get raceId(): string {
return this.viewData.raceId;
}
/** UI-specific: Badge variant */
get statusBadgeVariant(): string {
return this.isRegistered ? 'success' : 'warning';
get driverId(): string {
return this.viewData.driverId;
}
/** UI-specific: Registration button text */
get registrationButtonText(): string {
return this.isRegistered ? 'Withdraw' : 'Register';
}
/** UI-specific: Whether can register (assuming always can if not registered) */
get canRegister(): boolean {
return !this.isRegistered;
return this.viewData.canRegister;
}
get statusMessage(): string {
return DriverRegistrationStatusDisplay.statusMessage(this.isRegistered);
}
get statusBadgeVariant(): string {
return DriverRegistrationStatusDisplay.statusBadgeVariant(this.isRegistered);
}
get registrationButtonText(): string {
return DriverRegistrationStatusDisplay.registrationButtonText(this.isRegistered);
}
}

View File

@@ -1,45 +1,51 @@
import { describe, it, expect } from 'vitest';
import { DriverSummaryViewModel } from './DriverSummaryViewModel';
import type { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO';
import type { DriverSummaryData } from '../view-data/LeagueDetailViewData';
const driverDto: GetDriverOutputDTO = {
id: 'driver-1',
iracingId: 'ir-123',
name: 'Test Driver',
country: 'DE',
joinedAt: '2024-01-01T00:00:00Z',
const viewData: DriverSummaryData = {
driverId: 'driver-1',
driverName: 'Test Driver',
avatarUrl: 'https://example.com/avatar.png',
rating: 2500,
rank: 10,
roleBadgeText: 'Owner',
roleBadgeClasses: 'bg-blue-50 text-blue-700',
profileUrl: '/drivers/driver-1',
};
describe('DriverSummaryViewModel', () => {
it('maps driver and optional fields from DTO', () => {
const viewModel = new DriverSummaryViewModel({
driver: driverDto,
rating: 2500,
rank: 10,
});
it('exposes driver identity fields from ViewData', () => {
const viewModel = new DriverSummaryViewModel(viewData);
expect(viewModel.id).toBe('driver-1');
expect(viewModel.name).toBe('Test Driver');
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.png');
expect(viewModel.profileUrl).toBe('/drivers/driver-1');
expect(viewModel.roleBadgeText).toBe('Owner');
expect(viewModel.roleBadgeClasses).toBe('bg-blue-50 text-blue-700');
});
it('derives ratingLabel and rankLabel for UI rendering', () => {
const viewModel = new DriverSummaryViewModel(viewData);
expect(viewModel.driver).toBe(driverDto);
expect(viewModel.rating).toBe(2500);
expect(viewModel.ratingLabel).toBe('2,500');
expect(viewModel.rank).toBe(10);
expect(viewModel.rankLabel).toBe('10');
});
it('defaults nullable rating and rank when undefined', () => {
it('renders placeholders when rating or rank are null', () => {
const viewModel = new DriverSummaryViewModel({
driver: driverDto,
});
expect(viewModel.rating).toBeNull();
expect(viewModel.rank).toBeNull();
});
it('keeps explicit null rating and rank', () => {
const viewModel = new DriverSummaryViewModel({
driver: driverDto,
...viewData,
rating: null,
rank: null,
});
expect(viewModel.rating).toBeNull();
expect(viewModel.ratingLabel).toBe('—');
expect(viewModel.rank).toBeNull();
expect(viewModel.rankLabel).toBe('—');
});
});

View File

@@ -1,23 +1,55 @@
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { DriverSummaryData } from "../view-data/LeagueDetailViewData";
import { NumberDisplay } from "../display-objects/NumberDisplay";
import { RatingDisplay } from "../display-objects/RatingDisplay";
/**
* View Model for driver summary with rating and rank
* Transform from DTO to ViewModel with UI fields
*
* Client-only UI helper built from ViewData.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
export class DriverSummaryViewModel extends ViewModel {
driver: GetDriverOutputDTO;
rating: number | null;
rank: number | null;
constructor(private readonly viewData: DriverSummaryData) {
super();
}
constructor(dto: {
driver: GetDriverOutputDTO;
rating?: number | null;
rank?: number | null;
}) {
this.driver = dto.driver;
this.rating = dto.rating ?? null;
this.rank = dto.rank ?? null;
get id(): string {
return this.viewData.driverId;
}
get name(): string {
return this.viewData.driverName;
}
get avatarUrl(): string | null {
return this.viewData.avatarUrl;
}
get rating(): number | null {
return this.viewData.rating;
}
get ratingLabel(): string {
return RatingDisplay.format(this.rating);
}
get rank(): number | null {
return this.viewData.rank;
}
get rankLabel(): string {
return this.rank === null ? '—' : NumberDisplay.format(this.rank);
}
get roleBadgeText(): string {
return this.viewData.roleBadgeText;
}
get roleBadgeClasses(): string {
return this.viewData.roleBadgeClasses;
}
get profileUrl(): string {
return this.viewData.profileUrl;
}
}

View File

@@ -1,68 +1,59 @@
import { describe, it, expect } from 'vitest';
import { DriverTeamViewModel } from './DriverTeamViewModel';
import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO';
import { describe, it, expect } from "vitest";
import { DriverTeamViewModel } from "./DriverTeamViewModel";
import type { TeamDetailData } from "../view-data/TeamDetailViewData";
const createTeamDto = (overrides: Partial<GetDriverTeamOutputDTO> = {}): GetDriverTeamOutputDTO => ({
team: {
id: 'team-1',
name: 'Test Team',
tag: 'TT',
description: 'Test team description',
ownerId: 'owner-1',
leagues: ['league-1'],
createdAt: '2024-01-01T00:00:00Z',
specialization: 'mixed',
region: 'EU',
languages: ['en'],
},
const createTeamViewData = (overrides: Partial<TeamDetailData> = {}): TeamDetailData => ({
id: "team-1",
name: "Test Team",
tag: "TT",
ownerId: "owner-1",
leagues: ["league-1"],
canManage: true,
membership: {
role: 'manager',
joinedAt: '2024-01-01T00:00:00Z',
role: "manager",
joinedAt: "2024-01-01T00:00:00Z",
isActive: true,
},
isOwner: false,
canManage: true,
...overrides,
});
describe('DriverTeamViewModel', () => {
it('maps team and membership fields from DTO', () => {
const dto = createTeamDto();
const viewModel = new DriverTeamViewModel(dto);
describe("DriverTeamViewModel", () => {
it("exposes team fields from ViewData", () => {
const viewData = createTeamViewData();
const viewModel = new DriverTeamViewModel(viewData);
expect(viewModel.teamId).toBe('team-1');
expect(viewModel.teamName).toBe('Test Team');
expect(viewModel.tag).toBe('TT');
expect(viewModel.role).toBe('manager');
expect(viewModel.teamId).toBe("team-1");
expect(viewModel.teamName).toBe("Test Team");
expect(viewModel.tag).toBe("TT");
expect(viewModel.role).toBe("manager");
expect(viewModel.isOwner).toBe(false);
expect(viewModel.canManage).toBe(true);
});
it('derives displayRole with capitalized first letter', () => {
const dto = createTeamDto({
it("derives displayRole with capitalized first letter", () => {
const viewData = createTeamViewData({
membership: {
role: 'owner',
joinedAt: '2024-01-01T00:00:00Z',
role: "owner",
joinedAt: "2024-01-01T00:00:00Z",
isActive: true,
},
});
const viewModel = new DriverTeamViewModel(dto);
const viewModel = new DriverTeamViewModel(viewData);
expect(viewModel.displayRole).toBe('Owner');
expect(viewModel.displayRole).toBe("Owner");
expect(viewModel.isOwner).toBe(true);
});
it('handles lower-case role strings consistently', () => {
const dto = createTeamDto({
membership: {
role: 'member',
joinedAt: '2024-01-01T00:00:00Z',
isActive: true,
},
it("defaults role when membership is missing", () => {
const viewData = createTeamViewData({
membership: null,
});
const viewModel = new DriverTeamViewModel(dto);
const viewModel = new DriverTeamViewModel(viewData);
expect(viewModel.displayRole).toBe('Member');
expect(viewModel.role).toBe("member");
expect(viewModel.displayRole).toBe("Member");
expect(viewModel.isOwner).toBe(false);
});
});

View File

@@ -1,31 +1,44 @@
import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO';
/**
* View Model for Driver's Team
*
* Represents a driver's team membership in a UI-ready format.
* Client-only UI helper built from ViewData.
*/
import { ProfileDisplay } from "../display-objects/ProfileDisplay";
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { TeamDetailData } from "../view-data/TeamDetailViewData";
export class DriverTeamViewModel extends ViewModel {
teamId: string;
teamName: string;
tag: string;
role: string;
isOwner: boolean;
canManage: boolean;
constructor(private readonly viewData: TeamDetailData) {
super();
}
constructor(dto: GetDriverTeamOutputDTO) {
this.teamId = dto.team.id;
this.teamName = dto.team.name;
this.tag = dto.team.tag;
this.role = dto.membership.role;
this.isOwner = dto.isOwner;
this.canManage = dto.canManage;
get teamId(): string {
return this.viewData.id;
}
get teamName(): string {
return this.viewData.name;
}
get tag(): string {
return this.viewData.tag;
}
get role(): string {
return this.viewData.membership?.role ?? "member";
}
get canManage(): boolean {
return this.viewData.canManage;
}
get isOwner(): boolean {
return this.viewData.membership?.role === "owner";
}
/** UI-specific: Display role */
get displayRole(): string {
return this.role.charAt(0).toUpperCase() + this.role.slice(1);
return ProfileDisplay.getTeamRole(this.role).text;
}
}

View File

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

View File

@@ -2,9 +2,15 @@
* Driver view model
* UI representation of a driver
*
* Note: No matching generated DTO available yet
* Note: client-only ViewModel created from ViewData (never DTO).
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { DriverData } from "../view-data/LeagueStandingsViewData";
type DriverViewModelViewData = DriverData & {
bio?: string;
joinedAt?: string;
};
export class DriverViewModel extends ViewModel {
id: string;
@@ -16,25 +22,16 @@ export class DriverViewModel extends ViewModel {
bio?: string;
joinedAt?: string;
constructor(dto: {
id: string;
name: string;
avatarUrl?: string | null;
iracingId?: string;
rating?: number;
country?: string;
bio?: string;
joinedAt?: string;
}) {
constructor(viewData: DriverViewModelViewData) {
super();
this.id = dto.id;
this.name = dto.name;
this.avatarUrl = dto.avatarUrl ?? null;
if (dto.iracingId !== undefined) this.iracingId = dto.iracingId;
if (dto.rating !== undefined) this.rating = dto.rating;
if (dto.country !== undefined) this.country = dto.country;
if (dto.bio !== undefined) this.bio = dto.bio;
if (dto.joinedAt !== undefined) this.joinedAt = dto.joinedAt;
this.id = viewData.id;
this.name = viewData.name;
this.avatarUrl = viewData.avatarUrl ?? null;
if (viewData.iracingId !== undefined) this.iracingId = viewData.iracingId;
if (viewData.rating !== undefined) this.rating = viewData.rating;
if (viewData.country !== undefined) this.country = viewData.country;
if (viewData.bio !== undefined) this.bio = viewData.bio;
if (viewData.joinedAt !== undefined) this.joinedAt = viewData.joinedAt;
}
/** UI-specific: Whether driver has an iRacing ID */
@@ -44,6 +41,6 @@ export class DriverViewModel extends ViewModel {
/** UI-specific: Formatted rating */
get formattedRating(): string {
return this.rating ? this.rating.toFixed(0) : 'Unrated';
return this.rating ? this.rating.toFixed(0) : "Unrated";
}
}

View File

@@ -1,8 +1,37 @@
import { describe, it, expect } from 'vitest';
import { EmailSignupViewModel } from './EmailSignupViewModel';
import type { EmailSignupViewData } from '../view-data/EmailSignupViewData';
describe('EmailSignupViewModel', () => {
it('should be defined', () => {
expect(EmailSignupViewModel).toBeDefined();
it('wraps EmailSignupViewData and exposes UI helpers', () => {
const viewData: EmailSignupViewData = {
email: 'test@example.com',
message: 'Thanks for signing up!',
status: 'success',
};
const viewModel = new EmailSignupViewModel(viewData);
expect(viewModel.email).toBe('test@example.com');
expect(viewModel.message).toBe('Thanks for signing up!');
expect(viewModel.status).toBe('success');
expect(viewModel.isSuccess).toBe(true);
expect(viewModel.isError).toBe(false);
expect(viewModel.isInfo).toBe(false);
});
it('reflects error status helpers', () => {
const viewData: EmailSignupViewData = {
email: 'test@example.com',
message: 'Something went wrong',
status: 'error',
};
const viewModel = new EmailSignupViewModel(viewData);
expect(viewModel.isSuccess).toBe(false);
expect(viewModel.isError).toBe(true);
expect(viewModel.isInfo).toBe(false);
});
});

View File

@@ -4,15 +4,34 @@
* View model for email signup responses
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { EmailSignupViewData } from "../view-data/EmailSignupViewData";
export class EmailSignupViewModel extends ViewModel {
readonly email: string;
readonly message: string;
readonly status: 'success' | 'error' | 'info';
constructor(private readonly viewData: EmailSignupViewData) {
super();
}
constructor(email: string, message: string, status: 'success' | 'error' | 'info') {
this.email = email;
this.message = message;
this.status = status;
get email(): string {
return this.viewData.email;
}
get message(): string {
return this.viewData.message;
}
get status(): EmailSignupViewData["status"] {
return this.viewData.status;
}
get isSuccess(): boolean {
return this.status === 'success';
}
get isError(): boolean {
return this.status === 'error';
}
get isInfo(): boolean {
return this.status === 'info';
}
}

View File

@@ -1,48 +1,35 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { HomeDiscoveryViewModel } from './HomeDiscoveryViewModel';
import { LeagueCardViewModel } from './LeagueCardViewModel';
import { TeamCardViewModel } from './TeamCardViewModel';
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
import type { HomeDiscoveryViewData } from '@/lib/view-data/HomeDiscoveryViewData';
describe('HomeDiscoveryViewModel', () => {
it('exposes the top leagues, teams and upcoming races from the DTO', () => {
const topLeagues = [
new LeagueCardViewModel({
id: 'league-1',
name: 'Pro League',
description: 'Top-tier league',
memberCount: 100,
isMember: false,
} as any),
];
it('exposes the discovery collections from ViewData', () => {
const viewData: HomeDiscoveryViewData = {
topLeagues: [{ id: 'league-1', name: 'Pro League', description: 'Top-tier league' }],
teams: [{ id: 'team-1', name: 'Team Alpha', description: 'Serious endurance', logoUrl: undefined }],
upcomingRaces: [
{ id: 'race-1', track: 'Spa-Francorchamps', car: 'GT3', formattedDate: 'Jan 1' },
],
};
const teams = [
new TeamCardViewModel({
id: 'team-1',
name: 'Team Alpha',
tag: 'ALPHA',
memberCount: 10,
isMember: true,
} as any),
];
const viewModel = new HomeDiscoveryViewModel(viewData);
const upcomingRaces = [
new UpcomingRaceCardViewModel({
id: 'race-1',
track: 'Spa-Francorchamps',
car: 'GT3',
scheduledAt: '2025-01-01T12:00:00Z',
} as any),
];
expect(viewModel.topLeagues).toBe(viewData.topLeagues);
expect(viewModel.teams).toBe(viewData.teams);
expect(viewModel.upcomingRaces).toBe(viewData.upcomingRaces);
});
const viewModel = new HomeDiscoveryViewModel({
topLeagues,
teams,
upcomingRaces,
});
it('provides basic UI helper booleans', () => {
const viewData: HomeDiscoveryViewData = {
topLeagues: [],
teams: [{ id: 'team-1', name: 'Team Alpha', description: 'Serious endurance' }],
upcomingRaces: [],
};
expect(viewModel.topLeagues).toBe(topLeagues);
expect(viewModel.teams).toBe(teams);
expect(viewModel.upcomingRaces).toBe(upcomingRaces);
const viewModel = new HomeDiscoveryViewModel(viewData);
expect(viewModel.hasTopLeagues).toBe(false);
expect(viewModel.hasTeams).toBe(true);
expect(viewModel.hasUpcomingRaces).toBe(false);
});
});

View File

@@ -1,27 +1,32 @@
import { LeagueCardViewModel } from './LeagueCardViewModel';
import { TeamCardViewModel } from './TeamCardViewModel';
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
interface HomeDiscoveryDTO {
topLeagues: LeagueCardViewModel[];
teams: TeamCardViewModel[];
upcomingRaces: UpcomingRaceCardViewModel[];
}
import type { HomeDiscoveryViewData } from '@/lib/view-data/HomeDiscoveryViewData';
/**
* Home discovery view model
* Aggregates discovery data for the landing page.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import { ViewModel } from '../contracts/view-models/ViewModel';
export class HomeDiscoveryViewModel extends ViewModel {
readonly topLeagues: LeagueCardViewModel[];
readonly teams: TeamCardViewModel[];
readonly upcomingRaces: UpcomingRaceCardViewModel[];
readonly topLeagues: HomeDiscoveryViewData['topLeagues'];
readonly teams: HomeDiscoveryViewData['teams'];
readonly upcomingRaces: HomeDiscoveryViewData['upcomingRaces'];
constructor(dto: HomeDiscoveryDTO) {
this.topLeagues = dto.topLeagues;
this.teams = dto.teams;
this.upcomingRaces = dto.upcomingRaces;
constructor(viewData: HomeDiscoveryViewData) {
super();
this.topLeagues = viewData.topLeagues;
this.teams = viewData.teams;
this.upcomingRaces = viewData.upcomingRaces;
}
get hasTopLeagues(): boolean {
return this.topLeagues.length > 0;
}
get hasTeams(): boolean {
return this.teams.length > 0;
}
get hasUpcomingRaces(): boolean {
return this.upcomingRaces.length > 0;
}
}

View File

@@ -1,9 +1,10 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import type { ImportRaceResultsSummaryViewData } from '../view-data/ImportRaceResultsSummaryViewData';
import { ImportRaceResultsSummaryViewModel } from './ImportRaceResultsSummaryViewModel';
describe('ImportRaceResultsSummaryViewModel', () => {
it('maps DTO fields including errors', () => {
const dto = {
it('exposes view data fields', () => {
const viewData: ImportRaceResultsSummaryViewData = {
success: true,
raceId: 'race-1',
driversProcessed: 10,
@@ -11,7 +12,7 @@ describe('ImportRaceResultsSummaryViewModel', () => {
errors: ['Driver missing', 'Invalid lap time'],
};
const viewModel = new ImportRaceResultsSummaryViewModel(dto);
const viewModel = new ImportRaceResultsSummaryViewModel(viewData);
expect(viewModel.success).toBe(true);
expect(viewModel.raceId).toBe('race-1');
@@ -20,16 +21,24 @@ describe('ImportRaceResultsSummaryViewModel', () => {
expect(viewModel.errors).toEqual(['Driver missing', 'Invalid lap time']);
});
it('defaults errors to an empty array when not provided', () => {
const dto = {
it('derives hasErrors UI helper', () => {
const viewDataWithErrors: ImportRaceResultsSummaryViewData = {
success: false,
raceId: 'race-2',
driversProcessed: 0,
resultsRecorded: 0,
errors: ['Some error'],
};
const viewModel = new ImportRaceResultsSummaryViewModel(dto);
const viewDataWithoutErrors: ImportRaceResultsSummaryViewData = {
success: false,
raceId: 'race-3',
driversProcessed: 0,
resultsRecorded: 0,
errors: [],
};
expect(viewModel.errors).toEqual([]);
expect(new ImportRaceResultsSummaryViewModel(viewDataWithErrors).hasErrors).toBe(true);
expect(new ImportRaceResultsSummaryViewModel(viewDataWithoutErrors).hasErrors).toBe(false);
});
});

View File

@@ -1,25 +1,32 @@
interface ImportRaceResultsSummaryDTO {
success: boolean;
raceId: string;
driversProcessed: number;
resultsRecorded: number;
errors?: string[];
}
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { ImportRaceResultsSummaryViewData } from "../view-data/ImportRaceResultsSummaryViewData";
export class ImportRaceResultsSummaryViewModel extends ViewModel {
success: boolean;
raceId: string;
driversProcessed: number;
resultsRecorded: number;
errors: string[];
constructor(private readonly viewData: ImportRaceResultsSummaryViewData) {
super();
}
constructor(dto: ImportRaceResultsSummaryDTO) {
this.success = dto.success;
this.raceId = dto.raceId;
this.driversProcessed = dto.driversProcessed;
this.resultsRecorded = dto.resultsRecorded;
this.errors = dto.errors || [];
get success(): boolean {
return this.viewData.success;
}
get raceId(): string {
return this.viewData.raceId;
}
get driversProcessed(): number {
return this.viewData.driversProcessed;
}
get resultsRecorded(): number {
return this.viewData.resultsRecorded;
}
get errors(): string[] {
return this.viewData.errors;
}
get hasErrors(): boolean {
return this.errors.length > 0;
}
}

View File

@@ -1,138 +1,79 @@
import { describe, it, expect } from 'vitest';
import { MediaViewModel } from './MediaViewModel';
import type { MediaViewData } from '@/lib/view-data/MediaViewData';
describe('MediaViewModel', () => {
it('should create instance with all properties', () => {
const dto = {
it('creates instance from asset ViewData', () => {
const asset: MediaViewData['assets'][number] = {
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
src: 'https://example.com/image.jpg',
title: 'Race Day Photo',
category: 'avatar',
uploadedAt: '2023-01-15T00:00:00.000Z',
size: 2048000,
date: '2023-01-15',
dimensions: '1920×1080',
};
const viewModel = new MediaViewModel(dto);
const viewModel = new MediaViewModel(asset);
expect(viewModel.id).toBe('media-123');
expect(viewModel.url).toBe('https://example.com/image.jpg');
expect(viewModel.type).toBe('image');
expect(viewModel.src).toBe('https://example.com/image.jpg');
expect(viewModel.title).toBe('Race Day Photo');
expect(viewModel.category).toBe('avatar');
expect(viewModel.uploadedAt).toEqual(new Date('2023-01-15'));
expect(viewModel.size).toBe(2048000);
expect(viewModel.date).toBe('2023-01-15');
expect(viewModel.dimensions).toBe('1920×1080');
});
it('should create instance without optional properties', () => {
const dto = {
it('subtitle matches MediaCard subtitle formatting', () => {
const asset: MediaViewData['assets'][number] = {
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: '2023-01-15T00:00:00.000Z',
src: 'https://example.com/image.jpg',
title: 'Race Day Photo',
category: 'avatar',
dimensions: '1920×1080',
};
const viewModel = new MediaViewModel(dto);
const viewModel = new MediaViewModel(asset);
expect(viewModel.category).toBeUndefined();
expect(viewModel.size).toBeUndefined();
expect(viewModel.subtitle).toBe('avatar • 1920×1080');
});
it('should return "Unknown" for formattedSize when size is undefined', () => {
const viewModel = new MediaViewModel({
it('subtitle omits dimensions when missing', () => {
const asset: MediaViewData['assets'][number] = {
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: new Date().toISOString(),
});
src: 'https://example.com/image.jpg',
title: 'Race Day Photo',
category: 'avatar',
};
expect(viewModel.formattedSize).toBe('Unknown');
const viewModel = new MediaViewModel(asset);
expect(viewModel.subtitle).toBe('avatar');
});
it('should format size in KB when less than 1 MB', () => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: new Date().toISOString(),
size: 512000, // 500 KB
});
expect(viewModel.formattedSize).toBe('500.00 KB');
});
it('should format size in MB when 1 MB or larger', () => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: new Date().toISOString(),
size: 2048000, // 2 MB in base-10, ~1.95 MB in base-2
});
expect(viewModel.formattedSize).toBe('1.95 MB');
});
it('should handle very small file sizes', () => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
uploadedAt: new Date().toISOString(),
size: 1024, // 1 KB
});
expect(viewModel.formattedSize).toBe('1.00 KB');
});
it('should handle very large file sizes', () => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/video.mp4',
type: 'video',
uploadedAt: new Date().toISOString(),
size: 104857600, // 100 MB
});
expect(viewModel.formattedSize).toBe('100.00 MB');
});
it('should support all media types', () => {
const imageVm = new MediaViewModel({
it('hasMetadata reflects presence of date or dimensions', () => {
const noMeta = new MediaViewModel({
id: '1',
url: 'image.jpg',
type: 'image',
uploadedAt: new Date().toISOString(),
src: 'a.jpg',
title: 'A',
category: 'misc',
});
const videoVm = new MediaViewModel({
const withDate = new MediaViewModel({
id: '2',
url: 'video.mp4',
type: 'video',
uploadedAt: new Date().toISOString(),
src: 'b.jpg',
title: 'B',
category: 'misc',
date: '2023-01-15',
});
const docVm = new MediaViewModel({
const withDimensions = new MediaViewModel({
id: '3',
url: 'doc.pdf',
type: 'document',
uploadedAt: new Date().toISOString(),
src: 'c.jpg',
title: 'C',
category: 'misc',
dimensions: '800×600',
});
expect(imageVm.type).toBe('image');
expect(videoVm.type).toBe('video');
expect(docVm.type).toBe('document');
});
it('should support all media categories', () => {
const categories = ['avatar', 'team-logo', 'league-cover', 'race-result'] as const;
categories.forEach(category => {
const viewModel = new MediaViewModel({
id: 'media-123',
url: 'https://example.com/image.jpg',
type: 'image',
category,
uploadedAt: new Date().toISOString(),
});
expect(viewModel.category).toBe(category);
});
expect(noMeta.hasMetadata).toBe(false);
expect(withDate.hasMetadata).toBe(true);
expect(withDimensions.hasMetadata).toBe(true);
});
});

View File

@@ -1,37 +1,40 @@
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
import type { MediaViewData } from '@/lib/view-data/MediaViewData';
import { ViewModel } from "../contracts/view-models/ViewModel";
type MediaAssetViewData = MediaViewData['assets'][number];
/**
* Media View Model
*
* Represents media information for the UI layer
* Client-only ViewModel created from ViewData (never DTO).
* Represents a single media asset card in the UI.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
export class MediaViewModel extends ViewModel {
id: string;
url: string;
type: 'image' | 'video' | 'document';
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
uploadedAt: Date;
size?: number;
src: string;
title: string;
category: string;
date?: string;
dimensions?: string;
constructor(dto: GetMediaOutputDTO) {
this.id = dto.id;
this.url = dto.url;
this.type = dto.type as 'image' | 'video' | 'document';
this.uploadedAt = new Date(dto.uploadedAt);
if (dto.category !== undefined) this.category = dto.category as 'avatar' | 'team-logo' | 'league-cover' | 'race-result';
if (dto.size !== undefined) this.size = dto.size;
constructor(viewData: MediaAssetViewData) {
super();
this.id = viewData.id;
this.src = viewData.src;
this.title = viewData.title;
this.category = viewData.category;
if (viewData.date !== undefined) this.date = viewData.date;
if (viewData.dimensions !== undefined) this.dimensions = viewData.dimensions;
}
/** UI-specific: Formatted file size */
get formattedSize(): string {
if (!this.size) return 'Unknown';
const kb = this.size / 1024;
/** UI-specific: Combined subtitle used by MediaCard */
get subtitle(): string {
return `${this.category}${this.dimensions ? `${this.dimensions}` : ''}`;
}
if (kb < 1024) return `${kb.toFixed(2)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(2)} MB`;
/** UI-specific: Whether any metadata is present */
get hasMetadata(): boolean {
return !!this.date || !!this.dimensions;
}
}