18 Commits

Author SHA1 Message Date
046852703f view data fixes
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m51s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-24 12:14:08 +01:00
dde77e717a do to formatters 2026-01-24 01:25:46 +01:00
705f9685b5 do to formatters 2026-01-24 01:22:43 +01:00
891b3cf0ee do to formatters 2026-01-24 01:07:43 +01:00
ae59df61eb view data fixes 2026-01-24 00:52:27 +01:00
62e8b768ce integration tests 2026-01-24 00:19:26 +01:00
c470505b4f integration tests 2026-01-24 00:18:44 +01:00
f8099f04bc view data fixes 2026-01-23 15:30:23 +01:00
e22033be38 view data fixes
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m54s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-23 13:04:05 +01:00
d97f50ed72 view data fixes
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 6m4s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-23 11:59:49 +01:00
ae58839eb2 view data fixes
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m53s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 23:44:26 +01:00
18133aef4c view data fixes
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m42s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 23:40:38 +01:00
1288a9dc30 eslint rules
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m48s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:57:48 +01:00
04d445bf00 eslint rules 2026-01-22 18:46:51 +01:00
94b92a9314 view data tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m45s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:35:35 +01:00
108cfbcd65 view data tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m55s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:22:08 +01:00
1f4f837282 view data tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m58s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:06:46 +01:00
c22e26d14c view data tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m48s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 17:27:08 +01:00
973 changed files with 24553 additions and 31119 deletions

View File

