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', text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30', 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: { admin: {
text: 'Admin', text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30', badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',

View File

@@ -23,7 +23,6 @@ describe('DriverRegistrationService', () => {
const mockDto = { const mockDto = {
isRegistered: true, isRegistered: true,
raceId: 'race-456', raceId: 'race-456',
driverId: 'driver-123',
}; };
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto); mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
@@ -36,7 +35,6 @@ describe('DriverRegistrationService', () => {
expect(result.raceId).toBe('race-456'); expect(result.raceId).toBe('race-456');
expect(result.driverId).toBe('driver-123'); expect(result.driverId).toBe('driver-123');
expect(result.statusMessage).toBe('Registered for this race'); expect(result.statusMessage).toBe('Registered for this race');
expect(result.statusColor).toBe('green');
expect(result.statusBadgeVariant).toBe('success'); expect(result.statusBadgeVariant).toBe('success');
expect(result.registrationButtonText).toBe('Withdraw'); expect(result.registrationButtonText).toBe('Withdraw');
expect(result.canRegister).toBe(false); expect(result.canRegister).toBe(false);
@@ -49,7 +47,6 @@ describe('DriverRegistrationService', () => {
const mockDto = { const mockDto = {
isRegistered: false, isRegistered: false,
raceId: 'race-456', raceId: 'race-456',
driverId: 'driver-123',
}; };
mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto); mockApiClient.getRegistrationStatus.mockResolvedValue(mockDto);
@@ -58,7 +55,6 @@ describe('DriverRegistrationService', () => {
expect(result.isRegistered).toBe(false); expect(result.isRegistered).toBe(false);
expect(result.statusMessage).toBe('Not registered'); expect(result.statusMessage).toBe('Not registered');
expect(result.statusColor).toBe('red');
expect(result.statusBadgeVariant).toBe('warning'); expect(result.statusBadgeVariant).toBe('warning');
expect(result.registrationButtonText).toBe('Register'); expect(result.registrationButtonText).toBe('Register');
expect(result.canRegister).toBe(true); 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 { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { Service } from '@/lib/contracts/services/Service'; import { Service } from '@/lib/contracts/services/Service';
import type { DriverRegistrationStatusViewData } from '@/lib/view-data/DriverRegistrationStatusViewData';
@injectable() @injectable()
export class DriverRegistrationService implements Service { export class DriverRegistrationService implements Service {
@@ -22,7 +23,15 @@ export class DriverRegistrationService implements Service {
} }
async getDriverRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusViewModel> { async getDriverRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusViewModel> {
const data = await this.apiClient.getRegistrationStatus(driverId, raceId); const dto = await this.apiClient.getRegistrationStatus(driverId, raceId);
return new DriverRegistrationStatusViewModel(data);
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 { describe, it, expect } from 'vitest';
import { DriverRegistrationStatusViewModel } from './DriverRegistrationStatusViewModel'; 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, isRegistered: true,
raceId: 'race-1', raceId: 'race-1',
driverId: 'driver-1', driverId: 'driver-1',
canRegister: false,
...overrides, ...overrides,
}); });
describe('DriverRegistrationStatusViewModel', () => { describe('DriverRegistrationStatusViewModel', () => {
it('maps basic registration status fields from DTO', () => { it('exposes basic registration status fields from ViewData', () => {
const dto = createStatusDto({ isRegistered: true }); const viewModel = new DriverRegistrationStatusViewModel(createViewData({ isRegistered: true }));
const viewModel = new DriverRegistrationStatusViewModel(dto);
expect(viewModel.isRegistered).toBe(true); expect(viewModel.isRegistered).toBe(true);
expect(viewModel.raceId).toBe('race-1'); expect(viewModel.raceId).toBe('race-1');
expect(viewModel.driverId).toBe('driver-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); 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', () => { 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.statusMessage).toBe('Not registered');
expect(viewModel.statusColor).toBe('red');
expect(viewModel.statusBadgeVariant).toBe('warning'); expect(viewModel.statusBadgeVariant).toBe('warning');
expect(viewModel.registrationButtonText).toBe('Register'); expect(viewModel.registrationButtonText).toBe('Register');
expect(viewModel.canRegister).toBe(true); 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 { ViewModel } from "../contracts/view-models/ViewModel";
import type { DriverRegistrationStatusViewData } from "../view-data/DriverRegistrationStatusViewData";
import { DriverRegistrationStatusDisplay } from "../display-objects/DriverRegistrationStatusDisplay";
export class DriverRegistrationStatusViewModel extends ViewModel { export class DriverRegistrationStatusViewModel extends ViewModel {
isRegistered!: boolean; constructor(private readonly viewData: DriverRegistrationStatusViewData) {
raceId!: string; super();
driverId!: string;
constructor(dto: DriverRegistrationStatusDTO) {
Object.assign(this, dto);
} }
/** UI-specific: Status message */ get isRegistered(): boolean {
get statusMessage(): string { return this.viewData.isRegistered;
return this.isRegistered ? 'Registered for this race' : 'Not registered';
} }
/** UI-specific: Status color */ get raceId(): string {
get statusColor(): string { return this.viewData.raceId;
return this.isRegistered ? 'green' : 'red';
} }
/** UI-specific: Badge variant */ get driverId(): string {
get statusBadgeVariant(): string { return this.viewData.driverId;
return this.isRegistered ? 'success' : 'warning';
} }
/** 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 { 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 { describe, it, expect } from 'vitest';
import { DriverSummaryViewModel } from './DriverSummaryViewModel'; import { DriverSummaryViewModel } from './DriverSummaryViewModel';
import type { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO'; import type { DriverSummaryData } from '../view-data/LeagueDetailViewData';
const driverDto: GetDriverOutputDTO = { const viewData: DriverSummaryData = {
id: 'driver-1', driverId: 'driver-1',
iracingId: 'ir-123', driverName: 'Test Driver',
name: 'Test Driver', avatarUrl: 'https://example.com/avatar.png',
country: 'DE', rating: 2500,
joinedAt: '2024-01-01T00:00:00Z', rank: 10,
roleBadgeText: 'Owner',
roleBadgeClasses: 'bg-blue-50 text-blue-700',
profileUrl: '/drivers/driver-1',
}; };
describe('DriverSummaryViewModel', () => { describe('DriverSummaryViewModel', () => {
it('maps driver and optional fields from DTO', () => { it('exposes driver identity fields from ViewData', () => {
const viewModel = new DriverSummaryViewModel({ const viewModel = new DriverSummaryViewModel(viewData);
driver: driverDto,
rating: 2500, expect(viewModel.id).toBe('driver-1');
rank: 10, 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.rating).toBe(2500);
expect(viewModel.ratingLabel).toBe('2,500');
expect(viewModel.rank).toBe(10); 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({ const viewModel = new DriverSummaryViewModel({
driver: driverDto, ...viewData,
});
expect(viewModel.rating).toBeNull();
expect(viewModel.rank).toBeNull();
});
it('keeps explicit null rating and rank', () => {
const viewModel = new DriverSummaryViewModel({
driver: driverDto,
rating: null, rating: null,
rank: null, rank: null,
}); });
expect(viewModel.rating).toBeNull(); expect(viewModel.rating).toBeNull();
expect(viewModel.ratingLabel).toBe('—');
expect(viewModel.rank).toBeNull(); 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 * 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 { export class DriverSummaryViewModel extends ViewModel {
driver: GetDriverOutputDTO; constructor(private readonly viewData: DriverSummaryData) {
rating: number | null; super();
rank: number | null; }
constructor(dto: { get id(): string {
driver: GetDriverOutputDTO; return this.viewData.driverId;
rating?: number | null; }
rank?: number | null;
}) { get name(): string {
this.driver = dto.driver; return this.viewData.driverName;
this.rating = dto.rating ?? null; }
this.rank = dto.rank ?? null;
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 { describe, it, expect } from "vitest";
import { DriverTeamViewModel } from './DriverTeamViewModel'; import { DriverTeamViewModel } from "./DriverTeamViewModel";
import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeamOutputDTO'; import type { TeamDetailData } from "../view-data/TeamDetailViewData";
const createTeamDto = (overrides: Partial<GetDriverTeamOutputDTO> = {}): GetDriverTeamOutputDTO => ({ const createTeamViewData = (overrides: Partial<TeamDetailData> = {}): TeamDetailData => ({
team: { id: "team-1",
id: 'team-1', name: "Test Team",
name: 'Test Team', tag: "TT",
tag: 'TT', ownerId: "owner-1",
description: 'Test team description', leagues: ["league-1"],
ownerId: 'owner-1', canManage: true,
leagues: ['league-1'],
createdAt: '2024-01-01T00:00:00Z',
specialization: 'mixed',
region: 'EU',
languages: ['en'],
},
membership: { membership: {
role: 'manager', role: "manager",
joinedAt: '2024-01-01T00:00:00Z', joinedAt: "2024-01-01T00:00:00Z",
isActive: true, isActive: true,
}, },
isOwner: false,
canManage: true,
...overrides, ...overrides,
}); });
describe('DriverTeamViewModel', () => { describe("DriverTeamViewModel", () => {
it('maps team and membership fields from DTO', () => { it("exposes team fields from ViewData", () => {
const dto = createTeamDto(); const viewData = createTeamViewData();
const viewModel = new DriverTeamViewModel(dto); const viewModel = new DriverTeamViewModel(viewData);
expect(viewModel.teamId).toBe('team-1'); expect(viewModel.teamId).toBe("team-1");
expect(viewModel.teamName).toBe('Test Team'); expect(viewModel.teamName).toBe("Test Team");
expect(viewModel.tag).toBe('TT'); expect(viewModel.tag).toBe("TT");
expect(viewModel.role).toBe('manager'); expect(viewModel.role).toBe("manager");
expect(viewModel.isOwner).toBe(false); expect(viewModel.isOwner).toBe(false);
expect(viewModel.canManage).toBe(true); expect(viewModel.canManage).toBe(true);
}); });
it('derives displayRole with capitalized first letter', () => { it("derives displayRole with capitalized first letter", () => {
const dto = createTeamDto({ const viewData = createTeamViewData({
membership: { membership: {
role: 'owner', role: "owner",
joinedAt: '2024-01-01T00:00:00Z', joinedAt: "2024-01-01T00:00:00Z",
isActive: true, 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', () => { it("defaults role when membership is missing", () => {
const dto = createTeamDto({ const viewData = createTeamViewData({
membership: { membership: null,
role: 'member',
joinedAt: '2024-01-01T00:00:00Z',
isActive: true,
},
}); });
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 * 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 { ViewModel } from "../contracts/view-models/ViewModel";
import type { TeamDetailData } from "../view-data/TeamDetailViewData";
export class DriverTeamViewModel extends ViewModel { export class DriverTeamViewModel extends ViewModel {
teamId: string; constructor(private readonly viewData: TeamDetailData) {
teamName: string; super();
tag: string; }
role: string;
isOwner: boolean;
canManage: boolean;
constructor(dto: GetDriverTeamOutputDTO) { get teamId(): string {
this.teamId = dto.team.id; return this.viewData.id;
this.teamName = dto.team.name; }
this.tag = dto.team.tag;
this.role = dto.membership.role; get teamName(): string {
this.isOwner = dto.isOwner; return this.viewData.name;
this.canManage = dto.canManage; }
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 */ /** UI-specific: Display role */
get displayRole(): string { 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 { describe, it, expect } from "vitest";
import { DriverViewModel } from './DriverViewModel'; import { DriverViewModel } from "./DriverViewModel";
import type { DriverData } from "../view-data/LeagueStandingsViewData";
describe('DriverViewModel', () => { describe("DriverViewModel", () => {
it('should create instance with all properties', () => { it("should create instance with all properties", () => {
const dto = { const viewData: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', name: "John Doe",
avatarUrl: 'https://example.com/avatar.jpg', avatarUrl: "https://example.com/avatar.jpg",
iracingId: 'iracing-456', iracingId: "iracing-456",
rating: 1500, rating: 1500,
country: "US",
}; };
const viewModel = new DriverViewModel(dto); const viewModel = new DriverViewModel(viewData);
expect(viewModel.id).toBe('driver-123'); expect(viewModel.id).toBe("driver-123");
expect(viewModel.name).toBe('John Doe'); expect(viewModel.name).toBe("John Doe");
expect(viewModel.avatarUrl).toBe('https://example.com/avatar.jpg'); expect(viewModel.avatarUrl).toBe("https://example.com/avatar.jpg");
expect(viewModel.iracingId).toBe('iracing-456'); expect(viewModel.iracingId).toBe("iracing-456");
expect(viewModel.rating).toBe(1500); expect(viewModel.rating).toBe(1500);
expect(viewModel.country).toBe("US");
}); });
it('should create instance with only required properties', () => { it("should create instance with only required properties", () => {
const dto = { const viewData: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', name: "John Doe",
avatarUrl: null,
}; };
const viewModel = new DriverViewModel(dto); const viewModel = new DriverViewModel(viewData);
expect(viewModel.id).toBe('driver-123'); expect(viewModel.id).toBe("driver-123");
expect(viewModel.name).toBe('John Doe'); expect(viewModel.name).toBe("John Doe");
expect(viewModel.avatarUrl).toBeNull(); expect(viewModel.avatarUrl).toBeNull();
expect(viewModel.iracingId).toBeUndefined(); expect(viewModel.iracingId).toBeUndefined();
expect(viewModel.rating).toBeUndefined(); expect(viewModel.rating).toBeUndefined();
expect(viewModel.country).toBeUndefined();
}); });
it('should return true for hasIracingId when iracingId exists', () => { it("should return true for hasIracingId when iracingId exists", () => {
const viewModel = new DriverViewModel({ const viewData: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', name: "John Doe",
iracingId: 'iracing-456', avatarUrl: null,
}); iracingId: "iracing-456",
};
const viewModel = new DriverViewModel(viewData);
expect(viewModel.hasIracingId).toBe(true); expect(viewModel.hasIracingId).toBe(true);
}); });
it('should return false for hasIracingId when iracingId is undefined', () => { it("should return false for hasIracingId when iracingId is undefined", () => {
const viewModel = new DriverViewModel({ const viewData: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', name: "John Doe",
}); avatarUrl: null,
};
const viewModel = new DriverViewModel(viewData);
expect(viewModel.hasIracingId).toBe(false); expect(viewModel.hasIracingId).toBe(false);
}); });
it('should return false for hasIracingId when iracingId is empty string', () => { it("should return false for hasIracingId when iracingId is empty string", () => {
const viewModel = new DriverViewModel({ const viewData: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', name: "John Doe",
iracingId: '', avatarUrl: null,
}); iracingId: "",
};
const viewModel = new DriverViewModel(viewData);
expect(viewModel.hasIracingId).toBe(false); expect(viewModel.hasIracingId).toBe(false);
}); });
it('should format rating correctly when rating exists', () => { it("should format rating correctly when rating exists", () => {
const viewModel = new DriverViewModel({ const viewData: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', name: "John Doe",
avatarUrl: null,
rating: 1547.89, 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', () => { it("should return \"Unrated\" when rating is undefined", () => {
const viewModel = new DriverViewModel({ const viewData: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', 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', () => { it("should handle zero rating", () => {
const viewModel = new DriverViewModel({ const viewData: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', name: "John Doe",
avatarUrl: null,
rating: 0, rating: 0,
}); };
expect(viewModel.formattedRating).toBe('Unrated'); const viewModel = new DriverViewModel(viewData);
expect(viewModel.formattedRating).toBe("Unrated");
}); });
it('should round rating to nearest integer', () => { it("should round rating to nearest integer", () => {
const viewModel1 = new DriverViewModel({ const viewData1: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', name: "John Doe",
avatarUrl: null,
rating: 1500.4, rating: 1500.4,
}); };
const viewModel2 = new DriverViewModel({ const viewData2: DriverData = {
id: 'driver-123', id: "driver-123",
name: 'John Doe', name: "John Doe",
avatarUrl: null,
rating: 1500.6, rating: 1500.6,
}); };
expect(viewModel1.formattedRating).toBe('1500'); const viewModel1 = new DriverViewModel(viewData1);
expect(viewModel2.formattedRating).toBe('1501'); const viewModel2 = new DriverViewModel(viewData2);
expect(viewModel1.formattedRating).toBe("1500");
expect(viewModel2.formattedRating).toBe("1501");
}); });
}); });

View File

@@ -2,9 +2,15 @@
* Driver view model * Driver view model
* UI representation of a driver * 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 { 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 { export class DriverViewModel extends ViewModel {
id: string; id: string;
@@ -16,25 +22,16 @@ export class DriverViewModel extends ViewModel {
bio?: string; bio?: string;
joinedAt?: string; joinedAt?: string;
constructor(dto: { constructor(viewData: DriverViewModelViewData) {
id: string;
name: string;
avatarUrl?: string | null;
iracingId?: string;
rating?: number;
country?: string;
bio?: string;
joinedAt?: string;
}) {
super(); super();
this.id = dto.id; this.id = viewData.id;
this.name = dto.name; this.name = viewData.name;
this.avatarUrl = dto.avatarUrl ?? null; this.avatarUrl = viewData.avatarUrl ?? null;
if (dto.iracingId !== undefined) this.iracingId = dto.iracingId; if (viewData.iracingId !== undefined) this.iracingId = viewData.iracingId;
if (dto.rating !== undefined) this.rating = dto.rating; if (viewData.rating !== undefined) this.rating = viewData.rating;
if (dto.country !== undefined) this.country = dto.country; if (viewData.country !== undefined) this.country = viewData.country;
if (dto.bio !== undefined) this.bio = dto.bio; if (viewData.bio !== undefined) this.bio = viewData.bio;
if (dto.joinedAt !== undefined) this.joinedAt = dto.joinedAt; if (viewData.joinedAt !== undefined) this.joinedAt = viewData.joinedAt;
} }
/** UI-specific: Whether driver has an iRacing ID */ /** UI-specific: Whether driver has an iRacing ID */
@@ -44,6 +41,6 @@ export class DriverViewModel extends ViewModel {
/** UI-specific: Formatted rating */ /** UI-specific: Formatted rating */
get formattedRating(): string { 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 { describe, it, expect } from 'vitest';
import { EmailSignupViewModel } from './EmailSignupViewModel'; import { EmailSignupViewModel } from './EmailSignupViewModel';
import type { EmailSignupViewData } from '../view-data/EmailSignupViewData';
describe('EmailSignupViewModel', () => { describe('EmailSignupViewModel', () => {
it('should be defined', () => { it('wraps EmailSignupViewData and exposes UI helpers', () => {
expect(EmailSignupViewModel).toBeDefined(); 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 * View model for email signup responses
*/ */
import { ViewModel } from "../contracts/view-models/ViewModel"; import { ViewModel } from "../contracts/view-models/ViewModel";
import type { EmailSignupViewData } from "../view-data/EmailSignupViewData";
export class EmailSignupViewModel extends ViewModel { export class EmailSignupViewModel extends ViewModel {
readonly email: string; constructor(private readonly viewData: EmailSignupViewData) {
readonly message: string; super();
readonly status: 'success' | 'error' | 'info'; }
constructor(email: string, message: string, status: 'success' | 'error' | 'info') { get email(): string {
this.email = email; return this.viewData.email;
this.message = message; }
this.status = status;
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 { HomeDiscoveryViewModel } from './HomeDiscoveryViewModel';
import { LeagueCardViewModel } from './LeagueCardViewModel'; import type { HomeDiscoveryViewData } from '@/lib/view-data/HomeDiscoveryViewData';
import { TeamCardViewModel } from './TeamCardViewModel';
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
describe('HomeDiscoveryViewModel', () => { describe('HomeDiscoveryViewModel', () => {
it('exposes the top leagues, teams and upcoming races from the DTO', () => { it('exposes the discovery collections from ViewData', () => {
const topLeagues = [ const viewData: HomeDiscoveryViewData = {
new LeagueCardViewModel({ topLeagues: [{ id: 'league-1', name: 'Pro League', description: 'Top-tier league' }],
id: 'league-1', teams: [{ id: 'team-1', name: 'Team Alpha', description: 'Serious endurance', logoUrl: undefined }],
name: 'Pro League', upcomingRaces: [
description: 'Top-tier league', { id: 'race-1', track: 'Spa-Francorchamps', car: 'GT3', formattedDate: 'Jan 1' },
memberCount: 100, ],
isMember: false, };
} as any),
];
const teams = [ const viewModel = new HomeDiscoveryViewModel(viewData);
new TeamCardViewModel({
id: 'team-1',
name: 'Team Alpha',
tag: 'ALPHA',
memberCount: 10,
isMember: true,
} as any),
];
const upcomingRaces = [ expect(viewModel.topLeagues).toBe(viewData.topLeagues);
new UpcomingRaceCardViewModel({ expect(viewModel.teams).toBe(viewData.teams);
id: 'race-1', expect(viewModel.upcomingRaces).toBe(viewData.upcomingRaces);
track: 'Spa-Francorchamps', });
car: 'GT3',
scheduledAt: '2025-01-01T12:00:00Z',
} as any),
];
const viewModel = new HomeDiscoveryViewModel({ it('provides basic UI helper booleans', () => {
topLeagues, const viewData: HomeDiscoveryViewData = {
teams, topLeagues: [],
upcomingRaces, teams: [{ id: 'team-1', name: 'Team Alpha', description: 'Serious endurance' }],
}); upcomingRaces: [],
};
expect(viewModel.topLeagues).toBe(topLeagues); const viewModel = new HomeDiscoveryViewModel(viewData);
expect(viewModel.teams).toBe(teams);
expect(viewModel.upcomingRaces).toBe(upcomingRaces); 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 type { HomeDiscoveryViewData } from '@/lib/view-data/HomeDiscoveryViewData';
import { TeamCardViewModel } from './TeamCardViewModel';
import { UpcomingRaceCardViewModel } from './UpcomingRaceCardViewModel';
interface HomeDiscoveryDTO {
topLeagues: LeagueCardViewModel[];
teams: TeamCardViewModel[];
upcomingRaces: UpcomingRaceCardViewModel[];
}
/** /**
* Home discovery view model * Home discovery view model
* Aggregates discovery data for the landing page. * 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 { export class HomeDiscoveryViewModel extends ViewModel {
readonly topLeagues: LeagueCardViewModel[]; readonly topLeagues: HomeDiscoveryViewData['topLeagues'];
readonly teams: TeamCardViewModel[]; readonly teams: HomeDiscoveryViewData['teams'];
readonly upcomingRaces: UpcomingRaceCardViewModel[]; readonly upcomingRaces: HomeDiscoveryViewData['upcomingRaces'];
constructor(dto: HomeDiscoveryDTO) { constructor(viewData: HomeDiscoveryViewData) {
this.topLeagues = dto.topLeagues; super();
this.teams = dto.teams; this.topLeagues = viewData.topLeagues;
this.upcomingRaces = dto.upcomingRaces; 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'; import { ImportRaceResultsSummaryViewModel } from './ImportRaceResultsSummaryViewModel';
describe('ImportRaceResultsSummaryViewModel', () => { describe('ImportRaceResultsSummaryViewModel', () => {
it('maps DTO fields including errors', () => { it('exposes view data fields', () => {
const dto = { const viewData: ImportRaceResultsSummaryViewData = {
success: true, success: true,
raceId: 'race-1', raceId: 'race-1',
driversProcessed: 10, driversProcessed: 10,
@@ -11,7 +12,7 @@ describe('ImportRaceResultsSummaryViewModel', () => {
errors: ['Driver missing', 'Invalid lap time'], errors: ['Driver missing', 'Invalid lap time'],
}; };
const viewModel = new ImportRaceResultsSummaryViewModel(dto); const viewModel = new ImportRaceResultsSummaryViewModel(viewData);
expect(viewModel.success).toBe(true); expect(viewModel.success).toBe(true);
expect(viewModel.raceId).toBe('race-1'); expect(viewModel.raceId).toBe('race-1');
@@ -20,16 +21,24 @@ describe('ImportRaceResultsSummaryViewModel', () => {
expect(viewModel.errors).toEqual(['Driver missing', 'Invalid lap time']); expect(viewModel.errors).toEqual(['Driver missing', 'Invalid lap time']);
}); });
it('defaults errors to an empty array when not provided', () => { it('derives hasErrors UI helper', () => {
const dto = { const viewDataWithErrors: ImportRaceResultsSummaryViewData = {
success: false, success: false,
raceId: 'race-2', raceId: 'race-2',
driversProcessed: 0, driversProcessed: 0,
resultsRecorded: 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 { ViewModel } from "../contracts/view-models/ViewModel";
import type { ImportRaceResultsSummaryViewData } from "../view-data/ImportRaceResultsSummaryViewData";
export class ImportRaceResultsSummaryViewModel extends ViewModel { export class ImportRaceResultsSummaryViewModel extends ViewModel {
success: boolean; constructor(private readonly viewData: ImportRaceResultsSummaryViewData) {
raceId: string; super();
driversProcessed: number; }
resultsRecorded: number;
errors: string[];
constructor(dto: ImportRaceResultsSummaryDTO) { get success(): boolean {
this.success = dto.success; return this.viewData.success;
this.raceId = dto.raceId; }
this.driversProcessed = dto.driversProcessed;
this.resultsRecorded = dto.resultsRecorded; get raceId(): string {
this.errors = dto.errors || []; 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 { describe, it, expect } from 'vitest';
import { MediaViewModel } from './MediaViewModel'; import { MediaViewModel } from './MediaViewModel';
import type { MediaViewData } from '@/lib/view-data/MediaViewData';
describe('MediaViewModel', () => { describe('MediaViewModel', () => {
it('should create instance with all properties', () => { it('creates instance from asset ViewData', () => {
const dto = { const asset: MediaViewData['assets'][number] = {
id: 'media-123', id: 'media-123',
url: 'https://example.com/image.jpg', src: 'https://example.com/image.jpg',
type: 'image', title: 'Race Day Photo',
category: 'avatar', category: 'avatar',
uploadedAt: '2023-01-15T00:00:00.000Z', date: '2023-01-15',
size: 2048000, dimensions: '1920×1080',
}; };
const viewModel = new MediaViewModel(dto); const viewModel = new MediaViewModel(asset);
expect(viewModel.id).toBe('media-123'); expect(viewModel.id).toBe('media-123');
expect(viewModel.url).toBe('https://example.com/image.jpg'); expect(viewModel.src).toBe('https://example.com/image.jpg');
expect(viewModel.type).toBe('image'); expect(viewModel.title).toBe('Race Day Photo');
expect(viewModel.category).toBe('avatar'); expect(viewModel.category).toBe('avatar');
expect(viewModel.uploadedAt).toEqual(new Date('2023-01-15')); expect(viewModel.date).toBe('2023-01-15');
expect(viewModel.size).toBe(2048000); expect(viewModel.dimensions).toBe('1920×1080');
}); });
it('should create instance without optional properties', () => { it('subtitle matches MediaCard subtitle formatting', () => {
const dto = { const asset: MediaViewData['assets'][number] = {
id: 'media-123', id: 'media-123',
url: 'https://example.com/image.jpg', src: 'https://example.com/image.jpg',
type: 'image', title: 'Race Day Photo',
uploadedAt: '2023-01-15T00:00:00.000Z', category: 'avatar',
dimensions: '1920×1080',
}; };
const viewModel = new MediaViewModel(dto); const viewModel = new MediaViewModel(asset);
expect(viewModel.category).toBeUndefined(); expect(viewModel.subtitle).toBe('avatar • 1920×1080');
expect(viewModel.size).toBeUndefined();
}); });
it('should return "Unknown" for formattedSize when size is undefined', () => { it('subtitle omits dimensions when missing', () => {
const viewModel = new MediaViewModel({ const asset: MediaViewData['assets'][number] = {
id: 'media-123', id: 'media-123',
url: 'https://example.com/image.jpg', src: 'https://example.com/image.jpg',
type: 'image', title: 'Race Day Photo',
uploadedAt: new Date().toISOString(), 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', () => { it('hasMetadata reflects presence of date or dimensions', () => {
const viewModel = new MediaViewModel({ const noMeta = 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({
id: '1', id: '1',
url: 'image.jpg', src: 'a.jpg',
type: 'image', title: 'A',
uploadedAt: new Date().toISOString(), category: 'misc',
}); });
const videoVm = new MediaViewModel({ const withDate = new MediaViewModel({
id: '2', id: '2',
url: 'video.mp4', src: 'b.jpg',
type: 'video', title: 'B',
uploadedAt: new Date().toISOString(), category: 'misc',
date: '2023-01-15',
}); });
const docVm = new MediaViewModel({ const withDimensions = new MediaViewModel({
id: '3', id: '3',
url: 'doc.pdf', src: 'c.jpg',
type: 'document', title: 'C',
uploadedAt: new Date().toISOString(), category: 'misc',
dimensions: '800×600',
}); });
expect(imageVm.type).toBe('image'); expect(noMeta.hasMetadata).toBe(false);
expect(videoVm.type).toBe('video'); expect(withDate.hasMetadata).toBe(true);
expect(docVm.type).toBe('document'); expect(withDimensions.hasMetadata).toBe(true);
});
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);
});
}); });
}); });

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 * 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 { export class MediaViewModel extends ViewModel {
id: string; id: string;
url: string; src: string;
type: 'image' | 'video' | 'document'; title: string;
category?: 'avatar' | 'team-logo' | 'league-cover' | 'race-result'; category: string;
uploadedAt: Date; date?: string;
size?: number; dimensions?: string;
constructor(dto: GetMediaOutputDTO) { constructor(viewData: MediaAssetViewData) {
this.id = dto.id; super();
this.url = dto.url; this.id = viewData.id;
this.type = dto.type as 'image' | 'video' | 'document'; this.src = viewData.src;
this.uploadedAt = new Date(dto.uploadedAt); this.title = viewData.title;
if (dto.category !== undefined) this.category = dto.category as 'avatar' | 'team-logo' | 'league-cover' | 'race-result'; this.category = viewData.category;
if (dto.size !== undefined) this.size = dto.size; if (viewData.date !== undefined) this.date = viewData.date;
if (viewData.dimensions !== undefined) this.dimensions = viewData.dimensions;
} }
/** UI-specific: Formatted file size */ /** UI-specific: Combined subtitle used by MediaCard */
get formattedSize(): string { get subtitle(): string {
if (!this.size) return 'Unknown'; return `${this.category}${this.dimensions ? `${this.dimensions}` : ''}`;
const kb = this.size / 1024; }
if (kb < 1024) return `${kb.toFixed(2)} KB`; /** UI-specific: Whether any metadata is present */
get hasMetadata(): boolean {
const mb = kb / 1024; return !!this.date || !!this.dimensions;
return `${mb.toFixed(2)} MB`;
} }
} }