From e22033be38b8043ba6016208f789650a6246f52d Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 23 Jan 2026 13:04:05 +0100 Subject: [PATCH] view data fixes --- .../DriverRegistrationStatusDisplay.tsx | 20 +++ .../lib/display-objects/ProfileDisplay.ts | 4 + .../drivers/DriverRegistrationService.test.ts | 4 - .../drivers/DriverRegistrationService.ts | 13 +- .../DriverRegistrationStatusViewData.ts | 14 ++ .../lib/view-data/EmailSignupViewData.ts | 9 + .../lib/view-data/HomeDiscoveryViewData.ts | 21 +++ .../ImportRaceResultsSummaryViewData.ts | 9 + .../DriverRegistrationStatusViewModel.test.ts | 34 ++-- .../DriverRegistrationStatusViewModel.ts | 47 +++-- .../DriverSummaryViewModel.test.ts | 56 +++--- .../lib/view-models/DriverSummaryViewModel.ts | 62 +++++-- .../view-models/DriverTeamViewModel.test.ts | 77 ++++----- .../lib/view-models/DriverTeamViewModel.ts | 47 +++-- .../lib/view-models/DriverViewModel.test.ts | 162 ++++++++++-------- .../lib/view-models/DriverViewModel.ts | 37 ++-- .../view-models/EmailSignupViewModel.test.ts | 33 +++- .../lib/view-models/EmailSignupViewModel.ts | 33 +++- .../HomeDiscoveryViewModel.test.ts | 65 +++---- .../lib/view-models/HomeDiscoveryViewModel.ts | 39 +++-- .../ImportRaceResultsSummaryViewModel.test.ts | 25 ++- .../ImportRaceResultsSummaryViewModel.ts | 45 +++-- .../lib/view-models/MediaViewModel.test.ts | 153 +++++------------ .../website/lib/view-models/MediaViewModel.ts | 51 +++--- 24 files changed, 605 insertions(+), 455 deletions(-) create mode 100644 apps/website/lib/display-objects/DriverRegistrationStatusDisplay.tsx create mode 100644 apps/website/lib/view-data/DriverRegistrationStatusViewData.ts create mode 100644 apps/website/lib/view-data/EmailSignupViewData.ts create mode 100644 apps/website/lib/view-data/HomeDiscoveryViewData.ts create mode 100644 apps/website/lib/view-data/ImportRaceResultsSummaryViewData.ts diff --git a/apps/website/lib/display-objects/DriverRegistrationStatusDisplay.tsx b/apps/website/lib/display-objects/DriverRegistrationStatusDisplay.tsx new file mode 100644 index 000000000..956345217 --- /dev/null +++ b/apps/website/lib/display-objects/DriverRegistrationStatusDisplay.tsx @@ -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"; + } +} diff --git a/apps/website/lib/display-objects/ProfileDisplay.ts b/apps/website/lib/display-objects/ProfileDisplay.ts index 5df288015..4a52c4fbe 100644 --- a/apps/website/lib/display-objects/ProfileDisplay.ts +++ b/apps/website/lib/display-objects/ProfileDisplay.ts @@ -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', diff --git a/apps/website/lib/services/drivers/DriverRegistrationService.test.ts b/apps/website/lib/services/drivers/DriverRegistrationService.test.ts index d264a5539..6003edbec 100644 --- a/apps/website/lib/services/drivers/DriverRegistrationService.test.ts +++ b/apps/website/lib/services/drivers/DriverRegistrationService.test.ts @@ -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); diff --git a/apps/website/lib/services/drivers/DriverRegistrationService.ts b/apps/website/lib/services/drivers/DriverRegistrationService.ts index c19c2c1a6..d6e587bb1 100644 --- a/apps/website/lib/services/drivers/DriverRegistrationService.ts +++ b/apps/website/lib/services/drivers/DriverRegistrationService.ts @@ -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 { - 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); } } diff --git a/apps/website/lib/view-data/DriverRegistrationStatusViewData.ts b/apps/website/lib/view-data/DriverRegistrationStatusViewData.ts new file mode 100644 index 000000000..ff2274923 --- /dev/null +++ b/apps/website/lib/view-data/DriverRegistrationStatusViewData.ts @@ -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; +} diff --git a/apps/website/lib/view-data/EmailSignupViewData.ts b/apps/website/lib/view-data/EmailSignupViewData.ts new file mode 100644 index 000000000..f3856548a --- /dev/null +++ b/apps/website/lib/view-data/EmailSignupViewData.ts @@ -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; +} diff --git a/apps/website/lib/view-data/HomeDiscoveryViewData.ts b/apps/website/lib/view-data/HomeDiscoveryViewData.ts new file mode 100644 index 000000000..84d1a20f0 --- /dev/null +++ b/apps/website/lib/view-data/HomeDiscoveryViewData.ts @@ -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; + }>; +} diff --git a/apps/website/lib/view-data/ImportRaceResultsSummaryViewData.ts b/apps/website/lib/view-data/ImportRaceResultsSummaryViewData.ts new file mode 100644 index 000000000..847810f0b --- /dev/null +++ b/apps/website/lib/view-data/ImportRaceResultsSummaryViewData.ts @@ -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[]; +} diff --git a/apps/website/lib/view-models/DriverRegistrationStatusViewModel.test.ts b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.test.ts index b3076e45a..0dd2d46c8 100644 --- a/apps/website/lib/view-models/DriverRegistrationStatusViewModel.test.ts +++ b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.test.ts @@ -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 => ({ +const createViewData = ( + overrides: Partial = {}, +): 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); diff --git a/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts index dacb15bee..1ba904679 100644 --- a/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts +++ b/apps/website/lib/view-models/DriverRegistrationStatusViewModel.ts @@ -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); } } \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverSummaryViewModel.test.ts b/apps/website/lib/view-models/DriverSummaryViewModel.test.ts index be9efae3d..b9ff1a7d9 100644 --- a/apps/website/lib/view-models/DriverSummaryViewModel.test.ts +++ b/apps/website/lib/view-models/DriverSummaryViewModel.test.ts @@ -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('—'); }); }); diff --git a/apps/website/lib/view-models/DriverSummaryViewModel.ts b/apps/website/lib/view-models/DriverSummaryViewModel.ts index 9a167d030..3926c03df 100644 --- a/apps/website/lib/view-models/DriverSummaryViewModel.ts +++ b/apps/website/lib/view-models/DriverSummaryViewModel.ts @@ -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; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverTeamViewModel.test.ts b/apps/website/lib/view-models/DriverTeamViewModel.test.ts index 022e1529a..a707c8fdf 100644 --- a/apps/website/lib/view-models/DriverTeamViewModel.test.ts +++ b/apps/website/lib/view-models/DriverTeamViewModel.test.ts @@ -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 => ({ - 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 => ({ + 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); }); }); diff --git a/apps/website/lib/view-models/DriverTeamViewModel.ts b/apps/website/lib/view-models/DriverTeamViewModel.ts index 54d0c1736..02367c4ab 100644 --- a/apps/website/lib/view-models/DriverTeamViewModel.ts +++ b/apps/website/lib/view-models/DriverTeamViewModel.ts @@ -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; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverViewModel.test.ts b/apps/website/lib/view-models/DriverViewModel.test.ts index 09692add4..cd015c51f 100644 --- a/apps/website/lib/view-models/DriverViewModel.test.ts +++ b/apps/website/lib/view-models/DriverViewModel.test.ts @@ -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"); }); }); \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverViewModel.ts b/apps/website/lib/view-models/DriverViewModel.ts index 05e937fd0..050756a85 100644 --- a/apps/website/lib/view-models/DriverViewModel.ts +++ b/apps/website/lib/view-models/DriverViewModel.ts @@ -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"; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/EmailSignupViewModel.test.ts b/apps/website/lib/view-models/EmailSignupViewModel.test.ts index 3b4f5fede..11d0e145a 100644 --- a/apps/website/lib/view-models/EmailSignupViewModel.test.ts +++ b/apps/website/lib/view-models/EmailSignupViewModel.test.ts @@ -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); }); }); diff --git a/apps/website/lib/view-models/EmailSignupViewModel.ts b/apps/website/lib/view-models/EmailSignupViewModel.ts index 88a409513..5aad392f8 100644 --- a/apps/website/lib/view-models/EmailSignupViewModel.ts +++ b/apps/website/lib/view-models/EmailSignupViewModel.ts @@ -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'; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts b/apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts index 92db7e4fb..aa387ba63 100644 --- a/apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts +++ b/apps/website/lib/view-models/HomeDiscoveryViewModel.test.ts @@ -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); }); }); diff --git a/apps/website/lib/view-models/HomeDiscoveryViewModel.ts b/apps/website/lib/view-models/HomeDiscoveryViewModel.ts index f7e9e8484..febe3efcb 100644 --- a/apps/website/lib/view-models/HomeDiscoveryViewModel.ts +++ b/apps/website/lib/view-models/HomeDiscoveryViewModel.ts @@ -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; } } diff --git a/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.test.ts b/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.test.ts index b79379150..e34d44a97 100644 --- a/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.test.ts +++ b/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.test.ts @@ -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); }); }); diff --git a/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.ts b/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.ts index 6fdc4ea3a..4f3c2ec3d 100644 --- a/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.ts +++ b/apps/website/lib/view-models/ImportRaceResultsSummaryViewModel.ts @@ -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; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/MediaViewModel.test.ts b/apps/website/lib/view-models/MediaViewModel.test.ts index bdf64eee2..0170335a0 100644 --- a/apps/website/lib/view-models/MediaViewModel.test.ts +++ b/apps/website/lib/view-models/MediaViewModel.test.ts @@ -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); }); }); \ No newline at end of file diff --git a/apps/website/lib/view-models/MediaViewModel.ts b/apps/website/lib/view-models/MediaViewModel.ts index ee7cd64b0..1d131e607 100644 --- a/apps/website/lib/view-models/MediaViewModel.ts +++ b/apps/website/lib/view-models/MediaViewModel.ts @@ -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; } }