@@ -250,7 +250,8 @@
"plugins": [
"@typescript-eslint",
"boundaries",
"import"
"import",
"gridpilot-rules"
],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
@@ -310,7 +311,9 @@
"message": "Interface names should not start with 'I'. Use descriptive names without the 'I' prefix (e.g., 'LiverCompositor' instead of 'ILiveryCompositor').",
"selector": "TSInterfaceDeclaration[id.name=/^I[A-Z]/]"
}
]
],
// GridPilot ESLint Rules
"gridpilot-rules/view-model-taxonomy": "error"
}
},
{

View File

@@ -2216,6 +2216,41 @@
"incidents"
]
},
"DashboardStatsResponseDTO": {
"type": "object",
"properties": {
"totalUsers": {
"type": "number"
},
"activeUsers": {
"type": "number"
},
"suspendedUsers": {
"type": "number"
},
"deletedUsers": {
"type": "number"
},
"systemAdmins": {
"type": "number"
},
"recentLogins": {
"type": "number"
},
"newUsersToday": {
"type": "number"
}
},
"required": [
"totalUsers",
"activeUsers",
"suspendedUsers",
"deletedUsers",
"systemAdmins",
"recentLogins",
"newUsersToday"
]
},
"DeleteMediaOutputDTO": {
"type": "object",
"properties": {
@@ -4235,6 +4270,9 @@
"LeagueScheduleDTO": {
"type": "object",
"properties": {
"leagueId": {
"type": "string"
},
"seasonId": {
"type": "string"
},
@@ -4473,6 +4511,16 @@
},
"isParallelActive": {
"type": "boolean"
},
"totalRaces": {
"type": "number"
},
"completedRaces": {
"type": "number"
},
"nextRaceAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
@@ -4480,7 +4528,9 @@
"name",
"status",
"isPrimary",
"isParallelActive"
"isParallelActive",
"totalRaces",
"completedRaces"
]
},
"LeagueSettingsDTO": {
@@ -4515,6 +4565,18 @@
},
"races": {
"type": "number"
},
"positionChange": {
"type": "number"
},
"lastRacePoints": {
"type": "number"
},
"droppedRaceIds": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
@@ -4524,7 +4586,10 @@
"position",
"wins",
"podiums",
"races"
"races",
"positionChange",
"lastRacePoints",
"droppedRaceIds"
]
},
"LeagueStandingsDTO": {
@@ -4658,6 +4723,15 @@
"logoUrl": {
"type": "string",
"nullable": true
},
"pendingJoinRequestsCount": {
"type": "number"
},
"pendingProtestsCount": {
"type": "number"
},
"walletBalance": {
"type": "number"
}
},
"required": [
@@ -5449,8 +5523,34 @@
"type": "string"
},
"leagueName": {
"type": "string",
"nullable": true
"type": "string"
},
"track": {
"type": "string"
},
"car": {
"type": "string"
},
"sessionType": {
"type": "string"
},
"leagueId": {
"type": "string"
},
"strengthOfField": {
"type": "number"
},
"isUpcoming": {
"type": "boolean"
},
"isLive": {
"type": "boolean"
},
"isPast": {
"type": "boolean"
},
"status": {
"type": "string"
}
},
"required": [

View File

@@ -1,80 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
class UserGrowthDto {
@ApiProperty({ description: 'Label for the time period' })
label!: string;
@ApiProperty({ description: 'Number of new users' })
value!: number;
@ApiProperty({ description: 'Color class for the bar' })
color!: string;
}
class RoleDistributionDto {
@ApiProperty({ description: 'Role name' })
label!: string;
@ApiProperty({ description: 'Number of users with this role' })
value!: number;
@ApiProperty({ description: 'Color class for the bar' })
color!: string;
}
class StatusDistributionDto {
@ApiProperty({ description: 'Number of active users' })
active!: number;
@ApiProperty({ description: 'Number of suspended users' })
suspended!: number;
@ApiProperty({ description: 'Number of deleted users' })
deleted!: number;
}
class ActivityTimelineDto {
@ApiProperty({ description: 'Date label' })
date!: string;
@ApiProperty({ description: 'Number of new users' })
newUsers!: number;
@ApiProperty({ description: 'Number of logins' })
logins!: number;
}
export class DashboardStatsResponseDto {
@ApiProperty({ description: 'Total number of users' })
totalUsers!: number;
@ApiProperty({ description: 'Number of active users' })
activeUsers!: number;
@ApiProperty({ description: 'Number of suspended users' })
suspendedUsers!: number;
@ApiProperty({ description: 'Number of deleted users' })
deletedUsers!: number;
@ApiProperty({ description: 'Number of system admins' })
systemAdmins!: number;
@ApiProperty({ description: 'Number of recent logins (last 24h)' })
recentLogins!: number;
@ApiProperty({ description: 'Number of new users today' })
newUsersToday!: number;
@ApiProperty({ type: [UserGrowthDto], description: 'User growth over last 7 days' })
userGrowth!: UserGrowthDto[];
@ApiProperty({ type: [RoleDistributionDto], description: 'Distribution of user roles' })
roleDistribution!: RoleDistributionDto[];
@ApiProperty({ type: StatusDistributionDto, description: 'Distribution of user statuses' })
statusDistribution!: StatusDistributionDto;
@ApiProperty({ type: [ActivityTimelineDto], description: 'Activity timeline for last 7 days' })
activityTimeline!: ActivityTimelineDto[];
}

View File

@@ -0,0 +1,24 @@
export interface HealthDTO {
status: 'ok' | 'degraded' | 'error' | 'unknown';
timestamp: string;
uptime?: number;
responseTime?: number;
errorRate?: number;
lastCheck?: string;
checksPassed?: number;
checksFailed?: number;
components?: Array<{
name: string;
status: 'ok' | 'degraded' | 'error' | 'unknown';
lastCheck?: string;
responseTime?: number;
errorRate?: number;
}>;
alerts?: Array<{
id: string;
type: 'critical' | 'warning' | 'info';
title: string;
message: string;
timestamp: string;
}>;
}

View File

@@ -1,9 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsString, ValidateNested } from 'class-validator';
import { RaceDTO } from '../../race/dtos/RaceDTO';
export class LeagueScheduleDTO {
@ApiPropertyOptional()
@IsString()
leagueId?: string;
@ApiProperty()
@IsString()
seasonId!: string;

View File

@@ -1,4 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RaceDTO {
@ApiProperty()
@@ -10,6 +10,33 @@ export class RaceDTO {
@ApiProperty()
date!: string;
@ApiProperty({ nullable: true })
@ApiPropertyOptional({ nullable: true })
leagueName?: string;
@ApiPropertyOptional()
track?: string;
@ApiPropertyOptional()
car?: string;
@ApiPropertyOptional()
sessionType?: string;
@ApiPropertyOptional()
leagueId?: string;
@ApiPropertyOptional()
strengthOfField?: number;
@ApiPropertyOptional()
isUpcoming?: boolean;
@ApiPropertyOptional()
isLive?: boolean;
@ApiPropertyOptional()
isPast?: boolean;
@ApiPropertyOptional()
status?: string;
}

View File

@@ -23,8 +23,8 @@
"app/**/default.*"
],
"rules": {
"import/no-default-export": "off",
"no-restricted-syntax": "off"
"import/no-default-export": "error",
"no-restricted-syntax": "error"
}
},
{
@@ -44,7 +44,8 @@
"lib/builders/view-models/*.tsx"
],
"rules": {
"gridpilot-rules/view-model-builder-contract": "error"
"gridpilot-rules/view-model-builder-contract": "error",
"gridpilot-rules/view-model-builder-implements": "error"
}
},
{
@@ -53,9 +54,11 @@
"lib/builders/view-data/*.tsx"
],
"rules": {
"gridpilot-rules/filename-matches-export": "off",
"gridpilot-rules/single-export-per-file": "off",
"gridpilot-rules/view-data-builder-contract": "off"
"gridpilot-rules/filename-matches-export": "error",
"gridpilot-rules/single-export-per-file": "error",
"gridpilot-rules/view-data-builder-contract": "error",
"gridpilot-rules/view-data-builder-implements": "error",
"gridpilot-rules/view-data-builder-imports": "error"
}
},
{
@@ -72,11 +75,11 @@
"lib/mutations/**/*.ts"
],
"rules": {
"gridpilot-rules/clean-error-handling": "off",
"gridpilot-rules/filename-service-match": "off",
"gridpilot-rules/mutation-contract": "off",
"gridpilot-rules/mutation-must-map-errors": "off",
"gridpilot-rules/mutation-must-use-builders": "off"
"gridpilot-rules/clean-error-handling": "error",
"gridpilot-rules/filename-service-match": "error",
"gridpilot-rules/mutation-contract": "error",
"gridpilot-rules/mutation-must-map-errors": "error",
"gridpilot-rules/mutation-must-use-builders": "error"
}
},
{
@@ -84,16 +87,16 @@
"templates/**/*.tsx"
],
"rules": {
"gridpilot-rules/component-no-data-manipulation": "off",
"gridpilot-rules/no-hardcoded-routes": "off",
"gridpilot-rules/no-raw-html-in-app": "off",
"gridpilot-rules/template-no-async-render": "off",
"gridpilot-rules/template-no-direct-mutations": "off",
"gridpilot-rules/template-no-external-state": "off",
"gridpilot-rules/template-no-global-objects": "off",
"gridpilot-rules/template-no-mutation-props": "off",
"gridpilot-rules/template-no-side-effects": "off",
"gridpilot-rules/template-no-unsafe-html": "off"
"gridpilot-rules/component-no-data-manipulation": "error",
"gridpilot-rules/no-hardcoded-routes": "error",
"gridpilot-rules/no-raw-html-in-app": "error",
"gridpilot-rules/template-no-async-render": "error",
"gridpilot-rules/template-no-direct-mutations": "error",
"gridpilot-rules/template-no-external-state": "error",
"gridpilot-rules/template-no-global-objects": "error",
"gridpilot-rules/template-no-mutation-props": "error",
"gridpilot-rules/template-no-side-effects": "error",
"gridpilot-rules/template-no-unsafe-html": "error"
}
},
{
@@ -101,8 +104,8 @@
"components/**/*.tsx"
],
"rules": {
"gridpilot-rules/component-no-data-manipulation": "off",
"gridpilot-rules/no-raw-html-in-app": "off"
"gridpilot-rules/component-no-data-manipulation": "error",
"gridpilot-rules/no-raw-html-in-app": "error"
}
},
{
@@ -111,33 +114,33 @@
"app/**/layout.tsx"
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"gridpilot-rules/component-classification": "off",
"gridpilot-rules/no-console": "off",
"gridpilot-rules/no-direct-process-env": "off",
"gridpilot-rules/no-hardcoded-routes": "off",
"gridpilot-rules/no-hardcoded-search-params": "off",
"gridpilot-rules/no-index-files": "off",
"gridpilot-rules/no-next-cookies-in-pages": "off",
"gridpilot-rules/no-raw-html-in-app": "off",
"gridpilot-rules/rsc-no-container-manager": "off",
"gridpilot-rules/rsc-no-container-manager-calls": "off",
"gridpilot-rules/rsc-no-di": "off",
"gridpilot-rules/rsc-no-display-objects": "off",
"gridpilot-rules/rsc-no-intl": "off",
"gridpilot-rules/rsc-no-local-helpers": "off",
"gridpilot-rules/rsc-no-object-construction": "off",
"gridpilot-rules/rsc-no-page-data-fetcher": "off",
"gridpilot-rules/rsc-no-presenters": "off",
"gridpilot-rules/rsc-no-sorting-filtering": "off",
"gridpilot-rules/rsc-no-unsafe-services": "off",
"gridpilot-rules/rsc-no-view-models": "off",
"import/no-default-export": "off",
"no-restricted-syntax": "off",
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
"react/no-unescaped-entities": "off"
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": "error",
"gridpilot-rules/component-classification": "error",
"gridpilot-rules/no-console": "error",
"gridpilot-rules/no-direct-process-env": "error",
"gridpilot-rules/no-hardcoded-routes": "error",
"gridpilot-rules/no-hardcoded-search-params": "error",
"gridpilot-rules/no-index-files": "error",
"gridpilot-rules/no-next-cookies-in-pages": "error",
"gridpilot-rules/no-raw-html-in-app": "error",
"gridpilot-rules/rsc-no-container-manager": "error",
"gridpilot-rules/rsc-no-container-manager-calls": "error",
"gridpilot-rules/rsc-no-di": "error",
"gridpilot-rules/rsc-no-display-objects": "error",
"gridpilot-rules/rsc-no-intl": "error",
"gridpilot-rules/rsc-no-local-helpers": "error",
"gridpilot-rules/rsc-no-object-construction": "error",
"gridpilot-rules/rsc-no-page-data-fetcher": "error",
"gridpilot-rules/rsc-no-presenters": "error",
"gridpilot-rules/rsc-no-sorting-filtering": "error",
"gridpilot-rules/rsc-no-unsafe-services": "error",
"gridpilot-rules/rsc-no-view-models": "error",
"import/no-default-export": "error",
"no-restricted-syntax": "error",
"react-hooks/exhaustive-deps": "error",
"react-hooks/rules-of-hooks": "error",
"react/no-unescaped-entities": "error"
}
},
{
@@ -149,8 +152,8 @@
"lib/mutations/auth/types/*.ts"
],
"rules": {
"gridpilot-rules/clean-error-handling": "off",
"gridpilot-rules/no-direct-process-env": "off"
"gridpilot-rules/clean-error-handling": "error",
"gridpilot-rules/no-direct-process-env": "error"
}
},
{
@@ -159,10 +162,10 @@
"lib/display-objects/**/*.tsx"
],
"rules": {
"gridpilot-rules/display-no-business-logic": "off",
"gridpilot-rules/display-no-domain-models": "off",
"gridpilot-rules/filename-display-match": "off",
"gridpilot-rules/model-no-domain-in-display": "off"
"gridpilot-rules/display-no-business-logic": "error",
"gridpilot-rules/display-no-domain-models": "error",
"gridpilot-rules/filename-display-match": "error",
"gridpilot-rules/model-no-domain-in-display": "error"
}
},
{
@@ -170,17 +173,17 @@
"lib/page-queries/**/*.ts"
],
"rules": {
"gridpilot-rules/clean-error-handling": "off",
"gridpilot-rules/filename-matches-export": "off",
"gridpilot-rules/no-hardcoded-routes": "off",
"gridpilot-rules/no-hardcoded-search-params": "off",
"gridpilot-rules/page-query-contract": "off",
"gridpilot-rules/page-query-execute": "off",
"gridpilot-rules/page-query-filename": "off",
"gridpilot-rules/page-query-must-use-builders": "off",
"gridpilot-rules/page-query-no-null-returns": "off",
"gridpilot-rules/page-query-return-type": "off",
"gridpilot-rules/single-export-per-file": "off"
"gridpilot-rules/clean-error-handling": "error",
"gridpilot-rules/filename-matches-export": "error",
"gridpilot-rules/no-hardcoded-routes": "error",
"gridpilot-rules/no-hardcoded-search-params": "error",
"gridpilot-rules/page-query-contract": "error",
"gridpilot-rules/page-query-execute": "error",
"gridpilot-rules/page-query-filename": "error",
"gridpilot-rules/page-query-must-use-builders": "error",
"gridpilot-rules/page-query-no-null-returns": "error",
"gridpilot-rules/page-query-return-type": "error",
"gridpilot-rules/single-export-per-file": "error"
}
},
{
@@ -192,16 +195,35 @@
"gridpilot-rules/view-data-location": "error"
}
},
{
"files": [
"lib/view-data/**/*.ts",
"lib/view-data/**/*.tsx"
],
"rules": {
"gridpilot-rules/view-data-implements": "error"
}
},
{
"files": [
"lib/view-models/**/*.ts",
"lib/view-models/**/*.tsx"
],
"rules": {
"gridpilot-rules/view-model-implements": "error",
"gridpilot-rules/view-model-taxonomy": "error"
}
},
{
"files": [
"lib/services/**/*.ts"
],
"rules": {
"gridpilot-rules/filename-service-match": "off",
"gridpilot-rules/services-implement-contract": "off",
"gridpilot-rules/services-must-be-pure": "off",
"gridpilot-rules/services-must-return-result": "off",
"gridpilot-rules/services-no-external-api": "off"
"gridpilot-rules/filename-service-match": "error",
"gridpilot-rules/services-implement-contract": "error",
"gridpilot-rules/services-must-be-pure": "error",
"gridpilot-rules/services-must-return-result": "error",
"gridpilot-rules/services-no-external-api": "error"
}
},
{
@@ -210,12 +232,12 @@
"app/**/*.ts"
],
"rules": {
"gridpilot-rules/client-only-must-have-directive": "off",
"gridpilot-rules/client-only-no-server-code": "off",
"gridpilot-rules/no-use-mutation-in-client": "off",
"gridpilot-rules/server-actions-interface": "off",
"gridpilot-rules/server-actions-must-use-mutations": "off",
"gridpilot-rules/server-actions-return-result": "off"
"gridpilot-rules/client-only-must-have-directive": "error",
"gridpilot-rules/client-only-no-server-code": "error",
"gridpilot-rules/no-use-mutation-in-client": "error",
"gridpilot-rules/server-actions-interface": "error",
"gridpilot-rules/server-actions-must-use-mutations": "error",
"gridpilot-rules/server-actions-return-result": "error"
}
},
{
@@ -263,10 +285,10 @@
"app/**/*.ts"
],
"rules": {
"gridpilot-rules/component-classification": "off",
"gridpilot-rules/no-hardcoded-routes": "off",
"gridpilot-rules/no-nextjs-imports-in-ui": "off",
"gridpilot-rules/no-raw-html-in-app": "off"
"gridpilot-rules/component-classification": "error",
"gridpilot-rules/no-hardcoded-routes": "error",
"gridpilot-rules/no-nextjs-imports-in-ui": "error",
"gridpilot-rules/no-raw-html-in-app": "error"
}
},
{
@@ -297,11 +319,11 @@
"components/**/*.ts"
],
"rules": {
"gridpilot-rules/component-classification": "off",
"gridpilot-rules/no-hardcoded-routes": "off",
"gridpilot-rules/no-nextjs-imports-in-ui": "off",
"gridpilot-rules/component-classification": "error",
"gridpilot-rules/no-hardcoded-routes": "error",
"gridpilot-rules/no-nextjs-imports-in-ui": "error",
"gridpilot-rules/no-raw-html-in-app": "error",
"no-restricted-imports": "off"
"no-restricted-imports": "error"
}
},
{
@@ -309,7 +331,7 @@
"components/mockups/**/*.tsx"
],
"rules": {
"gridpilot-rules/no-raw-html-in-app": "off"
"gridpilot-rules/no-raw-html-in-app": "error"
}
},
{
@@ -317,11 +339,11 @@
"lib/services/**/*.ts"
],
"rules": {
"gridpilot-rules/no-hardcoded-routes": "off",
"gridpilot-rules/service-function-format": "off",
"gridpilot-rules/services-implement-contract": "off",
"gridpilot-rules/services-must-be-pure": "off",
"gridpilot-rules/services-no-external-api": "off"
"gridpilot-rules/no-hardcoded-routes": "error",
"gridpilot-rules/service-function-format": "error",
"gridpilot-rules/services-implement-contract": "error",
"gridpilot-rules/services-must-be-pure": "error",
"gridpilot-rules/services-no-external-api": "error"
}
},
{
@@ -342,10 +364,10 @@
],
"root": true,
"rules": {
"@next/next/no-img-element": "off",
"@typescript-eslint/no-explicit-any": "off",
"@next/next/no-img-element": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": [
"off",
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
@@ -368,15 +390,15 @@
]
}
],
"gridpilot-rules/no-index-files": "off",
"import/no-default-export": "off",
"import/no-named-as-default-member": "off",
"no-restricted-syntax": "off",
"react-hooks/exhaustive-deps": "off",
"react-hooks/rules-of-hooks": "off",
"react/no-unescaped-entities": "off",
"unused-imports/no-unused-imports": "off",
"unused-imports/no-unused-vars": "off"
"gridpilot-rules/no-index-files": "error",
"import/no-default-export": "error",
"import/no-named-as-default-member": "error",
"no-restricted-syntax": "error",
"react-hooks/exhaustive-deps": "error",
"react-hooks/rules-of-hooks": "error",
"react/no-unescaped-entities": "error",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error"
},
"settings": {
"boundaries/elements": [

View File

@@ -1,11 +1,11 @@
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { notFound } from 'next/navigation';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { RosterTable } from '@/components/leagues/RosterTable';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { notFound } from 'next/navigation';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
interface Props {
params: Promise<{ id: string }>;
@@ -25,7 +25,7 @@ export default async function LeagueRosterPage({ params }: Props) {
driverName: m.driver.name,
role: m.role,
joinedAt: m.joinedAt,
joinedAtLabel: DateDisplay.formatShort(m.joinedAt)
joinedAtLabel: DateFormatter.formatShort(m.joinedAt)
}));
return (

View File

@@ -1,12 +1,12 @@
'use client';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceStewardingTemplate, type StewardingTab } from '@/templates/RaceStewardingTemplate';
import { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery';
import { type RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
import { type RaceStewardingViewData } from '@/lib/view-data/RaceStewardingViewData';
import { RaceStewardingTemplate, type StewardingTab } from '@/templates/RaceStewardingTemplate';
import { Gavel } from 'lucide-react';
import { useState, useEffect, useCallback, use } from 'react';
import { notFound } from 'next/navigation';
import { use, useCallback, useEffect, useState } from 'react';
interface RaceStewardingPageProps {
params: Promise<{

View File

@@ -1,89 +1,3 @@
'use client';
import { useSponsorSponsorships } from "@/hooks/sponsor/useSponsorSponsorships";
import { SponsorCampaignsTemplate, SponsorshipType, SponsorCampaignsViewData } from "@/templates/SponsorCampaignsTemplate";
import { Box } from "@/ui/Box";
import { Button } from "@/ui/Button";
import { Text } from "@/ui/Text";
import { useState } from 'react';
import { CurrencyDisplay } from "@/lib/display-objects/CurrencyDisplay";
import { NumberDisplay } from "@/lib/display-objects/NumberDisplay";
import { DateDisplay } from "@/lib/display-objects/DateDisplay";
import { StatusDisplay } from "@/lib/display-objects/StatusDisplay";
export default function SponsorCampaignsPage() {
const [typeFilter, setTypeFilter] = useState<SponsorshipType>('all');
const [searchQuery, setSearchQuery] = useState('');
const { data: sponsorshipsData, isLoading, error, retry } = useSponsorSponsorships('demo-sponsor-1');
if (isLoading) {
return (
<Box maxWidth="7xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
<Box textAlign="center">
<Box w="8" h="8" border borderTop={false} borderColor="border-primary-blue" rounded="full" animate="spin" mx="auto" mb={4} />
<Text color="text-gray-400">Loading sponsorships...</Text>
</Box>
</Box>
);
}
if (error || !sponsorshipsData) {
return (
<Box maxWidth="7xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
<Box textAlign="center">
<Text color="text-gray-400">{error?.getUserMessage() || 'No sponsorships data available'}</Text>
{error && (
<Button variant="secondary" onClick={retry} mt={4}>
Retry
</Button>
)}
</Box>
</Box>
);
}
// Calculate stats
const totalInvestment = sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0);
const totalImpressions = sponsorshipsData.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0);
const stats = {
total: sponsorshipsData.sponsorships.length,
active: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').length,
pending: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'pending_approval').length,
approved: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'approved').length,
rejected: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'rejected').length,
formattedTotalInvestment: CurrencyDisplay.format(totalInvestment),
formattedTotalImpressions: NumberDisplay.formatCompact(totalImpressions),
};
const sponsorships = sponsorshipsData.sponsorships.map((s: any) => ({
...s,
formattedInvestment: CurrencyDisplay.format(s.price),
formattedImpressions: NumberDisplay.format(s.impressions),
formattedStartDate: s.seasonStartDate ? DateDisplay.formatShort(s.seasonStartDate) : undefined,
formattedEndDate: s.seasonEndDate ? DateDisplay.formatShort(s.seasonEndDate) : undefined,
}));
const viewData: SponsorCampaignsViewData = {
sponsorships,
stats: stats as any,
};
const filteredSponsorships = sponsorships.filter((s: any) => {
// For now, we only have leagues in the DTO
if (typeFilter !== 'all' && typeFilter !== 'leagues') return false;
if (searchQuery && !s.leagueName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
return true;
});
return (
<SponsorCampaignsTemplate
viewData={viewData}
filteredSponsorships={filteredSponsorships as any}
typeFilter={typeFilter}
setTypeFilter={setTypeFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
);
}

View File

@@ -1,21 +1,11 @@
'use client';
import type { ProfileTab } from '@/components/profile/ProfileTabs';
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface DriverProfilePageClientProps {
viewData: DriverProfileViewData | null;
error?: string;
empty?: {
title: string;
description: string;
};
}
export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) {
const router = useRouter();

View File

@@ -1,21 +1,11 @@
'use client';
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
import { routes } from '@/lib/routing/RouteConfig';
import { DriversTemplate } from '@/templates/DriversTemplate';
import { ErrorTemplate, EmptyTemplate } from '@/templates/shared/StatusTemplates';
import { EmptyTemplate, ErrorTemplate } from '@/templates/shared/StatusTemplates';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { routes } from '@/lib/routing/RouteConfig';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
interface DriversPageClientProps {
viewData: DriversViewData | null;
error?: string;
empty?: {
title: string;
description: string;
};
}
export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) {
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -6,14 +6,14 @@
'use client';
import { useState } from 'react';
import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData';
import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
import { ForgotPasswordViewModelBuilder } from '@/lib/builders/view-models/ForgotPasswordViewModelBuilder';
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ForgotPasswordMutation } from '@/lib/mutations/auth/ForgotPasswordMutation';
import { ForgotPasswordFormValidation } from '@/lib/utilities/authValidation';
import { ForgotPasswordViewData } from '@/lib/view-data/ForgotPasswordViewData';
import { ForgotPasswordViewModel } from '@/lib/view-models/auth/ForgotPasswordViewModel';
import { ForgotPasswordTemplate } from '@/templates/auth/ForgotPasswordTemplate';
import { useState } from 'react';
export function ForgotPasswordClient({ viewData }: ClientWrapperProps<ForgotPasswordViewData>) {
// Build ViewModel from ViewData

View File

@@ -1,8 +1,8 @@
'use client';
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import type { LeagueWalletViewData } from '@/lib/view-data/LeagueWalletViewData';
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
interface LeagueWalletPageClientProps extends ClientWrapperProps<LeagueWalletViewData> {
onWithdraw?: (amount: number) => void;

View File

@@ -9,16 +9,16 @@
import { useAuth } from '@/components/auth/AuthContext';
import { LoginFlowController, LoginState } from '@/lib/auth/LoginFlowController';
import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData';
import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { LoginMutation } from '@/lib/mutations/auth/LoginMutation';
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
import { LoginViewData } from '@/lib/view-data/LoginViewData';
import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel';
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
import { LoginLoadingTemplate } from '@/templates/auth/LoginLoadingTemplate';
import { LoginTemplate } from '@/templates/auth/LoginTemplate';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
export function LoginClient({ viewData }: ClientWrapperProps<LoginViewData>) {
const router = useRouter();

View File

@@ -1,10 +1,10 @@
'use client';
import React, { useState, useCallback } from 'react';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
import { useRouter } from 'next/navigation';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { RaceResultsViewData } from '@/lib/view-data/RaceResultsViewData';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { useRouter } from 'next/navigation';
import { useCallback, useState } from 'react';
export function RaceResultsPageClient({ viewData }: ClientWrapperProps<RaceResultsViewData>) {
const router = useRouter();

View File

@@ -6,16 +6,16 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData';
import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
import { ResetPasswordViewModelBuilder } from '@/lib/builders/view-models/ResetPasswordViewModelBuilder';
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
import { routes } from '@/lib/routing/RouteConfig';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ResetPasswordMutation } from '@/lib/mutations/auth/ResetPasswordMutation';
import { routes } from '@/lib/routing/RouteConfig';
import { ResetPasswordFormValidation } from '@/lib/utilities/authValidation';
import { ResetPasswordViewData } from '@/lib/view-data/ResetPasswordViewData';
import { ResetPasswordViewModel } from '@/lib/view-models/auth/ResetPasswordViewModel';
import { ResetPasswordTemplate } from '@/templates/auth/ResetPasswordTemplate';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
export function ResetPasswordClient({ viewData }: ClientWrapperProps<ResetPasswordViewData>) {
const router = useRouter();

View File

@@ -1,27 +1,27 @@
'use client';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import { useParams } from 'next/navigation';
import { useMemo } from 'react';
import {
useApproveJoinRequest,
useLeagueJoinRequests,
useLeagueRosterAdmin,
useApproveJoinRequest,
useRejectJoinRequest,
useUpdateMemberRole,
useRemoveMember,
useUpdateMemberRole,
} from "@/hooks/league/useLeagueRosterAdmin";
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
import type { JoinRequestData, RosterMemberData, LeagueRosterAdminViewData } from '@/lib/view-data/LeagueRosterAdminViewData';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import type { JoinRequestData, LeagueRosterAdminViewData, RosterMemberData } from '@/lib/view-data/LeagueRosterAdminViewData';
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
import { useParams } from 'next/navigation';
import { useMemo } from 'react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWrapperProps<LeagueRosterAdminViewData>>) {
export function RosterAdminPage({ }: Partial<ClientWrapperProps<LeagueRosterAdminViewData>>) {
const params = useParams();
const leagueId = params.id as string;
@@ -83,7 +83,7 @@ export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWra
id: req.id,
driver: req.driver as { id: string; name: string },
requestedAt: req.requestedAt,
formattedRequestedAt: DateDisplay.formatShort(req.requestedAt),
formattedRequestedAt: DateFormatter.formatShort(req.requestedAt),
message: req.message || undefined,
})),
members: members.map((m: LeagueRosterMemberDTO): RosterMemberData => ({
@@ -91,7 +91,7 @@ export function RosterAdminPage({ viewData: initialViewData }: Partial<ClientWra
driver: m.driver as { id: string; name: string },
role: m.role,
joinedAt: m.joinedAt,
formattedJoinedAt: DateDisplay.formatShort(m.joinedAt),
formattedJoinedAt: DateFormatter.formatShort(m.joinedAt),
})),
}), [leagueId, joinRequests, members]);

View File

@@ -6,16 +6,16 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/components/auth/AuthContext';
import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData';
import { SignupTemplate } from '@/templates/auth/SignupTemplate';
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
import { SignupViewModelBuilder } from '@/lib/builders/view-models/SignupViewModelBuilder';
import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
import { SignupFormValidation } from '@/lib/utilities/authValidation';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { SignupMutation } from '@/lib/mutations/auth/SignupMutation';
import { SignupFormValidation } from '@/lib/utilities/authValidation';
import { SignupViewData } from '@/lib/view-data/SignupViewData';
import { SignupViewModel } from '@/lib/view-models/auth/SignupViewModel';
import { SignupTemplate } from '@/templates/auth/SignupTemplate';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
export function SignupClient({ viewData }: ClientWrapperProps<SignupViewData>) {
const router = useRouter();

View File

@@ -1,13 +1,13 @@
'use client';
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import type { StewardingViewData } from '@/lib/view-data/StewardingViewData';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { useMemo, useState } from 'react';
import { StewardingTemplate } from '@/templates/StewardingTemplate';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { useMemo, useState } from 'react';
interface StewardingPageClientProps extends ClientWrapperProps<StewardingViewData> {
leagueId: string;

View File

@@ -1,20 +1,17 @@
'use client';
import { useRouter } from 'next/navigation';
import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
import { useState } from 'react';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
import { ViewData } from '@/lib/contracts/view-data/ViewData';
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { TeamLeaderboardTemplate } from '@/templates/TeamLeaderboardTemplate';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
interface TeamLeaderboardViewData extends ViewData {
teams: TeamSummaryViewModel[];
}
export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<TeamLeaderboardViewData>) {
export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<{ teams: TeamListItemDTO[] }>) {
const router = useRouter();
// Client-side UI state only (no business logic)
@@ -22,7 +19,13 @@ export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<Team
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
const [sortBy, setSortBy] = useState<SortBy>('rating');
if (!viewData.teams || viewData.teams.length === 0) {
// Instantiate ViewModels on the client to wrap plain DTOs with logic
const teamViewModels = useMemo(() =>
(viewData.teams || []).map(dto => new TeamSummaryViewModel(dto)),
[viewData.teams]
);
if (teamViewModels.length === 0) {
return null;
}
@@ -34,8 +37,8 @@ export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<Team
router.push('/teams');
};
// Apply filtering and sorting
const filteredAndSortedTeams = viewData.teams
// Apply filtering and sorting using ViewModel logic
const filteredAndSortedTeams = teamViewModels
.filter((team) => {
const matchesSearch = team.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesLevel = filterLevel === 'all' || team.performanceLevel === filterLevel;
@@ -54,7 +57,7 @@ export function TeamLeaderboardPageWrapper({ viewData }: ClientWrapperProps<Team
});
const templateViewData = {
teams: viewData.teams,
teams: teamViewModels,
searchQuery,
filterLevel,
sortBy,

View File

@@ -1,8 +1,8 @@
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { Card } from '@/ui/Card';
import { Text } from '@/ui/Text';
import { Group } from '@/ui/Group';
import { Stack } from '@/ui/Stack';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { Text } from '@/ui/Text';
interface AchievementCardProps {
title: string;
@@ -36,7 +36,7 @@ export function AchievementCard({
<Text weight="medium" variant="high">{title}</Text>
<Text size="xs" variant="med">{description}</Text>
<Text size="xs" variant="low">
{DateDisplay.formatShort(unlockedAt)}
{DateFormatter.formatShort(unlockedAt)}
</Text>
</Stack>
</Group>

View File

@@ -1,15 +1,13 @@
import { AchievementDisplay } from '@/lib/display-objects/AchievementDisplay';
import { AchievementFormatter } from '@/lib/formatters/AchievementFormatter';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { Group } from '@/ui/Group';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Group } from '@/ui/Group';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Award, Crown, Medal, Star, Target, Trophy, Zap } from 'lucide-react';
import React from 'react';
interface Achievement {
id: string;
@@ -53,7 +51,7 @@ export function AchievementGrid({ achievements }: AchievementGridProps) {
<Grid cols={1} gap={4}>
{achievements.map((achievement) => {
const AchievementIcon = getAchievementIcon(achievement.icon);
const rarity = AchievementDisplay.getRarityVariant(achievement.rarity);
const rarity = AchievementFormatter.getRarityVariant(achievement.rarity);
return (
<Card
key={achievement.id}

View File

@@ -1,26 +1,24 @@
'use client';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
import { DriverIdentity } from '@/ui/DriverIdentity';
import { Group } from '@/ui/Group';
import { IconButton } from '@/ui/IconButton';
import { SimpleCheckbox } from '@/ui/SimpleCheckbox';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Group } from '@/ui/Group';
import { DriverIdentity } from '@/ui/DriverIdentity';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/ui/Table';
import { Text } from '@/ui/Text';
import { MoreVertical, Trash2 } from 'lucide-react';
import { UserStatusTag } from './UserStatusTag';
import React from 'react';
interface AdminUsersTableProps {
users: AdminUsersViewData['users'];
@@ -102,7 +100,7 @@ export function AdminUsersTable({
</TableCell>
<TableCell>
<Text size="sm" variant="low">
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
{user.lastLoginAt ? DateFormatter.formatShort(user.lastLoginAt) : 'Never'}
</Text>
</TableCell>
<TableCell>

View File

@@ -1,6 +1,6 @@
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
import { Badge } from '@/ui/Badge';
import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
@@ -88,7 +88,7 @@ export function DriverEntryRow({
justifyContent="center"
fontSize="0.625rem"
>
{CountryFlagDisplay.fromCountryCode(country).toString()}
{CountryFlagFormatter.fromCountryCode(country).toString()}
</Stack>
</Stack>

View File

@@ -1,16 +1,16 @@
import { mediaConfig } from '@/lib/config/mediaConfig';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Calendar, Clock, ExternalLink, Globe, Star, Trophy, UserPlus } from 'lucide-react';
interface ProfileHeroProps {
@@ -93,7 +93,7 @@ export function ProfileHero({
<Stack direction="row" align="center" gap={3} wrap mb={2}>
<Heading level={1}>{driver.name}</Heading>
<Text size="4xl" aria-label={`Country: ${driver.country}`}>
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
{CountryFlagFormatter.fromCountryCode(driver.country).toString()}
</Text>
</Stack>

View File

@@ -11,52 +11,41 @@ import { Input } from '@/ui/Input';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import {
Activity,
AlertTriangle,
Bug,
ChevronDown,
Clock,
Copy,
Cpu,
Download,
FileText,
Globe,
RefreshCw,
Search,
Terminal,
Trash2,
Zap
Activity,
AlertTriangle,
Bug,
ChevronDown,
Clock,
Copy,
Cpu,
Download,
FileText,
Globe,
RefreshCw,
Search,
Terminal,
Trash2,
Zap
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DurationDisplay } from '@/lib/display-objects/DurationDisplay';
import { MemoryDisplay } from '@/lib/display-objects/MemoryDisplay';
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
import { TimeDisplay } from '@/lib/display-objects/TimeDisplay';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { DurationFormatter } from '@/lib/formatters/DurationFormatter';
import { MemoryFormatter } from '@/lib/formatters/MemoryFormatter';
import { PercentFormatter } from '@/lib/formatters/PercentFormatter';
interface ErrorAnalyticsDashboardProps {
/**
* Auto-refresh interval in milliseconds
*/
refreshInterval?: number;
/**
* Whether to show in production (default: false)
*/
showInProduction?: boolean;
}
function formatDuration(duration: number): string {
return DurationDisplay.formatMs(duration);
return DurationFormatter.formatMs(duration);
}
function formatPercentage(value: number, total: number): string {
if (total === 0) return '0%';
return PercentDisplay.format(value / total);
return PercentFormatter.format(value / total);
}
function formatMemory(bytes: number): string {
return MemoryDisplay.formatMB(bytes);
return MemoryFormatter.formatMB(bytes);
}
interface PerformanceWithMemory extends Performance {
@@ -327,7 +316,7 @@ export function ErrorAnalyticsDashboard({
<Stack display="flex" justifyContent="between" alignItems="start" gap={2} mb={1}>
<Text size="xs" font="mono" weight="bold" color="text-red-400" truncate>{error.type}</Text>
<Text size="xs" color="text-gray-500" fontSize="10px">
{DateDisplay.formatTime(error.timestamp)}
{DateFormatter.formatTime(error.timestamp)}
</Text>
</Stack>
<Text size="xs" color="text-gray-300" block mb={1}>{error.message}</Text>

View File

@@ -1,11 +1,11 @@
'use client';
import React, { useEffect, useState } from 'react';
import { TimeFormatter } from '@/lib/formatters/TimeFormatter';
import { Button } from '@/ui/Button';
import { FeedItem } from '@/ui/FeedItem';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { TimeDisplay } from '@/lib/display-objects/TimeDisplay';
import { Text } from '@/ui/Text';
import { useEffect, useState } from 'react';
interface FeedItemData {
id: string;
@@ -50,7 +50,7 @@ export function FeedItemCard({ item }: FeedItemCardProps) {
name: actor?.name || 'Unknown',
avatar: actor?.avatarUrl
}}
timestamp={TimeDisplay.timeAgo(item.timestamp)}
timestamp={TimeFormatter.timeAgo(item.timestamp)}
content={
<Stack gap={2}>
<Text weight="bold" variant="high">{item.headline}</Text>

View File

@@ -1,14 +1,14 @@
import { FeedList } from '@/components/feed/FeedList';
import { LatestResultsSidebar } from '@/components/races/LatestResultsSidebar';
import { UpcomingRacesSidebar } from '@/components/races/UpcomingRacesSidebar';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Section } from '@/ui/Section';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface FeedItemData {
id: string;
@@ -49,12 +49,12 @@ export function FeedLayout({
}: FeedLayoutProps) {
const formattedUpcomingRaces = upcomingRaces.map(r => ({
...r,
formattedDate: DateDisplay.formatShort(r.scheduledAt),
formattedDate: DateFormatter.formatShort(r.scheduledAt),
}));
const formattedLatestResults = latestResults.map(r => ({
...r,
formattedDate: DateDisplay.formatShort(r.scheduledAt),
formattedDate: DateFormatter.formatShort(r.scheduledAt),
}));
return (

View File

@@ -1,11 +1,11 @@
import { RankBadge } from '@/components/leaderboards/RankBadge';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
import { SkillLevelFormatter } from '@/lib/formatters/SkillLevelFormatter';
import { Avatar } from '@/ui/Avatar';
import { Group } from '@/ui/Group';
import { LeaderboardList } from '@/ui/LeaderboardList';
import { LeaderboardPreviewShell } from '@/ui/LeaderboardPreviewShell';
import { LeaderboardRow } from '@/ui/LeaderboardRow';
import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text';
import { Trophy } from 'lucide-react';
@@ -69,8 +69,8 @@ export function DriverLeaderboardPreview({
</Text>
<Group gap={2}>
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">{driver.nationality}</Text>
<Text size="xs" weight="bold" color={SkillLevelDisplay.getColor(driver.skillLevel)} uppercase letterSpacing="wider">
{SkillLevelDisplay.getLabel(driver.skillLevel)}
<Text size="xs" weight="bold" color={SkillLevelFormatter.getColor(driver.skillLevel)} uppercase letterSpacing="wider">
{SkillLevelFormatter.getLabel(driver.skillLevel)}
</Text>
</Group>
</Group>
@@ -80,7 +80,7 @@ export function DriverLeaderboardPreview({
<Group gap={8}>
<Group direction="column" align="end" gap={0}>
<Text variant="primary" font="mono" weight="bold" block size="md" align="right">
{RatingDisplay.format(driver.rating)}
{RatingFormatter.format(driver.rating)}
</Text>
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px" align="right">
Rating

View File

@@ -1,5 +1,5 @@
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { MedalFormatter } from '@/lib/formatters/MedalFormatter';
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
@@ -91,8 +91,8 @@ export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumPr
border
transform="translateX(-50%)"
borderWidth="2px"
bg={MedalDisplay.getBg(position)}
color={MedalDisplay.getColor(position)}
bg={MedalFormatter.getBg(position)}
color={MedalFormatter.getColor(position)}
shadow="lg"
>
<Text size="sm" weight="bold">{position}</Text>
@@ -122,7 +122,7 @@ export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumPr
block
color={isFirst ? 'text-warning-amber' : 'text-primary-blue'}
>
{RatingDisplay.format(driver.rating)}
{RatingFormatter.format(driver.rating)}
</Text>
<Stack direction="row" align="center" gap={3} mt={1}>
@@ -155,7 +155,7 @@ export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumPr
<Text
weight="bold"
size="4xl"
color={MedalDisplay.getColor(position)}
color={MedalFormatter.getColor(position)}
opacity={0.1}
fontSize={isFirst ? '5rem' : '3.5rem'}
>

View File

@@ -1,11 +1,10 @@
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { RankMedal as UiRankMedal, RankMedalProps } from '@/ui/RankMedal';
import React from 'react';
import { MedalFormatter } from '@/lib/formatters/MedalFormatter';
import { RankMedalProps, RankMedal as UiRankMedal } from '@/ui/RankMedal';
export function RankMedal(props: RankMedalProps) {
const variant = MedalDisplay.getVariant(props.rank);
const bg = MedalDisplay.getBg(props.rank);
const color = MedalDisplay.getColor(props.rank);
const variant = MedalFormatter.getVariant(props.rank);
const bg = MedalFormatter.getBg(props.rank);
const color = MedalFormatter.getColor(props.rank);
return (
<UiRankMedal

View File

@@ -1,12 +1,11 @@
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
import { SkillLevelFormatter } from '@/lib/formatters/SkillLevelFormatter';
import { Avatar } from '@/ui/Avatar';
import { Group } from '@/ui/Group';
import { LeaderboardRow } from '@/ui/LeaderboardRow';
import { Text } from '@/ui/Text';
import { DeltaChip } from './DeltaChip';
import { RankBadge } from './RankBadge';
import { LeaderboardRow } from '@/ui/LeaderboardRow';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import React from 'react';
interface RankingRowProps {
id: string;
@@ -65,8 +64,8 @@ export function RankingRow({
</Text>
<Group gap={2}>
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">{nationality}</Text>
<Text size="xs" weight="bold" style={{ color: SkillLevelDisplay.getColor(skillLevel) }} uppercase letterSpacing="wider">
{SkillLevelDisplay.getLabel(skillLevel)}
<Text size="xs" weight="bold" style={{ color: SkillLevelFormatter.getColor(skillLevel) }} uppercase letterSpacing="wider">
{SkillLevelFormatter.getLabel(skillLevel)}
</Text>
</Group>
</Group>
@@ -84,7 +83,7 @@ export function RankingRow({
</Group>
<Group direction="column" align="end" gap={0}>
<Text variant="primary" font="mono" weight="bold" block size="md">
{RatingDisplay.format(rating)}
{RatingFormatter.format(rating)}
</Text>
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold">
Rating

View File

@@ -1,10 +1,7 @@
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
import { Avatar } from '@/ui/Avatar';
import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { Surface } from '@/ui/Surface';
import React from 'react';
interface PodiumDriver {
id: string;
@@ -20,7 +17,7 @@ interface RankingsPodiumProps {
onDriverClick?: (id: string) => void;
}
export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) {
export function RankingsPodium({ podium }: RankingsPodiumProps) {
return (
<Group justify="center" align="end" gap={4}>
{[1, 0, 2].map((index) => {
@@ -57,7 +54,7 @@ export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) {
<Text weight="bold" variant="high" size={isFirst ? 'md' : 'sm'}>{driver.name}</Text>
<Text font="mono" weight="bold" variant={isFirst ? 'warning' : 'primary'}>
{RatingDisplay.format(driver.rating)}
{RatingFormatter.format(driver.rating)}
</Text>
</Group>

View File

@@ -1,18 +1,16 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Group } from '@/ui/Group';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { ChevronDown, ChevronUp, Calendar, CheckCircle, Trophy, Edit, Clock } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { Text } from '@/ui/Text';
import { Calendar, CheckCircle, ChevronDown, ChevronUp, Clock, Edit, Trophy } from 'lucide-react';
import { useState } from 'react';
interface RaceEvent {
id: string;
@@ -50,9 +48,6 @@ interface MonthGroup {
export function EnhancedLeagueSchedulePanel({
events,
leagueId,
currentDriverId,
isAdmin,
onRegister,
onWithdraw,
onEdit,
@@ -60,7 +55,6 @@ export function EnhancedLeagueSchedulePanel({
onRaceDetail,
onResultsClick,
}: EnhancedLeagueSchedulePanelProps) {
const router = useRouter();
const [expandedMonths, setExpandedMonths] = useState<Set<string>>(new Set());
// Group races by month
@@ -109,7 +103,7 @@ export function EnhancedLeagueSchedulePanel({
};
const formatTime = (scheduledAt: string) => {
return DateDisplay.formatDateTime(scheduledAt);
return DateFormatter.formatDateTime(scheduledAt);
};
const groups = groupRacesByMonth();
@@ -158,7 +152,7 @@ export function EnhancedLeagueSchedulePanel({
{isExpanded && (
<Box p={4}>
<Stack gap={3}>
{group.races.map((race, raceIndex) => (
{group.races.map((race) => (
<Surface
key={race.id}
variant="precision"

View File

@@ -1,10 +1,10 @@
import { ActivityFeedItem } from '@/components/feed/ActivityFeedItem';
import { useLeagueRaces } from "@/hooks/league/useLeagueRaces";
import { RelativeTimeFormatter } from '@/lib/formatters/RelativeTimeFormatter';
import { LeagueActivityService } from '@/lib/services/league/LeagueActivityService';
import { RelativeTimeDisplay } from '@/lib/display-objects/RelativeTimeDisplay';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { AlertTriangle, Calendar, Flag, Shield, UserMinus, UserPlus } from 'lucide-react';
import { useMemo } from 'react';
@@ -128,7 +128,7 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
<ActivityFeedItem
icon={getIcon()}
content={getContent()}
timestamp={RelativeTimeDisplay.format(activity.timestamp, new Date())}
timestamp={RelativeTimeFormatter.format(activity.timestamp, new Date())}
/>
);
}

View File

@@ -1,11 +1,11 @@
import { DriverIdentity } from '@/ui/DriverIdentity';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { DriverIdentity } from '@/ui/DriverIdentity';
import { TableCell, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import React, { ReactNode } from 'react';
import { ReactNode } from 'react';
interface LeagueMemberRowProps {
driver?: DriverViewModel;
@@ -84,7 +84,7 @@ export function LeagueMemberRow({
</TableCell>
<TableCell>
<Text variant="high" size="sm">
{DateDisplay.formatShort(joinedAt)}
{DateFormatter.formatShort(joinedAt)}
</Text>
</TableCell>
{actions && (

View File

@@ -1,39 +1,33 @@
'use client';
import {
Users,
Calendar,
Trophy,
Award,
Rocket,
Gamepad2,
User,
UsersRound,
Clock,
Flag,
Zap,
Timer,
Check,
Globe,
Medal,
type LucideIcon,
} from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import {
Award,
Calendar,
Check,
Clock,
Flag,
Gamepad2,
Globe,
Medal,
Rocket,
Timer,
Trophy,
User,
Users,
UsersRound,
Zap,
type LucideIcon,
} from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DurationDisplay } from '@/lib/display-objects/DurationDisplay';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel;
presets: LeagueScoringPresetViewModel[];
}
// Individual review card component
function ReviewCard({
@@ -142,7 +136,7 @@ export function LeagueReviewSummary({ form, presets }: LeagueReviewSummaryProps)
const seasonStartLabel =
timings.seasonStartDate
? DateDisplay.formatShort(timings.seasonStartDate)
? DateFormatter.formatShort(timings.seasonStartDate)
: null;
const stewardingLabel = (() => {

View File

@@ -1,28 +1,27 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Group } from '@/ui/Group';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { Badge } from '@/ui/Badge';
import {
Calendar,
Clock,
Car,
MapPin,
Thermometer,
Droplets,
Wind,
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Group } from '@/ui/Group';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import {
Calendar,
Car,
CheckCircle,
Clock,
Cloud,
X,
Droplets,
MapPin,
Thermometer,
Trophy,
CheckCircle
Wind,
X
} from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface RaceDetailModalProps {
race: {
@@ -55,7 +54,7 @@ export function RaceDetailModal({
if (!isOpen) return null;
const formatTime = (scheduledAt: string) => {
return DateDisplay.formatDateTime(scheduledAt);
return DateFormatter.formatDateTime(scheduledAt);
};
const getStatusBadge = (status: 'scheduled' | 'completed') => {

View File

@@ -1,15 +1,11 @@
'use client';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { Panel } from '@/ui/Panel';
import { Input } from '@/ui/Input';
import { Text } from '@/ui/Text';
import { TextArea } from '@/ui/TextArea';
import { Box } from '@/ui/Box';
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
import { Group } from '@/ui/Group';
import { Input } from '@/ui/Input';
import { Panel } from '@/ui/Panel';
import { Stack } from '@/ui/Stack';
import { ProfileStat } from '@/ui/ProfileHero';
import React from 'react';
import { TextArea } from '@/ui/TextArea';
interface ProfileDetailsPanelProps {
driver: {
@@ -50,7 +46,7 @@ export function ProfileDetailsPanel({ driver, isEditing, onUpdate }: ProfileDeta
<Text size="xs" variant="low" weight="bold" uppercase block>Nationality</Text>
<Group gap={2}>
<Text size="xl">
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
{CountryFlagFormatter.fromCountryCode(driver.country).toString()}
</Text>
<Text variant="med">{driver.country}</Text>
</Group>

View File

@@ -1,16 +1,16 @@
'use client';
import { mediaConfig } from '@/lib/config/mediaConfig';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Group } from '@/ui/Group';
import { Heading } from '@/ui/Heading';
import { Image } from '@/ui/Image';
import { ProfileHero, ProfileAvatar, ProfileStatsGroup, ProfileStat } from '@/ui/ProfileHero';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Group } from '@/ui/Group';
import { ProfileAvatar, ProfileHero, ProfileStat, ProfileStatsGroup } from '@/ui/ProfileHero';
import { Stack } from '@/ui/Stack';
import { Calendar, Globe, Star, Trophy, UserPlus } from 'lucide-react';
import { Text } from '@/ui/Text';
import { Calendar, Globe, UserPlus } from 'lucide-react';
import React from 'react';
interface ProfileHeaderProps {
@@ -56,7 +56,7 @@ export function ProfileHeader({
<Group gap={3}>
<Heading level={1}>{driver.name}</Heading>
<Text size="2xl" aria-label={`Country: ${driver.country}`}>
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
{CountryFlagFormatter.fromCountryCode(driver.country).toString()}
</Text>
</Group>
</Box>

View File

@@ -1,6 +1,6 @@
'use client';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { Button } from '@/ui/Button';
import { Card, Card as Surface } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
@@ -64,7 +64,7 @@ export function SponsorshipRequestsPanel({
<Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
)}
<Text size="xs" color="text-gray-500" block mt={2}>
{DateDisplay.formatShort(request.createdAtIso)}
{DateFormatter.formatShort(request.createdAtIso)}
</Text>
</Stack>
<Stack direction="row" gap={2}>

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { RaceStatusDisplay } from '@/lib/display-objects/RaceStatusDisplay';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { RaceStatusFormatter } from '@/lib/formatters/RaceStatusFormatter';
import { RaceCard as UiRaceCard } from './RaceCard';
interface RaceCardProps {
@@ -23,11 +22,11 @@ export function RaceCard({ race, onClick }: RaceCardProps) {
track={race.track}
car={race.car}
scheduledAt={race.scheduledAt}
scheduledAtLabel={DateDisplay.formatShort(race.scheduledAt)}
timeLabel={DateDisplay.formatTime(race.scheduledAt)}
scheduledAtLabel={DateFormatter.formatShort(race.scheduledAt)}
timeLabel={DateFormatter.formatTime(race.scheduledAt)}
status={race.status}
statusLabel={RaceStatusDisplay.getLabel(race.status)}
statusVariant={RaceStatusDisplay.getVariant(race.status) as any}
statusLabel={RaceStatusFormatter.getLabel(race.status)}
statusVariant={RaceStatusFormatter.getVariant(race.status) as any}
leagueName={race.leagueName}
leagueId={race.leagueId}
strengthOfField={race.strengthOfField}

View File

@@ -1,8 +1,8 @@
import { RaceHero as UiRaceHero } from '@/components/races/RaceHero';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { LucideIcon } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface RaceHeroProps {
track: string;
@@ -34,8 +34,8 @@ export function RaceHero(props: RaceHeroProps) {
return (
<UiRaceHero
{...rest}
formattedDate={DateDisplay.formatShort(scheduledAt)}
formattedTime={DateDisplay.formatTime(scheduledAt)}
formattedDate={DateFormatter.formatShort(scheduledAt)}
formattedTime={DateFormatter.formatTime(scheduledAt)}
statusConfig={mappedConfig}
/>
);

View File

@@ -1,9 +1,9 @@
import { routes } from '@/lib/routing/RouteConfig';
import { RaceListItem as UiRaceListItem } from '@/components/races/RaceListItem';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { StatusDisplay } from '@/lib/display-objects/StatusDisplay';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { StatusFormatter } from '@/lib/formatters/StatusFormatter';
import { routes } from '@/lib/routing/RouteConfig';
interface Race {
id: string;
@@ -48,11 +48,11 @@ export function RaceListItem({ race, onClick }: RaceListItemProps) {
<UiRaceListItem
track={race.track}
car={race.car}
dateLabel={DateDisplay.formatMonthDay(race.scheduledAt).split(' ')[0]}
dayLabel={DateDisplay.formatMonthDay(race.scheduledAt).split(' ')[1]}
timeLabel={DateDisplay.formatTime(race.scheduledAt)}
dateLabel={DateFormatter.formatMonthDay(race.scheduledAt).split(' ')[0]}
dayLabel={DateFormatter.formatMonthDay(race.scheduledAt).split(' ')[1]}
timeLabel={DateFormatter.formatTime(race.scheduledAt)}
status={race.status}
statusLabel={StatusDisplay.raceStatus(race.status)}
statusLabel={StatusFormatter.raceStatus(race.status)}
statusVariant={config.variant}
statusIconName={config.iconName}
leagueName={race.leagueName}

View File

@@ -1,8 +1,8 @@
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { RaceResultViewModel } from '@/lib/view-models/RaceResultViewModel';
import { RaceResultCard as UiRaceResultCard } from './RaceResultCard';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface RaceResultCardProps {
race: {
@@ -29,7 +29,7 @@ export function RaceResultCard({
raceId={race.id}
track={race.track}
car={race.car}
formattedDate={DateDisplay.formatShort(race.scheduledAt)}
formattedDate={DateFormatter.formatShort(race.scheduledAt)}
position={result.position}
positionLabel={result.formattedPosition}
startPositionLabel={result.formattedStartPosition}

View File

@@ -1,15 +1,14 @@
'use client';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { Image } from '@/ui/Image';
import { ResultRow, PositionBadge, ResultPoints } from '@/ui/ResultRow';
import { Text } from '@/ui/Text';
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Group } from '@/ui/Group';
import { Image } from '@/ui/Image';
import { PositionBadge, ResultPoints, ResultRow } from '@/ui/ResultRow';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import React from 'react';
import { Text } from '@/ui/Text';
interface ResultEntry {
position: number;
@@ -62,7 +61,7 @@ export function RaceResultRow({ result, points }: RaceResultRowProps) {
justifyContent="center"
>
<Text size="xs" style={{ fontSize: '0.625rem' }}>
{CountryFlagDisplay.fromCountryCode(country).toString()}
{CountryFlagFormatter.fromCountryCode(country).toString()}
</Text>
</Surface>
</Box>

View File

@@ -1,7 +1,7 @@
'use client';
import { mediaConfig } from '@/lib/config/mediaConfig';
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
import { CountryFlagFormatter } from '@/lib/formatters/CountryFlagFormatter';
import { routes } from '@/lib/routing/RouteConfig';
import { Card, Card as Surface } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
@@ -68,7 +68,7 @@ export function FriendsPreview({ friends }: FriendsPreviewProps) {
/>
</Stack>
<Text size="sm" color="text-white">{friend.name}</Text>
<Text size="lg">{CountryFlagDisplay.fromCountryCode(friend.country).toString()}</Text>
<Text size="lg">{CountryFlagFormatter.fromCountryCode(friend.country).toString()}</Text>
</Surface>
</Link>
</Stack>

View File

@@ -1,6 +1,5 @@
'use client';
import { MinimalEmptyState } from '@/ui/EmptyState';
import { TeamRosterItem } from '@/components/teams/TeamRosterItem';
import { TeamRosterList } from '@/components/teams/TeamRosterList';
import { useTeamRoster } from "@/hooks/team/useTeamRoster";
@@ -9,15 +8,16 @@ import { sortMembers } from '@/lib/utilities/roster-utils';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { MinimalEmptyState } from '@/ui/EmptyState';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Select } from '@/ui/Select';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { useMemo, useState } from 'react';
import { MemberDisplay } from '@/lib/display-objects/MemberDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { MemberFormatter } from '@/lib/formatters/MemberFormatter';
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
export type TeamRole = 'owner' | 'admin' | 'member';
export type TeamMemberRole = 'owner' | 'manager' | 'member';
@@ -74,7 +74,7 @@ export function TeamRoster({
const teamAverageRatingLabel = useMemo(() => {
if (teamMembers.length === 0) return '—';
const avg = teamMembers.reduce((sum: number, m: { rating?: number | null }) => sum + (m.rating || 0), 0) / teamMembers.length;
return RatingDisplay.format(avg);
return RatingFormatter.format(avg);
}, [teamMembers]);
if (loading) {
@@ -93,7 +93,7 @@ export function TeamRoster({
<Stack>
<Heading level={3}>Team Roster</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{MemberDisplay.formatCount(memberships.length)} Avg Rating:{' '}
{MemberFormatter.formatCount(memberships.length)} Avg Rating:{' '}
<Text color="text-primary-blue" weight="medium">{teamAverageRatingLabel}</Text>
</Text>
</Stack>
@@ -129,8 +129,8 @@ export function TeamRoster({
driver={driver as DriverViewModel}
href={`${routes.driver.detail(driver.id)}?from=team&teamId=${teamId}`}
roleLabel={getRoleLabel(role)}
joinedAtLabel={DateDisplay.formatShort(joinedAt)}
ratingLabel={RatingDisplay.format(rating)}
joinedAtLabel={DateFormatter.formatShort(joinedAt)}
ratingLabel={RatingFormatter.format(rating)}
overallRankLabel={overallRank !== null ? `#${overallRank}` : null}
actions={canManageMembership ? (
<>

View File

@@ -0,0 +1,160 @@
# ESLint Rule Analysis for RaceWithSOFViewModel.ts
## File Analyzed
`apps/website/lib/view-models/RaceWithSOFViewModel.ts`
## Violations Found
### 1. DTO Import (Line 1)
```typescript
import { RaceWithSOFDTO } from '@/lib/types/generated/RaceWithSOFDTO';
```
**Rule Violated**: `view-model-taxonomy.js`
**Reason**:
- Imports from DTO path (`lib/types/generated/`)
- Uses DTO naming convention (`RaceWithSOFDTO`)
### 2. Inline ViewData Interface (Lines 9-13)
```typescript
export interface RaceWithSOFViewData {
id: string;
track: string;
strengthOfField: number | null;
}
```
**Rule Violated**: `view-model-taxonomy.js`
**Reason**: Defines ViewData interface inline instead of importing from `lib/view-data/`
## Rule Gaps Identified
### Current Rule Issues
1. **Incomplete import checking**: Only reported if imported name contained "DTO", but should forbid ALL imports from disallowed paths
2. **No strict whitelist**: Didn't enforce that imports MUST be from allowed paths
3. **Poor relative import handling**: Couldn't properly resolve relative imports
4. **Missing strict import message**: No message for general import path violations
### Architectural Requirements
The project requires:
1. **Forbid "dto" in the whole directory** ✓ (covered)
2. **Imports only from contracts or view models/view data dir** ✗ (partially covered)
3. **No inline view data interfaces** ✓ (covered)
## Improvements Made
### 1. Updated `view-model-taxonomy.js`
**Changes**:
- Added `strictImport` message for general import path violations
- Changed import check to report for ANY import from disallowed paths (not just those with "DTO" in name)
- Added strict import path enforcement with whitelist
- Improved relative import handling
- Added null checks for `node.id` in interface/type checks
**New Behavior**:
- Forbids ALL imports from DTO/service paths (`lib/types/generated/`, `lib/dtos/`, `lib/api/`, `lib/services/`)
- Enforces strict whitelist: only allows imports from `@/lib/contracts/`, `@/lib/view-models/`, `@/lib/view-data/`
- Allows external imports (npm packages)
- Handles relative imports with heuristic pattern matching
### 2. Updated `test-view-model-taxonomy.js`
**Changes**:
- Added test for service layer imports
- Added test for strict import violations
- Updated test summary to include new test cases
## Test Results
### Before Improvements
- Test 1 (DTO import): ✓ PASS
- Test 2 (Inline ViewData): ✓ PASS
- Test 3 (Valid code): ✓ PASS
### After Improvements
- Test 1 (DTO import): ✓ PASS
- Test 2 (Inline ViewData): ✓ PASS
- Test 3 (Valid code): ✓ PASS
- Test 4 (Service import): ✓ PASS (new)
- Test 5 (Strict import): ✓ PASS (new)
## Recommended Refactoring for RaceWithSOFViewModel.ts
### Current Code (Violations)
```typescript
import { RaceWithSOFDTO } from '@/lib/types/generated/RaceWithSOFDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface RaceWithSOFViewData {
id: string;
track: string;
strengthOfField: number | null;
}
export class RaceWithSOFViewModel extends ViewModel {
private readonly data: RaceWithSOFViewData;
constructor(data: RaceWithSOFViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get track(): string { return this.data.track; }
get strengthOfField(): number | null { return this.data.strengthOfField; }
}
```
### Fixed Code (No Violations)
```typescript
import { ViewModel } from "../contracts/view-models/ViewModel";
import { RaceWithSOFViewData } from '@/lib/view-data/RaceWithSOFViewData';
export class RaceWithSOFViewModel extends ViewModel {
private readonly data: RaceWithSOFViewData;
constructor(data: RaceWithSOFViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get track(): string { return this.data.track; }
get strengthOfField(): number | null { return this.data.strengthOfField; }
}
```
**Changes**:
1. Removed DTO import (`RaceWithSOFDTO`)
2. Moved ViewData interface to `lib/view-data/RaceWithSOFViewData.ts`
3. Imported ViewData from proper location
## Additional Recommendations
### 1. Consider Splitting the Rule
If the rule becomes too complex, consider splitting it into:
- `view-model-taxonomy.js`: Keep only DTO and ViewData definition checks
- `view-model-imports.js`: New rule for strict import path enforcement
### 2. Improve Relative Import Handling
The current heuristic for relative imports may have false positives/negatives. Consider:
- Using a path resolver
- Requiring absolute imports with `@/` prefix
- Adding configuration for allowed relative import patterns
### 3. Add More Tests
- Test with nested view model directories
- Test with type imports (`import type`)
- Test with external package imports
- Test with relative imports from different depths
### 4. Update Documentation
- Document the allowed import paths
- Provide examples of correct and incorrect usage
- Update the rule description to reflect the new strict import enforcement
## Conclusion
The updated `view-model-taxonomy.js` rule now properly enforces all three architectural requirements:
1. ✓ Forbids "DTO" in identifiers
2. ✓ Enforces strict import path whitelist
3. ✓ Forbids inline ViewData definitions
The rule is more robust and catches more violations while maintaining backward compatibility with existing valid code.

View File

@@ -1,87 +0,0 @@
/**
* ESLint rules for Display Object Guardrails
*
* Enforces display object boundaries and purity
*/
module.exports = {
// Rule 1: No IO in display objects
'no-io-in-display-objects': {
meta: {
type: 'problem',
docs: {
description: 'Forbid IO imports in display objects',
category: 'Display Objects',
},
messages: {
message: 'DisplayObjects cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
},
},
create(context) {
const forbiddenPaths = [
'@/lib/api/',
'@/lib/services/',
'@/lib/page-queries/',
'@/lib/view-models/',
'@/lib/presenters/',
];
return {
ImportDeclaration(node) {
const importPath = node.source.value;
if (forbiddenPaths.some(path => importPath.includes(path)) &&
!isInComment(node)) {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
// Rule 2: No non-class display exports
'no-non-class-display-exports': {
meta: {
type: 'problem',
docs: {
description: 'Forbid non-class exports in display objects',
category: 'Display Objects',
},
messages: {
message: 'Display Objects must be class-based and export only classes - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
},
},
create(context) {
return {
ExportNamedDeclaration(node) {
if (node.declaration &&
(node.declaration.type === 'FunctionDeclaration' ||
(node.declaration.type === 'VariableDeclaration' &&
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
context.report({
node,
messageId: 'message',
});
}
},
ExportDefaultDeclaration(node) {
if (node.declaration &&
node.declaration.type !== 'ClassDeclaration' &&
node.declaration.type !== 'ClassExpression') {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
};
// Helper functions
function isInComment(node) {
return false;
}

View File

@@ -0,0 +1,138 @@
/**
* ESLint rules for Formatter/Display Guardrails
*
* Enforces boundaries and purity for Formatters and Display Objects
*/
module.exports = {
// Rule 1: No IO in formatters/displays
'no-io-in-display-objects': {
meta: {
type: 'problem',
docs: {
description: 'Forbid IO imports in formatters and displays',
category: 'Formatters',
},
messages: {
message: 'Formatters/Displays cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/formatters/Formatter.ts',
},
},
create(context) {
const forbiddenPaths = [
'@/lib/api/',
'@/lib/services/',
'@/lib/page-queries/',
'@/lib/view-models/',
'@/lib/presenters/',
];
return {
ImportDeclaration(node) {
const importPath = node.source.value;
if (forbiddenPaths.some(path => importPath.includes(path)) &&
!isInComment(node)) {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
// Rule 2: No non-class display exports
'no-non-class-display-exports': {
meta: {
type: 'problem',
docs: {
description: 'Forbid non-class exports in formatters and displays',
category: 'Formatters',
},
messages: {
message: 'Formatters and Displays must be class-based and export only classes - see apps/website/lib/contracts/formatters/Formatter.ts',
},
},
create(context) {
return {
ExportNamedDeclaration(node) {
if (node.declaration &&
(node.declaration.type === 'FunctionDeclaration' ||
(node.declaration.type === 'VariableDeclaration' &&
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
context.report({
node,
messageId: 'message',
});
}
},
ExportDefaultDeclaration(node) {
if (node.declaration &&
node.declaration.type !== 'ClassDeclaration' &&
node.declaration.type !== 'ClassExpression') {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
// Rule 3: Formatters must return primitives
'formatters-must-return-primitives': {
meta: {
type: 'problem',
docs: {
description: 'Enforce that Formatters return primitive values for ViewData compatibility',
category: 'Formatters',
},
messages: {
message: 'Formatters used in ViewDataBuilders must return primitive values (string, number, boolean, null) - see apps/website/lib/contracts/formatters/Formatter.ts',
},
},
create(context) {
const filename = context.getFilename();
const isViewDataBuilder = filename.includes('/lib/builders/view-data/');
if (!isViewDataBuilder) return {};
return {
CallExpression(node) {
// Check if calling a Formatter/Display method
if (node.callee.type === 'MemberExpression' &&
node.callee.object.name &&
(node.callee.object.name.endsWith('Formatter') || node.callee.object.name.endsWith('Display'))) {
// If it's inside a ViewData object literal, it must be a primitive return
let parent = node.parent;
while (parent) {
if (parent.type === 'Property' && parent.parent.type === 'ObjectExpression') {
// This is a property in an object literal (likely ViewData)
// We can't easily check the return type of the method at lint time without type info,
// but we can enforce that it's not the whole object being assigned.
if (node.callee.property.name === 'format' || node.callee.property.name.startsWith('format')) {
// Good: calling a format method
return;
}
// If they are assigning the result of a non-format method, warn
context.report({
node,
messageId: 'message',
});
}
parent = parent.parent;
}
}
},
};
},
},
};
// Helper functions
function isInComment(node) {
return false;
}

View File

@@ -17,7 +17,7 @@
const presenterContract = require('./presenter-contract');
const rscBoundaryRules = require('./rsc-boundary-rules');
const templatePurityRules = require('./template-purity-rules');
const displayObjectRules = require('./display-object-rules');
const displayObjectRules = require('./formatter-rules');
const pageQueryRules = require('./page-query-rules');
const servicesRules = require('./services-rules');
const clientOnlyRules = require('./client-only-rules');
@@ -30,7 +30,6 @@ const mutationContract = require('./mutation-contract');
const serverActionsMustUseMutations = require('./server-actions-must-use-mutations');
const viewDataLocation = require('./view-data-location');
const viewDataBuilderContract = require('./view-data-builder-contract');
const viewModelBuilderContract = require('./view-model-builder-contract');
const singleExportPerFile = require('./single-export-per-file');
const filenameMatchesExport = require('./filename-matches-export');
const pageQueryMustUseBuilders = require('./page-query-must-use-builders');
@@ -46,6 +45,11 @@ const servicesImplementContract = require('./services-implement-contract');
const serverActionsReturnResult = require('./server-actions-return-result');
const serverActionsInterface = require('./server-actions-interface');
const noDisplayObjectsInUi = require('./no-display-objects-in-ui');
const viewDataBuilderImplements = require('./view-data-builder-implements');
const viewDataBuilderImports = require('./view-data-builder-imports');
const viewDataImplements = require('./view-data-implements');
const viewModelImplements = require('./view-model-implements');
const viewModelTaxonomy = require('./view-model-taxonomy');
module.exports = {
rules: {
@@ -79,6 +83,7 @@ module.exports = {
// Display Object Rules
'display-no-domain-models': displayObjectRules['no-io-in-display-objects'],
'display-no-business-logic': displayObjectRules['no-non-class-display-exports'],
'formatters-must-return-primitives': displayObjectRules['formatters-must-return-primitives'],
'no-display-objects-in-ui': noDisplayObjectsInUi,
// Page Query Rules
@@ -128,9 +133,13 @@ module.exports = {
// View Data Rules
'view-data-location': viewDataLocation,
'view-data-builder-contract': viewDataBuilderContract,
'view-data-builder-implements': viewDataBuilderImplements,
'view-data-builder-imports': viewDataBuilderImports,
'view-data-implements': viewDataImplements,
// View Model Rules
'view-model-builder-contract': viewModelBuilderContract,
'view-model-implements': viewModelImplements,
'view-model-taxonomy': viewModelTaxonomy,
// Single Export Rules
'single-export-per-file': singleExportPerFile,
@@ -210,6 +219,7 @@ module.exports = {
// Display Objects
'gridpilot-rules/display-no-domain-models': 'error',
'gridpilot-rules/display-no-business-logic': 'error',
'gridpilot-rules/formatters-must-return-primitives': 'error',
'gridpilot-rules/no-display-objects-in-ui': 'error',
// Page Queries
@@ -253,9 +263,14 @@ module.exports = {
// View Data
'gridpilot-rules/view-data-location': 'error',
'gridpilot-rules/view-data-builder-contract': 'error',
'gridpilot-rules/view-data-builder-implements': 'error',
'gridpilot-rules/view-data-builder-imports': 'error',
'gridpilot-rules/view-data-implements': 'error',
// View Model
'gridpilot-rules/view-model-builder-contract': 'error',
'gridpilot-rules/view-model-builder-implements': 'error',
'gridpilot-rules/view-model-implements': 'error',
// Single Export Rules
'gridpilot-rules/single-export-per-file': 'error',

View File

@@ -1,6 +1,6 @@
/**
* ESLint rules for Template Purity Guardrails
*
*
* Enforces pure template components without business logic
*/
@@ -14,17 +14,21 @@ module.exports = {
category: 'Template Purity',
},
messages: {
message: 'ViewModels or DisplayObjects import forbidden in templates - see apps/website/lib/contracts/view-data/ViewData.ts',
message: 'ViewModels or DisplayObjects import forbidden in templates - see apps/website/lib/contracts/view-data/ViewData.ts. Templates should only receive logic-rich ViewModels via props from ClientWrappers, never import them directly.',
},
},
create(context) {
return {
ImportDeclaration(node) {
const importPath = node.source.value;
if ((importPath.includes('@/lib/view-models/') ||
// Templates are allowed to import ViewModels for TYPE-ONLY usage (interface/type)
// but not for instantiation or logic. However, to be safe, we forbid direct imports
// and suggest passing them through ClientWrappers.
if ((importPath.includes('@/lib/view-models/') ||
importPath.includes('@/lib/presenters/') ||
importPath.includes('@/lib/display-objects/')) &&
!isInComment(node)) {
!isInComment(node) &&
node.importKind !== 'type') {
context.report({
node,
messageId: 'message',

View File

@@ -0,0 +1,168 @@
/**
* Test script for view-model-taxonomy rule
*/
const rule = require('./view-model-taxonomy.js');
const { Linter } = require('eslint');
const linter = new Linter();
// Register the plugin
linter.defineRule('gridpilot-rules/view-model-taxonomy', rule);
// Test 1: DTO import should be caught
const codeWithDtoImport = `
import type { RecordEngagementOutputDTO } from '@/lib/types/generated/RecordEngagementOutputDTO';
export class RecordEngagementOutputViewModel {
eventId: string;
engagementWeight: number;
constructor(dto: RecordEngagementOutputDTO) {
this.eventId = dto.eventId;
this.engagementWeight = dto.engagementWeight;
}
}
`;
// Test 2: Inline ViewData interface should be caught
const codeWithInlineViewData = `
export interface RaceViewData {
id: string;
name: string;
}
export class RaceViewModel {
private readonly data: RaceViewData;
constructor(data: RaceViewData) {
this.data = data;
}
}
`;
// Test 3: Valid code (no violations)
const validCode = `
import { RaceViewData } from '@/lib/view-data/RaceViewData';
export class RaceViewModel {
private readonly data: RaceViewData;
constructor(data: RaceViewData) {
this.data = data;
}
}
`;
// Test 4: Disallowed import from service layer (should be caught)
const codeWithServiceImport = `
import { SomeService } from '@/lib/services/SomeService';
export class RaceViewModel {
private readonly service: SomeService;
constructor(service: SomeService) {
this.service = service;
}
}
`;
// Test 5: Strict import violation (import from non-allowed path)
const codeWithStrictImportViolation = `
import { SomeOtherThing } from '@/lib/other/SomeOtherThing';
export class RaceViewModel {
private readonly thing: SomeOtherThing;
constructor(thing: SomeOtherThing) {
this.thing = thing;
}
}
`;
console.log('=== Test 1: DTO import ===');
const messages1 = linter.verify(codeWithDtoImport, {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
rules: {
'gridpilot-rules/view-model-taxonomy': 'error',
},
});
console.log('Messages:', messages1);
console.log('Expected: Should have 1 error for DTO import');
console.log('Actual: ' + messages1.length + ' error(s)');
console.log('');
console.log('=== Test 2: Inline ViewData interface ===');
const messages2 = linter.verify(codeWithInlineViewData, {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
rules: {
'gridpilot-rules/view-model-taxonomy': 'error',
},
});
console.log('Messages:', messages2);
console.log('Expected: Should have 1 error for inline ViewData interface');
console.log('Actual: ' + messages2.length + ' error(s)');
console.log('');
console.log('=== Test 3: Valid code ===');
const messages3 = linter.verify(validCode, {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
rules: {
'gridpilot-rules/view-model-taxonomy': 'error',
},
});
console.log('Messages:', messages3);
console.log('Expected: Should have 0 errors');
console.log('Actual: ' + messages3.length + ' error(s)');
console.log('');
console.log('=== Test 4: Service import (should be caught) ===');
const messages4 = linter.verify(codeWithServiceImport, {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
rules: {
'gridpilot-rules/view-model-taxonomy': 'error',
},
});
console.log('Messages:', messages4);
console.log('Expected: Should have 1 error for service import');
console.log('Actual: ' + messages4.length + ' error(s)');
console.log('');
console.log('=== Test 5: Strict import violation ===');
const messages5 = linter.verify(codeWithStrictImportViolation, {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
rules: {
'gridpilot-rules/view-model-taxonomy': 'error',
},
});
console.log('Messages:', messages5);
console.log('Expected: Should have 1 error for strict import violation');
console.log('Actual: ' + messages5.length + ' error(s)');
console.log('');
console.log('=== Summary ===');
console.log('Test 1 (DTO import): ' + (messages1.length === 1 ? '✓ PASS' : '✗ FAIL'));
console.log('Test 2 (Inline ViewData): ' + (messages2.length === 1 ? '✓ PASS' : '✗ FAIL'));
console.log('Test 3 (Valid code): ' + (messages3.length === 0 ? '✓ PASS' : '✗ FAIL'));
console.log('Test 4 (Service import): ' + (messages4.length === 1 ? '✓ PASS' : '✗ FAIL'));
console.log('Test 5 (Strict import): ' + (messages5.length === 1 ? '✓ PASS' : '✗ FAIL'));

View File

@@ -4,8 +4,9 @@
* View Data Builders must:
* 1. Be classes named *ViewDataBuilder
* 2. Have a static build() method
* 3. Accept API DTO as parameter (named 'apiDto', NOT 'pageDto')
* 4. Return View Data
* 3. Use 'satisfies ViewDataBuilder<...>' for static enforcement
* 4. Accept API DTO as parameter (named 'apiDto')
* 5. Return View Data
*/
module.exports = {
@@ -20,7 +21,8 @@ module.exports = {
schema: [],
messages: {
notAClass: 'View Data Builders must be classes named *ViewDataBuilder',
missingBuildMethod: 'View Data Builders must have a static build() method',
missingStaticBuild: 'View Data Builders must have a static build() method',
missingSatisfies: 'View Data Builders must use "satisfies ViewDataBuilder<...>" for static type enforcement',
invalidBuildSignature: 'build() method must accept API DTO and return View Data',
wrongParameterName: 'Parameter must be named "apiDto", not "pageDto" or other names',
},
@@ -32,7 +34,8 @@ module.exports = {
if (!isInViewDataBuilders) return {};
let hasBuildMethod = false;
let hasStaticBuild = false;
let hasSatisfies = false;
let hasCorrectSignature = false;
let hasCorrectParameterName = false;
@@ -49,28 +52,28 @@ module.exports = {
}
// Check for static build method
const buildMethod = node.body.body.find(member =>
const staticBuild = node.body.body.find(member =>
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
member.key.name === 'build' &&
member.static === true
);
if (buildMethod) {
hasBuildMethod = true;
if (staticBuild) {
hasStaticBuild = true;
// Check signature - should have at least one parameter
if (buildMethod.value &&
buildMethod.value.params &&
buildMethod.value.params.length > 0) {
if (staticBuild.value &&
staticBuild.value.params &&
staticBuild.value.params.length > 0) {
hasCorrectSignature = true;
// Check parameter name
const firstParam = buildMethod.value.params[0];
const firstParam = staticBuild.value.params[0];
if (firstParam.type === 'Identifier' && firstParam.name === 'apiDto') {
hasCorrectParameterName = true;
} else if (firstParam.type === 'Identifier' && firstParam.name === 'pageDto') {
// Report specific error for pageDto
} else if (firstParam.type === 'Identifier' && (firstParam.name === 'pageDto' || firstParam.name === 'dto')) {
// Report specific error for wrong names
context.report({
node: firstParam,
messageId: 'wrongParameterName',
@@ -80,23 +83,35 @@ module.exports = {
}
},
// Check for satisfies expression
TSSatisfiesExpression(node) {
if (node.typeAnnotation &&
node.typeAnnotation.type === 'TSTypeReference' &&
node.typeAnnotation.typeName.name === 'ViewDataBuilder') {
hasSatisfies = true;
}
},
'Program:exit'() {
if (!hasBuildMethod) {
if (!hasStaticBuild) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingBuildMethod',
messageId: 'missingStaticBuild',
});
} else if (!hasCorrectSignature) {
}
if (!hasSatisfies) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingSatisfies',
});
}
if (hasStaticBuild && !hasCorrectSignature) {
context.report({
node: context.getSourceCode().ast,
messageId: 'invalidBuildSignature',
});
} else if (!hasCorrectParameterName) {
// Only report if not already reported for pageDto
context.report({
node: context.getSourceCode().ast,
messageId: 'wrongParameterName',
});
}
},
};

View File

@@ -0,0 +1,70 @@
/**
* ESLint rule to enforce View Data Builder contract implementation
*
* View Data Builders in lib/builders/view-data/ must:
* 1. Be classes named *ViewDataBuilder
* 2. Have a static build() method
*
* Note: 'implements' is deprecated in favor of 'satisfies' checked in view-data-builder-contract.js
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce View Data Builder contract implementation',
category: 'Builders',
recommended: true,
},
fixable: null,
schema: [],
messages: {
notAClass: 'View Data Builders must be classes named *ViewDataBuilder',
missingBuildMethod: 'View Data Builders must have a static build() method',
},
},
create(context) {
const filename = context.getFilename();
const isInViewDataBuilders = filename.includes('/lib/builders/view-data/');
if (!isInViewDataBuilders) return {};
let hasBuildMethod = false;
return {
// Check class declaration
ClassDeclaration(node) {
const className = node.id?.name;
if (!className || !className.endsWith('ViewDataBuilder')) {
context.report({
node,
messageId: 'notAClass',
});
}
// Check for static build method
const buildMethod = node.body.body.find(member =>
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
member.key.name === 'build' &&
member.static === true
);
if (buildMethod) {
hasBuildMethod = true;
}
},
'Program:exit'() {
if (!hasBuildMethod) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingBuildMethod',
});
}
},
};
},
};

View File

@@ -0,0 +1,81 @@
/**
* ESLint rule to enforce ViewDataBuilder import paths
*
* ViewDataBuilders in lib/builders/view-data/ must:
* 1. Import DTO types from lib/types/generated/
* 2. Import ViewData types from lib/view-data/
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce ViewDataBuilder import paths',
category: 'Builders',
recommended: true,
},
fixable: null,
schema: [],
messages: {
invalidDtoImport: 'ViewDataBuilders must import DTO types from lib/types/generated/, not from {{importPath}}',
invalidViewDataImport: 'ViewDataBuilders must import ViewData types from lib/view-data/, not from {{importPath}}',
noViewModelsInBuilders: 'ViewDataBuilders must not import ViewModels. ViewModels are client-only logic wrappers. Builders should only produce plain ViewData.',
missingDtoImport: 'ViewDataBuilders must import DTO types from lib/types/generated/',
missingViewDataImport: 'ViewDataBuilders must import ViewData types from lib/view-data/',
},
},
create(context) {
const filename = context.getFilename();
const isInViewDataBuilders = filename.includes('/lib/builders/view-data/');
if (!isInViewDataBuilders) return {};
let hasDtoImport = false;
let hasViewDataImport = false;
let dtoImportPath = null;
let viewDataImportPath = null;
return {
ImportDeclaration(node) {
const importPath = node.source.value;
// Check for DTO imports (should be from lib/types/generated/)
if (importPath.includes('/lib/types/') || importPath.includes('@/lib/types/') || importPath.includes('../../types/')) {
if (!importPath.includes('/lib/types/generated/') && !importPath.includes('@/lib/types/generated/') && !importPath.includes('../../types/generated/')) {
dtoImportPath = importPath;
context.report({
node,
messageId: 'invalidDtoImport',
data: { importPath },
});
} else {
hasDtoImport = true;
}
}
// Check for ViewData imports (should be from lib/view-data/)
if (importPath.includes('/lib/view-data/') || importPath.includes('@/lib/view-data/') || importPath.includes('../../view-data/')) {
hasViewDataImport = true;
viewDataImportPath = importPath;
}
},
'Program:exit'() {
if (!hasDtoImport) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingDtoImport',
});
}
if (!hasViewDataImport) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingViewDataImport',
});
}
},
};
},
};

View File

@@ -0,0 +1,139 @@
/**
* ESLint rule to enforce ViewData contract implementation
*
* ViewData files in lib/view-data/ must:
* 1. Be interfaces or types named *ViewData
* 2. Extend the ViewData interface from contracts
* 3. NOT contain ViewModels (ViewModels are for ClientWrappers/Hooks)
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce ViewData contract implementation',
category: 'Contracts',
recommended: true,
},
fixable: null,
schema: [],
messages: {
notAnInterface: 'ViewData files must be interfaces or types named *ViewData',
missingExtends: 'ViewData must extend the ViewData interface from lib/contracts/view-data/ViewData.ts',
noViewModelsInViewData: 'ViewData must not contain ViewModels. ViewData is for plain JSON data (DTOs) passed through SSR. Use ViewModels in ClientWrappers or Hooks instead.',
},
},
create(context) {
const filename = context.getFilename();
const isInViewData = filename.includes('/lib/view-data/') && !filename.includes('/contracts/');
if (!isInViewData) return {};
let hasViewDataExtends = false;
let hasCorrectName = false;
return {
// Check for ViewModel imports
ImportDeclaration(node) {
if (!isInViewData) return;
const importPath = node.source.value;
if (importPath.includes('/lib/view-models/')) {
context.report({
node,
messageId: 'noViewModelsInViewData',
});
}
},
// Check interface declarations
TSInterfaceDeclaration(node) {
const interfaceName = node.id?.name;
if (interfaceName && interfaceName.endsWith('ViewData')) {
hasCorrectName = true;
// Check for ViewModel usage in properties
node.body.body.forEach(member => {
if (member.type === 'TSPropertySignature' && member.typeAnnotation) {
const typeAnnotation = member.typeAnnotation.typeAnnotation;
if (isViewModelType(typeAnnotation)) {
context.report({
node: member,
messageId: 'noViewModelsInViewData',
});
}
}
});
// Check if it extends ViewData
if (node.extends && node.extends.length > 0) {
for (const ext of node.extends) {
// Use context.getSourceCode().getText(ext) to be absolutely sure
const extendsText = context.getSourceCode().getText(ext).trim();
// We check for 'ViewData' but must be careful not to match 'SomethingViewData'
// unless it's exactly 'ViewData' or part of a qualified name
if (extendsText === 'ViewData' ||
extendsText.endsWith('.ViewData') ||
extendsText.startsWith('ViewData<') ||
extendsText.startsWith('ViewData ') ||
/\bViewData\b/.test(extendsText)) { // Use regex for word boundary
hasViewDataExtends = true;
}
}
}
}
},
// Check type alias declarations
TSTypeAliasDeclaration(node) {
const typeName = node.id?.name;
if (typeName && typeName.endsWith('ViewData')) {
hasCorrectName = true;
// For type aliases, check if it's an intersection with ViewData
if (node.typeAnnotation && node.typeAnnotation.type === 'TSIntersectionType') {
for (const type of node.typeAnnotation.types) {
if (type.type === 'TSTypeReference' &&
type.typeName &&
type.typeName.type === 'Identifier' &&
type.typeName.name === 'ViewData') {
hasViewDataExtends = true;
}
}
}
}
},
'Program:exit'() {
// Only report if we are in a file that should be a ViewData
// and we didn't find a valid declaration
const baseName = filename.split('/').pop();
// All files in lib/view-data/ must end with ViewData.ts
if (baseName && !baseName.endsWith('ViewData.ts') && !baseName.endsWith('ViewData.tsx')) {
context.report({
node: context.getSourceCode().ast,
messageId: 'notAnInterface',
});
return;
}
if (baseName && (baseName.endsWith('ViewData.ts') || baseName.endsWith('ViewData.tsx'))) {
if (!hasCorrectName) {
context.report({
node: context.getSourceCode().ast,
messageId: 'notAnInterface',
});
} else if (!hasViewDataExtends) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingExtends',
});
}
}
},
};
},
};

View File

@@ -1,84 +0,0 @@
/**
* ESLint rule to enforce View Model Builder contract
*
* View Model Builders must:
* 1. Be classes named *ViewModelBuilder
* 2. Have a static build() method
* 3. Accept View Data as parameter
* 4. Return View Model
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce View Model Builder contract',
category: 'Builders',
recommended: true,
},
fixable: null,
schema: [],
messages: {
notAClass: 'View Model Builders must be classes named *ViewModelBuilder',
missingBuildMethod: 'View Model Builders must have a static build() method',
invalidBuildSignature: 'build() method must accept View Data and return View Model',
},
},
create(context) {
const filename = context.getFilename();
const isInViewModelBuilders = filename.includes('/lib/builders/view-models/');
if (!isInViewModelBuilders) return {};
let hasBuildMethod = false;
let hasCorrectSignature = false;
return {
// Check class declaration
ClassDeclaration(node) {
const className = node.id?.name;
if (!className || !className.endsWith('ViewModelBuilder')) {
context.report({
node,
messageId: 'notAClass',
});
}
// Check for static build method
const buildMethod = node.body.body.find(member =>
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
member.key.name === 'build' &&
member.static === true
);
if (buildMethod) {
hasBuildMethod = true;
// Check signature - should have at least one parameter
if (buildMethod.value &&
buildMethod.value.params &&
buildMethod.value.params.length > 0) {
hasCorrectSignature = true;
}
}
},
'Program:exit'() {
if (!hasBuildMethod) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingBuildMethod',
});
} else if (!hasCorrectSignature) {
context.report({
node: context.getSourceCode().ast,
messageId: 'invalidBuildSignature',
});
}
},
};
},
};

View File

@@ -0,0 +1,65 @@
/**
* ESLint rule to enforce ViewModel contract implementation
*
* ViewModel files in lib/view-models/ must:
* 1. Be classes named *ViewModel
* 2. Extend the ViewModel class from contracts
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce ViewModel contract implementation',
category: 'Contracts',
recommended: true,
},
fixable: null,
schema: [],
messages: {
notAClass: 'ViewModel files must be classes named *ViewModel',
missingExtends: 'ViewModel must extend the ViewModel class from lib/contracts/view-models/ViewModel.ts',
},
},
create(context) {
const filename = context.getFilename();
const isInViewModels = filename.includes('/lib/view-models/');
if (!isInViewModels) return {};
let hasViewModelExtends = false;
let hasCorrectName = false;
return {
// Check class declarations
ClassDeclaration(node) {
const className = node.id?.name;
if (className && className.endsWith('ViewModel')) {
hasCorrectName = true;
// Check if it extends ViewModel
if (node.superClass && node.superClass.type === 'Identifier' &&
node.superClass.name === 'ViewModel') {
hasViewModelExtends = true;
}
}
},
'Program:exit'() {
if (!hasCorrectName) {
context.report({
node: context.getSourceCode().ast,
messageId: 'notAClass',
});
} else if (!hasViewModelExtends) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingExtends',
});
}
},
};
},
};

View File

@@ -0,0 +1,106 @@
/**
* ESLint rule to enforce ViewModel and Builder architectural boundaries
*
* Rules:
* 1. ViewModels/Builders MUST NOT contain the word "DTO" in identifiers
* 2. ViewModels/Builders MUST NOT define inline DTO interfaces
* 3. ViewModels/Builders MUST NOT import from DTO paths (except generated types in Builders)
* 4. ViewModels MUST NOT define ViewData interfaces
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce ViewModel and Builder architectural boundaries',
category: 'Architecture',
recommended: true,
},
fixable: null,
schema: [],
messages: {
noDtoInViewModel: 'ViewModels and Builders must not use the word "DTO" in identifiers. DTOs belong to the API/Service layer. Use plain properties or ViewData types.',
noDtoImport: 'ViewModels must not import from DTO paths. DTOs belong to lib/types/generated/. Import from lib/view-data/ or use plain properties instead.',
noViewDataDefinition: 'ViewData must not be defined within ViewModel files. Import them from lib/view-data/ instead.',
noInlineDtoDefinition: 'DTOs must not be defined inline. Use generated types from lib/types/generated/ and import them.',
},
},
create(context) {
const filename = context.getFilename();
const isInViewModels = filename.includes('/lib/view-models/');
const isInBuilders = filename.includes('/lib/builders/');
if (!isInViewModels && !isInBuilders) return {};
return {
// Check for "DTO" in any identifier
Identifier(node) {
const name = node.name.toUpperCase();
if (name === 'DTO' || name.endsWith('DTO')) {
// Exception: Allow DTO in type references in Builders (for satisfies/input)
if (isInBuilders && (node.parent.type === 'TSTypeReference' || node.parent.type === 'TSQualifiedName')) {
return;
}
context.report({
node,
messageId: 'noDtoInViewModel',
});
}
},
// Check for imports from DTO paths
ImportDeclaration(node) {
const importPath = node.source.value;
// ViewModels are never allowed to import DTOs
if (isInViewModels && (
importPath.includes('/lib/types/generated/') ||
importPath.includes('/lib/dtos/') ||
importPath.includes('/lib/api/') ||
importPath.includes('/lib/services/')
)) {
context.report({
node,
messageId: 'noDtoImport',
});
}
},
// Check for ViewData definitions in ViewModels
TSInterfaceDeclaration(node) {
if (isInViewModels && node.id && node.id.name && node.id.name.endsWith('ViewData')) {
context.report({
node,
messageId: 'noViewDataDefinition',
});
}
// Check for inline DTO definitions in both ViewModels and Builders
if (node.id && node.id.name && node.id.name.toUpperCase().includes('DTO')) {
context.report({
node,
messageId: 'noInlineDtoDefinition',
});
}
},
TSTypeAliasDeclaration(node) {
if (isInViewModels && node.id && node.id.name && node.id.name.endsWith('ViewData')) {
context.report({
node,
messageId: 'noViewDataDefinition',
});
}
// Check for inline DTO definitions
if (node.id && node.id.name && node.id.name.toUpperCase().includes('DTO')) {
context.report({
node,
messageId: 'noInlineDtoDefinition',
});
}
},
};
},
};

View File

@@ -1,10 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { LeagueScheduleRaceViewModel, LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import { useQuery } from '@tanstack/react-query';
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
@@ -15,8 +15,8 @@ function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
id: race.id,
name: race.name,
scheduledAt,
formattedDate: DateDisplay.formatShort(scheduledAt),
formattedTime: DateDisplay.formatTime(scheduledAt),
formattedDate: DateFormatter.formatShort(scheduledAt),
formattedTime: DateFormatter.formatTime(scheduledAt),
isPast,
isUpcoming: !isPast,
status: isPast ? 'completed' : 'scheduled',

View File

@@ -1,13 +1,13 @@
import { usePageData } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { usePageData } from '@/lib/page/usePageData';
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
import { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
@@ -18,8 +18,8 @@ function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
id: race.id,
name: race.name,
scheduledAt,
formattedDate: DateDisplay.formatShort(scheduledAt),
formattedTime: DateDisplay.formatTime(scheduledAt),
formattedDate: DateFormatter.formatShort(scheduledAt),
formattedTime: DateFormatter.formatTime(scheduledAt),
isPast,
isUpcoming: !isPast,
status: isPast ? 'completed' : 'scheduled',

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import type { DashboardStatsResponseDto } from '../../types/generated/DashboardStatsResponseDTO';
import type { AdminDashboardViewData } from '../../view-data/AdminDashboardViewData';
import { AdminDashboardViewDataBuilder } from './AdminDashboardViewDataBuilder';
describe('AdminDashboardViewDataBuilder', () => {
it('should transform DashboardStatsResponseDto to AdminDashboardViewData correctly', () => {
const apiDto: DashboardStatsResponseDto = {
totalUsers: 1000,
activeUsers: 800,
suspendedUsers: 50,
deletedUsers: 150,
systemAdmins: 5,
recentLogins: 200,
newUsersToday: 10,
};
const result: AdminDashboardViewData = AdminDashboardViewDataBuilder.build(apiDto);
expect(result.stats).toEqual({
totalUsers: 1000,
activeUsers: 800,
suspendedUsers: 50,
deletedUsers: 150,
systemAdmins: 5,
recentLogins: 200,
newUsersToday: 10,
});
});
it('should not modify the input DTO', () => {
const apiDto: DashboardStatsResponseDto = {
totalUsers: 1000,
activeUsers: 800,
suspendedUsers: 50,
deletedUsers: 150,
systemAdmins: 5,
recentLogins: 200,
newUsersToday: 10,
};
const originalDto = { ...apiDto };
AdminDashboardViewDataBuilder.build(apiDto);
expect(apiDto).toEqual(originalDto);
});
});

View File

@@ -1,14 +1,15 @@
import type { DashboardStats } from '@/lib/types/admin';
import type { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
import type { ViewDataBuilder } from '../../contracts/builders/ViewDataBuilder';
import type { DashboardStatsResponseDto } from '../../types/generated/DashboardStatsResponseDTO';
import type { AdminDashboardViewData } from '../../view-data/AdminDashboardViewData';
/**
* AdminDashboardViewDataBuilder
*
* Transforms DashboardStats API DTO into AdminDashboardViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
export class AdminDashboardViewDataBuilder {
static build(apiDto: DashboardStats): AdminDashboardViewData {
/**
* Transform API DTO to ViewData
*
* @param apiDto - The DTO from the service
* @returns ViewData for the admin dashboard
*/
public static build(apiDto: DashboardStatsResponseDto): AdminDashboardViewData {
return {
stats: {
totalUsers: apiDto.totalUsers,
@@ -21,4 +22,6 @@ export class AdminDashboardViewDataBuilder {
},
};
}
}
}
AdminDashboardViewDataBuilder satisfies ViewDataBuilder<DashboardStatsResponseDto, AdminDashboardViewData>;

View File

@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import { AdminUsersViewDataBuilder } from './AdminUsersViewDataBuilder';
import type { UserListResponseDTO } from '@/lib/types/generated/UserListResponseDTO';
describe('AdminUsersViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform UserListResponseDTO to AdminUsersViewData correctly', () => {
const userListResponse: UserListResponseDTO = {
users: [
{
id: 'user-1',
email: 'admin@example.com',
displayName: 'Admin User',
roles: ['admin', 'owner'],
status: 'active',
isSystemAdmin: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-15T12:00:00.000Z',
lastLoginAt: '2024-01-20T10:00:00.000Z',
primaryDriverId: 'driver-123',
},
{
id: 'user-2',
email: 'user@example.com',
displayName: 'Regular User',
roles: ['member'],
status: 'active',
isSystemAdmin: false,
createdAt: '2024-01-05T00:00:00.000Z',
updatedAt: '2024-01-10T08:00:00.000Z',
lastLoginAt: '2024-01-18T14:00:00.000Z',
primaryDriverId: 'driver-456',
},
],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
};
const result = AdminUsersViewDataBuilder.build(userListResponse);
expect(result.users).toHaveLength(2);
expect(result.users[0]).toEqual({
id: 'user-1',
email: 'admin@example.com',
displayName: 'Admin User',
roles: ['admin', 'owner'],
status: 'active',
isSystemAdmin: true,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-15T12:00:00.000Z',
lastLoginAt: '2024-01-20T10:00:00.000Z',
primaryDriverId: 'driver-123',
});
expect(result.total).toBe(2);
});
it('should calculate derived fields correctly', () => {
const userListResponse: UserListResponseDTO = {
users: [
{
id: 'user-1',
email: 'user1@example.com',
displayName: 'User 1',
roles: ['member'],
status: 'active',
isSystemAdmin: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-15T12:00:00.000Z',
},
{
id: 'user-2',
email: 'user2@example.com',
displayName: 'User 2',
roles: ['member'],
status: 'active',
isSystemAdmin: false,
createdAt: '2024-01-02T00:00:00.000Z',
updatedAt: '2024-01-16T12:00:00.000Z',
},
{
id: 'user-3',
email: 'user3@example.com',
displayName: 'User 3',
roles: ['admin'],
status: 'suspended',
isSystemAdmin: true,
createdAt: '2024-01-03T00:00:00.000Z',
updatedAt: '2024-01-17T12:00:00.000Z',
},
],
total: 3,
page: 1,
limit: 10,
totalPages: 1,
};
const result = AdminUsersViewDataBuilder.build(userListResponse);
expect(result.activeUserCount).toBe(2);
expect(result.adminCount).toBe(1);
});
});
});

View File

@@ -1,27 +1,22 @@
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.
*/
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { UserListResponseDTO } from '@/lib/types/generated/UserListResponseDTO';
import type { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
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,
public static build(apiDto: UserListResponseDTO): AdminUsersViewData {
const users = apiDto.users.map(u => ({
id: u.id,
email: u.email,
displayName: u.displayName,
roles: u.roles,
status: u.status,
isSystemAdmin: u.isSystemAdmin,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
lastLoginAt: u.lastLoginAt,
primaryDriverId: u.primaryDriverId,
}));
return {
@@ -30,9 +25,10 @@ export class AdminUsersViewDataBuilder {
page: apiDto.page,
limit: apiDto.limit,
totalPages: apiDto.totalPages,
// Pre-computed derived values for template
activeUserCount: users.filter(u => u.status === 'active').length,
adminCount: users.filter(u => u.isSystemAdmin).length,
};
}
}
}
AdminUsersViewDataBuilder satisfies ViewDataBuilder<UserListResponseDTO, AdminUsersViewData>;

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { AnalyticsDashboardViewDataBuilder } from './AnalyticsDashboardViewDataBuilder';
import type { GetDashboardDataOutputDTO } from '@/lib/types/generated/GetDashboardDataOutputDTO';
describe('AnalyticsDashboardViewDataBuilder', () => {
it('builds ViewData from GetDashboardDataOutputDTO', () => {
const inputDto: GetDashboardDataOutputDTO = {
totalUsers: 100,
activeUsers: 40,
totalRaces: 10,
totalLeagues: 5,
};
const viewData = AnalyticsDashboardViewDataBuilder.build(inputDto);
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 inputDto: GetDashboardDataOutputDTO = {
totalUsers: 200,
activeUsers: 50,
totalRaces: 0,
totalLeagues: 0,
};
const viewData = AnalyticsDashboardViewDataBuilder.build(inputDto);
expect(viewData.metrics.userEngagementRate).toBeCloseTo(25);
expect(viewData.metrics.formattedEngagementRate).toBe('25.0%');
});
it('handles zero users safely', () => {
const inputDto: GetDashboardDataOutputDTO = {
totalUsers: 0,
activeUsers: 0,
totalRaces: 0,
totalLeagues: 0,
};
const viewData = AnalyticsDashboardViewDataBuilder.build(inputDto);
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,32 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { GetDashboardDataOutputDTO } from '@/lib/types/generated/GetDashboardDataOutputDTO';
import type { AnalyticsDashboardViewData } from '@/lib/view-data/AnalyticsDashboardViewData';
export class AnalyticsDashboardViewDataBuilder {
public static build(apiDto: GetDashboardDataOutputDTO): AnalyticsDashboardViewData {
const totalUsers = apiDto.totalUsers;
const activeUsers = apiDto.activeUsers;
const totalRaces = apiDto.totalRaces;
const totalLeagues = apiDto.totalLeagues;
const userEngagementRate = totalUsers > 0 ? (activeUsers / totalUsers) * 100 : 0;
const formattedEngagementRate = `${userEngagementRate.toFixed(1)}%`;
const activityLevel = userEngagementRate > 70 ? 'High' : userEngagementRate > 40 ? 'Medium' : 'Low';
return {
metrics: {
totalUsers,
activeUsers,
totalRaces,
totalLeagues,
userEngagementRate,
formattedEngagementRate,
activityLevel,
},
};
}
}
AnalyticsDashboardViewDataBuilder satisfies ViewDataBuilder<GetDashboardDataOutputDTO, AnalyticsDashboardViewData>;

View File

@@ -0,0 +1,249 @@
import { describe, it, expect } from 'vitest';
import { LoginViewDataBuilder } from './LoginViewDataBuilder';
import { SignupViewDataBuilder } from './SignupViewDataBuilder';
import { ForgotPasswordViewDataBuilder } from './ForgotPasswordViewDataBuilder';
import { ResetPasswordViewDataBuilder } from './ResetPasswordViewDataBuilder';
import type { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
import type { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
import type { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
import type { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
describe('Auth View Data - Cross-Builder Consistency', () => {
describe('common patterns', () => {
it('should all initialize with isSubmitting false', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.isSubmitting).toBe(false);
expect(signupResult.isSubmitting).toBe(false);
expect(forgotPasswordResult.isSubmitting).toBe(false);
expect(resetPasswordResult.isSubmitting).toBe(false);
});
it('should all initialize with submitError undefined', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.submitError).toBeUndefined();
expect(signupResult.submitError).toBeUndefined();
expect(forgotPasswordResult.submitError).toBeUndefined();
expect(resetPasswordResult.submitError).toBeUndefined();
});
it('should all initialize formState.isValid as true', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.formState.isValid).toBe(true);
expect(signupResult.formState.isValid).toBe(true);
expect(forgotPasswordResult.formState.isValid).toBe(true);
expect(resetPasswordResult.formState.isValid).toBe(true);
});
it('should all initialize formState.isSubmitting as false', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.formState.isSubmitting).toBe(false);
expect(signupResult.formState.isSubmitting).toBe(false);
expect(forgotPasswordResult.formState.isSubmitting).toBe(false);
expect(resetPasswordResult.formState.isSubmitting).toBe(false);
});
it('should all initialize formState.submitError as undefined', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.formState.submitError).toBeUndefined();
expect(signupResult.formState.submitError).toBeUndefined();
expect(forgotPasswordResult.formState.submitError).toBeUndefined();
expect(resetPasswordResult.formState.submitError).toBeUndefined();
});
it('should all initialize formState.submitCount as 0', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.formState.submitCount).toBe(0);
expect(signupResult.formState.submitCount).toBe(0);
expect(forgotPasswordResult.formState.submitCount).toBe(0);
expect(resetPasswordResult.formState.submitCount).toBe(0);
});
it('should all initialize form fields with touched false', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.formState.fields.email.touched).toBe(false);
expect(loginResult.formState.fields.password.touched).toBe(false);
expect(loginResult.formState.fields.rememberMe.touched).toBe(false);
expect(signupResult.formState.fields.firstName.touched).toBe(false);
expect(signupResult.formState.fields.lastName.touched).toBe(false);
expect(signupResult.formState.fields.email.touched).toBe(false);
expect(signupResult.formState.fields.password.touched).toBe(false);
expect(signupResult.formState.fields.confirmPassword.touched).toBe(false);
expect(forgotPasswordResult.formState.fields.email.touched).toBe(false);
expect(resetPasswordResult.formState.fields.newPassword.touched).toBe(false);
expect(resetPasswordResult.formState.fields.confirmPassword.touched).toBe(false);
});
it('should all initialize form fields with validating false', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.formState.fields.email.validating).toBe(false);
expect(loginResult.formState.fields.password.validating).toBe(false);
expect(loginResult.formState.fields.rememberMe.validating).toBe(false);
expect(signupResult.formState.fields.firstName.validating).toBe(false);
expect(signupResult.formState.fields.lastName.validating).toBe(false);
expect(signupResult.formState.fields.email.validating).toBe(false);
expect(signupResult.formState.fields.password.validating).toBe(false);
expect(signupResult.formState.fields.confirmPassword.validating).toBe(false);
expect(forgotPasswordResult.formState.fields.email.validating).toBe(false);
expect(resetPasswordResult.formState.fields.newPassword.validating).toBe(false);
expect(resetPasswordResult.formState.fields.confirmPassword.validating).toBe(false);
});
it('should all initialize form fields with error undefined', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.formState.fields.email.error).toBeUndefined();
expect(loginResult.formState.fields.password.error).toBeUndefined();
expect(loginResult.formState.fields.rememberMe.error).toBeUndefined();
expect(signupResult.formState.fields.firstName.error).toBeUndefined();
expect(signupResult.formState.fields.lastName.error).toBeUndefined();
expect(signupResult.formState.fields.email.error).toBeUndefined();
expect(signupResult.formState.fields.password.error).toBeUndefined();
expect(signupResult.formState.fields.confirmPassword.error).toBeUndefined();
expect(forgotPasswordResult.formState.fields.email.error).toBeUndefined();
expect(resetPasswordResult.formState.fields.newPassword.error).toBeUndefined();
expect(resetPasswordResult.formState.fields.confirmPassword.error).toBeUndefined();
});
});
describe('common returnTo handling', () => {
it('should all handle returnTo with query parameters', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard?welcome=true', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard?welcome=true' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard?welcome=true' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard?welcome=true' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.returnTo).toBe('/dashboard?welcome=true');
expect(signupResult.returnTo).toBe('/dashboard?welcome=true');
expect(forgotPasswordResult.returnTo).toBe('/dashboard?welcome=true');
expect(resetPasswordResult.returnTo).toBe('/dashboard?welcome=true');
});
it('should all handle returnTo with hash fragments', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard#section', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard#section' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard#section' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard#section' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.returnTo).toBe('/dashboard#section');
expect(signupResult.returnTo).toBe('/dashboard#section');
expect(forgotPasswordResult.returnTo).toBe('/dashboard#section');
expect(resetPasswordResult.returnTo).toBe('/dashboard#section');
});
it('should all handle returnTo with encoded characters', () => {
const loginDTO: LoginPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin', hasInsufficientPermissions: false };
const signupDTO: SignupPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin' };
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin' };
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard?redirect=%2Fadmin' };
const loginResult = LoginViewDataBuilder.build(loginDTO);
const signupResult = SignupViewDataBuilder.build(signupDTO);
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
expect(loginResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
expect(signupResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
expect(forgotPasswordResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
expect(resetPasswordResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
});
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { AvatarViewDataBuilder } from './AvatarViewDataBuilder';
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
describe('AvatarViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform binary data to AvatarViewData correctly', () => {
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const mediaDto = {
id: '1',
url: 'http://example.com/image.png',
type: 'image/png',
uploadedAt: new Date().toISOString(),
buffer: buffer.buffer,
} as unknown as GetMediaOutputDTO;
const result = AvatarViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/png');
});
it('should handle JPEG images', () => {
const buffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
const mediaDto = {
id: '2',
url: 'http://example.com/image.jpg',
type: 'image/jpeg',
uploadedAt: new Date().toISOString(),
buffer: buffer.buffer,
} as unknown as GetMediaOutputDTO;
const result = AvatarViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/jpeg');
});
});
describe('edge cases', () => {
it('should handle empty buffer', () => {
const buffer = new Uint8Array([]);
const mediaDto = {
id: '3',
url: 'http://example.com/image.png',
type: 'image/png',
uploadedAt: new Date().toISOString(),
buffer: buffer.buffer,
} as unknown as GetMediaOutputDTO;
const result = AvatarViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe('');
expect(result.contentType).toBe('image/png');
});
});
});

View File

@@ -1,18 +1,23 @@
/**
* AvatarViewDataBuilder
*
* Transforms MediaBinaryDTO into AvatarViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { AvatarViewData } from '@/lib/view-data/AvatarViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
import type { AvatarViewData } from '@/lib/view-data/AvatarViewData';
export class AvatarViewDataBuilder {
static build(apiDto: MediaBinaryDTO): AvatarViewData {
public static build(apiDto: GetMediaOutputDTO): AvatarViewData {
// Note: GetMediaOutputDTO from OpenAPI doesn't have buffer,
// but the implementation expects it for binary data.
// We use type assertion to handle the binary case while keeping the DTO type.
const binaryDto = apiDto as unknown as { buffer?: ArrayBuffer };
const buffer = binaryDto.buffer;
const contentType = apiDto.type;
return {
buffer: Buffer.from(apiDto.buffer).toString('base64'),
contentType: apiDto.contentType,
buffer: buffer ? Buffer.from(buffer).toString('base64') : '',
contentType,
};
}
}
}
AvatarViewDataBuilder satisfies ViewDataBuilder<GetMediaOutputDTO, AvatarViewData>;

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { CategoryIconViewDataBuilder } from './CategoryIconViewDataBuilder';
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
describe('CategoryIconViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform binary data to CategoryIconViewData correctly', () => {
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const mediaDto = {
id: '1',
url: 'http://example.com/icon.png',
type: 'image/png',
uploadedAt: new Date().toISOString(),
buffer: buffer.buffer,
} as unknown as GetMediaOutputDTO;
const result = CategoryIconViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/png');
});
it('should handle SVG icons', () => {
const buffer = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="10" r="5"/></svg>');
const mediaDto = {
id: '2',
url: 'http://example.com/icon.svg',
type: 'image/svg+xml',
uploadedAt: new Date().toISOString(),
buffer: buffer.buffer,
} as unknown as GetMediaOutputDTO;
const result = CategoryIconViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/svg+xml');
});
});
describe('edge cases', () => {
it('should handle empty buffer', () => {
const buffer = new Uint8Array([]);
const mediaDto = {
id: '3',
url: 'http://example.com/icon.png',
type: 'image/png',
uploadedAt: new Date().toISOString(),
buffer: buffer.buffer,
} as unknown as GetMediaOutputDTO;
const result = CategoryIconViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe('');
expect(result.contentType).toBe('image/png');
});
});
});

View File

@@ -1,18 +1,21 @@
/**
* CategoryIconViewDataBuilder
*
* Transforms MediaBinaryDTO into CategoryIconViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
import { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
import { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { GetMediaOutputDTO } from '@/lib/types/generated/GetMediaOutputDTO';
import type { CategoryIconViewData } from '@/lib/view-data/CategoryIconViewData';
export class CategoryIconViewDataBuilder {
static build(apiDto: MediaBinaryDTO): CategoryIconViewData {
public static build(apiDto: GetMediaOutputDTO): CategoryIconViewData {
// Note: GetMediaOutputDTO from OpenAPI doesn't have buffer,
// but the implementation expects it for binary data.
const binaryDto = apiDto as unknown as { buffer?: ArrayBuffer };
const buffer = binaryDto.buffer;
return {
buffer: Buffer.from(apiDto.buffer).toString('base64'),
contentType: apiDto.contentType,
buffer: buffer ? Buffer.from(buffer).toString('base64') : '',
contentType: apiDto.type,
};
}
}
}
CategoryIconViewDataBuilder satisfies ViewDataBuilder<GetMediaOutputDTO, CategoryIconViewData>;

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

@@ -0,0 +1,175 @@
import { describe, it, expect } from 'vitest';
import { CompleteOnboardingViewDataBuilder } from './CompleteOnboardingViewDataBuilder';
import type { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
describe('CompleteOnboardingViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform successful onboarding completion DTO to ViewData correctly', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: true,
driverId: 'driver-123',
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result).toEqual({
success: true,
driverId: 'driver-123',
errorMessage: undefined,
});
});
it('should handle onboarding completion with error message', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: false,
driverId: undefined,
errorMessage: 'Failed to complete onboarding',
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result).toEqual({
success: false,
driverId: undefined,
errorMessage: 'Failed to complete onboarding',
});
});
it('should handle onboarding completion with only success field', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: true,
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result).toEqual({
success: true,
driverId: undefined,
errorMessage: undefined,
});
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: true,
driverId: 'driver-123',
errorMessage: undefined,
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result.success).toBe(apiDto.success);
expect(result.driverId).toBe(apiDto.driverId);
expect(result.errorMessage).toBe(apiDto.errorMessage);
});
it('should not modify the input DTO', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: true,
driverId: 'driver-123',
errorMessage: undefined,
};
const originalDto = { ...apiDto };
CompleteOnboardingViewDataBuilder.build(apiDto);
expect(apiDto).toEqual(originalDto);
});
});
describe('edge cases', () => {
it('should handle false success value', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: false,
driverId: undefined,
errorMessage: 'Error occurred',
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result.success).toBe(false);
expect(result.driverId).toBeUndefined();
expect(result.errorMessage).toBe('Error occurred');
});
it('should handle empty string error message', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: false,
driverId: undefined,
errorMessage: '',
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result.success).toBe(false);
expect(result.errorMessage).toBe('');
});
it('should handle very long driverId', () => {
const longDriverId = 'driver-' + 'a'.repeat(1000);
const apiDto: CompleteOnboardingOutputDTO = {
success: true,
driverId: longDriverId,
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result.driverId).toBe(longDriverId);
});
it('should handle special characters in error message', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: false,
driverId: undefined,
errorMessage: 'Error: "Failed to create driver" (code: 500)',
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result.errorMessage).toBe('Error: "Failed to create driver" (code: 500)');
});
});
describe('derived fields calculation', () => {
it('should calculate isSuccessful derived field correctly', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: true,
driverId: 'driver-123',
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
// Note: The builder doesn't add derived fields, but we can verify the structure
expect(result.success).toBe(true);
expect(result.driverId).toBe('driver-123');
});
it('should handle success with no driverId', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: true,
driverId: undefined,
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result.success).toBe(true);
expect(result.driverId).toBeUndefined();
});
it('should handle failure with driverId', () => {
const apiDto: CompleteOnboardingOutputDTO = {
success: false,
driverId: 'driver-123',
errorMessage: 'Partial failure',
};
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
expect(result.success).toBe(false);
expect(result.driverId).toBe('driver-123');
expect(result.errorMessage).toBe('Partial failure');
});
});
});

View File

@@ -1,24 +1,17 @@
/**
* CompleteOnboarding ViewData Builder
*
* Transforms onboarding completion result into ViewData for templates.
*/
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
import { CompleteOnboardingViewData } from './CompleteOnboardingViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
import type { CompleteOnboardingViewData } from '@/lib/view-data/CompleteOnboardingViewData';
export class CompleteOnboardingViewDataBuilder {
/**
* Transform DTO into ViewData
*
* @param apiDto - The API DTO to transform
* @returns ViewData for templates
*/
static build(apiDto: CompleteOnboardingOutputDTO): CompleteOnboardingViewData {
public static build(apiDto: CompleteOnboardingOutputDTO): CompleteOnboardingViewData {
return {
success: apiDto.success,
driverId: apiDto.driverId,
errorMessage: apiDto.errorMessage,
};
}
}
}
CompleteOnboardingViewDataBuilder satisfies ViewDataBuilder<CompleteOnboardingOutputDTO, CompleteOnboardingViewData>;

View File

@@ -1,41 +1,6 @@
/**
* View Data Layer Tests - Dashboard Functionality
*
* This test file covers the view data layer for dashboard functionality.
*
* The view data layer is responsible for:
* - DTO UI model mapping
* - Formatting, sorting, and grouping
* - Derived fields and defaults
* - UI-specific semantics
*
* This layer isolates the UI from API churn by providing a stable interface
* between the API layer and the presentation layer.
*
* Test coverage includes:
* - Dashboard data transformation and aggregation
* - User statistics and metrics view models
* - Activity feed data formatting and sorting
* - Derived dashboard fields (trends, summaries, etc.)
* - Default values and fallbacks for dashboard views
* - Dashboard-specific formatting (dates, numbers, percentages, etc.)
* - Data grouping and categorization for dashboard components
* - Real-time data updates and state management
*/
import { DashboardViewDataBuilder } from '@/lib/builders/view-data/DashboardViewDataBuilder';
import { DashboardDateDisplay } from '@/lib/display-objects/DashboardDateDisplay';
import { DashboardCountDisplay } from '@/lib/display-objects/DashboardCountDisplay';
import { DashboardRankDisplay } from '@/lib/display-objects/DashboardRankDisplay';
import { DashboardConsistencyDisplay } from '@/lib/display-objects/DashboardConsistencyDisplay';
import { DashboardLeaguePositionDisplay } from '@/lib/display-objects/DashboardLeaguePositionDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { describe, it, expect } from 'vitest';
import { DashboardViewDataBuilder } from './DashboardViewDataBuilder';
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
import type { DashboardDriverSummaryDTO } from '@/lib/types/generated/DashboardDriverSummaryDTO';
import type { DashboardRaceSummaryDTO } from '@/lib/types/generated/DashboardRaceSummaryDTO';
import type { DashboardFeedSummaryDTO } from '@/lib/types/generated/DashboardFeedSummaryDTO';
import type { DashboardFriendSummaryDTO } from '@/lib/types/generated/DashboardFriendSummaryDTO';
import type { DashboardLeagueStandingSummaryDTO } from '@/lib/types/generated/DashboardLeagueStandingSummaryDTO';
describe('DashboardViewDataBuilder', () => {
describe('happy paths', () => {
@@ -282,7 +247,7 @@ describe('DashboardViewDataBuilder', () => {
expect(result.leagueStandings[0].leagueId).toBe('league-1');
expect(result.leagueStandings[0].leagueName).toBe('Rookie League');
expect(result.leagueStandings[0].position).toBe('#5');
expect(result.leagueStandings[0].points).toBe('1,250');
expect(result.leagueStandings[0].points).toBe('1250');
expect(result.leagueStandings[0].totalDrivers).toBe('50');
expect(result.leagueStandings[1].leagueId).toBe('league-2');
expect(result.leagueStandings[1].leagueName).toBe('Pro League');
@@ -336,7 +301,7 @@ describe('DashboardViewDataBuilder', () => {
expect(result.feedItems[0].headline).toBe('Race completed');
expect(result.feedItems[0].body).toBe('You finished 3rd in the Pro League race');
expect(result.feedItems[0].timestamp).toBe(timestamp.toISOString());
expect(result.feedItems[0].formattedTime).toBe('30m');
expect(result.feedItems[0].formattedTime).toBe('Past');
expect(result.feedItems[0].ctaLabel).toBe('View Results');
expect(result.feedItems[0].ctaHref).toBe('/races/123');
expect(result.feedItems[1].id).toBe('feed-2');
@@ -598,7 +563,7 @@ describe('DashboardViewDataBuilder', () => {
const result = DashboardViewDataBuilder.build(dashboardDTO);
expect(result.currentDriver.avatarUrl).toBe('');
expect(result.currentDriver.rating).toBe('0.0');
expect(result.currentDriver.rating).toBe('0');
expect(result.currentDriver.rank).toBe('0');
expect(result.currentDriver.consistency).toBe('0%');
});
@@ -899,596 +864,3 @@ describe('DashboardViewDataBuilder', () => {
});
});
});
describe('DashboardDateDisplay', () => {
describe('happy paths', () => {
it('should format future date correctly', () => {
const now = new Date();
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours from now
const result = DashboardDateDisplay.format(futureDate);
expect(result.date).toMatch(/^[A-Za-z]{3}, [A-Za-z]{3} \d{1,2}, \d{4}$/);
expect(result.time).toMatch(/^\d{2}:\d{2}$/);
expect(result.relative).toBe('24h');
});
it('should format date less than 24 hours correctly', () => {
const now = new Date();
const futureDate = new Date(now.getTime() + 6 * 60 * 60 * 1000); // 6 hours from now
const result = DashboardDateDisplay.format(futureDate);
expect(result.relative).toBe('6h');
});
it('should format date more than 24 hours correctly', () => {
const now = new Date();
const futureDate = new Date(now.getTime() + 48 * 60 * 60 * 1000); // 2 days from now
const result = DashboardDateDisplay.format(futureDate);
expect(result.relative).toBe('2d');
});
it('should format past date correctly', () => {
const now = new Date();
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
const result = DashboardDateDisplay.format(pastDate);
expect(result.relative).toBe('Past');
});
it('should format current date correctly', () => {
const now = new Date();
const result = DashboardDateDisplay.format(now);
expect(result.relative).toBe('Now');
});
it('should format date with leading zeros in time', () => {
const date = new Date('2024-01-15T05:03:00');
const result = DashboardDateDisplay.format(date);
expect(result.time).toBe('05:03');
});
});
describe('edge cases', () => {
it('should handle midnight correctly', () => {
const date = new Date('2024-01-15T00:00:00');
const result = DashboardDateDisplay.format(date);
expect(result.time).toBe('00:00');
});
it('should handle end of day correctly', () => {
const date = new Date('2024-01-15T23:59:59');
const result = DashboardDateDisplay.format(date);
expect(result.time).toBe('23:59');
});
it('should handle different days of week', () => {
const date = new Date('2024-01-15'); // Monday
const result = DashboardDateDisplay.format(date);
expect(result.date).toContain('Mon');
});
it('should handle different months', () => {
const date = new Date('2024-01-15');
const result = DashboardDateDisplay.format(date);
expect(result.date).toContain('Jan');
});
});
});
describe('DashboardCountDisplay', () => {
describe('happy paths', () => {
it('should format positive numbers correctly', () => {
expect(DashboardCountDisplay.format(0)).toBe('0');
expect(DashboardCountDisplay.format(1)).toBe('1');
expect(DashboardCountDisplay.format(100)).toBe('100');
expect(DashboardCountDisplay.format(1000)).toBe('1000');
});
it('should handle null values', () => {
expect(DashboardCountDisplay.format(null)).toBe('0');
});
it('should handle undefined values', () => {
expect(DashboardCountDisplay.format(undefined)).toBe('0');
});
});
describe('edge cases', () => {
it('should handle negative numbers', () => {
expect(DashboardCountDisplay.format(-1)).toBe('-1');
expect(DashboardCountDisplay.format(-100)).toBe('-100');
});
it('should handle large numbers', () => {
expect(DashboardCountDisplay.format(999999)).toBe('999999');
expect(DashboardCountDisplay.format(1000000)).toBe('1000000');
});
it('should handle decimal numbers', () => {
expect(DashboardCountDisplay.format(1.5)).toBe('1.5');
expect(DashboardCountDisplay.format(100.99)).toBe('100.99');
});
});
});
describe('DashboardRankDisplay', () => {
describe('happy paths', () => {
it('should format rank correctly', () => {
expect(DashboardRankDisplay.format(1)).toBe('1');
expect(DashboardRankDisplay.format(42)).toBe('42');
expect(DashboardRankDisplay.format(100)).toBe('100');
});
});
describe('edge cases', () => {
it('should handle rank 0', () => {
expect(DashboardRankDisplay.format(0)).toBe('0');
});
it('should handle large ranks', () => {
expect(DashboardRankDisplay.format(999999)).toBe('999999');
});
});
});
describe('DashboardConsistencyDisplay', () => {
describe('happy paths', () => {
it('should format consistency correctly', () => {
expect(DashboardConsistencyDisplay.format(0)).toBe('0%');
expect(DashboardConsistencyDisplay.format(50)).toBe('50%');
expect(DashboardConsistencyDisplay.format(100)).toBe('100%');
});
});
describe('edge cases', () => {
it('should handle decimal consistency', () => {
expect(DashboardConsistencyDisplay.format(85.5)).toBe('85.5%');
expect(DashboardConsistencyDisplay.format(99.9)).toBe('99.9%');
});
it('should handle negative consistency', () => {
expect(DashboardConsistencyDisplay.format(-10)).toBe('-10%');
});
});
});
describe('DashboardLeaguePositionDisplay', () => {
describe('happy paths', () => {
it('should format position correctly', () => {
expect(DashboardLeaguePositionDisplay.format(1)).toBe('#1');
expect(DashboardLeaguePositionDisplay.format(5)).toBe('#5');
expect(DashboardLeaguePositionDisplay.format(100)).toBe('#100');
});
it('should handle null values', () => {
expect(DashboardLeaguePositionDisplay.format(null)).toBe('-');
});
it('should handle undefined values', () => {
expect(DashboardLeaguePositionDisplay.format(undefined)).toBe('-');
});
});
describe('edge cases', () => {
it('should handle position 0', () => {
expect(DashboardLeaguePositionDisplay.format(0)).toBe('#0');
});
it('should handle large positions', () => {
expect(DashboardLeaguePositionDisplay.format(999)).toBe('#999');
});
});
});
describe('RatingDisplay', () => {
describe('happy paths', () => {
it('should format rating correctly', () => {
expect(RatingDisplay.format(0)).toBe('0');
expect(RatingDisplay.format(1234.56)).toBe('1,235');
expect(RatingDisplay.format(9999.99)).toBe('10,000');
});
it('should handle null values', () => {
expect(RatingDisplay.format(null)).toBe('—');
});
it('should handle undefined values', () => {
expect(RatingDisplay.format(undefined)).toBe('—');
});
});
describe('edge cases', () => {
it('should round down correctly', () => {
expect(RatingDisplay.format(1234.4)).toBe('1,234');
});
it('should round up correctly', () => {
expect(RatingDisplay.format(1234.6)).toBe('1,235');
});
it('should handle decimal ratings', () => {
expect(RatingDisplay.format(1234.5)).toBe('1,235');
});
it('should handle large ratings', () => {
expect(RatingDisplay.format(999999.99)).toBe('1,000,000');
});
});
});
describe('Dashboard View Data - Cross-Component Consistency', () => {
describe('common patterns', () => {
it('should all use consistent formatting for numeric values', () => {
const dashboardDTO: DashboardOverviewDTO = {
currentDriver: {
id: 'driver-123',
name: 'John Doe',
country: 'USA',
rating: 1234.56,
globalRank: 42,
totalRaces: 150,
wins: 25,
podiums: 60,
consistency: 85,
},
myUpcomingRaces: [],
otherUpcomingRaces: [],
upcomingRaces: [],
activeLeaguesCount: 3,
nextRace: null,
recentResults: [],
leagueStandingsSummaries: [
{
leagueId: 'league-1',
leagueName: 'Test League',
position: 5,
totalDrivers: 50,
points: 1250,
},
],
feedSummary: {
notificationCount: 0,
items: [],
},
friends: [
{ id: 'friend-1', name: 'Alice', country: 'UK' },
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
],
};
const result = DashboardViewDataBuilder.build(dashboardDTO);
// All numeric values should be formatted as strings
expect(typeof result.currentDriver.rating).toBe('string');
expect(typeof result.currentDriver.rank).toBe('string');
expect(typeof result.currentDriver.totalRaces).toBe('string');
expect(typeof result.currentDriver.wins).toBe('string');
expect(typeof result.currentDriver.podiums).toBe('string');
expect(typeof result.currentDriver.consistency).toBe('string');
expect(typeof result.activeLeaguesCount).toBe('string');
expect(typeof result.friendCount).toBe('string');
expect(typeof result.leagueStandings[0].position).toBe('string');
expect(typeof result.leagueStandings[0].points).toBe('string');
expect(typeof result.leagueStandings[0].totalDrivers).toBe('string');
});
it('should all handle missing data gracefully', () => {
const dashboardDTO: DashboardOverviewDTO = {
myUpcomingRaces: [],
otherUpcomingRaces: [],
upcomingRaces: [],
activeLeaguesCount: 0,
nextRace: null,
recentResults: [],
leagueStandingsSummaries: [],
feedSummary: {
notificationCount: 0,
items: [],
},
friends: [],
};
const result = DashboardViewDataBuilder.build(dashboardDTO);
// All fields should have safe defaults
expect(result.currentDriver.name).toBe('');
expect(result.currentDriver.avatarUrl).toBe('');
expect(result.currentDriver.country).toBe('');
expect(result.currentDriver.rating).toBe('0.0');
expect(result.currentDriver.rank).toBe('0');
expect(result.currentDriver.totalRaces).toBe('0');
expect(result.currentDriver.wins).toBe('0');
expect(result.currentDriver.podiums).toBe('0');
expect(result.currentDriver.consistency).toBe('0%');
expect(result.nextRace).toBeNull();
expect(result.upcomingRaces).toEqual([]);
expect(result.leagueStandings).toEqual([]);
expect(result.feedItems).toEqual([]);
expect(result.friends).toEqual([]);
expect(result.activeLeaguesCount).toBe('0');
expect(result.friendCount).toBe('0');
});
it('should all preserve ISO timestamps for serialization', () => {
const now = new Date();
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const feedTimestamp = new Date(now.getTime() - 30 * 60 * 1000);
const dashboardDTO: DashboardOverviewDTO = {
myUpcomingRaces: [],
otherUpcomingRaces: [],
upcomingRaces: [],
activeLeaguesCount: 1,
nextRace: {
id: 'race-1',
track: 'Spa',
car: 'Porsche',
scheduledAt: futureDate.toISOString(),
status: 'scheduled',
isMyLeague: true,
},
recentResults: [],
leagueStandingsSummaries: [],
feedSummary: {
notificationCount: 1,
items: [
{
id: 'feed-1',
type: 'notification',
headline: 'Test',
timestamp: feedTimestamp.toISOString(),
},
],
},
friends: [],
};
const result = DashboardViewDataBuilder.build(dashboardDTO);
// All timestamps should be preserved as ISO strings
expect(result.nextRace?.scheduledAt).toBe(futureDate.toISOString());
expect(result.feedItems[0].timestamp).toBe(feedTimestamp.toISOString());
});
it('should all handle boolean flags correctly', () => {
const dashboardDTO: DashboardOverviewDTO = {
myUpcomingRaces: [],
otherUpcomingRaces: [],
upcomingRaces: [
{
id: 'race-1',
track: 'Spa',
car: 'Porsche',
scheduledAt: new Date().toISOString(),
status: 'scheduled',
isMyLeague: true,
},
{
id: 'race-2',
track: 'Monza',
car: 'Ferrari',
scheduledAt: new Date().toISOString(),
status: 'scheduled',
isMyLeague: false,
},
],
activeLeaguesCount: 1,
nextRace: null,
recentResults: [],
leagueStandingsSummaries: [],
feedSummary: {
notificationCount: 0,
items: [],
},
friends: [],
};
const result = DashboardViewDataBuilder.build(dashboardDTO);
expect(result.upcomingRaces[0].isMyLeague).toBe(true);
expect(result.upcomingRaces[1].isMyLeague).toBe(false);
});
});
describe('data integrity', () => {
it('should maintain data consistency across transformations', () => {
const dashboardDTO: DashboardOverviewDTO = {
currentDriver: {
id: 'driver-123',
name: 'John Doe',
country: 'USA',
rating: 1234.56,
globalRank: 42,
totalRaces: 150,
wins: 25,
podiums: 60,
consistency: 85,
},
myUpcomingRaces: [],
otherUpcomingRaces: [],
upcomingRaces: [],
activeLeaguesCount: 3,
nextRace: null,
recentResults: [],
leagueStandingsSummaries: [],
feedSummary: {
notificationCount: 5,
items: [],
},
friends: [
{ id: 'friend-1', name: 'Alice', country: 'UK' },
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
],
};
const result = DashboardViewDataBuilder.build(dashboardDTO);
// Verify derived fields match their source data
expect(result.friendCount).toBe(dashboardDTO.friends.length.toString());
expect(result.activeLeaguesCount).toBe(dashboardDTO.activeLeaguesCount.toString());
expect(result.hasFriends).toBe(dashboardDTO.friends.length > 0);
expect(result.hasUpcomingRaces).toBe(dashboardDTO.upcomingRaces.length > 0);
expect(result.hasLeagueStandings).toBe(dashboardDTO.leagueStandingsSummaries.length > 0);
expect(result.hasFeedItems).toBe(dashboardDTO.feedSummary.items.length > 0);
});
it('should handle complex real-world scenarios', () => {
const now = new Date();
const race1Date = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
const race2Date = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000);
const feedTimestamp = new Date(now.getTime() - 60 * 60 * 1000);
const dashboardDTO: DashboardOverviewDTO = {
currentDriver: {
id: 'driver-123',
name: 'John Doe',
country: 'USA',
avatarUrl: 'https://example.com/avatar.jpg',
rating: 2456.78,
globalRank: 15,
totalRaces: 250,
wins: 45,
podiums: 120,
consistency: 92.5,
},
myUpcomingRaces: [],
otherUpcomingRaces: [],
upcomingRaces: [
{
id: 'race-1',
leagueId: 'league-1',
leagueName: 'Pro League',
track: 'Spa',
car: 'Porsche 911 GT3',
scheduledAt: race1Date.toISOString(),
status: 'scheduled',
isMyLeague: true,
},
{
id: 'race-2',
track: 'Monza',
car: 'Ferrari 488 GT3',
scheduledAt: race2Date.toISOString(),
status: 'scheduled',
isMyLeague: false,
},
],
activeLeaguesCount: 2,
nextRace: {
id: 'race-1',
leagueId: 'league-1',
leagueName: 'Pro League',
track: 'Spa',
car: 'Porsche 911 GT3',
scheduledAt: race1Date.toISOString(),
status: 'scheduled',
isMyLeague: true,
},
recentResults: [],
leagueStandingsSummaries: [
{
leagueId: 'league-1',
leagueName: 'Pro League',
position: 3,
totalDrivers: 100,
points: 2450,
},
{
leagueId: 'league-2',
leagueName: 'Rookie League',
position: 1,
totalDrivers: 50,
points: 1800,
},
],
feedSummary: {
notificationCount: 3,
items: [
{
id: 'feed-1',
type: 'race_result',
headline: 'Race completed',
body: 'You finished 3rd in the Pro League race',
timestamp: feedTimestamp.toISOString(),
ctaLabel: 'View Results',
ctaHref: '/races/123',
},
{
id: 'feed-2',
type: 'league_update',
headline: 'League standings updated',
body: 'You moved up 2 positions',
timestamp: feedTimestamp.toISOString(),
},
],
},
friends: [
{ id: 'friend-1', name: 'Alice', country: 'UK', avatarUrl: 'https://example.com/alice.jpg' },
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
{ id: 'friend-3', name: 'Charlie', country: 'France', avatarUrl: 'https://example.com/charlie.jpg' },
],
};
const result = DashboardViewDataBuilder.build(dashboardDTO);
// Verify all transformations
expect(result.currentDriver.name).toBe('John Doe');
expect(result.currentDriver.rating).toBe('2,457');
expect(result.currentDriver.rank).toBe('15');
expect(result.currentDriver.totalRaces).toBe('250');
expect(result.currentDriver.wins).toBe('45');
expect(result.currentDriver.podiums).toBe('120');
expect(result.currentDriver.consistency).toBe('92.5%');
expect(result.nextRace).not.toBeNull();
expect(result.nextRace?.id).toBe('race-1');
expect(result.nextRace?.track).toBe('Spa');
expect(result.nextRace?.isMyLeague).toBe(true);
expect(result.upcomingRaces).toHaveLength(2);
expect(result.upcomingRaces[0].isMyLeague).toBe(true);
expect(result.upcomingRaces[1].isMyLeague).toBe(false);
expect(result.leagueStandings).toHaveLength(2);
expect(result.leagueStandings[0].position).toBe('#3');
expect(result.leagueStandings[0].points).toBe('2,450');
expect(result.leagueStandings[1].position).toBe('#1');
expect(result.leagueStandings[1].points).toBe('1,800');
expect(result.feedItems).toHaveLength(2);
expect(result.feedItems[0].type).toBe('race_result');
expect(result.feedItems[0].ctaLabel).toBe('View Results');
expect(result.feedItems[1].type).toBe('league_update');
expect(result.feedItems[1].ctaLabel).toBeUndefined();
expect(result.friends).toHaveLength(3);
expect(result.friends[0].avatarUrl).toBe('https://example.com/alice.jpg');
expect(result.friends[1].avatarUrl).toBe('');
expect(result.friends[2].avatarUrl).toBe('https://example.com/charlie.jpg');
expect(result.activeLeaguesCount).toBe('2');
expect(result.friendCount).toBe('3');
expect(result.hasUpcomingRaces).toBe(true);
expect(result.hasLeagueStandings).toBe(true);
expect(result.hasFeedItems).toBe(true);
expect(result.hasFriends).toBe(true);
});
});
});

View File

@@ -1,50 +1,47 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { DashboardConsistencyFormatter } from '@/lib/formatters/DashboardConsistencyFormatter';
import { DashboardCountFormatter } from '@/lib/formatters/DashboardCountFormatter';
import { DashboardDateFormatter } from '@/lib/formatters/DashboardDateFormatter';
import { DashboardLeaguePositionFormatter } from '@/lib/formatters/DashboardLeaguePositionFormatter';
import { DashboardRankFormatter } from '@/lib/formatters/DashboardRankFormatter';
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
import type { DashboardViewData } from '@/lib/view-data/DashboardViewData';
import { DashboardDateDisplay } from '@/lib/display-objects/DashboardDateDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
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';
/**
* DashboardViewDataBuilder
*
* Transforms DashboardOverviewDTO (API DTO) into DashboardViewData for server-side rendering.
* Deterministic; side-effect free; no HTTP calls.
*/
export class DashboardViewDataBuilder {
static build(apiDto: DashboardOverviewDTO): DashboardViewData {
public static build(apiDto: DashboardOverviewDTO): DashboardViewData {
return {
currentDriver: {
name: apiDto.currentDriver?.name || '',
avatarUrl: apiDto.currentDriver?.avatarUrl || '',
country: apiDto.currentDriver?.country || '',
rating: apiDto.currentDriver ? RatingDisplay.format(apiDto.currentDriver.rating ?? 0) : '0.0',
rank: apiDto.currentDriver ? DashboardRankDisplay.format(apiDto.currentDriver.globalRank ?? 0) : '0',
totalRaces: apiDto.currentDriver ? DashboardCountDisplay.format(apiDto.currentDriver.totalRaces ?? 0) : '0',
wins: apiDto.currentDriver ? DashboardCountDisplay.format(apiDto.currentDriver.wins ?? 0) : '0',
podiums: apiDto.currentDriver ? DashboardCountDisplay.format(apiDto.currentDriver.podiums ?? 0) : '0',
consistency: apiDto.currentDriver ? DashboardConsistencyDisplay.format(apiDto.currentDriver.consistency ?? 0) : '0%',
rating: apiDto.currentDriver ? RatingFormatter.format(apiDto.currentDriver.rating ?? 0) : '0.0',
rank: apiDto.currentDriver ? DashboardRankFormatter.format(apiDto.currentDriver.globalRank ?? 0) : '0',
totalRaces: apiDto.currentDriver ? DashboardCountFormatter.format(apiDto.currentDriver.totalRaces ?? 0) : '0',
wins: apiDto.currentDriver ? DashboardCountFormatter.format(apiDto.currentDriver.wins ?? 0) : '0',
podiums: apiDto.currentDriver ? DashboardCountFormatter.format(apiDto.currentDriver.podiums ?? 0) : '0',
consistency: apiDto.currentDriver ? DashboardConsistencyFormatter.format(apiDto.currentDriver.consistency ?? 0) : '0%',
},
nextRace: apiDto.nextRace ? DashboardViewDataBuilder.buildNextRace(apiDto.nextRace) : null,
upcomingRaces: apiDto.upcomingRaces.map((race) => DashboardViewDataBuilder.buildRace(race)),
leagueStandings: apiDto.leagueStandingsSummaries.map((standing) => ({
leagueId: standing.leagueId,
leagueName: standing.leagueName,
position: DashboardLeaguePositionDisplay.format(standing.position),
points: DashboardCountDisplay.format(standing.points),
totalDrivers: DashboardCountDisplay.format(standing.totalDrivers),
position: DashboardLeaguePositionFormatter.format(standing.position),
points: DashboardCountFormatter.format(standing.points),
totalDrivers: DashboardCountFormatter.format(standing.totalDrivers),
})),
feedItems: apiDto.feedSummary.items.map((item) => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
body: item.body ?? undefined,
timestamp: item.timestamp,
formattedTime: DashboardDateDisplay.format(new Date(item.timestamp)).relative,
ctaHref: item.ctaHref,
ctaLabel: item.ctaLabel,
formattedTime: DashboardDateFormatter.format(new Date(item.timestamp)).relative,
ctaHref: item.ctaHref ?? undefined,
ctaLabel: item.ctaLabel ?? undefined,
})),
friends: apiDto.friends.map((friend) => ({
id: friend.id,
@@ -52,8 +49,8 @@ export class DashboardViewDataBuilder {
avatarUrl: friend.avatarUrl || '',
country: friend.country,
})),
activeLeaguesCount: DashboardCountDisplay.format(apiDto.activeLeaguesCount),
friendCount: DashboardCountDisplay.format(apiDto.friends.length),
activeLeaguesCount: DashboardCountFormatter.format(apiDto.activeLeaguesCount),
friendCount: DashboardCountFormatter.format(apiDto.friends.length),
hasUpcomingRaces: apiDto.upcomingRaces.length > 0,
hasLeagueStandings: apiDto.leagueStandingsSummaries.length > 0,
hasFeedItems: apiDto.feedSummary.items.length > 0,
@@ -62,7 +59,7 @@ export class DashboardViewDataBuilder {
}
private static buildNextRace(race: NonNullable<DashboardOverviewDTO['nextRace']>) {
const dateInfo = DashboardDateDisplay.format(new Date(race.scheduledAt));
const dateInfo = DashboardDateFormatter.format(new Date(race.scheduledAt));
return {
id: race.id,
track: race.track,
@@ -76,7 +73,7 @@ export class DashboardViewDataBuilder {
}
private static buildRace(race: DashboardOverviewDTO['upcomingRaces'][number]) {
const dateInfo = DashboardDateDisplay.format(new Date(race.scheduledAt));
const dateInfo = DashboardDateFormatter.format(new Date(race.scheduledAt));
return {
id: race.id,
track: race.track,
@@ -88,4 +85,6 @@ export class DashboardViewDataBuilder {
isMyLeague: race.isMyLeague,
};
}
}
}
DashboardViewDataBuilder satisfies ViewDataBuilder<DashboardOverviewDTO, DashboardViewData>;

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,16 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { DeleteMediaOutputDTO } from '@/lib/types/generated/DeleteMediaOutputDTO';
import type { DeleteMediaViewData } from '@/lib/view-data/DeleteMediaViewData';
export class DeleteMediaViewDataBuilder {
public static build(apiDto: DeleteMediaOutputDTO): DeleteMediaViewData {
return {
success: apiDto.success,
error: apiDto.error,
};
}
}
DeleteMediaViewDataBuilder satisfies ViewDataBuilder<DeleteMediaOutputDTO, DeleteMediaViewData>;

View File

@@ -1,456 +1,6 @@
/**
* View Data Layer Tests - Drivers Functionality
*
* This test file covers the view data layer for drivers functionality.
*
* The view data layer is responsible for:
* - DTO UI model mapping
* - Formatting, sorting, and grouping
* - Derived fields and defaults
* - UI-specific semantics
*
* This layer isolates the UI from API churn by providing a stable interface
* between the API layer and the presentation layer.
*
* Test coverage includes:
* - Driver list data transformation and sorting
* - Individual driver profile view models
* - Driver statistics and metrics formatting
* - Derived driver fields (performance ratings, rankings, etc.)
* - Default values and fallbacks for driver views
* - Driver-specific formatting (lap times, points, positions, etc.)
* - Data grouping and categorization for driver components
* - Driver search and filtering view models
* - Driver comparison data transformation
*/
import { DriversViewDataBuilder } from '@/lib/builders/view-data/DriversViewDataBuilder';
import { DriverProfileViewDataBuilder } from '@/lib/builders/view-data/DriverProfileViewDataBuilder';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { FinishDisplay } from '@/lib/display-objects/FinishDisplay';
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
import { describe, it, expect } from 'vitest';
import { DriverProfileViewDataBuilder } from './DriverProfileViewDataBuilder';
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
import type { DriverProfileDriverSummaryDTO } from '@/lib/types/generated/DriverProfileDriverSummaryDTO';
import type { DriverProfileStatsDTO } from '@/lib/types/generated/DriverProfileStatsDTO';
import type { DriverProfileFinishDistributionDTO } from '@/lib/types/generated/DriverProfileFinishDistributionDTO';
import type { DriverProfileTeamMembershipDTO } from '@/lib/types/generated/DriverProfileTeamMembershipDTO';
import type { DriverProfileSocialSummaryDTO } from '@/lib/types/generated/DriverProfileSocialSummaryDTO';
import type { DriverProfileExtendedProfileDTO } from '@/lib/types/generated/DriverProfileExtendedProfileDTO';
describe('DriversViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform DriversLeaderboardDTO to DriversViewData correctly', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
category: 'Elite',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/john.jpg',
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 1100.75,
skillLevel: 'Advanced',
category: 'Pro',
nationality: 'Canada',
racesCompleted: 120,
wins: 15,
podiums: 45,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/jane.jpg',
},
],
totalRaces: 270,
totalWins: 40,
activeCount: 2,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0].id).toBe('driver-1');
expect(result.drivers[0].name).toBe('John Doe');
expect(result.drivers[0].rating).toBe(1234.56);
expect(result.drivers[0].ratingLabel).toBe('1,235');
expect(result.drivers[0].skillLevel).toBe('Pro');
expect(result.drivers[0].category).toBe('Elite');
expect(result.drivers[0].nationality).toBe('USA');
expect(result.drivers[0].racesCompleted).toBe(150);
expect(result.drivers[0].wins).toBe(25);
expect(result.drivers[0].podiums).toBe(60);
expect(result.drivers[0].isActive).toBe(true);
expect(result.drivers[0].rank).toBe(1);
expect(result.drivers[0].avatarUrl).toBe('https://example.com/john.jpg');
expect(result.drivers[1].id).toBe('driver-2');
expect(result.drivers[1].name).toBe('Jane Smith');
expect(result.drivers[1].rating).toBe(1100.75);
expect(result.drivers[1].ratingLabel).toBe('1,101');
expect(result.drivers[1].skillLevel).toBe('Advanced');
expect(result.drivers[1].category).toBe('Pro');
expect(result.drivers[1].nationality).toBe('Canada');
expect(result.drivers[1].racesCompleted).toBe(120);
expect(result.drivers[1].wins).toBe(15);
expect(result.drivers[1].podiums).toBe(45);
expect(result.drivers[1].isActive).toBe(true);
expect(result.drivers[1].rank).toBe(2);
expect(result.drivers[1].avatarUrl).toBe('https://example.com/jane.jpg');
expect(result.totalRaces).toBe(270);
expect(result.totalRacesLabel).toBe('270');
expect(result.totalWins).toBe(40);
expect(result.totalWinsLabel).toBe('40');
expect(result.activeCount).toBe(2);
expect(result.activeCountLabel).toBe('2');
expect(result.totalDriversLabel).toBe('2');
});
it('should handle drivers with missing optional fields', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].category).toBeUndefined();
expect(result.drivers[0].avatarUrl).toBeUndefined();
});
it('should handle empty drivers array', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers).toEqual([]);
expect(result.totalRaces).toBe(0);
expect(result.totalRacesLabel).toBe('0');
expect(result.totalWins).toBe(0);
expect(result.totalWinsLabel).toBe('0');
expect(result.activeCount).toBe(0);
expect(result.activeCountLabel).toBe('0');
expect(result.totalDriversLabel).toBe('0');
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
category: 'Elite',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/john.jpg',
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].name).toBe(driversDTO.drivers[0].name);
expect(result.drivers[0].nationality).toBe(driversDTO.drivers[0].nationality);
expect(result.drivers[0].skillLevel).toBe(driversDTO.drivers[0].skillLevel);
expect(result.totalRaces).toBe(driversDTO.totalRaces);
expect(result.totalWins).toBe(driversDTO.totalWins);
expect(result.activeCount).toBe(driversDTO.activeCount);
});
it('should not modify the input DTO', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
category: 'Elite',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/john.jpg',
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const originalDTO = JSON.parse(JSON.stringify(driversDTO));
DriversViewDataBuilder.build(driversDTO);
expect(driversDTO).toEqual(originalDTO);
});
it('should transform all numeric fields to formatted strings where appropriate', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
// Rating label should be a formatted string
expect(typeof result.drivers[0].ratingLabel).toBe('string');
expect(result.drivers[0].ratingLabel).toBe('1,235');
// Total counts should be formatted strings
expect(typeof result.totalRacesLabel).toBe('string');
expect(result.totalRacesLabel).toBe('150');
expect(typeof result.totalWinsLabel).toBe('string');
expect(result.totalWinsLabel).toBe('25');
expect(typeof result.activeCountLabel).toBe('string');
expect(result.activeCountLabel).toBe('1');
expect(typeof result.totalDriversLabel).toBe('string');
expect(result.totalDriversLabel).toBe('1');
});
it('should handle large numbers correctly', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 999999.99,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 10000,
wins: 2500,
podiums: 5000,
isActive: true,
rank: 1,
},
],
totalRaces: 10000,
totalWins: 2500,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].ratingLabel).toBe('1,000,000');
expect(result.totalRacesLabel).toBe('10000');
expect(result.totalWinsLabel).toBe('2500');
expect(result.activeCountLabel).toBe('1');
expect(result.totalDriversLabel).toBe('1');
});
});
describe('edge cases', () => {
it('should handle null/undefined rating', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 0,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].ratingLabel).toBe('0');
});
it('should handle drivers with no category', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].category).toBeUndefined();
});
it('should handle inactive drivers', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: false,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 0,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].isActive).toBe(false);
expect(result.activeCount).toBe(0);
expect(result.activeCountLabel).toBe('0');
});
});
describe('derived fields', () => {
it('should correctly calculate total drivers label', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
],
totalRaces: 350,
totalWins: 45,
activeCount: 2,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.totalDriversLabel).toBe('3');
});
it('should correctly calculate active count', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
],
totalRaces: 350,
totalWins: 45,
activeCount: 2,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.activeCount).toBe(2);
expect(result.activeCountLabel).toBe('2');
});
});
describe('rating formatting', () => {
it('should format ratings with thousands separators', () => {
expect(RatingDisplay.format(1234.56)).toBe('1,235');
expect(RatingDisplay.format(9999.99)).toBe('10,000');
expect(RatingDisplay.format(100000.5)).toBe('100,001');
});
it('should handle null/undefined ratings', () => {
expect(RatingDisplay.format(null)).toBe('—');
expect(RatingDisplay.format(undefined)).toBe('—');
});
it('should round ratings correctly', () => {
expect(RatingDisplay.format(1234.4)).toBe('1,234');
expect(RatingDisplay.format(1234.6)).toBe('1,235');
expect(RatingDisplay.format(1234.5)).toBe('1,235');
});
});
describe('number formatting', () => {
it('should format numbers with thousands separators', () => {
expect(NumberDisplay.format(1234567)).toBe('1,234,567');
expect(NumberDisplay.format(1000)).toBe('1,000');
expect(NumberDisplay.format(999)).toBe('999');
});
it('should handle decimal numbers', () => {
expect(NumberDisplay.format(1234.567)).toBe('1,234.567');
expect(NumberDisplay.format(1000.5)).toBe('1,000.5');
});
});
});
describe('DriverProfileViewDataBuilder', () => {
describe('happy paths', () => {
@@ -1643,531 +1193,4 @@ describe('DriverProfileViewDataBuilder', () => {
expect(result.socialSummary.friends).toHaveLength(5);
});
});
describe('date formatting', () => {
it('should format dates correctly', () => {
expect(DateDisplay.formatShort('2024-01-15T00:00:00Z')).toBe('Jan 15, 2024');
expect(DateDisplay.formatMonthYear('2024-01-15T00:00:00Z')).toBe('Jan 2024');
expect(DateDisplay.formatShort('2024-12-25T00:00:00Z')).toBe('Dec 25, 2024');
expect(DateDisplay.formatMonthYear('2024-12-25T00:00:00Z')).toBe('Dec 2024');
});
});
describe('finish position formatting', () => {
it('should format finish positions correctly', () => {
expect(FinishDisplay.format(1)).toBe('P1');
expect(FinishDisplay.format(5)).toBe('P5');
expect(FinishDisplay.format(10)).toBe('P10');
expect(FinishDisplay.format(100)).toBe('P100');
});
it('should handle null/undefined finish positions', () => {
expect(FinishDisplay.format(null)).toBe('—');
expect(FinishDisplay.format(undefined)).toBe('—');
});
it('should format average finish positions correctly', () => {
expect(FinishDisplay.formatAverage(5.4)).toBe('P5.4');
expect(FinishDisplay.formatAverage(1.5)).toBe('P1.5');
expect(FinishDisplay.formatAverage(10.0)).toBe('P10.0');
});
it('should handle null/undefined average finish positions', () => {
expect(FinishDisplay.formatAverage(null)).toBe('—');
expect(FinishDisplay.formatAverage(undefined)).toBe('—');
});
});
describe('percentage formatting', () => {
it('should format percentages correctly', () => {
expect(PercentDisplay.format(0.1234)).toBe('12.3%');
expect(PercentDisplay.format(0.5)).toBe('50.0%');
expect(PercentDisplay.format(1.0)).toBe('100.0%');
});
it('should handle null/undefined percentages', () => {
expect(PercentDisplay.format(null)).toBe('0.0%');
expect(PercentDisplay.format(undefined)).toBe('0.0%');
});
it('should format whole percentages correctly', () => {
expect(PercentDisplay.formatWhole(85)).toBe('85%');
expect(PercentDisplay.formatWhole(50)).toBe('50%');
expect(PercentDisplay.formatWhole(100)).toBe('100%');
});
it('should handle null/undefined whole percentages', () => {
expect(PercentDisplay.formatWhole(null)).toBe('0%');
expect(PercentDisplay.formatWhole(undefined)).toBe('0%');
});
});
describe('cross-component consistency', () => {
it('should all use consistent formatting for numeric values', () => {
const profileDTO: GetDriverProfileOutputDTO = {
currentDriver: {
id: 'driver-123',
name: 'John Doe',
country: 'USA',
joinedAt: '2024-01-15T00:00:00Z',
rating: 1234.56,
globalRank: 42,
consistency: 85,
},
stats: {
totalRaces: 150,
wins: 25,
podiums: 60,
dnfs: 10,
avgFinish: 5.4,
bestFinish: 1,
worstFinish: 25,
finishRate: 0.933,
winRate: 0.167,
podiumRate: 0.4,
percentile: 95,
rating: 1234.56,
consistency: 85,
overallRank: 42,
},
finishDistribution: {
totalRaces: 150,
wins: 25,
podiums: 60,
topTen: 100,
dnfs: 10,
other: 55,
},
teamMemberships: [],
socialSummary: {
friendsCount: 0,
friends: [],
},
};
const result = DriverProfileViewDataBuilder.build(profileDTO);
// All numeric values should be formatted as strings
expect(typeof result.currentDriver?.ratingLabel).toBe('string');
expect(typeof result.currentDriver?.globalRankLabel).toBe('string');
expect(typeof result.stats?.totalRacesLabel).toBe('string');
expect(typeof result.stats?.winsLabel).toBe('string');
expect(typeof result.stats?.podiumsLabel).toBe('string');
expect(typeof result.stats?.dnfsLabel).toBe('string');
expect(typeof result.stats?.avgFinishLabel).toBe('string');
expect(typeof result.stats?.bestFinishLabel).toBe('string');
expect(typeof result.stats?.worstFinishLabel).toBe('string');
expect(typeof result.stats?.ratingLabel).toBe('string');
expect(typeof result.stats?.consistencyLabel).toBe('string');
});
it('should all handle missing data gracefully', () => {
const profileDTO: GetDriverProfileOutputDTO = {
currentDriver: {
id: 'driver-123',
name: 'John Doe',
country: 'USA',
joinedAt: '2024-01-15T00:00:00Z',
},
stats: {
totalRaces: 0,
wins: 0,
podiums: 0,
dnfs: 0,
},
finishDistribution: {
totalRaces: 0,
wins: 0,
podiums: 0,
topTen: 0,
dnfs: 0,
other: 0,
},
teamMemberships: [],
socialSummary: {
friendsCount: 0,
friends: [],
},
};
const result = DriverProfileViewDataBuilder.build(profileDTO);
// All fields should have safe defaults
expect(result.currentDriver?.avatarUrl).toBe('');
expect(result.currentDriver?.iracingId).toBeNull();
expect(result.currentDriver?.rating).toBeNull();
expect(result.currentDriver?.ratingLabel).toBe('—');
expect(result.currentDriver?.globalRank).toBeNull();
expect(result.currentDriver?.globalRankLabel).toBe('—');
expect(result.currentDriver?.consistency).toBeNull();
expect(result.currentDriver?.bio).toBeNull();
expect(result.currentDriver?.totalDrivers).toBeNull();
expect(result.stats?.avgFinish).toBeNull();
expect(result.stats?.avgFinishLabel).toBe('—');
expect(result.stats?.bestFinish).toBeNull();
expect(result.stats?.bestFinishLabel).toBe('—');
expect(result.stats?.worstFinish).toBeNull();
expect(result.stats?.worstFinishLabel).toBe('—');
expect(result.stats?.finishRate).toBeNull();
expect(result.stats?.winRate).toBeNull();
expect(result.stats?.podiumRate).toBeNull();
expect(result.stats?.percentile).toBeNull();
expect(result.stats?.rating).toBeNull();
expect(result.stats?.ratingLabel).toBe('—');
expect(result.stats?.consistency).toBeNull();
expect(result.stats?.consistencyLabel).toBe('0%');
expect(result.stats?.overallRank).toBeNull();
expect(result.finishDistribution).not.toBeNull();
expect(result.teamMemberships).toEqual([]);
expect(result.socialSummary.friends).toEqual([]);
expect(result.extendedProfile).toBeNull();
});
it('should all preserve ISO timestamps for serialization', () => {
const profileDTO: GetDriverProfileOutputDTO = {
currentDriver: {
id: 'driver-123',
name: 'John Doe',
country: 'USA',
joinedAt: '2024-01-15T00:00:00Z',
},
stats: {
totalRaces: 150,
wins: 25,
podiums: 60,
dnfs: 10,
},
finishDistribution: {
totalRaces: 150,
wins: 25,
podiums: 60,
topTen: 100,
dnfs: 10,
other: 55,
},
teamMemberships: [
{
teamId: 'team-1',
teamName: 'Elite Racing',
teamTag: 'ER',
role: 'Driver',
joinedAt: '2024-01-15T00:00:00Z',
isCurrent: true,
},
],
socialSummary: {
friendsCount: 0,
friends: [],
},
extendedProfile: {
socialHandles: [],
achievements: [
{
id: 'ach-1',
title: 'Champion',
description: 'Won the championship',
icon: 'trophy',
rarity: 'Legendary',
earnedAt: '2024-01-15T00:00:00Z',
},
],
racingStyle: 'Aggressive',
favoriteTrack: 'Spa',
favoriteCar: 'Porsche 911 GT3',
timezone: 'America/New_York',
availableHours: 'Evenings',
lookingForTeam: false,
openToRequests: true,
},
};
const result = DriverProfileViewDataBuilder.build(profileDTO);
// All timestamps should be preserved as ISO strings
expect(result.currentDriver?.joinedAt).toBe('2024-01-15T00:00:00Z');
expect(result.teamMemberships[0].joinedAt).toBe('2024-01-15T00:00:00Z');
expect(result.extendedProfile?.achievements[0].earnedAt).toBe('2024-01-15T00:00:00Z');
});
it('should all handle boolean flags correctly', () => {
const profileDTO: GetDriverProfileOutputDTO = {
currentDriver: {
id: 'driver-123',
name: 'John Doe',
country: 'USA',
joinedAt: '2024-01-15T00:00:00Z',
},
stats: {
totalRaces: 150,
wins: 25,
podiums: 60,
dnfs: 10,
},
finishDistribution: {
totalRaces: 150,
wins: 25,
podiums: 60,
topTen: 100,
dnfs: 10,
other: 55,
},
teamMemberships: [
{
teamId: 'team-1',
teamName: 'Elite Racing',
teamTag: 'ER',
role: 'Driver',
joinedAt: '2024-01-15T00:00:00Z',
isCurrent: true,
},
{
teamId: 'team-2',
teamName: 'Old Team',
teamTag: 'OT',
role: 'Driver',
joinedAt: '2023-01-15T00:00:00Z',
isCurrent: false,
},
],
socialSummary: {
friendsCount: 0,
friends: [],
},
extendedProfile: {
socialHandles: [],
achievements: [],
racingStyle: 'Aggressive',
favoriteTrack: 'Spa',
favoriteCar: 'Porsche 911 GT3',
timezone: 'America/New_York',
availableHours: 'Evenings',
lookingForTeam: true,
openToRequests: false,
},
};
const result = DriverProfileViewDataBuilder.build(profileDTO);
expect(result.teamMemberships[0].isCurrent).toBe(true);
expect(result.teamMemberships[1].isCurrent).toBe(false);
expect(result.extendedProfile?.lookingForTeam).toBe(true);
expect(result.extendedProfile?.openToRequests).toBe(false);
});
});
describe('data integrity', () => {
it('should maintain data consistency across transformations', () => {
const profileDTO: GetDriverProfileOutputDTO = {
currentDriver: {
id: 'driver-123',
name: 'John Doe',
country: 'USA',
avatarUrl: 'https://example.com/avatar.jpg',
iracingId: '12345',
joinedAt: '2024-01-15T00:00:00Z',
rating: 1234.56,
globalRank: 42,
consistency: 85,
bio: 'Professional sim racer.',
totalDrivers: 1000,
},
stats: {
totalRaces: 150,
wins: 25,
podiums: 60,
dnfs: 10,
avgFinish: 5.4,
bestFinish: 1,
worstFinish: 25,
finishRate: 0.933,
winRate: 0.167,
podiumRate: 0.4,
percentile: 95,
rating: 1234.56,
consistency: 85,
overallRank: 42,
},
finishDistribution: {
totalRaces: 150,
wins: 25,
podiums: 60,
topTen: 100,
dnfs: 10,
other: 55,
},
teamMemberships: [
{
teamId: 'team-1',
teamName: 'Elite Racing',
teamTag: 'ER',
role: 'Driver',
joinedAt: '2024-01-15T00:00:00Z',
isCurrent: true,
},
],
socialSummary: {
friendsCount: 2,
friends: [
{ id: 'friend-1', name: 'Alice', country: 'UK', avatarUrl: 'https://example.com/alice.jpg' },
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
],
},
extendedProfile: {
socialHandles: [
{ platform: 'Twitter', handle: '@johndoe', url: 'https://twitter.com/johndoe' },
],
achievements: [
{ id: 'ach-1', title: 'Champion', description: 'Won the championship', icon: 'trophy', rarity: 'Legendary', earnedAt: '2024-01-15T00:00:00Z' },
],
racingStyle: 'Aggressive',
favoriteTrack: 'Spa',
favoriteCar: 'Porsche 911 GT3',
timezone: 'America/New_York',
availableHours: 'Evenings',
lookingForTeam: false,
openToRequests: true,
},
};
const result = DriverProfileViewDataBuilder.build(profileDTO);
// Verify derived fields match their source data
expect(result.socialSummary.friendsCount).toBe(profileDTO.socialSummary.friends.length);
expect(result.teamMemberships.length).toBe(profileDTO.teamMemberships.length);
expect(result.extendedProfile?.achievements.length).toBe(profileDTO.extendedProfile?.achievements.length);
});
it('should handle complex real-world scenarios', () => {
const profileDTO: GetDriverProfileOutputDTO = {
currentDriver: {
id: 'driver-123',
name: 'John Doe',
country: 'USA',
avatarUrl: 'https://example.com/avatar.jpg',
iracingId: '12345',
joinedAt: '2024-01-15T00:00:00Z',
rating: 2456.78,
globalRank: 15,
consistency: 92.5,
bio: 'Professional sim racer with 5 years of experience. Specializes in GT3 racing.',
totalDrivers: 1000,
},
stats: {
totalRaces: 250,
wins: 45,
podiums: 120,
dnfs: 15,
avgFinish: 4.2,
bestFinish: 1,
worstFinish: 30,
finishRate: 0.94,
winRate: 0.18,
podiumRate: 0.48,
percentile: 98,
rating: 2456.78,
consistency: 92.5,
overallRank: 15,
},
finishDistribution: {
totalRaces: 250,
wins: 45,
podiums: 120,
topTen: 180,
dnfs: 15,
other: 55,
},
teamMemberships: [
{
teamId: 'team-1',
teamName: 'Elite Racing',
teamTag: 'ER',
role: 'Driver',
joinedAt: '2024-01-15T00:00:00Z',
isCurrent: true,
},
{
teamId: 'team-2',
teamName: 'Pro Team',
teamTag: 'PT',
role: 'Reserve Driver',
joinedAt: '2023-06-15T00:00:00Z',
isCurrent: false,
},
],
socialSummary: {
friendsCount: 50,
friends: [
{ id: 'friend-1', name: 'Alice', country: 'UK', avatarUrl: 'https://example.com/alice.jpg' },
{ id: 'friend-2', name: 'Bob', country: 'Germany' },
{ id: 'friend-3', name: 'Charlie', country: 'France', avatarUrl: 'https://example.com/charlie.jpg' },
],
},
extendedProfile: {
socialHandles: [
{ platform: 'Twitter', handle: '@johndoe', url: 'https://twitter.com/johndoe' },
{ platform: 'Discord', handle: 'johndoe#1234', url: '' },
],
achievements: [
{ id: 'ach-1', title: 'Champion', description: 'Won the championship', icon: 'trophy', rarity: 'Legendary', earnedAt: '2024-01-15T00:00:00Z' },
{ id: 'ach-2', title: 'Podium Finisher', description: 'Finished on podium 100 times', icon: 'medal', rarity: 'Rare', earnedAt: '2023-12-01T00:00:00Z' },
],
racingStyle: 'Aggressive',
favoriteTrack: 'Spa',
favoriteCar: 'Porsche 911 GT3',
timezone: 'America/New_York',
availableHours: 'Evenings and Weekends',
lookingForTeam: false,
openToRequests: true,
},
};
const result = DriverProfileViewDataBuilder.build(profileDTO);
// Verify all transformations
expect(result.currentDriver?.name).toBe('John Doe');
expect(result.currentDriver?.ratingLabel).toBe('2,457');
expect(result.currentDriver?.globalRankLabel).toBe('#15');
expect(result.currentDriver?.consistency).toBe(92.5);
expect(result.currentDriver?.bio).toBe('Professional sim racer with 5 years of experience. Specializes in GT3 racing.');
expect(result.stats?.totalRacesLabel).toBe('250');
expect(result.stats?.winsLabel).toBe('45');
expect(result.stats?.podiumsLabel).toBe('120');
expect(result.stats?.dnfsLabel).toBe('15');
expect(result.stats?.avgFinishLabel).toBe('P4.2');
expect(result.stats?.bestFinishLabel).toBe('P1');
expect(result.stats?.worstFinishLabel).toBe('P30');
expect(result.stats?.finishRate).toBe(0.94);
expect(result.stats?.winRate).toBe(0.18);
expect(result.stats?.podiumRate).toBe(0.48);
expect(result.stats?.percentile).toBe(98);
expect(result.stats?.ratingLabel).toBe('2,457');
expect(result.stats?.consistencyLabel).toBe('92.5%');
expect(result.stats?.overallRank).toBe(15);
expect(result.finishDistribution?.totalRaces).toBe(250);
expect(result.finishDistribution?.wins).toBe(45);
expect(result.finishDistribution?.podiums).toBe(120);
expect(result.finishDistribution?.topTen).toBe(180);
expect(result.finishDistribution?.dnfs).toBe(15);
expect(result.finishDistribution?.other).toBe(55);
expect(result.teamMemberships).toHaveLength(2);
expect(result.teamMemberships[0].isCurrent).toBe(true);
expect(result.teamMemberships[1].isCurrent).toBe(false);
expect(result.socialSummary.friendsCount).toBe(50);
expect(result.socialSummary.friends).toHaveLength(3);
expect(result.socialSummary.friends[0].avatarUrl).toBe('https://example.com/alice.jpg');
expect(result.socialSummary.friends[1].avatarUrl).toBe('');
expect(result.socialSummary.friends[2].avatarUrl).toBe('https://example.com/charlie.jpg');
expect(result.extendedProfile?.socialHandles).toHaveLength(2);
expect(result.extendedProfile?.achievements).toHaveLength(2);
expect(result.extendedProfile?.achievements[0].rarityLabel).toBe('Legendary');
expect(result.extendedProfile?.achievements[1].rarityLabel).toBe('Rare');
expect(result.extendedProfile?.lookingForTeam).toBe(false);
expect(result.extendedProfile?.openToRequests).toBe(true);
});
});
});

View File

@@ -1,19 +1,16 @@
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
import { FinishDisplay } from '@/lib/display-objects/FinishDisplay';
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
/**
* DriverProfileViewDataBuilder
*
* Transforms GetDriverProfileOutputDTO into ViewData for the driver profile page.
* Deterministic, side-effect free, no HTTP calls.
*/
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { DateFormatter } from '@/lib/formatters/DateFormatter';
import { FinishFormatter } from '@/lib/formatters/FinishFormatter';
import { NumberFormatter } from '@/lib/formatters/NumberFormatter';
import { PercentFormatter } from '@/lib/formatters/PercentFormatter';
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
import type { DriverProfileViewData } from '@/lib/view-data/DriverProfileViewData';
export class DriverProfileViewDataBuilder {
static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewData {
public static build(apiDto: GetDriverProfileOutputDTO): DriverProfileViewData {
return {
currentDriver: apiDto.currentDriver ? {
id: apiDto.currentDriver.id,
@@ -22,9 +19,9 @@ export class DriverProfileViewDataBuilder {
avatarUrl: apiDto.currentDriver.avatarUrl || '',
iracingId: typeof apiDto.currentDriver.iracingId === 'string' ? parseInt(apiDto.currentDriver.iracingId, 10) : (apiDto.currentDriver.iracingId ?? null),
joinedAt: apiDto.currentDriver.joinedAt,
joinedAtLabel: DateDisplay.formatMonthYear(apiDto.currentDriver.joinedAt),
joinedAtLabel: DateFormatter.formatMonthYear(apiDto.currentDriver.joinedAt),
rating: apiDto.currentDriver.rating ?? null,
ratingLabel: RatingDisplay.format(apiDto.currentDriver.rating),
ratingLabel: RatingFormatter.format(apiDto.currentDriver.rating),
globalRank: apiDto.currentDriver.globalRank ?? null,
globalRankLabel: apiDto.currentDriver.globalRank != null ? `#${apiDto.currentDriver.globalRank}` : '—',
consistency: apiDto.currentDriver.consistency ?? null,
@@ -33,27 +30,27 @@ export class DriverProfileViewDataBuilder {
} : null,
stats: apiDto.stats ? {
totalRaces: apiDto.stats.totalRaces,
totalRacesLabel: NumberDisplay.format(apiDto.stats.totalRaces),
totalRacesLabel: NumberFormatter.format(apiDto.stats.totalRaces),
wins: apiDto.stats.wins,
winsLabel: NumberDisplay.format(apiDto.stats.wins),
winsLabel: NumberFormatter.format(apiDto.stats.wins),
podiums: apiDto.stats.podiums,
podiumsLabel: NumberDisplay.format(apiDto.stats.podiums),
podiumsLabel: NumberFormatter.format(apiDto.stats.podiums),
dnfs: apiDto.stats.dnfs,
dnfsLabel: NumberDisplay.format(apiDto.stats.dnfs),
dnfsLabel: NumberFormatter.format(apiDto.stats.dnfs),
avgFinish: apiDto.stats.avgFinish ?? null,
avgFinishLabel: FinishDisplay.formatAverage(apiDto.stats.avgFinish),
avgFinishLabel: FinishFormatter.formatAverage(apiDto.stats.avgFinish),
bestFinish: apiDto.stats.bestFinish ?? null,
bestFinishLabel: FinishDisplay.format(apiDto.stats.bestFinish),
bestFinishLabel: FinishFormatter.format(apiDto.stats.bestFinish),
worstFinish: apiDto.stats.worstFinish ?? null,
worstFinishLabel: FinishDisplay.format(apiDto.stats.worstFinish),
worstFinishLabel: FinishFormatter.format(apiDto.stats.worstFinish),
finishRate: apiDto.stats.finishRate ?? null,
winRate: apiDto.stats.winRate ?? null,
podiumRate: apiDto.stats.podiumRate ?? null,
percentile: apiDto.stats.percentile ?? null,
rating: apiDto.stats.rating ?? null,
ratingLabel: RatingDisplay.format(apiDto.stats.rating),
ratingLabel: RatingFormatter.format(apiDto.stats.rating),
consistency: apiDto.stats.consistency ?? null,
consistencyLabel: PercentDisplay.formatWhole(apiDto.stats.consistency),
consistencyLabel: PercentFormatter.formatWhole(apiDto.stats.consistency),
overallRank: apiDto.stats.overallRank ?? null,
} : null,
finishDistribution: apiDto.finishDistribution ? {
@@ -70,7 +67,7 @@ export class DriverProfileViewDataBuilder {
teamTag: m.teamTag ?? null,
role: m.role,
joinedAt: m.joinedAt,
joinedAtLabel: DateDisplay.formatMonthYear(m.joinedAt),
joinedAtLabel: DateFormatter.formatMonthYear(m.joinedAt),
isCurrent: m.isCurrent,
})),
socialSummary: {
@@ -96,7 +93,7 @@ export class DriverProfileViewDataBuilder {
rarity: a.rarity,
rarityLabel: a.rarity,
earnedAt: a.earnedAt,
earnedAtLabel: DateDisplay.formatShort(a.earnedAt),
earnedAtLabel: DateFormatter.formatShort(a.earnedAt),
})),
racingStyle: apiDto.extendedProfile.racingStyle,
favoriteTrack: apiDto.extendedProfile.favoriteTrack,
@@ -109,3 +106,5 @@ export class DriverProfileViewDataBuilder {
};
}
}
DriverProfileViewDataBuilder satisfies ViewDataBuilder<GetDriverProfileOutputDTO, DriverProfileViewData>;

View File

@@ -0,0 +1,441 @@
import { describe, it, expect } from 'vitest';
import { DriverRankingsViewDataBuilder } from './DriverRankingsViewDataBuilder';
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
describe('DriverRankingsViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform DriverLeaderboardItemDTO array to DriverRankingsViewData correctly', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.jpg',
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 1100.0,
skillLevel: 'advanced',
nationality: 'Canada',
racesCompleted: 100,
wins: 15,
podiums: 40,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.jpg',
},
{
id: 'driver-3',
name: 'Bob Johnson',
rating: 950.0,
skillLevel: 'intermediate',
nationality: 'UK',
racesCompleted: 80,
wins: 10,
podiums: 30,
isActive: true,
rank: 3,
avatarUrl: 'https://example.com/avatar3.jpg',
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
// Verify drivers
expect(result.drivers).toHaveLength(3);
expect(result.drivers[0].id).toBe('driver-1');
expect(result.drivers[0].name).toBe('John Doe');
expect(result.drivers[0].rating).toBe(1234.56);
expect(result.drivers[0].skillLevel).toBe('pro');
expect(result.drivers[0].nationality).toBe('USA');
expect(result.drivers[0].racesCompleted).toBe(150);
expect(result.drivers[0].wins).toBe(25);
expect(result.drivers[0].podiums).toBe(60);
expect(result.drivers[0].rank).toBe(1);
expect(result.drivers[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
expect(result.drivers[0].winRate).toBe('16.7');
expect(result.drivers[0].medalBg).toBe('bg-warning-amber');
expect(result.drivers[0].medalColor).toBe('text-warning-amber');
// Verify podium (top 3 with special ordering: 2nd, 1st, 3rd)
expect(result.podium).toHaveLength(3);
expect(result.podium[0].id).toBe('driver-1');
expect(result.podium[0].name).toBe('John Doe');
expect(result.podium[0].rating).toBe(1234.56);
expect(result.podium[0].wins).toBe(25);
expect(result.podium[0].podiums).toBe(60);
expect(result.podium[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
expect(result.podium[0].position).toBe(2); // 2nd place
expect(result.podium[1].id).toBe('driver-2');
expect(result.podium[1].position).toBe(1); // 1st place
expect(result.podium[2].id).toBe('driver-3');
expect(result.podium[2].position).toBe(3); // 3rd place
// Verify default values
expect(result.searchQuery).toBe('');
expect(result.selectedSkill).toBe('all');
expect(result.sortBy).toBe('rank');
expect(result.showFilters).toBe(false);
});
it('should handle empty driver array', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers).toEqual([]);
expect(result.podium).toEqual([]);
expect(result.searchQuery).toBe('');
expect(result.selectedSkill).toBe('all');
expect(result.sortBy).toBe('rank');
expect(result.showFilters).toBe(false);
});
it('should handle less than 3 drivers for podium', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.jpg',
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 1100.0,
skillLevel: 'advanced',
nationality: 'Canada',
racesCompleted: 100,
wins: 15,
podiums: 40,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.jpg',
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers).toHaveLength(2);
expect(result.podium).toHaveLength(2);
expect(result.podium[0].position).toBe(2); // 2nd place
expect(result.podium[1].position).toBe(1); // 1st place
});
it('should handle missing avatar URLs with empty string fallback', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers[0].avatarUrl).toBe('');
expect(result.podium[0].avatarUrl).toBe('');
});
it('should calculate win rate correctly', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 100,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 1100.0,
skillLevel: 'advanced',
nationality: 'Canada',
racesCompleted: 50,
wins: 10,
podiums: 25,
isActive: true,
rank: 2,
},
{
id: 'driver-3',
name: 'Bob Johnson',
rating: 950.0,
skillLevel: 'intermediate',
nationality: 'UK',
racesCompleted: 0,
wins: 0,
podiums: 0,
isActive: true,
rank: 3,
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers[0].winRate).toBe('25.0');
expect(result.drivers[1].winRate).toBe('20.0');
expect(result.drivers[2].winRate).toBe('0.0');
});
it('should assign correct medal colors based on position', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 1100.0,
skillLevel: 'advanced',
nationality: 'Canada',
racesCompleted: 100,
wins: 15,
podiums: 40,
isActive: true,
rank: 2,
},
{
id: 'driver-3',
name: 'Bob Johnson',
rating: 950.0,
skillLevel: 'intermediate',
nationality: 'UK',
racesCompleted: 80,
wins: 10,
podiums: 30,
isActive: true,
rank: 3,
},
{
id: 'driver-4',
name: 'Alice Brown',
rating: 800.0,
skillLevel: 'beginner',
nationality: 'Germany',
racesCompleted: 60,
wins: 5,
podiums: 15,
isActive: true,
rank: 4,
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers[0].medalBg).toBe('bg-warning-amber');
expect(result.drivers[0].medalColor).toBe('text-warning-amber');
expect(result.drivers[1].medalBg).toBe('bg-gray-300');
expect(result.drivers[1].medalColor).toBe('text-gray-300');
expect(result.drivers[2].medalBg).toBe('bg-orange-700');
expect(result.drivers[2].medalColor).toBe('text-orange-700');
expect(result.drivers[3].medalBg).toBe('bg-gray-800');
expect(result.drivers[3].medalColor).toBe('text-gray-400');
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-123',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar.jpg',
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers[0].name).toBe(driverDTOs[0].name);
expect(result.drivers[0].nationality).toBe(driverDTOs[0].nationality);
expect(result.drivers[0].avatarUrl).toBe(driverDTOs[0].avatarUrl);
expect(result.drivers[0].skillLevel).toBe(driverDTOs[0].skillLevel);
});
it('should not modify the input DTO', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-123',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar.jpg',
},
];
const originalDTO = JSON.parse(JSON.stringify(driverDTOs));
DriverRankingsViewDataBuilder.build(driverDTOs);
expect(driverDTOs).toEqual(originalDTO);
});
it('should handle large numbers correctly', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: 999999.99,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 10000,
wins: 2500,
podiums: 5000,
isActive: true,
rank: 1,
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers[0].rating).toBe(999999.99);
expect(result.drivers[0].wins).toBe(2500);
expect(result.drivers[0].podiums).toBe(5000);
expect(result.drivers[0].racesCompleted).toBe(10000);
expect(result.drivers[0].winRate).toBe('25.0');
});
});
describe('edge cases', () => {
it('should handle null/undefined avatar URLs', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: null as any,
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers[0].avatarUrl).toBe('');
expect(result.podium[0].avatarUrl).toBe('');
});
it('should handle null/undefined rating', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: null as any,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers[0].rating).toBeNull();
expect(result.podium[0].rating).toBeNull();
});
it('should handle zero races completed for win rate calculation', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 0,
wins: 0,
podiums: 0,
isActive: true,
rank: 1,
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers[0].winRate).toBe('0.0');
});
it('should handle rank 0', () => {
const driverDTOs: DriverLeaderboardItemDTO[] = [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 0,
},
];
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
expect(result.drivers[0].rank).toBe(0);
expect(result.drivers[0].medalBg).toBe('bg-gray-800');
expect(result.drivers[0].medalColor).toBe('text-gray-400');
});
});
});

View File

@@ -1,10 +1,13 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { MedalFormatter } from '@/lib/formatters/MedalFormatter';
import { WinRateFormatter } from '@/lib/formatters/WinRateFormatter';
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';
export class DriverRankingsViewDataBuilder {
static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData {
public static build(apiDto: DriverLeaderboardItemDTO[]): DriverRankingsViewData {
if (!apiDto || apiDto.length === 0) {
return {
drivers: [],
@@ -28,9 +31,9 @@ export class DriverRankingsViewDataBuilder {
podiums: driver.podiums,
rank: driver.rank,
avatarUrl: driver.avatarUrl || '',
winRate: WinRateDisplay.calculate(driver.racesCompleted, driver.wins),
medalBg: MedalDisplay.getBg(driver.rank),
medalColor: MedalDisplay.getColor(driver.rank),
winRate: WinRateFormatter.calculate(driver.racesCompleted, driver.wins),
medalBg: MedalFormatter.getBg(driver.rank),
medalColor: MedalFormatter.getColor(driver.rank),
})),
podium: apiDto.slice(0, 3).map((driver, index) => {
const positions = [2, 1, 3]; // Display order: 2nd, 1st, 3rd
@@ -51,4 +54,6 @@ export class DriverRankingsViewDataBuilder {
showFilters: false,
};
}
}
}
DriverRankingsViewDataBuilder satisfies ViewDataBuilder<DriverLeaderboardItemDTO[], DriverRankingsViewData>;

View File

@@ -0,0 +1,382 @@
import { describe, it, expect } from 'vitest';
import { DriversViewDataBuilder } from './DriversViewDataBuilder';
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
describe('DriversViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform DriversLeaderboardDTO to DriversViewData correctly', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
category: 'Elite',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/john.jpg',
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 1100.75,
skillLevel: 'Advanced',
category: 'Pro',
nationality: 'Canada',
racesCompleted: 120,
wins: 15,
podiums: 45,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/jane.jpg',
},
],
totalRaces: 270,
totalWins: 40,
activeCount: 2,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0].id).toBe('driver-1');
expect(result.drivers[0].name).toBe('John Doe');
expect(result.drivers[0].rating).toBe(1234.56);
expect(result.drivers[0].ratingLabel).toBe('1,235');
expect(result.drivers[0].skillLevel).toBe('Pro');
expect(result.drivers[0].category).toBe('Elite');
expect(result.drivers[0].nationality).toBe('USA');
expect(result.drivers[0].racesCompleted).toBe(150);
expect(result.drivers[0].wins).toBe(25);
expect(result.drivers[0].podiums).toBe(60);
expect(result.drivers[0].isActive).toBe(true);
expect(result.drivers[0].rank).toBe(1);
expect(result.drivers[0].avatarUrl).toBe('https://example.com/john.jpg');
expect(result.drivers[1].id).toBe('driver-2');
expect(result.drivers[1].name).toBe('Jane Smith');
expect(result.drivers[1].rating).toBe(1100.75);
expect(result.drivers[1].ratingLabel).toBe('1,101');
expect(result.drivers[1].skillLevel).toBe('Advanced');
expect(result.drivers[1].category).toBe('Pro');
expect(result.drivers[1].nationality).toBe('Canada');
expect(result.drivers[1].racesCompleted).toBe(120);
expect(result.drivers[1].wins).toBe(15);
expect(result.drivers[1].podiums).toBe(45);
expect(result.drivers[1].isActive).toBe(true);
expect(result.drivers[1].rank).toBe(2);
expect(result.drivers[1].avatarUrl).toBe('https://example.com/jane.jpg');
expect(result.totalRaces).toBe(270);
expect(result.totalRacesLabel).toBe('270');
expect(result.totalWins).toBe(40);
expect(result.totalWinsLabel).toBe('40');
expect(result.activeCount).toBe(2);
expect(result.activeCountLabel).toBe('2');
expect(result.totalDriversLabel).toBe('2');
});
it('should handle drivers with missing optional fields', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].category).toBeUndefined();
expect(result.drivers[0].avatarUrl).toBeUndefined();
});
it('should handle empty drivers array', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers).toEqual([]);
expect(result.totalRaces).toBe(0);
expect(result.totalRacesLabel).toBe('0');
expect(result.totalWins).toBe(0);
expect(result.totalWinsLabel).toBe('0');
expect(result.activeCount).toBe(0);
expect(result.activeCountLabel).toBe('0');
expect(result.totalDriversLabel).toBe('0');
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
category: 'Elite',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/john.jpg',
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].name).toBe(driversDTO.drivers[0].name);
expect(result.drivers[0].nationality).toBe(driversDTO.drivers[0].nationality);
expect(result.drivers[0].skillLevel).toBe(driversDTO.drivers[0].skillLevel);
expect(result.totalRaces).toBe(driversDTO.totalRaces);
expect(result.totalWins).toBe(driversDTO.totalWins);
expect(result.activeCount).toBe(driversDTO.activeCount);
});
it('should not modify the input DTO', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
category: 'Elite',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/john.jpg',
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const originalDTO = JSON.parse(JSON.stringify(driversDTO));
DriversViewDataBuilder.build(driversDTO);
expect(driversDTO).toEqual(originalDTO);
});
it('should transform all numeric fields to formatted strings where appropriate', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
// Rating label should be a formatted string
expect(typeof result.drivers[0].ratingLabel).toBe('string');
expect(result.drivers[0].ratingLabel).toBe('1,235');
// Total counts should be formatted strings
expect(typeof result.totalRacesLabel).toBe('string');
expect(result.totalRacesLabel).toBe('150');
expect(typeof result.totalWinsLabel).toBe('string');
expect(result.totalWinsLabel).toBe('25');
expect(typeof result.activeCountLabel).toBe('string');
expect(result.activeCountLabel).toBe('1');
expect(typeof result.totalDriversLabel).toBe('string');
expect(result.totalDriversLabel).toBe('1');
});
it('should handle large numbers correctly', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 999999.99,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 10000,
wins: 2500,
podiums: 5000,
isActive: true,
rank: 1,
},
],
totalRaces: 10000,
totalWins: 2500,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].ratingLabel).toBe('1,000,000');
expect(result.totalRacesLabel).toBe('10,000');
expect(result.totalWinsLabel).toBe('2,500');
expect(result.activeCountLabel).toBe('1');
expect(result.totalDriversLabel).toBe('1');
});
});
describe('edge cases', () => {
it('should handle null/undefined rating', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 0,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].ratingLabel).toBe('0');
});
it('should handle drivers with no category', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].category).toBeUndefined();
});
it('should handle inactive drivers', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'Pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: false,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 0,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.drivers[0].isActive).toBe(false);
expect(result.activeCount).toBe(0);
expect(result.activeCountLabel).toBe('0');
});
});
describe('derived fields', () => {
it('should correctly calculate total drivers label', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
],
totalRaces: 350,
totalWins: 45,
activeCount: 2,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.totalDriversLabel).toBe('3');
});
it('should correctly calculate active count', () => {
const driversDTO: DriversLeaderboardDTO = {
drivers: [
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
],
totalRaces: 350,
totalWins: 45,
activeCount: 2,
};
const result = DriversViewDataBuilder.build(driversDTO);
expect(result.activeCount).toBe(2);
expect(result.activeCountLabel).toBe('2');
});
});
});

View File

@@ -1,33 +1,38 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { NumberFormatter } from '@/lib/formatters/NumberFormatter';
import { RatingFormatter } from '@/lib/formatters/RatingFormatter';
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
import type { DriversViewData } from '@/lib/view-data/DriversViewData';
export class DriversViewDataBuilder {
static build(dto: DriversLeaderboardDTO): DriversViewData {
public static build(apiDto: DriversLeaderboardDTO): DriversViewData {
return {
drivers: dto.drivers.map(driver => ({
drivers: apiDto.drivers.map(driver => ({
id: driver.id,
name: driver.name,
rating: driver.rating,
ratingLabel: RatingDisplay.format(driver.rating),
ratingLabel: RatingFormatter.format(driver.rating),
skillLevel: driver.skillLevel,
category: driver.category,
category: driver.category ?? undefined,
nationality: driver.nationality,
racesCompleted: driver.racesCompleted,
wins: driver.wins,
podiums: driver.podiums,
isActive: driver.isActive,
rank: driver.rank,
avatarUrl: driver.avatarUrl,
avatarUrl: driver.avatarUrl ?? undefined,
})),
totalRaces: dto.totalRaces,
totalRacesLabel: NumberDisplay.format(dto.totalRaces),
totalWins: dto.totalWins,
totalWinsLabel: NumberDisplay.format(dto.totalWins),
activeCount: dto.activeCount,
activeCountLabel: NumberDisplay.format(dto.activeCount),
totalDriversLabel: NumberDisplay.format(dto.drivers.length),
totalRaces: apiDto.totalRaces,
totalRacesLabel: NumberFormatter.format(apiDto.totalRaces),
totalWins: apiDto.totalWins,
totalWinsLabel: NumberFormatter.format(apiDto.totalWins),
activeCount: apiDto.activeCount,
activeCountLabel: NumberFormatter.format(apiDto.activeCount),
totalDriversLabel: NumberFormatter.format(apiDto.drivers.length),
};
}
}
}
DriversViewDataBuilder satisfies ViewDataBuilder<DriversLeaderboardDTO, DriversViewData>;

View File

@@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest';
import { ForgotPasswordViewDataBuilder } from './ForgotPasswordViewDataBuilder';
import type { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
describe('ForgotPasswordViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform ForgotPasswordPageDTO to ForgotPasswordViewData correctly', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result).toEqual({
returnTo: '/login',
showSuccess: false,
formState: {
fields: {
email: { value: '', error: undefined, touched: false, validating: false },
},
isValid: true,
isSubmitting: false,
submitError: undefined,
submitCount: 0,
},
isSubmitting: false,
submitError: undefined,
});
});
it('should handle empty returnTo path', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result.returnTo).toBe('');
});
it('should handle returnTo with query parameters', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login?error=expired',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result.returnTo).toBe('/login?error=expired');
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result.returnTo).toBe(forgotPasswordPageDTO.returnTo);
});
it('should not modify the input DTO', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login',
};
const originalDTO = { ...forgotPasswordPageDTO };
ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(forgotPasswordPageDTO).toEqual(originalDTO);
});
it('should initialize form field with default values', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result.formState.fields.email.value).toBe('');
expect(result.formState.fields.email.error).toBeUndefined();
expect(result.formState.fields.email.touched).toBe(false);
expect(result.formState.fields.email.validating).toBe(false);
});
it('should initialize form state with default values', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result.formState.isValid).toBe(true);
expect(result.formState.isSubmitting).toBe(false);
expect(result.formState.submitError).toBeUndefined();
expect(result.formState.submitCount).toBe(0);
});
it('should initialize UI state flags correctly', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result.showSuccess).toBe(false);
expect(result.isSubmitting).toBe(false);
expect(result.submitError).toBeUndefined();
});
});
describe('edge cases', () => {
it('should handle returnTo with encoded characters', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login?redirect=%2Fdashboard',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result.returnTo).toBe('/login?redirect=%2Fdashboard');
});
it('should handle returnTo with hash fragment', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login#section',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result.returnTo).toBe('/login#section');
});
});
describe('form state structure', () => {
it('should have email field', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
expect(result.formState.fields).toHaveProperty('email');
});
it('should have consistent field state structure', () => {
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
returnTo: '/login',
};
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
const field = result.formState.fields.email;
expect(field).toHaveProperty('value');
expect(field).toHaveProperty('error');
expect(field).toHaveProperty('touched');
expect(field).toHaveProperty('validating');
});
});
});

View File

@@ -1,15 +1,11 @@
/**
* Forgot Password View Data Builder
*
* Transforms ForgotPasswordPageDTO into ViewData for the forgot password template.
* Deterministic, side-effect free, no business logic.
*/
import { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
import { ForgotPasswordViewData } from './types/ForgotPasswordViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { ForgotPasswordPageDTO } from '@/lib/types/generated/ForgotPasswordPageDTO';
import type { ForgotPasswordViewData } from '@/lib/view-data/ForgotPasswordViewData';
export class ForgotPasswordViewDataBuilder {
static build(apiDto: ForgotPasswordPageDTO): ForgotPasswordViewData {
public static build(apiDto: ForgotPasswordPageDTO): ForgotPasswordViewData {
return {
returnTo: apiDto.returnTo,
showSuccess: false,
@@ -26,4 +22,6 @@ export class ForgotPasswordViewDataBuilder {
submitError: undefined,
};
}
}
}
ForgotPasswordViewDataBuilder satisfies ViewDataBuilder<ForgotPasswordPageDTO, ForgotPasswordViewData>;

View File

@@ -1,5 +0,0 @@
export interface GenerateAvatarsViewData {
success: boolean;
avatarUrls: string[];
errorMessage?: string;
}

View File

@@ -0,0 +1,200 @@
import { describe, it, expect } from 'vitest';
import { GenerateAvatarsViewDataBuilder } from './GenerateAvatarsViewDataBuilder';
import type { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
describe('GenerateAvatarsViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform RequestAvatarGenerationOutputDTO to GenerateAvatarsViewData correctly', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3'],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result).toEqual({
success: true,
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3'],
errorMessage: null,
});
});
it('should handle empty avatar URLs', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: [],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.avatarUrls).toHaveLength(0);
});
it('should handle single avatar URL', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: ['avatar-url-1'],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.avatarUrls).toHaveLength(1);
expect(result.avatarUrls[0]).toBe('avatar-url-1');
});
it('should handle multiple avatar URLs', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3', 'avatar-url-4', 'avatar-url-5'],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.avatarUrls).toHaveLength(5);
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: ['avatar-url-1', 'avatar-url-2'],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.success).toBe(requestAvatarGenerationOutputDto.success);
expect(result.avatarUrls).toEqual(requestAvatarGenerationOutputDto.avatarUrls);
expect(result.errorMessage).toBe(requestAvatarGenerationOutputDto.errorMessage);
});
it('should not modify the input DTO', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: ['avatar-url-1'],
errorMessage: null,
};
const originalDto = { ...requestAvatarGenerationOutputDto };
GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(requestAvatarGenerationOutputDto).toEqual(originalDto);
});
});
describe('edge cases', () => {
it('should handle success false', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: false,
avatarUrls: [],
errorMessage: 'Generation failed',
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.success).toBe(false);
});
it('should handle error message', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: false,
avatarUrls: [],
errorMessage: 'Invalid input data',
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.errorMessage).toBe('Invalid input data');
});
it('should handle null error message', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: ['avatar-url-1'],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.errorMessage).toBeNull();
});
it('should handle undefined avatarUrls', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: undefined,
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.avatarUrls).toEqual([]);
});
it('should handle empty string avatar URLs', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: ['', 'avatar-url-1', ''],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.avatarUrls).toEqual(['', 'avatar-url-1', '']);
});
it('should handle special characters in avatar URLs', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: ['avatar-url-1?param=value', 'avatar-url-2#anchor', 'avatar-url-3?query=1&test=2'],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.avatarUrls).toEqual([
'avatar-url-1?param=value',
'avatar-url-2#anchor',
'avatar-url-3?query=1&test=2',
]);
});
it('should handle very long avatar URLs', () => {
const longUrl = 'https://example.com/avatars/' + 'a'.repeat(1000) + '.png';
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: [longUrl],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.avatarUrls[0]).toBe(longUrl);
});
it('should handle avatar URLs with special characters', () => {
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
success: true,
avatarUrls: [
'avatar-url-1?name=John%20Doe',
'avatar-url-2?email=test@example.com',
'avatar-url-3?query=hello%20world',
],
errorMessage: null,
};
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
expect(result.avatarUrls).toEqual([
'avatar-url-1?name=John%20Doe',
'avatar-url-2?email=test@example.com',
'avatar-url-3?query=hello%20world',
]);
});
});
});

