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

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

View File

@@ -1,3 +1,4 @@
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import type { DashboardStats } from '@/lib/types/admin';
import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
@@ -7,7 +8,14 @@ import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewD
* Transforms DashboardStats API DTO into AdminDashboardViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
export class AdminDashboardViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class AdminDashboardViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return AdminDashboardViewDataBuilder.build(input);
}
static build(
static build(apiDto: DashboardStats): AdminDashboardViewData {
return {
stats: {

View File

@@ -1,27 +1,19 @@
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import type { UserListResponse } from '@/lib/types/admin';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
/**
* AdminUsersViewDataBuilder
*
* Server-side builder that transforms API DTO
* into ViewData for the AdminUsersTemplate.
*
* Deterministic, side-effect free.
*/
export class AdminUsersViewDataBuilder {
static build(apiDto: UserListResponse): AdminUsersViewData {
const users = apiDto.users.map(user => ({
id: user.id,
email: user.email,
displayName: user.displayName,
roles: user.roles,
status: user.status,
isSystemAdmin: user.isSystemAdmin,
createdAt: typeof user.createdAt === 'string' ? user.createdAt : (user.createdAt as unknown as Date).toISOString(),
updatedAt: typeof user.updatedAt === 'string' ? user.updatedAt : (user.updatedAt as unknown as Date).toISOString(),
lastLoginAt: user.lastLoginAt ? (typeof user.lastLoginAt === 'string' ? user.lastLoginAt : (user.lastLoginAt as unknown as Date).toISOString()) : undefined,
primaryDriverId: user.primaryDriverId,
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class AdminUsersViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return AdminUsersViewDataBuilder.build(input);
}
static build(
public static build(apiDto: UserListResponse): AdminUsersViewData {
const users = apiDto.users.map(u => ({
...u,
joinedAt: new Date(u.joinedAt),
}));
return {
@@ -35,4 +27,4 @@ export class AdminUsersViewDataBuilder {
adminCount: users.filter(u => u.isSystemAdmin).length,
};
}
}
}

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { AnalyticsDashboardViewDataBuilder } from './AnalyticsDashboardViewDataBuilder';
import { AnalyticsDashboardInputViewData } from '@/lib/view-data/AnalyticsDashboardInputViewData';
describe('AnalyticsDashboardViewDataBuilder', () => {
it('builds ViewData from AnalyticsDashboardInputViewData', () => {
const inputViewData: AnalyticsDashboardInputViewData = {
totalUsers: 100,
activeUsers: 40,
totalRaces: 10,
totalLeagues: 5,
};
const viewData = AnalyticsDashboardViewDataBuilder.build(inputViewData);
expect(viewData.metrics.totalUsers).toBe(100);
expect(viewData.metrics.activeUsers).toBe(40);
expect(viewData.metrics.totalRaces).toBe(10);
expect(viewData.metrics.totalLeagues).toBe(5);
expect(viewData.metrics.userEngagementRate).toBeCloseTo(40);
expect(viewData.metrics.formattedEngagementRate).toBe('40.0%');
expect(viewData.metrics.activityLevel).toBe('Low');
});
it('computes engagement rate and formatted engagement rate', () => {
const inputViewData: AnalyticsDashboardInputViewData = {
totalUsers: 200,
activeUsers: 50,
totalRaces: 0,
totalLeagues: 0,
};
const viewData = AnalyticsDashboardViewDataBuilder.build(inputViewData);
expect(viewData.metrics.userEngagementRate).toBeCloseTo(25);
expect(viewData.metrics.formattedEngagementRate).toBe('25.0%');
});
it('handles zero users safely', () => {
const inputViewData: AnalyticsDashboardInputViewData = {
totalUsers: 0,
activeUsers: 0,
totalRaces: 0,
totalLeagues: 0,
};
const viewData = AnalyticsDashboardViewDataBuilder.build(inputViewData);
expect(viewData.metrics.userEngagementRate).toBe(0);
expect(viewData.metrics.formattedEngagementRate).toBe('0.0%');
expect(viewData.metrics.activityLevel).toBe('Low');
});
it('derives activity level buckets from engagement rate', () => {
const low = AnalyticsDashboardViewDataBuilder.build({
totalUsers: 100,
activeUsers: 30,
totalRaces: 0,
totalLeagues: 0,
});
const medium = AnalyticsDashboardViewDataBuilder.build({
totalUsers: 100,
activeUsers: 50,
totalRaces: 0,
totalLeagues: 0,
});
const high = AnalyticsDashboardViewDataBuilder.build({
totalUsers: 100,
activeUsers: 90,
totalRaces: 0,
totalLeagues: 0,
});
expect(low.metrics.activityLevel).toBe('Low');
expect(medium.metrics.activityLevel).toBe('Medium');
expect(high.metrics.activityLevel).toBe('High');
});
});

View File

@@ -0,0 +1,34 @@
import { AnalyticsDashboardInputViewData } from '@/lib/view-data/AnalyticsDashboardInputViewData';
import { AnalyticsDashboardViewData } from '@/lib/view-data/AnalyticsDashboardViewData';
/**
* AnalyticsDashboardViewDataBuilder
*
* Transforms AnalyticsDashboardInputViewData into AnalyticsDashboardViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class AnalyticsDashboardViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return AnalyticsDashboardViewDataBuilder.build(input);
}
static build(viewData: AnalyticsDashboardInputViewData): AnalyticsDashboardViewData {
const userEngagementRate = viewData.totalUsers > 0 ? (viewData.activeUsers / viewData.totalUsers) * 100 : 0;
const formattedEngagementRate = `${userEngagementRate.toFixed(1)}%`;
const activityLevel = userEngagementRate > 70 ? 'High' : userEngagementRate > 40 ? 'Medium' : 'Low';
return {
metrics: {
totalUsers: viewData.totalUsers,
activeUsers: viewData.activeUsers,
totalRaces: viewData.totalRaces,
totalLeagues: viewData.totalLeagues,
userEngagementRate,
formattedEngagementRate,
activityLevel,
},
};
}
}

View File

@@ -8,7 +8,14 @@
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { AvatarViewData } from '@/lib/view-data/AvatarViewData';
export class AvatarViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class AvatarViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return AvatarViewDataBuilder.build(input);
}
static build(
static build(apiDto: MediaBinaryDTO): AvatarViewData {
return {
buffer: Buffer.from(apiDto.buffer).toString('base64'),

View File

@@ -8,7 +8,14 @@
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData';
export class CategoryIconViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class CategoryIconViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return CategoryIconViewDataBuilder.build(input);
}
static build(
static build(apiDto: MediaBinaryDTO): CategoryIconViewData {
return {
buffer: Buffer.from(apiDto.buffer).toString('base64'),

View File

@@ -1,4 +1,6 @@
export interface CompleteOnboardingViewData {
import { ViewData } from "@/lib/contracts/view-data/ViewData";
export interface CompleteOnboardingViewData extends ViewData {
success: boolean;
driverId?: string;
errorMessage?: string;

View File

@@ -6,8 +6,16 @@
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
import { CompleteOnboardingViewData } from './CompleteOnboardingViewData';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export class CompleteOnboardingViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class CompleteOnboardingViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return CompleteOnboardingViewDataBuilder.build(input);
}
static build(
/**
* Transform DTO into ViewData
*

View File

@@ -6,6 +6,8 @@ import { DashboardRankDisplay } from '@/lib/display-objects/DashboardRankDisplay
import { DashboardConsistencyDisplay } from '@/lib/display-objects/DashboardConsistencyDisplay';
import { DashboardCountDisplay } from '@/lib/display-objects/DashboardCountDisplay';
import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardLeaguePositionDisplay';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import { number } from 'zod';
/**
* DashboardViewDataBuilder
@@ -13,7 +15,14 @@ import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardL
* Transforms DashboardOverviewDTO (API DTO) into DashboardViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
export class DashboardViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class DashboardViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return DashboardViewDataBuilder.build(input);
}
static build(
static build(apiDto: DashboardOverviewDTO): DashboardViewData {
return {
currentDriver: {

View File

@@ -0,0 +1,6 @@
import { ViewData } from "@/lib/contracts/view-data/ViewData";
export interface DeleteMediaViewData extends ViewData {
success: boolean;
error?: string;
}

View File

@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import { DeleteMediaViewDataBuilder } from './DeleteMediaViewDataBuilder';
import type { DeleteMediaOutputDTO } from '@/lib/types/generated/DeleteMediaOutputDTO';
describe('DeleteMediaViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform successful deletion DTO to ViewData correctly', () => {
const apiDto: DeleteMediaOutputDTO = {
success: true,
};
const result = DeleteMediaViewDataBuilder.build(apiDto);
expect(result).toEqual({
success: true,
error: undefined,
});
});
it('should handle deletion with error message', () => {
const apiDto: DeleteMediaOutputDTO = {
success: false,
error: 'Failed to delete media',
};
const result = DeleteMediaViewDataBuilder.build(apiDto);
expect(result).toEqual({
success: false,
error: 'Failed to delete media',
});
});
it('should handle deletion with only success field', () => {
const apiDto: DeleteMediaOutputDTO = {
success: true,
};
const result = DeleteMediaViewDataBuilder.build(apiDto);
expect(result).toEqual({
success: true,
error: undefined,
});
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const apiDto: DeleteMediaOutputDTO = {
success: false,
error: 'Something went wrong',
};
const result = DeleteMediaViewDataBuilder.build(apiDto);
expect(result.success).toBe(apiDto.success);
expect(result.error).toBe(apiDto.error);
});
it('should not modify the input DTO', () => {
const apiDto: DeleteMediaOutputDTO = {
success: false,
error: 'Error',
};
const originalDto = { ...apiDto };
DeleteMediaViewDataBuilder.build(apiDto);
expect(apiDto).toEqual(originalDto);
});
});
describe('edge cases', () => {
it('should handle false success value', () => {
const apiDto: DeleteMediaOutputDTO = {
success: false,
error: 'Error occurred',
};
const result = DeleteMediaViewDataBuilder.build(apiDto);
expect(result.success).toBe(false);
expect(result.error).toBe('Error occurred');
});
it('should handle empty string error message', () => {
const apiDto: DeleteMediaOutputDTO = {
success: false,
error: '',
};
const result = DeleteMediaViewDataBuilder.build(apiDto);
expect(result.success).toBe(false);
expect(result.error).toBe('');
});
it('should handle very long error message', () => {
const longError = 'Error: ' + 'a'.repeat(1000);
const apiDto: DeleteMediaOutputDTO = {
success: false,
error: longError,
};
const result = DeleteMediaViewDataBuilder.build(apiDto);
expect(result.error).toBe(longError);
});
it('should handle special characters in error message', () => {
const apiDto: DeleteMediaOutputDTO = {
success: false,
error: 'Error: "Failed to delete media" (code: 500)',
};
const result = DeleteMediaViewDataBuilder.build(apiDto);
expect(result.error).toBe('Error: "Failed to delete media" (code: 500)');
});
});
});

View File

@@ -0,0 +1,30 @@
/**
* DeleteMedia ViewData Builder
*
* Transforms media deletion result into ViewData for templates.
*/
import { DeleteMediaOutputDTO } from '@/lib/types/generated/DeleteMediaOutputDTO';
import { DeleteMediaViewData } from './DeleteMediaViewData';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class DeleteMediaViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return DeleteMediaViewDataBuilder.build(input);
}
/**
* Transform DTO into ViewData
*
* @param apiDto - The API DTO to transform
* @returns ViewData for templates
*/
static build(apiDto: DeleteMediaOutputDTO): DeleteMediaViewData {
return {
success: apiDto.success,
error: apiDto.error,
};
}
}

View File

@@ -1,5 +1,5 @@
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
import type { DriverProfileViewData } from '@/lib/view-data/DriverProfileViewData';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
@@ -12,7 +12,14 @@ import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
* Transforms GetDriverProfileOutputDTO into ViewData for the driver profile page.
* Deterministic, side-effect free, no HTTP calls.
*/
export class DriverProfileViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class DriverProfileViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return DriverProfileViewDataBuilder.build(input);
}
static build(
static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewData {
return {
currentDriver: apiDto.currentDriver ? {

View File

@@ -1,9 +1,15 @@
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
import { WinRateDisplay } from '@/lib/display-objects/WinRateDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export class DriverRankingsViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class DriverRankingsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return DriverRankingsViewDataBuilder.build(input);
}
static build(
static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData {
if (!apiDto || apiDto.length === 0) {
return {

View File

@@ -1,9 +1,16 @@
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
import type { DriversViewData } from '@/lib/view-data/DriversViewData';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
export class DriversViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class DriversViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return DriversViewDataBuilder.build(input);
}
static build(
static build(dto: DriversLeaderboardDTO): DriversViewData {
return {
drivers: dto.drivers.map(driver => ({

View File

@@ -6,9 +6,18 @@
*/
import { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
import { ForgotPasswordViewData } from './types/ForgotPasswordViewData';
import { ForgotPasswordViewData } from '../../view-data/ForgotPasswordViewData';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import { error } from 'console';
export class ForgotPasswordViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class ForgotPasswordViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return ForgotPasswordViewDataBuilder.build(input);
}
static build(
static build(apiDto: ForgotPasswordPageDTO): ForgotPasswordViewData {
return {
returnTo: apiDto.returnTo,

View File

@@ -1,4 +1,6 @@
export interface GenerateAvatarsViewData {
import { ViewData } from "@/lib/contracts/view-data/ViewData";
export interface GenerateAvatarsViewData extends ViewData {
success: boolean;
avatarUrls: string[];
errorMessage?: string;

View File

@@ -7,8 +7,16 @@
import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
import { GenerateAvatarsViewData } from './GenerateAvatarsViewData';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
export class GenerateAvatarsViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class GenerateAvatarsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return GenerateAvatarsViewDataBuilder.build(input);
}
static build(
/**
* Transform DTO into ViewData
*

View File

@@ -37,7 +37,14 @@ export interface HealthDTO {
}>;
}
export class HealthViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class HealthViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return HealthViewDataBuilder.build(input);
}
static build(
static build(dto: HealthDTO): HealthViewData {
const now = new Date();
const lastUpdated = dto.timestamp || now.toISOString();

View File

@@ -1,12 +1,20 @@
import type { HomeViewData } from '@/templates/HomeTemplate';
import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
/**
* HomeViewDataBuilder
*
* Transforms HomeDataDTO to HomeViewData.
*/
export class HomeViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class HomeViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return HomeViewDataBuilder.build(input);
}
static build(
/**
* Build HomeViewData from HomeDataDTO
*

View File

@@ -1,8 +1,16 @@
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
import type { GetTeamsLeaderboardOutputDTO } from '@/lib/types/generated/GetTeamsLeaderboardOutputDTO';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
export class LeaderboardsViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeaderboardsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeaderboardsViewDataBuilder.build(input);
}
static build(
static build(
apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: GetTeamsLeaderboardOutputDTO }
): LeaderboardsViewData {

View File

@@ -8,7 +8,14 @@
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { LeagueCoverViewData } from '@/lib/view-data/LeagueCoverViewData';
export class LeagueCoverViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeagueCoverViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeagueCoverViewDataBuilder.build(input);
}
static build(
static build(apiDto: MediaBinaryDTO): LeagueCoverViewData {
return {
buffer: Buffer.from(apiDto.buffer).toString('base64'),

View File

@@ -11,7 +11,14 @@ import type { LeagueDetailViewData, LeagueInfoData, LiveRaceData, DriverSummaryD
* Transforms API DTOs into LeagueDetailViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
export class LeagueDetailViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeagueDetailViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeagueDetailViewDataBuilder.build(input);
}
static build(
static build(input: {
league: LeagueWithCapacityAndScoringDTO;
owner: GetDriverOutputDTO | null;

View File

@@ -8,7 +8,14 @@
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { LeagueLogoViewData } from '@/lib/view-data/LeagueLogoViewData';
export class LeagueLogoViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeagueLogoViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeagueLogoViewDataBuilder.build(input);
}
static build(
static build(apiDto: MediaBinaryDTO): LeagueLogoViewData {
return {
buffer: Buffer.from(apiDto.buffer).toString('base64'),

View File

@@ -9,7 +9,14 @@ import { DateDisplay } from '@/lib/display-objects/DateDisplay';
* Transforms API DTOs into LeagueRosterAdminViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
export class LeagueRosterAdminViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeagueRosterAdminViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeagueRosterAdminViewDataBuilder.build(input);
}
static build(
static build(input: {
leagueId: string;
members: LeagueRosterMemberDTO[];

View File

@@ -1,7 +1,14 @@
import { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData';
import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto';
export class LeagueScheduleViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeagueScheduleViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeagueScheduleViewDataBuilder.build(input);
}
static build(
static build(apiDto: LeagueScheduleApiDto, currentDriverId?: string, isAdmin: boolean = false): LeagueScheduleViewData {
const now = new Date();

View File

@@ -1,7 +1,14 @@
import { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData';
import { LeagueSettingsApiDto } from '@/lib/types/tbd/LeagueSettingsApiDto';
import { LeagueSettingsViewData } from '@/lib/view-data/LeagueSettingsViewData';
export class LeagueSettingsViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeagueSettingsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeagueSettingsViewDataBuilder.build(input);
}
static build(
static build(apiDto: LeagueSettingsApiDto): LeagueSettingsViewData {
return {
leagueId: apiDto.leagueId,

View File

@@ -1,9 +1,16 @@
import { LeagueSponsorshipsViewData } from '@/lib/view-data/leagues/LeagueSponsorshipsViewData';
import { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { StatusDisplay } from '@/lib/display-objects/StatusDisplay';
import { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
import { LeagueSponsorshipsViewData } from '@/lib/view-data/LeagueSponsorshipsViewData';
export class LeagueSponsorshipsViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeagueSponsorshipsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeagueSponsorshipsViewDataBuilder.build(input);
}
static build(
static build(apiDto: LeagueSponsorshipsApiDto): LeagueSponsorshipsViewData {
return {
leagueId: apiDto.leagueId,

View File

@@ -16,7 +16,14 @@ interface LeagueMembershipsApiDto {
* Transforms API DTOs into LeagueStandingsViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
export class LeagueStandingsViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeagueStandingsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeagueStandingsViewDataBuilder.build(input);
}
static build(
static build(
standingsDto: LeagueStandingsApiDto,
membershipsDto: LeagueMembershipsApiDto,

View File

@@ -1,9 +1,16 @@
import { LeagueWalletViewData, LeagueWalletTransactionViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
import { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto';
import { CurrencyDisplay } from '@/lib/display-objects/CurrencyDisplay';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto';
import { LeagueWalletTransactionViewData, LeagueWalletViewData } from '@/lib/view-data/LeagueWalletViewData';
export class LeagueWalletViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeagueWalletViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeagueWalletViewDataBuilder.build(input);
}
static build(
static build(apiDto: LeagueWalletApiDto): LeagueWalletViewData {
const transactions: LeagueWalletTransactionViewData[] = apiDto.transactions.map(t => ({
...t,

View File

@@ -7,7 +7,14 @@ import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
* Transforms AllLeaguesWithCapacityAndScoringDTO (API DTO) into LeaguesViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
export class LeaguesViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LeaguesViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LeaguesViewDataBuilder.build(input);
}
static build(
static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData {
return {
leagues: apiDto.leagues.map((league) => ({

View File

@@ -6,9 +6,18 @@
*/
import { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
import { LoginViewData } from './types/LoginViewData';
import { LoginViewData } from '../../view-data/LoginViewData';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import { error } from 'console';
export class LoginViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class LoginViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return LoginViewDataBuilder.build(input);
}
static build(
static build(apiDto: LoginPageDTO): LoginViewData {
return {
returnTo: apiDto.returnTo,

View File

@@ -6,7 +6,14 @@
import { OnboardingPageViewData } from '@/lib/view-data/OnboardingPageViewData';
export class OnboardingPageViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class OnboardingPageViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return OnboardingPageViewDataBuilder.build(input);
}
static build(
/**
* Transform driver data into ViewData
*

View File

@@ -9,7 +9,14 @@ import { Result } from '@/lib/contracts/Result';
import { PresentationError } from '@/lib/contracts/page-queries/PresentationError';
import { OnboardingPageViewData } from '@/lib/view-data/OnboardingPageViewData';
export class OnboardingViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class OnboardingViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return OnboardingViewDataBuilder.build(input);
}
static build(
static build(apiDto: Result<{ isAlreadyOnboarded: boolean }, PresentationError>): Result<OnboardingPageViewData, PresentationError> {
if (apiDto.isErr()) {
return Result.err(apiDto.getError());

View File

@@ -19,7 +19,14 @@ interface ProfileLeaguesPageDto {
* ViewData Builder for Profile Leagues page
* Transforms Page DTO to ViewData for templates
*/
export class ProfileLeaguesViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class ProfileLeaguesViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return ProfileLeaguesViewDataBuilder.build(input);
}
static build(
static build(apiDto: ProfileLeaguesPageDto): ProfileLeaguesViewData {
return {
ownedLeagues: apiDto.ownedLeagues.map((league: { leagueId: string; name: string; description: string; membershipRole: 'owner' | 'admin' | 'steward' | 'member'; }) => ({

View File

@@ -8,7 +8,14 @@ import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
export class ProfileViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class ProfileViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return ProfileViewDataBuilder.build(input);
}
static build(
static build(apiDto: GetDriverProfileOutputDTO): ProfileViewData {
const driver = apiDto.currentDriver;

View File

@@ -1,4 +1,4 @@
import { ProtestDetailViewData } from '@/lib/view-data/leagues/ProtestDetailViewData';
import { ProtestDetailViewData } from '@/lib/view-data/ProtestDetailViewData';
interface ProtestDetailApiDto {
id: string;
@@ -29,7 +29,14 @@ interface ProtestDetailApiDto {
}>;
}
export class ProtestDetailViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class ProtestDetailViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return ProtestDetailViewDataBuilder.build(input);
}
static build(
static build(apiDto: ProtestDetailApiDto): ProtestDetailViewData {
return {
protestId: apiDto.id,

View File

@@ -1,4 +1,4 @@
import { RaceDetailViewData, RaceDetailRace, RaceDetailLeague, RaceDetailEntry, RaceDetailRegistration, RaceDetailUserResult } from '@/lib/view-data/races/RaceDetailViewData';
import { RaceDetailEntry, RaceDetailLeague, RaceDetailRace, RaceDetailRegistration, RaceDetailUserResult, RaceDetailViewData } from '@/lib/view-data/RaceDetailViewData';
/**
* Race Detail View Data Builder
@@ -6,7 +6,14 @@ import { RaceDetailViewData, RaceDetailRace, RaceDetailLeague, RaceDetailEntry,
* Transforms API DTO into ViewData for the race detail template.
* Deterministic, side-effect free.
*/
export class RaceDetailViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class RaceDetailViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return RaceDetailViewDataBuilder.build(input);
}
static build(
static build(apiDto: any): RaceDetailViewData {
if (!apiDto || !apiDto.race) {
return {

View File

@@ -1,4 +1,4 @@
import { RaceResultsViewData, RaceResultsResult, RaceResultsPenalty } from '@/lib/view-data/races/RaceResultsViewData';
import { RaceResultsPenalty, RaceResultsResult, RaceResultsViewData } from '@/lib/view-data/RaceResultsViewData';
/**
* Race Results View Data Builder
@@ -6,7 +6,14 @@ import { RaceResultsViewData, RaceResultsResult, RaceResultsPenalty } from '@/li
* Transforms API DTO into ViewData for the race results template.
* Deterministic, side-effect free.
*/
export class RaceResultsViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class RaceResultsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return RaceResultsViewDataBuilder.build(input);
}
static build(
static build(apiDto: unknown): RaceResultsViewData {
if (!apiDto) {
return {

View File

@@ -1,4 +1,4 @@
import { RaceStewardingViewData, Protest, Penalty, Driver } from '@/lib/view-data/races/RaceStewardingViewData';
import { Driver, Penalty, Protest, RaceStewardingViewData } from '@/lib/view-data/RaceStewardingViewData';
/**
* Race Stewarding View Data Builder
@@ -6,7 +6,14 @@ import { RaceStewardingViewData, Protest, Penalty, Driver } from '@/lib/view-dat
* Transforms API DTO into ViewData for the race stewarding template.
* Deterministic, side-effect free.
*/
export class RaceStewardingViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class RaceStewardingViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return RaceStewardingViewDataBuilder.build(input);
}
static build(
static build(apiDto: unknown): RaceStewardingViewData {
if (!apiDto) {
return {

View File

@@ -4,7 +4,14 @@ import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { RaceStatusDisplay } from '@/lib/display-objects/RaceStatusDisplay';
import { RelativeTimeDisplay } from '@/lib/display-objects/RelativeTimeDisplay';
export class RacesViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class RacesViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return RacesViewDataBuilder.build(input);
}
static build(
static build(apiDto: RacesPageDataDTO): RacesViewData {
const now = new Date();
const races = apiDto.races.map((race): RaceViewData => {

View File

@@ -6,9 +6,18 @@
*/
import { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
import { ResetPasswordViewData } from './types/ResetPasswordViewData';
import { ResetPasswordViewData } from '../../view-data/ResetPasswordViewData';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import { error } from 'console';
export class ResetPasswordViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class ResetPasswordViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return ResetPasswordViewDataBuilder.build(input);
}
static build(
static build(apiDto: ResetPasswordPageDTO): ResetPasswordViewData {
return {
token: apiDto.token,

View File

@@ -1,7 +1,14 @@
import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData';
import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto';
import { RulebookViewData } from '@/lib/view-data/RulebookViewData';
export class RulebookViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class RulebookViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return RulebookViewDataBuilder.build(input);
}
static build(
static build(apiDto: RulebookApiDto): RulebookViewData {
const primaryChampionship = apiDto.scoringConfig.championships.find(c => c.type === 'driver') ?? apiDto.scoringConfig.championships[0];
const positionPoints: { position: number; points: number }[] = primaryChampionship?.pointsPreview

View File

@@ -6,9 +6,14 @@
*/
import { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
import { SignupViewData } from './types/SignupViewData';
import { SignupViewData } from '../../view-data/SignupViewData';
import { ViewDataBuilder } from '../../contracts/builders/ViewDataBuilder';
export class SignupViewDataBuilder implements ViewDataBuilder<SignupPageDTO, SignupViewData> {
build(apiDto: SignupPageDTO): SignupViewData {
return SignupViewDataBuilder.build(apiDto);
}
export class SignupViewDataBuilder {
static build(apiDto: SignupPageDTO): SignupViewData {
return {
returnTo: apiDto.returnTo,

View File

@@ -9,7 +9,14 @@ import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
* Transforms SponsorDashboardDTO into ViewData for templates.
* Deterministic and side-effect free.
*/
export class SponsorDashboardViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class SponsorDashboardViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return SponsorDashboardViewDataBuilder.build(input);
}
static build(
static build(apiDto: SponsorDashboardDTO): SponsorDashboardViewData {
const totalInvestmentValue = apiDto.investment.activeSponsorships * 1000;

View File

@@ -8,7 +8,14 @@
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { SponsorLogoViewData } from '@/lib/view-data/SponsorLogoViewData';
export class SponsorLogoViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class SponsorLogoViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return SponsorLogoViewDataBuilder.build(input);
}
static build(
static build(apiDto: MediaBinaryDTO): SponsorLogoViewData {
return {
buffer: Buffer.from(apiDto.buffer).toString('base64'),

View File

@@ -5,7 +5,14 @@ import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipReq
* ViewData Builder for Sponsorship Requests page
* Transforms API DTO to ViewData for templates
*/
export class SponsorshipRequestsPageViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class SponsorshipRequestsPageViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return SponsorshipRequestsPageViewDataBuilder.build(input);
}
static build(
static build(apiDto: GetPendingSponsorshipRequestsOutputDTO): SponsorshipRequestsViewData {
return {
sections: [{

View File

@@ -1,7 +1,14 @@
import type { GetPendingSponsorshipRequestsOutputDTO } from '@/lib/types/generated/GetPendingSponsorshipRequestsOutputDTO';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
export class SponsorshipRequestsViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class SponsorshipRequestsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return SponsorshipRequestsViewDataBuilder.build(input);
}
static build(
static build(apiDto: GetPendingSponsorshipRequestsOutputDTO): SponsorshipRequestsViewData {
return {
sections: [

View File

@@ -1,8 +1,15 @@
import { StewardingApiDto } from '@/lib/types/tbd/StewardingApiDto';
import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
import { StewardingViewData } from '@/lib/view-data/StewardingViewData';
export class StewardingViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class StewardingViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return StewardingViewDataBuilder.build(input);
}
static build(
static build(apiDto: StewardingApiDto): StewardingViewData {
return {
leagueId: apiDto.leagueId,

View File

@@ -9,7 +9,14 @@ import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
* TeamDetailViewDataBuilder - Transforms TeamDetailPageDto into ViewData
* Deterministic; side-effect free; no HTTP calls
*/
export class TeamDetailViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class TeamDetailViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return TeamDetailViewDataBuilder.build(input);
}
static build(
static build(apiDto: TeamDetailPageDto): TeamDetailViewData {
const team: TeamDetailData = {
id: apiDto.team.id,

View File

@@ -8,7 +8,14 @@
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { TeamLogoViewData } from '@/lib/view-data/TeamLogoViewData';
export class TeamLogoViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class TeamLogoViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return TeamLogoViewDataBuilder.build(input);
}
static build(
static build(apiDto: MediaBinaryDTO): TeamLogoViewData {
return {
buffer: Buffer.from(apiDto.buffer).toString('base64'),

View File

@@ -1,21 +1,18 @@
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import type { GetTeamsLeaderboardOutputDTO } from '@/lib/types/generated/GetTeamsLeaderboardOutputDTO';
import type { TeamRankingsViewData } from '@/lib/view-data/TeamRankingsViewData';
export class TeamRankingsViewDataBuilder {
static build(apiDto: GetTeamsLeaderboardOutputDTO): TeamRankingsViewData {
const allTeams = apiDto.teams.map((team, index) => ({
id: team.id,
name: team.name,
tag: team.tag,
memberCount: team.memberCount,
category: undefined,
totalWins: team.totalWins || 0,
logoUrl: team.logoUrl || '',
position: index + 1,
isRecruiting: team.isRecruiting,
performanceLevel: team.performanceLevel || 'N/A',
rating: team.rating || 0,
totalRaces: team.totalRaces || 0,
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class TeamRankingsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return TeamRankingsViewDataBuilder.build(input);
}
static build(
public static build(apiDto: GetTeamsLeaderboardOutputDTO): TeamRankingsViewData {
const allTeams = apiDto.teams.map(t => ({
...t,
}));
return {

View File

@@ -8,7 +8,14 @@ import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
* TeamsViewDataBuilder - Transforms TeamsPageDto into ViewData for TeamsTemplate
* Deterministic; side-effect free; no HTTP calls
*/
export class TeamsViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class TeamsViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return TeamsViewDataBuilder.build(input);
}
static build(
static build(apiDto: TeamsPageDto): TeamsViewData {
const teams: TeamSummaryData[] = apiDto.teams.map((team: TeamListItemDTO): TeamSummaryData => ({
teamId: team.id,

View File

@@ -8,7 +8,14 @@
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { TrackImageViewData } from '@/lib/view-data/TrackImageViewData';
export class TrackImageViewDataBuilder {
import { ViewDataBuilder } from "../../contracts/builders/ViewDataBuilder";
export class TrackImageViewDataBuilder implements ViewDataBuilder<any, any> {
build(input: any): any {
return TrackImageViewDataBuilder.build(input);
}
static build(
static build(apiDto: MediaBinaryDTO): TrackImageViewData {
return {
buffer: Buffer.from(apiDto.buffer).toString('base64'),

View File

@@ -1,15 +0,0 @@
/**
* Forgot Password View Data
*
* ViewData for the forgot password template.
*/
export interface ForgotPasswordViewData {
returnTo: string;
showSuccess: boolean;
successMessage?: string;
magicLink?: string;
formState: any; // Will be managed by client component
isSubmitting: boolean;
submitError?: string;
}

View File

@@ -1,17 +0,0 @@
/**
* Login View Data
*
* ViewData for the login template.
*/
import { FormState } from './FormState';
export interface LoginViewData {
returnTo: string;
hasInsufficientPermissions: boolean;
showPassword: boolean;
showErrorDetails: boolean;
formState: FormState;
isSubmitting: boolean;
submitError?: string;
}

View File

@@ -1,15 +0,0 @@
/**
* Reset Password View Data
*
* ViewData for the reset password template.
*/
export interface ResetPasswordViewData {
token: string;
returnTo: string;
showSuccess: boolean;
successMessage?: string;
formState: any; // Will be managed by client component
isSubmitting: boolean;
submitError?: string;
}

View File

@@ -1,12 +0,0 @@
/**
* Signup View Data
*
* ViewData for the signup template.
*/
export interface SignupViewData {
returnTo: string;
formState: any; // Will be managed by client component
isSubmitting: boolean;
submitError?: string;
}

View File

@@ -21,7 +21,14 @@ import type {
* Transforms GetDriverProfileOutputDTO into DriverProfileViewModel.
* Deterministic, side-effect free, no HTTP calls.
*/
export class DriverProfileViewModelBuilder {
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
export class DriverProfileViewModelBuilder implements ViewModelBuilder<any, any> {
build(input: any): any {
return DriverProfileViewModelBuilder.build(input);
}
static build(
/**
* Build ViewModel from API DTO
*

View File

@@ -7,7 +7,14 @@ import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardV
* Transforms DriversLeaderboardDTO into DriverLeaderboardViewModel.
* Deterministic, side-effect free, no HTTP calls.
*/
export class DriversViewModelBuilder {
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
export class DriversViewModelBuilder implements ViewModelBuilder<any, any> {
build(input: any): any {
return DriversViewModelBuilder.build(input);
}
static build(
static build(apiDto: DriversLeaderboardDTO): DriverLeaderboardViewModel {
return new DriverLeaderboardViewModel({
drivers: apiDto.drivers,

View File

@@ -5,10 +5,16 @@
* Deterministic, side-effect free, no business logic.
*/
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
import { ForgotPasswordViewModel, ForgotPasswordFormState } from '@/lib/view-models/auth/ForgotPasswordViewModel';
import { ForgotPasswordViewData } from '@/lib/view-data/ForgotPasswordViewData';
import { ForgotPasswordFormState, ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
export class ForgotPasswordViewModelBuilder implements ViewModelBuilder<any, any> {
build(input: any): any {
return ForgotPasswordViewModelBuilder.build(input);
}
export class ForgotPasswordViewModelBuilder {
static build(viewData: ForgotPasswordViewData): ForgotPasswordViewModel {
const formState: ForgotPasswordFormState = {
fields: {

View File

@@ -1,7 +1,13 @@
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
export class LeagueSummaryViewModelBuilder {
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
export class LeagueSummaryViewModelBuilder implements ViewModelBuilder<any, any> {
build(input: any): any {
return LeagueSummaryViewModelBuilder.build(input);
}
static build(league: LeaguesViewData['leagues'][number]): LeagueSummaryViewModel {
return {
id: league.id,

View File

@@ -5,10 +5,16 @@
* Deterministic, side-effect free, no business logic.
*/
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
import { LoginViewModel, LoginFormState, LoginUIState } from '@/lib/view-models/auth/LoginViewModel';
import { LoginViewData } from '@/lib/view-data/LoginViewData';
import { LoginFormState, LoginUIState, LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
export class LoginViewModelBuilder implements ViewModelBuilder<any, any> {
build(input: any): any {
return LoginViewModelBuilder.build(input);
}
export class LoginViewModelBuilder {
static build(viewData: LoginViewData): LoginViewModel {
const formState: LoginFormState = {
fields: {

View File

@@ -9,7 +9,14 @@ import { Result } from '@/lib/contracts/Result';
import { DomainError } from '@/lib/contracts/services/Service';
import { OnboardingViewModel } from '@/lib/view-models/OnboardingViewModel';
export class OnboardingViewModelBuilder {
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
export class OnboardingViewModelBuilder implements ViewModelBuilder<any, any> {
build(input: any): any {
return OnboardingViewModelBuilder.build(input);
}
static build(
static build(apiDto: { isAlreadyOnboarded: boolean }): Result<OnboardingViewModel, DomainError> {
try {
return Result.ok({

View File

@@ -5,10 +5,17 @@
* Deterministic, side-effect free, no business logic.
*/
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
import { ResetPasswordViewModel, ResetPasswordFormState, ResetPasswordUIState } from '@/lib/view-models/auth/ResetPasswordViewModel';
import { ResetPasswordViewData } from '@/lib/view-data/ResetPasswordViewData';
import { ResetPasswordFormState, ResetPasswordUIState, ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
export class ResetPasswordViewModelBuilder {
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
export class ResetPasswordViewModelBuilder implements ViewModelBuilder<any, any> {
build(input: any): any {
return ResetPasswordViewModelBuilder.build(input);
}
static build(
static build(viewData: ResetPasswordViewData): ResetPasswordViewModel {
const formState: ResetPasswordFormState = {
fields: {

View File

@@ -5,10 +5,17 @@
* Deterministic, side-effect free, no business logic.
*/
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
import { SignupViewModel, SignupFormState, SignupUIState } from '@/lib/view-models/auth/SignupViewModel';
import { SignupViewData } from '@/lib/view-data/SignupViewData';
import { SignupFormState, SignupUIState, SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
export class SignupViewModelBuilder {
import { ViewModelBuilder } from "../../contracts/builders/ViewModelBuilder";
export class SignupViewModelBuilder implements ViewModelBuilder<any, any> {
build(input: any): any {
return SignupViewModelBuilder.build(input);
}
static build(
static build(viewData: SignupViewData): SignupViewModel {
const formState: SignupFormState = {
fields: {

View File

@@ -19,9 +19,10 @@ export class AvatarDisplay {
/**
* Determines if avatar data is valid for display.
* Accepts base64-encoded string buffer.
*/
static hasValidData(buffer: ArrayBuffer, contentType: string): boolean {
return buffer.byteLength > 0 && contentType.length > 0;
static hasValidData(buffer: string, contentType: string): boolean {
return buffer.length > 0 && contentType.length > 0;
}
/**

View File

@@ -0,0 +1,14 @@
/**
* LeagueCreationStatusDisplay
*
* Deterministic mapping of league creation status to display messages.
*/
export class LeagueCreationStatusDisplay {
/**
* Maps league creation success status to display message.
*/
static statusMessage(success: boolean): string {
return success ? 'League created successfully!' : 'Failed to create league.';
}
}

View File

@@ -0,0 +1,15 @@
export class RatingTrendDisplay {
static getTrend(currentRating: number, previousRating: number | undefined): 'up' | 'down' | 'same' {
if (!previousRating) return 'same';
if (currentRating > previousRating) return 'up';
if (currentRating < previousRating) return 'down';
return 'same';
}
static getChangeIndicator(currentRating: number, previousRating: number | undefined): string {
const change = previousRating ? currentRating - previousRating : 0;
if (change > 0) return `+${change}`;
if (change < 0) return `${change}`;
return '0';
}
}

View File

@@ -0,0 +1,11 @@
export class SkillLevelIconDisplay {
static getIcon(skillLevel: string): string {
const icons: Record<string, string> = {
beginner: '🥉',
intermediate: '🥈',
advanced: '🥇',
expert: '👑',
};
return icons[skillLevel] || '🏁';
}
}

View File

@@ -0,0 +1,14 @@
/**
* TeamCreationStatusDisplay
*
* Deterministic mapping of team creation status to display messages.
*/
export class TeamCreationStatusDisplay {
/**
* Maps team creation success status to display message.
*/
static statusMessage(success: boolean): string {
return success ? 'Team created successfully!' : 'Failed to create team.';
}
}

View File

@@ -5,19 +5,21 @@ export interface AvailableLeaguesDTO {
export interface AvailableLeagueDTO {
id: string;
name: string;
game: string;
description: string;
drivers: number;
avgViewsPerRace: number;
mainSponsorSlot: {
available: boolean;
price: number;
};
secondarySlots: {
available: number;
total: number;
price: number;
};
cpm: number;
season: {
startDate: string;
endDate: string;
};
rating: number;
tier: 'premium' | 'standard' | 'starter';
nextRace?: string;
seasonStatus: 'active' | 'upcoming' | 'completed';
}

View File

@@ -0,0 +1,8 @@
import { ViewData } from "../contracts/view-data/ViewData";
export interface AnalyticsDashboardInputViewData extends ViewData {
totalUsers: number;
activeUsers: number;
totalRaces: number;
totalLeagues: number;
}

View File

@@ -0,0 +1,8 @@
import { ViewData } from "../contracts/view-data/ViewData";
export interface AnalyticsMetricsViewData extends ViewData {
pageViews: number;
uniqueVisitors: number;
averageSessionDuration: number;
bounceRate: number;
}

View File

@@ -0,0 +1,47 @@
import { ViewData } from "../contracts/view-data/ViewData";
export interface BillingViewData extends ViewData {
paymentMethods: Array<{
id: string;
type: 'card' | 'bank' | 'sepa';
last4: string;
brand?: string;
isDefault: boolean;
expiryMonth?: number;
expiryYear?: number;
bankName?: string;
displayLabel: string;
expiryDisplay: string | null;
}>;
invoices: Array<{
id: string;
invoiceNumber: string;
date: string;
dueDate: string;
amount: number;
vatAmount: number;
totalAmount: number;
status: 'paid' | 'pending' | 'overdue' | 'failed';
description: string;
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
pdfUrl: string;
formattedTotalAmount: string;
formattedVatAmount: string;
formattedDate: string;
isOverdue: boolean;
}>;
stats: {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: string;
nextPaymentAmount: number;
activeSponsorships: number;
averageMonthlySpend: number;
formattedTotalSpent: string;
formattedPendingAmount: string;
formattedNextPaymentAmount: string;
formattedAverageMonthlySpend: string;
formattedNextPaymentDate: string;
};
}

View File

@@ -0,0 +1,14 @@
import { ViewData } from '../contracts/view-data/ViewData';
/**
* CreateTeamViewData
*
* ViewData for the create team result page.
* Contains only raw serializable data, no methods or computed properties
*/
export interface CreateTeamViewData extends ViewData {
teamId: string;
success: boolean;
successMessage: string;
}

View File

@@ -0,0 +1,38 @@
import { ViewData } from '@/lib/contracts/view-data/ViewData';
/**
* DashboardStatsViewData
*
* ViewData for DashboardStatsViewModel.
* Template-ready data structure with only primitives.
*/
export interface DashboardStatsViewData extends ViewData {
totalUsers: number;
activeUsers: number;
suspendedUsers: number;
deletedUsers: number;
systemAdmins: number;
recentLogins: number;
newUsersToday: number;
userGrowth: {
label: string;
value: number;
color: string;
}[];
roleDistribution: {
label: string;
value: number;
color: string;
}[];
statusDistribution: {
active: number;
suspended: number;
deleted: number;
};
activityTimeline: {
date: string;
newUsers: number;
logins: number;
}[];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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