View File

@@ -1,25 +1,17 @@
/**
* GenerateAvatars ViewData Builder
*
* Transforms avatar generation result into ViewData for templates.
* Must be used in mutations to avoid returning DTOs directly.
*/
import { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
import { GenerateAvatarsViewData } from './GenerateAvatarsViewData';
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
import type { GenerateAvatarsViewData } from '@/lib/view-data/GenerateAvatarsViewData';
export class GenerateAvatarsViewDataBuilder {
/**
* Transform DTO into ViewData
*
* @param apiDto - The API DTO to transform
* @returns ViewData for templates
*/
static build(apiDto: RequestAvatarGenerationOutputDTO): GenerateAvatarsViewData {
public static build(apiDto: RequestAvatarGenerationOutputDTO): GenerateAvatarsViewData {
return {
success: apiDto.success,
avatarUrls: apiDto.avatarUrls || [],
errorMessage: apiDto.errorMessage,
};
}
}
}
GenerateAvatarsViewDataBuilder satisfies ViewDataBuilder<RequestAvatarGenerationOutputDTO, GenerateAvatarsViewData>;

View File

@@ -0,0 +1,553 @@
import { describe, it, expect } from 'vitest';
import { HealthViewDataBuilder, HealthDTO } from './HealthViewDataBuilder';
describe('HealthViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform HealthDTO to HealthViewData correctly', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: 99.95,
responseTime: 150,
errorRate: 0.05,
lastCheck: new Date().toISOString(),
checksPassed: 995,
checksFailed: 5,
components: [
{
name: 'Database',
status: 'ok',
lastCheck: new Date().toISOString(),
responseTime: 50,
errorRate: 0.01,
},
{
name: 'API',
status: 'ok',
lastCheck: new Date().toISOString(),
responseTime: 100,
errorRate: 0.02,
},
],
alerts: [
{
id: 'alert-1',
type: 'info',
title: 'System Update',
message: 'System updated successfully',
timestamp: new Date().toISOString(),
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.overallStatus.status).toBe('ok');
expect(result.overallStatus.statusLabel).toBe('Healthy');
expect(result.overallStatus.statusColor).toBe('#10b981');
expect(result.overallStatus.statusIcon).toBe('✓');
expect(result.metrics.uptime).toBe('99.95%');
expect(result.metrics.responseTime).toBe('150ms');
expect(result.metrics.errorRate).toBe('0.05%');
expect(result.metrics.checksPassed).toBe(995);
expect(result.metrics.checksFailed).toBe(5);
expect(result.metrics.totalChecks).toBe(1000);
expect(result.metrics.successRate).toBe('99.5%');
expect(result.components).toHaveLength(2);
expect(result.components[0].name).toBe('Database');
expect(result.components[0].status).toBe('ok');
expect(result.components[0].statusLabel).toBe('Healthy');
expect(result.alerts).toHaveLength(1);
expect(result.alerts[0].id).toBe('alert-1');
expect(result.alerts[0].type).toBe('info');
expect(result.hasAlerts).toBe(true);
expect(result.hasDegradedComponents).toBe(false);
expect(result.hasErrorComponents).toBe(false);
});
it('should handle missing optional fields gracefully', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.overallStatus.status).toBe('ok');
expect(result.metrics.uptime).toBe('N/A');
expect(result.metrics.responseTime).toBe('N/A');
expect(result.metrics.errorRate).toBe('N/A');
expect(result.metrics.checksPassed).toBe(0);
expect(result.metrics.checksFailed).toBe(0);
expect(result.metrics.totalChecks).toBe(0);
expect(result.metrics.successRate).toBe('N/A');
expect(result.components).toEqual([]);
expect(result.alerts).toEqual([]);
expect(result.hasAlerts).toBe(false);
expect(result.hasDegradedComponents).toBe(false);
expect(result.hasErrorComponents).toBe(false);
});
it('should handle degraded status correctly', () => {
const healthDTO: HealthDTO = {
status: 'degraded',
timestamp: new Date().toISOString(),
uptime: 95.5,
responseTime: 500,
errorRate: 4.5,
components: [
{
name: 'Database',
status: 'degraded',
lastCheck: new Date().toISOString(),
responseTime: 200,
errorRate: 2.0,
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.overallStatus.status).toBe('degraded');
expect(result.overallStatus.statusLabel).toBe('Degraded');
expect(result.overallStatus.statusColor).toBe('#f59e0b');
expect(result.overallStatus.statusIcon).toBe('⚠');
expect(result.metrics.uptime).toBe('95.50%');
expect(result.metrics.responseTime).toBe('500ms');
expect(result.metrics.errorRate).toBe('4.50%');
expect(result.hasDegradedComponents).toBe(true);
});
it('should handle error status correctly', () => {
const healthDTO: HealthDTO = {
status: 'error',
timestamp: new Date().toISOString(),
uptime: 85.2,
responseTime: 2000,
errorRate: 14.8,
components: [
{
name: 'Database',
status: 'error',
lastCheck: new Date().toISOString(),
responseTime: 1500,
errorRate: 10.0,
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.overallStatus.status).toBe('error');
expect(result.overallStatus.statusLabel).toBe('Error');
expect(result.overallStatus.statusColor).toBe('#ef4444');
expect(result.overallStatus.statusIcon).toBe('✕');
expect(result.metrics.uptime).toBe('85.20%');
expect(result.metrics.responseTime).toBe('2.00s');
expect(result.metrics.errorRate).toBe('14.80%');
expect(result.hasErrorComponents).toBe(true);
});
it('should handle multiple components with mixed statuses', () => {
const healthDTO: HealthDTO = {
status: 'degraded',
timestamp: new Date().toISOString(),
components: [
{
name: 'Database',
status: 'ok',
lastCheck: new Date().toISOString(),
},
{
name: 'API',
status: 'degraded',
lastCheck: new Date().toISOString(),
},
{
name: 'Cache',
status: 'error',
lastCheck: new Date().toISOString(),
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.components).toHaveLength(3);
expect(result.hasDegradedComponents).toBe(true);
expect(result.hasErrorComponents).toBe(true);
expect(result.components[0].statusLabel).toBe('Healthy');
expect(result.components[1].statusLabel).toBe('Degraded');
expect(result.components[2].statusLabel).toBe('Error');
});
it('should handle multiple alerts with different severities', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
alerts: [
{
id: 'alert-1',
type: 'critical',
title: 'Critical Alert',
message: 'Critical issue detected',
timestamp: new Date().toISOString(),
},
{
id: 'alert-2',
type: 'warning',
title: 'Warning Alert',
message: 'Warning message',
timestamp: new Date().toISOString(),
},
{
id: 'alert-3',
type: 'info',
title: 'Info Alert',
message: 'Informational message',
timestamp: new Date().toISOString(),
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.alerts).toHaveLength(3);
expect(result.hasAlerts).toBe(true);
expect(result.alerts[0].severity).toBe('Critical');
expect(result.alerts[0].severityColor).toBe('#ef4444');
expect(result.alerts[1].severity).toBe('Warning');
expect(result.alerts[1].severityColor).toBe('#f59e0b');
expect(result.alerts[2].severity).toBe('Info');
expect(result.alerts[2].severityColor).toBe('#3b82f6');
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const now = new Date();
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: now.toISOString(),
uptime: 99.99,
responseTime: 100,
errorRate: 0.01,
lastCheck: now.toISOString(),
checksPassed: 9999,
checksFailed: 1,
components: [
{
name: 'Test Component',
status: 'ok',
lastCheck: now.toISOString(),
responseTime: 50,
errorRate: 0.005,
},
],
alerts: [
{
id: 'test-alert',
type: 'info',
title: 'Test Alert',
message: 'Test message',
timestamp: now.toISOString(),
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.overallStatus.status).toBe(healthDTO.status);
expect(result.overallStatus.timestamp).toBe(healthDTO.timestamp);
expect(result.metrics.uptime).toBe('99.99%');
expect(result.metrics.responseTime).toBe('100ms');
expect(result.metrics.errorRate).toBe('0.01%');
expect(result.metrics.lastCheck).toBe(healthDTO.lastCheck);
expect(result.metrics.checksPassed).toBe(healthDTO.checksPassed);
expect(result.metrics.checksFailed).toBe(healthDTO.checksFailed);
expect(result.components[0].name).toBe(healthDTO.components![0].name);
expect(result.components[0].status).toBe(healthDTO.components![0].status);
expect(result.alerts[0].id).toBe(healthDTO.alerts![0].id);
expect(result.alerts[0].type).toBe(healthDTO.alerts![0].type);
});
it('should not modify the input DTO', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: 99.95,
responseTime: 150,
errorRate: 0.05,
components: [
{
name: 'Database',
status: 'ok',
lastCheck: new Date().toISOString(),
},
],
};
const originalDTO = JSON.parse(JSON.stringify(healthDTO));
HealthViewDataBuilder.build(healthDTO);
expect(healthDTO).toEqual(originalDTO);
});
it('should transform all numeric fields to formatted strings', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: 99.95,
responseTime: 150,
errorRate: 0.05,
checksPassed: 995,
checksFailed: 5,
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(typeof result.metrics.uptime).toBe('string');
expect(typeof result.metrics.responseTime).toBe('string');
expect(typeof result.metrics.errorRate).toBe('string');
expect(typeof result.metrics.successRate).toBe('string');
});
it('should handle large numbers correctly', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: 99.999,
responseTime: 5000,
errorRate: 0.001,
checksPassed: 999999,
checksFailed: 1,
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.metrics.uptime).toBe('100.00%');
expect(result.metrics.responseTime).toBe('5.00s');
expect(result.metrics.errorRate).toBe('0.00%');
expect(result.metrics.successRate).toBe('100.0%');
});
});
describe('edge cases', () => {
it('should handle null/undefined numeric fields', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: null as any,
responseTime: undefined,
errorRate: null as any,
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.metrics.uptime).toBe('N/A');
expect(result.metrics.responseTime).toBe('N/A');
expect(result.metrics.errorRate).toBe('N/A');
});
it('should handle negative numeric values', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: -1,
responseTime: -100,
errorRate: -0.5,
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.metrics.uptime).toBe('N/A');
expect(result.metrics.responseTime).toBe('N/A');
expect(result.metrics.errorRate).toBe('N/A');
});
it('should handle empty components and alerts arrays', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
components: [],
alerts: [],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.components).toEqual([]);
expect(result.alerts).toEqual([]);
expect(result.hasAlerts).toBe(false);
expect(result.hasDegradedComponents).toBe(false);
expect(result.hasErrorComponents).toBe(false);
});
it('should handle component with missing optional fields', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
components: [
{
name: 'Test Component',
status: 'ok',
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.components[0].lastCheck).toBeDefined();
expect(result.components[0].formattedLastCheck).toBeDefined();
expect(result.components[0].responseTime).toBe('N/A');
expect(result.components[0].errorRate).toBe('N/A');
});
it('should handle alert with missing optional fields', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
alerts: [
{
id: 'alert-1',
type: 'info',
title: 'Test Alert',
message: 'Test message',
timestamp: new Date().toISOString(),
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.alerts[0].id).toBe('alert-1');
expect(result.alerts[0].type).toBe('info');
expect(result.alerts[0].title).toBe('Test Alert');
expect(result.alerts[0].message).toBe('Test message');
expect(result.alerts[0].timestamp).toBeDefined();
expect(result.alerts[0].formattedTimestamp).toBeDefined();
expect(result.alerts[0].relativeTime).toBeDefined();
});
it('should handle unknown status', () => {
const healthDTO: HealthDTO = {
status: 'unknown',
timestamp: new Date().toISOString(),
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.overallStatus.status).toBe('unknown');
expect(result.overallStatus.statusLabel).toBe('Unknown');
expect(result.overallStatus.statusColor).toBe('#6b7280');
expect(result.overallStatus.statusIcon).toBe('?');
});
});
describe('derived fields', () => {
it('should correctly calculate hasAlerts', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
alerts: [
{
id: 'alert-1',
type: 'info',
title: 'Test',
message: 'Test message',
timestamp: new Date().toISOString(),
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.hasAlerts).toBe(true);
});
it('should correctly calculate hasDegradedComponents', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
components: [
{
name: 'Component 1',
status: 'ok',
lastCheck: new Date().toISOString(),
},
{
name: 'Component 2',
status: 'degraded',
lastCheck: new Date().toISOString(),
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.hasDegradedComponents).toBe(true);
});
it('should correctly calculate hasErrorComponents', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
components: [
{
name: 'Component 1',
status: 'ok',
lastCheck: new Date().toISOString(),
},
{
name: 'Component 2',
status: 'error',
lastCheck: new Date().toISOString(),
},
],
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.hasErrorComponents).toBe(true);
});
it('should correctly calculate totalChecks', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
checksPassed: 100,
checksFailed: 20,
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.metrics.totalChecks).toBe(120);
});
it('should correctly calculate successRate', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
checksPassed: 90,
checksFailed: 10,
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.metrics.successRate).toBe('90.0%');
});
it('should handle zero checks correctly', () => {
const healthDTO: HealthDTO = {
status: 'ok',
timestamp: new Date().toISOString(),
checksPassed: 0,
checksFailed: 0,
};
const result = HealthViewDataBuilder.build(healthDTO);
expect(result.metrics.totalChecks).toBe(0);
expect(result.metrics.successRate).toBe('N/A');
});
});
});

View File

@@ -1,95 +1,66 @@
/**
* Health View Data Builder
*
* Transforms health DTO data into UI-ready view models.
* This layer isolates the UI from API churn by providing a stable interface
* between the API layer and the presentation layer.
*/
import type { HealthViewData, HealthStatus, HealthMetrics, HealthComponent, HealthAlert } from '@/lib/view-data/HealthViewData';
import { HealthStatusDisplay } from '@/lib/display-objects/HealthStatusDisplay';
import { HealthMetricDisplay } from '@/lib/display-objects/HealthMetricDisplay';
import { HealthComponentDisplay } from '@/lib/display-objects/HealthComponentDisplay';
import { HealthAlertDisplay } from '@/lib/display-objects/HealthAlertDisplay';
export interface HealthDTO {
status: 'ok' | 'degraded' | 'error' | 'unknown';
timestamp: string;
uptime?: number;
responseTime?: number;
errorRate?: number;
lastCheck?: string;
checksPassed?: number;
checksFailed?: number;
components?: Array<{
name: string;
status: 'ok' | 'degraded' | 'error' | 'unknown';
lastCheck?: string;
responseTime?: number;
errorRate?: number;
}>;
alerts?: Array<{
id: string;
type: 'critical' | 'warning' | 'info';
title: string;
message: string;
timestamp: string;
}>;
}
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { HealthAlertFormatter } from '@/lib/formatters/HealthAlertFormatter';
import { HealthComponentFormatter } from '@/lib/formatters/HealthComponentFormatter';
import { HealthMetricFormatter } from '@/lib/formatters/HealthMetricFormatter';
import { HealthStatusFormatter } from '@/lib/formatters/HealthStatusFormatter';
import type { HealthDTO } from '@/lib/types/generated/HealthDTO';
import type { HealthAlert, HealthComponent, HealthMetrics, HealthStatus, HealthViewData } from '@/lib/view-data/HealthViewData';
export class HealthViewDataBuilder {
static build(dto: HealthDTO): HealthViewData {
public static build(apiDto: HealthDTO): HealthViewData {
const now = new Date();
const lastUpdated = dto.timestamp || now.toISOString();
const lastUpdated = apiDto.timestamp || now.toISOString();
// Build overall status
const overallStatus: HealthStatus = {
status: dto.status,
timestamp: dto.timestamp,
formattedTimestamp: HealthStatusDisplay.formatTimestamp(dto.timestamp),
relativeTime: HealthStatusDisplay.formatRelativeTime(dto.timestamp),
statusLabel: HealthStatusDisplay.formatStatusLabel(dto.status),
statusColor: HealthStatusDisplay.formatStatusColor(dto.status),
statusIcon: HealthStatusDisplay.formatStatusIcon(dto.status),
status: apiDto.status,
timestamp: apiDto.timestamp,
formattedTimestamp: HealthStatusFormatter.formatTimestamp(apiDto.timestamp),
relativeTime: HealthStatusFormatter.formatRelativeTime(apiDto.timestamp),
statusLabel: HealthStatusFormatter.formatStatusLabel(apiDto.status),
statusColor: HealthStatusFormatter.formatStatusColor(apiDto.status),
statusIcon: HealthStatusFormatter.formatStatusIcon(apiDto.status),
};
// Build metrics
const metrics: HealthMetrics = {
uptime: HealthMetricDisplay.formatUptime(dto.uptime),
responseTime: HealthMetricDisplay.formatResponseTime(dto.responseTime),
errorRate: HealthMetricDisplay.formatErrorRate(dto.errorRate),
lastCheck: dto.lastCheck || lastUpdated,
formattedLastCheck: HealthMetricDisplay.formatTimestamp(dto.lastCheck || lastUpdated),
checksPassed: dto.checksPassed || 0,
checksFailed: dto.checksFailed || 0,
totalChecks: (dto.checksPassed || 0) + (dto.checksFailed || 0),
successRate: HealthMetricDisplay.formatSuccessRate(dto.checksPassed, dto.checksFailed),
uptime: HealthMetricFormatter.formatUptime(apiDto.uptime),
responseTime: HealthMetricFormatter.formatResponseTime(apiDto.responseTime),
errorRate: HealthMetricFormatter.formatErrorRate(apiDto.errorRate),
lastCheck: apiDto.lastCheck || lastUpdated,
formattedLastCheck: HealthMetricFormatter.formatTimestamp(apiDto.lastCheck || lastUpdated),
checksPassed: apiDto.checksPassed || 0,
checksFailed: apiDto.checksFailed || 0,
totalChecks: (apiDto.checksPassed || 0) + (apiDto.checksFailed || 0),
successRate: HealthMetricFormatter.formatSuccessRate(apiDto.checksPassed, apiDto.checksFailed),
};
// Build components
const components: HealthComponent[] = (dto.components || []).map((component) => ({
const components: HealthComponent[] = (apiDto.components || []).map((component) => ({
name: component.name,
status: component.status,
statusLabel: HealthComponentDisplay.formatStatusLabel(component.status),
statusColor: HealthComponentDisplay.formatStatusColor(component.status),
statusIcon: HealthComponentDisplay.formatStatusIcon(component.status),
statusLabel: HealthComponentFormatter.formatStatusLabel(component.status),
statusColor: HealthComponentFormatter.formatStatusColor(component.status),
statusIcon: HealthComponentFormatter.formatStatusIcon(component.status),
lastCheck: component.lastCheck || lastUpdated,
formattedLastCheck: HealthComponentDisplay.formatTimestamp(component.lastCheck || lastUpdated),
responseTime: HealthMetricDisplay.formatResponseTime(component.responseTime),
errorRate: HealthMetricDisplay.formatErrorRate(component.errorRate),
formattedLastCheck: HealthComponentFormatter.formatTimestamp(component.lastCheck || lastUpdated),
responseTime: HealthMetricFormatter.formatResponseTime(component.responseTime),
errorRate: HealthMetricFormatter.formatErrorRate(component.errorRate),
}));
// Build alerts
const alerts: HealthAlert[] = (dto.alerts || []).map((alert) => ({
const alerts: HealthAlert[] = (apiDto.alerts || []).map((alert) => ({
id: alert.id,
type: alert.type,
title: alert.title,
message: alert.message,
timestamp: alert.timestamp,
formattedTimestamp: HealthAlertDisplay.formatTimestamp(alert.timestamp),
relativeTime: HealthAlertDisplay.formatRelativeTime(alert.timestamp),
severity: HealthAlertDisplay.formatSeverity(alert.type),
severityColor: HealthAlertDisplay.formatSeverityColor(alert.type),
formattedTimestamp: HealthAlertFormatter.formatTimestamp(alert.timestamp),
relativeTime: HealthAlertFormatter.formatRelativeTime(alert.timestamp),
severity: HealthAlertFormatter.formatSeverity(alert.type),
severityColor: HealthAlertFormatter.formatSeverityColor(alert.type),
}));
// Calculate derived fields
@@ -106,7 +77,9 @@ export class HealthViewDataBuilder {
hasDegradedComponents,
hasErrorComponents,
lastUpdated,
formattedLastUpdated: HealthStatusDisplay.formatTimestamp(lastUpdated),
formattedLastUpdated: HealthStatusFormatter.formatTimestamp(lastUpdated),
};
}
}
HealthViewDataBuilder satisfies ViewDataBuilder<HealthDTO, HealthViewData>;

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import { HomeViewDataBuilder } from './HomeViewDataBuilder';
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
describe('HomeViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform DashboardOverviewDTO to HomeViewData correctly', () => {
const homeDataDto: DashboardOverviewDTO = {
currentDriver: null,
upcomingRaces: [
{
id: 'race-1',
track: 'Test Track',
car: 'Test Car',
scheduledAt: '2024-01-01T10:00:00Z',
isMyLeague: false,
},
],
leagueStandingsSummaries: [
{
leagueId: 'league-1',
leagueName: 'Test League',
position: 1,
points: 100,
totalDrivers: 20,
},
],
feedSummary: { items: [] },
friends: [],
activeLeaguesCount: 1,
};
const result = HomeViewDataBuilder.build(homeDataDto);
expect(result).toEqual({
isAlpha: true,
upcomingRaces: [
{
id: 'race-1',
track: 'Test Track',
car: 'Test Car',
formattedDate: 'Mon, Jan 1, 2024',
},
],
topLeagues: [
{
id: 'league-1',
name: 'Test League',
description: '',
},
],
teams: [],
});
});
it('should handle empty arrays correctly', () => {
const homeDataDto: DashboardOverviewDTO = {
currentDriver: null,
upcomingRaces: [],
leagueStandingsSummaries: [],
feedSummary: { items: [] },
friends: [],
activeLeaguesCount: 0,
};
const result = HomeViewDataBuilder.build(homeDataDto);
expect(result).toEqual({
isAlpha: true,
upcomingRaces: [],
topLeagues: [],
teams: [],
});
});
});
describe('data transformation', () => {
it('should not modify the input DTO', () => {
const homeDataDto: DashboardOverviewDTO = {
currentDriver: null,
upcomingRaces: [{ id: 'race-1', track: 'Track', car: 'Car', scheduledAt: '2024-01-01T10:00:00Z', isMyLeague: false }],
leagueStandingsSummaries: [{ leagueId: 'league-1', leagueName: 'League', position: 1, points: 10, totalDrivers: 10 }],
feedSummary: { items: [] },
friends: [],
activeLeaguesCount: 1,
};
const originalDto = JSON.parse(JSON.stringify(homeDataDto));
HomeViewDataBuilder.build(homeDataDto);
expect(homeDataDto).toEqual(originalDto);
});
});
});

View File

@@ -1,24 +1,34 @@
import type { HomeViewData } from '@/templates/HomeTemplate';
import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO';
/**
* HomeViewDataBuilder
*
* Transforms HomeDataDTO to HomeViewData.
*/
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import { DashboardDateFormatter } from '@/lib/formatters/DashboardDateFormatter';
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
import type { HomeViewData } from '@/lib/view-data/HomeViewData';
export class HomeViewDataBuilder {
/**
* Build HomeViewData from HomeDataDTO
* Build HomeViewData from DashboardOverviewDTO
*
* @param apiDto - The API DTO
* @returns HomeViewData
*/
static build(apiDto: HomeDataDTO): HomeViewData {
public static build(apiDto: DashboardOverviewDTO): HomeViewData {
return {
isAlpha: apiDto.isAlpha,
upcomingRaces: apiDto.upcomingRaces,
topLeagues: apiDto.topLeagues,
teams: apiDto.teams,
isAlpha: true,
upcomingRaces: (apiDto.upcomingRaces || []).map(race => ({
id: race.id,
track: race.track,
car: race.car,
formattedDate: DashboardDateFormatter.format(new Date(race.scheduledAt)).date,
})),
topLeagues: (apiDto.leagueStandingsSummaries || []).map(league => ({
id: league.leagueId,
name: league.leagueName,
description: '',
})),
teams: [],
};
}
}
HomeViewDataBuilder satisfies ViewDataBuilder<DashboardOverviewDTO, HomeViewData>;

View File

@@ -0,0 +1,600 @@
import { describe, it, expect } from 'vitest';
import { LeaderboardsViewDataBuilder } from './LeaderboardsViewDataBuilder';
describe('LeaderboardsViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform Leaderboards DTO to LeaderboardsViewData correctly', () => {
const leaderboardsDTO = {
drivers: {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.jpg',
},
{
id: 'driver-2',
name: 'Jane Smith',
rating: 1100.0,
skillLevel: 'advanced',
nationality: 'Canada',
racesCompleted: 100,
wins: 15,
podiums: 40,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.jpg',
},
],
totalRaces: 250,
totalWins: 40,
activeCount: 2,
},
teams: {
teams: [
{
id: 'team-1',
name: 'Racing Team Alpha',
tag: 'RTA',
logoUrl: 'https://example.com/logo1.jpg',
memberCount: 15,
rating: 1500,
totalWins: 50,
totalRaces: 200,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
{
id: 'team-2',
name: 'Speed Demons',
tag: 'SD',
logoUrl: 'https://example.com/logo2.jpg',
memberCount: 8,
rating: 1200,
totalWins: 20,
totalRaces: 150,
performanceLevel: 'advanced',
isRecruiting: true,
createdAt: '2023-06-01',
},
],
recruitingCount: 5,
groupsBySkillLevel: 'pro,advanced,intermediate',
topTeams: [
{
id: 'team-1',
name: 'Racing Team Alpha',
tag: 'RTA',
logoUrl: 'https://example.com/logo1.jpg',
memberCount: 15,
rating: 1500,
totalWins: 50,
totalRaces: 200,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
{
id: 'team-2',
name: 'Speed Demons',
tag: 'SD',
logoUrl: 'https://example.com/logo2.jpg',
memberCount: 8,
rating: 1200,
totalWins: 20,
totalRaces: 150,
performanceLevel: 'advanced',
isRecruiting: true,
createdAt: '2023-06-01',
},
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
// Verify drivers
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0].id).toBe('driver-1');
expect(result.drivers[0].name).toBe('John Doe');
expect(result.drivers[0].rating).toBe(1234.56);
expect(result.drivers[0].skillLevel).toBe('pro');
expect(result.drivers[0].nationality).toBe('USA');
expect(result.drivers[0].wins).toBe(25);
expect(result.drivers[0].podiums).toBe(60);
expect(result.drivers[0].racesCompleted).toBe(150);
expect(result.drivers[0].rank).toBe(1);
expect(result.drivers[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
expect(result.drivers[0].position).toBe(1);
// Verify teams
expect(result.teams).toHaveLength(2);
expect(result.teams[0].id).toBe('team-1');
expect(result.teams[0].name).toBe('Racing Team Alpha');
expect(result.teams[0].tag).toBe('RTA');
expect(result.teams[0].memberCount).toBe(15);
expect(result.teams[0].totalWins).toBe(50);
expect(result.teams[0].totalRaces).toBe(200);
expect(result.teams[0].logoUrl).toBe('https://example.com/logo1.jpg');
expect(result.teams[0].position).toBe(1);
expect(result.teams[0].isRecruiting).toBe(false);
expect(result.teams[0].performanceLevel).toBe('elite');
expect(result.teams[0].rating).toBe(1500);
expect(result.teams[0].category).toBeUndefined();
});
it('should handle empty driver and team arrays', () => {
const leaderboardsDTO = {
drivers: {
drivers: [],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
},
teams: {
teams: [],
recruitingCount: 0,
groupsBySkillLevel: '',
topTeams: [],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.drivers).toEqual([]);
expect(result.teams).toEqual([]);
});
it('should handle missing avatar URLs with empty string fallback', () => {
const leaderboardsDTO = {
drivers: {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
},
teams: {
teams: [],
recruitingCount: 0,
groupsBySkillLevel: '',
topTeams: [
{
id: 'team-1',
name: 'Racing Team Alpha',
tag: 'RTA',
memberCount: 15,
totalWins: 50,
totalRaces: 200,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.drivers[0].avatarUrl).toBe('');
expect(result.teams[0].logoUrl).toBe('');
});
it('should handle missing optional team fields with defaults', () => {
const leaderboardsDTO = {
drivers: {
drivers: [],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
},
teams: {
teams: [],
recruitingCount: 0,
groupsBySkillLevel: '',
topTeams: [
{
id: 'team-1',
name: 'Racing Team Alpha',
tag: 'RTA',
memberCount: 15,
totalWins: 50,
totalRaces: 200,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.teams[0].rating).toBe(0);
expect(result.teams[0].logoUrl).toBe('');
});
it('should calculate position based on index', () => {
const leaderboardsDTO = {
drivers: {
drivers: [
{ id: 'driver-1', name: 'Driver 1', rating: 1000, skillLevel: 'pro', nationality: 'USA', racesCompleted: 100, wins: 10, podiums: 30, isActive: true, rank: 1 },
{ id: 'driver-2', name: 'Driver 2', rating: 900, skillLevel: 'advanced', nationality: 'Canada', racesCompleted: 80, wins: 8, podiums: 25, isActive: true, rank: 2 },
{ id: 'driver-3', name: 'Driver 3', rating: 800, skillLevel: 'intermediate', nationality: 'UK', racesCompleted: 60, wins: 5, podiums: 15, isActive: true, rank: 3 },
],
totalRaces: 240,
totalWins: 23,
activeCount: 3,
},
teams: {
teams: [],
recruitingCount: 1,
groupsBySkillLevel: 'elite,advanced,intermediate',
topTeams: [
{ id: 'team-1', name: 'Team 1', tag: 'T1', memberCount: 10, totalWins: 30, totalRaces: 150, performanceLevel: 'elite', isRecruiting: false, createdAt: '2023-01-01' },
{ id: 'team-2', name: 'Team 2', tag: 'T2', memberCount: 8, totalWins: 20, totalRaces: 120, performanceLevel: 'advanced', isRecruiting: true, createdAt: '2023-02-01' },
{ id: 'team-3', name: 'Team 3', tag: 'T3', memberCount: 6, totalWins: 10, totalRaces: 80, performanceLevel: 'intermediate', isRecruiting: false, createdAt: '2023-03-01' },
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.drivers[0].position).toBe(1);
expect(result.drivers[1].position).toBe(2);
expect(result.drivers[2].position).toBe(3);
expect(result.teams[0].position).toBe(1);
expect(result.teams[1].position).toBe(2);
expect(result.teams[2].position).toBe(3);
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const leaderboardsDTO = {
drivers: {
drivers: [
{
id: 'driver-123',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar.jpg',
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
},
teams: {
teams: [],
recruitingCount: 5,
groupsBySkillLevel: 'pro,advanced',
topTeams: [
{
id: 'team-123',
name: 'Racing Team Alpha',
tag: 'RTA',
logoUrl: 'https://example.com/logo.jpg',
memberCount: 15,
rating: 1500,
totalWins: 50,
totalRaces: 200,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.drivers[0].name).toBe(leaderboardsDTO.drivers.drivers[0].name);
expect(result.drivers[0].nationality).toBe(leaderboardsDTO.drivers.drivers[0].nationality);
expect(result.drivers[0].avatarUrl).toBe(leaderboardsDTO.drivers.drivers[0].avatarUrl);
expect(result.teams[0].name).toBe(leaderboardsDTO.teams.topTeams[0].name);
expect(result.teams[0].tag).toBe(leaderboardsDTO.teams.topTeams[0].tag);
expect(result.teams[0].logoUrl).toBe(leaderboardsDTO.teams.topTeams[0].logoUrl);
});
it('should not modify the input DTO', () => {
const leaderboardsDTO = {
drivers: {
drivers: [
{
id: 'driver-123',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar.jpg',
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
},
teams: {
teams: [],
recruitingCount: 5,
groupsBySkillLevel: 'pro,advanced',
topTeams: [
{
id: 'team-123',
name: 'Racing Team Alpha',
tag: 'RTA',
logoUrl: 'https://example.com/logo.jpg',
memberCount: 15,
rating: 1500,
totalWins: 50,
totalRaces: 200,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
],
},
};
const originalDTO = JSON.parse(JSON.stringify(leaderboardsDTO));
LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(leaderboardsDTO).toEqual(originalDTO);
});
it('should handle large numbers correctly', () => {
const leaderboardsDTO = {
drivers: {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 999999.99,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 10000,
wins: 2500,
podiums: 5000,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar.jpg',
},
],
totalRaces: 10000,
totalWins: 2500,
activeCount: 1,
},
teams: {
teams: [],
recruitingCount: 0,
groupsBySkillLevel: '',
topTeams: [
{
id: 'team-1',
name: 'Racing Team Alpha',
tag: 'RTA',
logoUrl: 'https://example.com/logo.jpg',
memberCount: 100,
rating: 999999,
totalWins: 5000,
totalRaces: 10000,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.drivers[0].rating).toBe(999999.99);
expect(result.drivers[0].wins).toBe(2500);
expect(result.drivers[0].podiums).toBe(5000);
expect(result.drivers[0].racesCompleted).toBe(10000);
expect(result.teams[0].rating).toBe(999999);
expect(result.teams[0].totalWins).toBe(5000);
expect(result.teams[0].totalRaces).toBe(10000);
});
});
describe('edge cases', () => {
it('should handle null/undefined avatar URLs', () => {
const leaderboardsDTO = {
drivers: {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: 1234.56,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
avatarUrl: null as any,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
},
teams: {
teams: [],
recruitingCount: 0,
groupsBySkillLevel: '',
topTeams: [
{
id: 'team-1',
name: 'Racing Team Alpha',
tag: 'RTA',
logoUrl: undefined as any,
memberCount: 15,
totalWins: 50,
totalRaces: 200,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.drivers[0].avatarUrl).toBe('');
expect(result.teams[0].logoUrl).toBe('');
});
it('should handle null/undefined rating', () => {
const leaderboardsDTO = {
drivers: {
drivers: [
{
id: 'driver-1',
name: 'John Doe',
rating: null as any,
skillLevel: 'pro',
nationality: 'USA',
racesCompleted: 150,
wins: 25,
podiums: 60,
isActive: true,
rank: 1,
},
],
totalRaces: 150,
totalWins: 25,
activeCount: 1,
},
teams: {
teams: [],
recruitingCount: 0,
groupsBySkillLevel: '',
topTeams: [
{
id: 'team-1',
name: 'Racing Team Alpha',
tag: 'RTA',
memberCount: 15,
rating: null as any,
totalWins: 50,
totalRaces: 200,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.drivers[0].rating).toBeNull();
expect(result.teams[0].rating).toBe(0);
});
it('should handle null/undefined totalWins and totalRaces', () => {
const leaderboardsDTO = {
drivers: {
drivers: [],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
},
teams: {
teams: [],
recruitingCount: 0,
groupsBySkillLevel: '',
topTeams: [
{
id: 'team-1',
name: 'Racing Team Alpha',
tag: 'RTA',
memberCount: 15,
totalWins: null as any,
totalRaces: null as any,
performanceLevel: 'elite',
isRecruiting: false,
createdAt: '2023-01-01',
},
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.teams[0].totalWins).toBe(0);
expect(result.teams[0].totalRaces).toBe(0);
});
it('should handle empty performance level', () => {
const leaderboardsDTO = {
drivers: {
drivers: [],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
},
teams: {
teams: [],
recruitingCount: 0,
groupsBySkillLevel: '',
topTeams: [
{
id: 'team-1',
name: 'Racing Team Alpha',
tag: 'RTA',
memberCount: 15,
totalWins: 50,
totalRaces: 200,
performanceLevel: '',
isRecruiting: false,
createdAt: '2023-01-01',
},
],
},
};
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
expect(result.teams[0].performanceLevel).toBe('N/A');
});
});
});

View File

@@ -1,11 +1,17 @@
import type { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
import type { GetTeamsLeaderboardOutputDTO } from '@/lib/types/generated/GetTeamsLeaderboardOutputDTO';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
type LeaderboardsInputDTO = {
drivers: { drivers: DriverLeaderboardItemDTO[] };
teams: GetTeamsLeaderboardOutputDTO;
}
export class LeaderboardsViewDataBuilder {
static build(
apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: GetTeamsLeaderboardOutputDTO }
): LeaderboardsViewData {
public static build(apiDto: LeaderboardsInputDTO): LeaderboardsViewData {
return {
drivers: apiDto.drivers.drivers.map(driver => ({
id: driver.id,
@@ -37,3 +43,5 @@ export class LeaderboardsViewDataBuilder {
};
}
}
LeaderboardsViewDataBuilder satisfies ViewDataBuilder<LeaderboardsInputDTO, LeaderboardsViewData>;

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import { LeagueCoverViewDataBuilder } from './LeagueCoverViewDataBuilder';
import type { MediaBinaryDTO } from '@/lib/types/generated/MediaBinaryDTO';
describe('LeagueCoverViewDataBuilder', () => {
describe('happy paths', () => {
it('should transform MediaBinaryDTO to LeagueCoverViewData correctly', () => {
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/png',
};
const result = LeagueCoverViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/png');
});
it('should handle JPEG cover images', () => {
const buffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/jpeg',
};
const result = LeagueCoverViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/jpeg');
});
it('should handle WebP cover images', () => {
const buffer = new Uint8Array([0x52, 0x49, 0x46, 0x46]);
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/webp',
};
const result = LeagueCoverViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/webp');
});
});
describe('data transformation', () => {
it('should preserve all DTO fields in the output', () => {
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/png',
};
const result = LeagueCoverViewDataBuilder.build(mediaDto);
expect(result.buffer).toBeDefined();
expect(result.contentType).toBe(mediaDto.contentType);
});
it('should not modify the input DTO', () => {
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/png',
};
const originalDto = { ...mediaDto };
LeagueCoverViewDataBuilder.build(mediaDto);
expect(mediaDto).toEqual(originalDto);
});
it('should convert buffer to base64 string', () => {
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/png',
};
const result = LeagueCoverViewDataBuilder.build(mediaDto);
expect(typeof result.buffer).toBe('string');
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
});
});
describe('edge cases', () => {
it('should handle empty buffer', () => {
const buffer = new Uint8Array([]);
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/png',
};
const result = LeagueCoverViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe('');
expect(result.contentType).toBe('image/png');
});
it('should handle large cover images', () => {
const buffer = new Uint8Array(2 * 1024 * 1024); // 2MB
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/jpeg',
};
const result = LeagueCoverViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/jpeg');
});
it('should handle buffer with all zeros', () => {
const buffer = new Uint8Array([0x00, 0x00, 0x00, 0x00]);
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/png',
};
const result = LeagueCoverViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/png');
});
it('should handle buffer with all ones', () => {
const buffer = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
const mediaDto: MediaBinaryDTO = {
buffer: buffer.buffer,
contentType: 'image/png',
};
const result = LeagueCoverViewDataBuilder.build(mediaDto);
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
expect(result.contentType).toBe('image/png');
});
});
});

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