Merge pull request 'view data tests' (#2) from tests/viewdata into main
Some checks failed
CI / lint-typecheck (push) Failing after 4m51s
CI / tests (push) Has been skipped
CI / contract-tests (push) Has been skipped
CI / e2e-tests (push) Has been skipped
CI / comment-pr (push) Has been skipped
CI / commit-types (push) Has been skipped

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-01-24 23:34:42 +00:00
1161 changed files with 35916 additions and 17048 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"
}
},
{
@@ -423,6 +426,16 @@
"no-restricted-syntax": "error"
}
},
{
"files": [
"apps/website/**/*.test.ts",
"apps/website/**/*.test.tsx"
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off"
}
},
{
"files": [
"tests/**/*.ts"

View File

@@ -3506,6 +3506,102 @@
"transactions"
]
},
"HomeDataDTO": {
"type": "object",
"properties": {
"isAlpha": {
"type": "boolean"
},
"upcomingRaces": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HomeUpcomingRaceDTO"
}
},
"topLeagues": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HomeTopLeagueDTO"
}
},
"teams": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HomeTeamDTO"
}
}
},
"required": [
"isAlpha",
"upcomingRaces",
"topLeagues",
"teams"
]
},
"HomeTeamDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"logoUrl": {
"type": "string"
}
},
"required": [
"id",
"name",
"description"
]
},
"HomeTopLeagueDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": [
"id",
"name",
"description"
]
},
"HomeUpcomingRaceDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"track": {
"type": "string"
},
"car": {
"type": "string"
},
"formattedDate": {
"type": "string"
}
},
"required": [
"id",
"track",
"car",
"formattedDate"
]
},
"ImportRaceResultsDTO": {
"type": "object",
"properties": {
@@ -4235,6 +4331,9 @@
"LeagueScheduleDTO": {
"type": "object",
"properties": {
"leagueId": {
"type": "string"
},
"seasonId": {
"type": "string"
},
@@ -4473,6 +4572,16 @@
},
"isParallelActive": {
"type": "boolean"
},
"totalRaces": {
"type": "number"
},
"completedRaces": {
"type": "number"
},
"nextRaceAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
@@ -4480,7 +4589,9 @@
"name",
"status",
"isPrimary",
"isParallelActive"
"isParallelActive",
"totalRaces",
"completedRaces"
]
},
"LeagueSettingsDTO": {
@@ -4515,6 +4626,18 @@
},
"races": {
"type": "number"
},
"positionChange": {
"type": "number"
},
"lastRacePoints": {
"type": "number"
},
"droppedRaceIds": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
@@ -4524,7 +4647,10 @@
"position",
"wins",
"podiums",
"races"
"races",
"positionChange",
"lastRacePoints",
"droppedRaceIds"
]
},
"LeagueStandingsDTO": {
@@ -4658,6 +4784,15 @@
"logoUrl": {
"type": "string",
"nullable": true
},
"pendingJoinRequestsCount": {
"type": "number"
},
"pendingProtestsCount": {
"type": "number"
},
"walletBalance": {
"type": "number"
}
},
"required": [
@@ -5449,8 +5584,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

@@ -3,7 +3,7 @@ import { RequireSystemAdmin, REQUIRE_SYSTEM_ADMIN_METADATA_KEY } from './Require
// Mock SetMetadata
vi.mock('@nestjs/common', () => ({
SetMetadata: vi.fn(() => () => {}),
SetMetadata: vi.fn(() => (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => descriptor),
}));
describe('RequireSystemAdmin', () => {
@@ -30,7 +30,7 @@ describe('RequireSystemAdmin', () => {
const result = decorator(mockTarget, mockPropertyKey, mockDescriptor);
// The decorator should return the descriptor
// The decorator should return the descriptor (SetMetadata returns the descriptor)
expect(result).toBe(mockDescriptor);
});

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

@@ -1,6 +1,4 @@
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
import { AuthorizationService } from '@core/admin/domain/services/AuthorizationService';
import { Result } from '@core/shared/domain/Result';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GetDashboardStatsUseCase } from './GetDashboardStatsUseCase';
@@ -413,15 +411,15 @@ describe('GetDashboardStatsUseCase', () => {
// Check that today has 1 user
const todayEntry = stats.userGrowth[6];
expect(todayEntry.value).toBe(1);
expect(todayEntry?.value).toBe(1);
// Check that yesterday has 1 user
const yesterdayEntry = stats.userGrowth[5];
expect(yesterdayEntry.value).toBe(1);
expect(yesterdayEntry?.value).toBe(1);
// Check that two days ago has 1 user
const twoDaysAgoEntry = stats.userGrowth[4];
expect(twoDaysAgoEntry.value).toBe(1);
expect(twoDaysAgoEntry?.value).toBe(1);
});
it('should calculate activity timeline for last 7 days', async () => {
@@ -643,8 +641,9 @@ describe('GetDashboardStatsUseCase', () => {
status: 'active',
});
const users = Array.from({ length: 1000 }, (_, i) =>
AdminUser.create({
const users = Array.from({ length: 1000 }, (_, i) => {
const hasRecentLogin = i % 10 === 0;
return AdminUser.create({
id: `user-${i}`,
email: `user${i}@example.com`,
displayName: `User ${i}`,
@@ -652,9 +651,9 @@ describe('GetDashboardStatsUseCase', () => {
status: i % 4 === 0 ? 'suspended' : i % 4 === 1 ? 'deleted' : 'active',
createdAt: new Date(Date.now() - i * 3600000),
updatedAt: new Date(Date.now() - i * 3600000),
lastLoginAt: i % 10 === 0 ? new Date(Date.now() - i * 3600000) : undefined,
})
);
...(hasRecentLogin && { lastLoginAt: new Date(Date.now() - i * 3600000) }),
});
});
mockAdminUserRepo.findById.mockResolvedValue(actor);
mockAdminUserRepo.list.mockResolvedValue({ users });

View File

@@ -107,13 +107,14 @@ export class GetDashboardStatsUseCase {
// User growth (last 7 days)
const userGrowth: DashboardStatsResult['userGrowth'] = [];
if (allUsers.length > 0) {
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const count = allUsers.filter((u: AdminUser) => {
const userDate = new Date(u.createdAt);
const userDate = u.createdAt;
return userDate.toDateString() === date.toDateString();
}).length;
@@ -123,16 +124,18 @@ export class GetDashboardStatsUseCase {
color: 'text-primary-blue',
});
}
}
// Activity timeline (last 7 days)
const activityTimeline: DashboardStatsResult['activityTimeline'] = [];
if (allUsers.length > 0) {
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const newUsers = allUsers.filter((u: AdminUser) => {
const userDate = new Date(u.createdAt);
const userDate = u.createdAt;
return userDate.toDateString() === date.toDateString();
}).length;
@@ -147,6 +150,7 @@ export class GetDashboardStatsUseCase {
logins,
});
}
}
const result: DashboardStatsResult = {
totalUsers,

View File

@@ -3,7 +3,7 @@ import { Public, PUBLIC_ROUTE_METADATA_KEY } from './Public';
// Mock SetMetadata
vi.mock('@nestjs/common', () => ({
SetMetadata: vi.fn(() => () => {}),
SetMetadata: vi.fn(() => (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => descriptor),
}));
describe('Public', () => {

View File

@@ -3,7 +3,7 @@ import { RequireAuthenticatedUser, REQUIRE_AUTHENTICATED_USER_METADATA_KEY } fro
// Mock SetMetadata
vi.mock('@nestjs/common', () => ({
SetMetadata: vi.fn(() => () => {}),
SetMetadata: vi.fn(() => (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => descriptor),
}));
describe('RequireAuthenticatedUser', () => {

View File

@@ -3,7 +3,7 @@ import { RequireRoles, REQUIRE_ROLES_METADATA_KEY } from './RequireRoles';
// Mock SetMetadata
vi.mock('@nestjs/common', () => ({
SetMetadata: vi.fn(() => () => {}),
SetMetadata: vi.fn(() => (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => descriptor),
}));
describe('RequireRoles', () => {

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

@@ -0,0 +1,66 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class HomeUpcomingRaceDTO {
@ApiProperty()
id!: string;
@ApiProperty()
track!: string;
@ApiProperty()
car!: string;
@ApiProperty()
formattedDate!: string;
}
export class HomeTopLeagueDTO {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
@ApiProperty()
description!: string;
}
export class HomeTeamDTO {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
@ApiProperty()
description!: string;
@ApiProperty({ required: false })
logoUrl?: string;
}
export class HomeDataDTO {
@ApiProperty()
@IsBoolean()
isAlpha!: boolean;
@ApiProperty({ type: [HomeUpcomingRaceDTO] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => HomeUpcomingRaceDTO)
upcomingRaces!: HomeUpcomingRaceDTO[];
@ApiProperty({ type: [HomeTopLeagueDTO] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => HomeTopLeagueDTO)
topLeagues!: HomeTopLeagueDTO[];
@ApiProperty({ type: [HomeTeamDTO] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => HomeTeamDTO)
teams!: HomeTeamDTO[];
}

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,14 +1,14 @@
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { Result } from '@/lib/contracts/Result';
import { ScheduleAdminMutation } from '@/lib/mutations/leagues/ScheduleAdminMutation';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { routes } from '@/lib/routing/RouteConfig';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { Result } from '@/lib/contracts/Result';
import { RacesApiClient } from '@/lib/gateways/api/races/RacesApiClient';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { ScheduleAdminMutation } from '@/lib/mutations/leagues/ScheduleAdminMutation';
import { routes } from '@/lib/routing/RouteConfig';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function publishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
@@ -124,16 +124,16 @@ export async function withdrawFromRaceAction(raceId: string, driverId: string, l
}
// eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function navigateToEditRaceAction(raceId: string, leagueId: string): Promise<void> {
export async function navigateToEditRaceAction(leagueId: string): Promise<void> {
redirect(routes.league.scheduleAdmin(leagueId));
}
// eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function navigateToRescheduleRaceAction(raceId: string, leagueId: string): Promise<void> {
export async function navigateToRescheduleRaceAction(leagueId: string): Promise<void> {
redirect(routes.league.scheduleAdmin(leagueId));
}
// eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function navigateToRaceResultsAction(raceId: string, leagueId: string): Promise<void> {
export async function navigateToRaceResultsAction(raceId: string): Promise<void> {
redirect(routes.race.results(raceId));
}

View File

@@ -32,14 +32,42 @@ export default async function LeagueLayout({
leagueId,
name: 'Error',
description: 'Failed to load league',
info: { name: 'Error', membersCount: 0, racesCount: 0, avgSOF: 0, structure: '', scoring: '', createdAt: '' },
info: { name: 'Error', description: 'Error', membersCount: 0, racesCount: 0, avgSOF: 0, structure: '', scoring: '', createdAt: '' },
runningRaces: [],
sponsors: [],
ownerSummary: null,
adminSummaries: [],
stewardSummaries: [],
memberSummaries: [],
sponsorInsights: null
sponsorInsights: null,
league: {
id: leagueId,
name: 'Error',
game: 'Unknown',
tier: 'starter',
season: 'Unknown',
description: 'Error',
drivers: 0,
races: 0,
completedRaces: 0,
totalImpressions: 0,
avgViewsPerRace: 0,
engagement: 0,
rating: 0,
seasonStatus: 'completed',
seasonDates: { start: '', end: '' },
sponsorSlots: {
main: { price: 0, status: 'occupied' },
secondary: { price: 0, total: 0, occupied: 0 }
}
},
drivers: [],
races: [],
seasonProgress: { completedRaces: 0, totalRaces: 0, percentage: 0 },
recentResults: [],
walletBalance: 0,
pendingProtestsCount: 0,
pendingJoinRequestsCount: 0
}}
tabs={[]}
>

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

@@ -22,22 +22,50 @@ export default async function LeagueSettingsPage({ params }: Props) {
}
// For serverError, show the template with empty data
return <LeagueSettingsTemplate viewData={{
leagueId,
league: {
id: leagueId,
name: 'Unknown League',
description: 'League information unavailable',
visibility: 'private',
ownerId: 'unknown',
createdAt: '1970-01-01T00:00:00Z',
updatedAt: '1970-01-01T00:00:00Z',
},
config: {
maxDrivers: 0,
scoringPresetId: 'unknown',
allowLateJoin: false,
requireApproval: false,
basics: {
name: 'Unknown League',
description: 'League information unavailable',
visibility: 'private',
gameId: 'unknown',
},
structure: {
mode: 'solo',
maxDrivers: 0,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
},
scoring: {
patternId: 'unknown',
},
dropPolicy: {
strategy: 'none',
},
timings: {},
stewarding: {
decisionMode: 'single_steward',
requireDefense: false,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 24,
stewardingClosesHours: 48,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
},
},
presets: [],
owner: null,
members: [],
}} />;
}

View File

@@ -29,6 +29,7 @@ export default async function Page({ params }: Props) {
leagueId,
currentDriverId: null,
isAdmin: false,
isTeamChampionship: false,
}}
/>;
}

View File

@@ -33,6 +33,8 @@ export default async function LeagueWalletPage({ params }: Props) {
formattedPendingPayouts: '$0.00',
currency: 'USD',
transactions: [],
totalWithdrawals: 0,
canWithdraw: false,
}} />;
}

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,11 +1,11 @@
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { SponsorLeagueDetailPageClient } from '@/client-wrapper/SponsorLeagueDetailPageClient';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { getWebsiteServerEnv } from '@/lib/config/env';
import { SponsorsApiClient } from '@/lib/gateways/api/sponsors/SponsorsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;

View File

@@ -1,10 +1,10 @@
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { SponsorLeaguesPageClient } from '@/client-wrapper/SponsorLeaguesPageClient';
import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { getWebsiteServerEnv } from '@/lib/config/env';
import { SponsorsApiClient } from '@/lib/gateways/api/sponsors/SponsorsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
export default async function Page() {
// Manual wiring: create dependencies

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,9 +1,9 @@
'use client';
import { ActionItem } from '@/lib/queries/ActionsPageQuery';
import { ActionStatusBadge } from './ActionStatusBadge';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { ActionItem } from '@/lib/page-queries/ActionsPageQuery';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text';
import { ActionStatusBadge } from './ActionStatusBadge';
interface ActionListProps {
actions: ActionItem[];

View File

@@ -1,14 +1,13 @@
'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,
@@ -20,7 +19,6 @@ import {
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

@@ -3,10 +3,10 @@
import { useNotifications } from '@/components/notifications/NotificationProvider';
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
import { ApiConnectionMonitor } from '@/lib/gateways/api/base/ApiConnectionMonitor';
import { CircuitBreakerRegistry } from '@/lib/gateways/api/base/RetryHandler';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { Activity, AlertTriangle, ChevronDown, ChevronUp, MessageSquare, Wrench, X } from 'lucide-react';
import { ChevronUp, Wrench, X } from 'lucide-react';
import { useEffect, useState } from 'react';
// Import our new components
@@ -15,8 +15,8 @@ import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Toolbar } from '@/ui/Toolbar';
import { APIStatusSection } from './sections/APIStatusSection';
import { NotificationSendSection } from './sections/NotificationSendSection';

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

@@ -1,10 +1,10 @@
'use client';
import React, { Component, ReactNode, useState } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { ErrorDisplay } from '@/components/shared/ErrorDisplay';
import { DevErrorPanel } from '@/components/shared/DevErrorPanel';
import { ErrorDisplay } from '@/components/shared/ErrorDisplay';
import { connectionMonitor } from '@/lib/gateways/api/base/ApiConnectionMonitor';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { Component, ReactNode, useState } from 'react';
interface Props {
children: ReactNode;

View File

@@ -1,10 +1,10 @@
'use client';
import React, { Component, ReactNode, ErrorInfo, useState, version } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { DevErrorPanel } from '@/components/shared/DevErrorPanel';
import { ErrorDisplay } from '@/components/shared/ErrorDisplay';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import React, { Component, ErrorInfo, ReactNode, useState, version } from 'react';
interface Props {
children: ReactNode;

View File

@@ -1,6 +1,6 @@
'use client';
import { ApiError } from '@/lib/api/base/ApiError';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { getErrorSeverity, isConnectivityError, isRetryable, parseApiError } from '@/lib/utils/errorUtils';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';

View File

@@ -29,34 +29,23 @@ import {
} 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,8 +1,7 @@
'use client';
import React from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { ErrorDisplay as UiErrorDisplay } from '@/components/shared/ErrorDisplay';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
interface ErrorDisplayProps {
error: ApiError;

View File

@@ -1,9 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { useNotifications } from '@/components/notifications/NotificationProvider';
import { ApiError } from '@/lib/api/base/ApiError';
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { connectionMonitor } from '@/lib/gateways/api/base/ApiConnectionMonitor';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { useEffect, useState } from 'react';
/**
* Integration component that listens for API errors and shows notifications

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 { 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,
Clock,
Car,
CheckCircle,
Clock,
Cloud,
Droplets,
MapPin,
Thermometer,
Droplets,
Wind,
Cloud,
X,
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,6 +1,6 @@
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
import { ApiError } from '@/lib/api/base/ApiError';
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
import { connectionMonitor } from '@/lib/gateways/api/base/ApiConnectionMonitor';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { CircuitBreakerRegistry } from '@/lib/gateways/api/base/RetryHandler';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';

View File

@@ -1,4 +1,4 @@
import { ApiError } from '@/lib/api/base/ApiError';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';

View File

@@ -1,7 +1,7 @@
import { EmptyState } from '@/ui/EmptyState';
import { ErrorDisplay } from '@/components/shared/ErrorDisplay';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { EmptyState } from '@/ui/EmptyState';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { ApiError } from '@/lib/api/base/ApiError';
import { Inbox, List, LucideIcon } from 'lucide-react';
import React, { ReactNode } from 'react';

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

@@ -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;
// 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,9 +1,9 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { SESSION_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import { SESSION_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
export function useCurrentSession(
options?: Omit<UseQueryOptions<SessionViewModel | null, ApiError>, 'queryKey' | 'queryFn'> & { initialData?: SessionViewModel | null }

View File

@@ -1,8 +1,8 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { ForgotPasswordDTO } from '@/lib/types/generated/ForgotPasswordDTO';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
export function useForgotPassword(
options?: Omit<UseMutationOptions<{ message: string; magicLink?: string }, ApiError, ForgotPasswordDTO>, 'mutationFn'>

View File

@@ -1,9 +1,9 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { LoginParamsDTO } from '@/lib/types/generated/LoginParamsDTO';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
export function useLogin(
options?: Omit<UseMutationOptions<SessionViewModel, ApiError, LoginParamsDTO>, 'mutationFn'>

View File

@@ -1,7 +1,7 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
export function useLogout(
options?: Omit<UseMutationOptions<void, ApiError, void>, 'mutationFn'>

View File

@@ -1,8 +1,8 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { ResetPasswordDTO } from '@/lib/types/generated/ResetPasswordDTO';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
export function useResetPassword(
options?: Omit<UseMutationOptions<{ message: string }, ApiError, ResetPasswordDTO>, 'mutationFn'>

View File

@@ -1,9 +1,9 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { AUTH_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { SignupParamsDTO } from '@/lib/types/generated/SignupParamsDTO';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
export function useSignup(
options?: Omit<UseMutationOptions<SessionViewModel, ApiError, SignupParamsDTO>, 'mutationFn'>

View File

@@ -1,11 +1,11 @@
'use client';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
import { CompleteOnboardingViewModel } from '@/lib/view-models/CompleteOnboardingViewModel';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
export function useCreateDriver(
options?: Omit<UseMutationOptions<CompleteOnboardingViewModel, ApiError, CompleteOnboardingInputDTO>, 'mutationFn'>

View File

@@ -1,10 +1,10 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import { useAuth } from '@/components/auth/AuthContext';
import { useInject } from '@/lib/di/hooks/useInject';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
export function useCurrentDriver(
options?: Omit<UseQueryOptions<DriverDTO | null, ApiError>, 'queryKey' | 'queryFn'>

View File

@@ -1,9 +1,9 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { DriverProfileViewModel, type DriverProfileViewModelData } from '@/lib/view-models/DriverProfileViewModel';
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
export function useDriverProfile(
driverId: string,
@@ -19,7 +19,83 @@ export function useDriverProfile(
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new globalThis.Date().toISOString() });
}
return new DriverProfileViewModel(result.unwrap() as unknown as DriverProfileViewModelData);
const dto = result.unwrap();
// Convert GetDriverProfileOutputDTO to ProfileViewData
const viewData: ProfileViewData = {
driver: dto.currentDriver ? {
id: dto.currentDriver.id,
name: dto.currentDriver.name,
countryCode: dto.currentDriver.countryCode || '',
countryFlag: dto.currentDriver.countryFlag || '',
avatarUrl: dto.currentDriver.avatarUrl || '',
bio: dto.currentDriver.bio || null,
iracingId: dto.currentDriver.iracingId || null,
joinedAtLabel: dto.currentDriver.joinedAt || '',
globalRankLabel: dto.currentDriver.globalRank || '',
} : {
id: '',
name: '',
countryCode: '',
countryFlag: '',
avatarUrl: '',
bio: null,
iracingId: null,
joinedAtLabel: '',
globalRankLabel: '',
},
stats: dto.stats ? {
ratingLabel: dto.stats.rating || '',
globalRankLabel: dto.stats.globalRank || '',
totalRacesLabel: dto.stats.totalRaces?.toString() || '',
winsLabel: dto.stats.wins?.toString() || '',
podiumsLabel: dto.stats.podiums?.toString() || '',
dnfsLabel: dto.stats.dnfs?.toString() || '',
bestFinishLabel: dto.stats.bestFinish?.toString() || '',
worstFinishLabel: dto.stats.worstFinish?.toString() || '',
avgFinishLabel: dto.stats.avgFinish?.toString() || '',
consistencyLabel: dto.stats.consistency?.toString() || '',
percentileLabel: dto.stats.percentile?.toString() || '',
} : null,
teamMemberships: dto.teamMemberships.map(m => ({
teamId: m.teamId,
teamName: m.teamName,
teamTag: m.teamTag || null,
roleLabel: m.role || '',
joinedAtLabel: m.joinedAt || '',
href: `/teams/${m.teamId}`,
})),
extendedProfile: dto.extendedProfile ? {
timezone: dto.extendedProfile.timezone || '',
racingStyle: dto.extendedProfile.racingStyle || '',
favoriteTrack: dto.extendedProfile.favoriteTrack || '',
favoriteCar: dto.extendedProfile.favoriteCar || '',
availableHours: dto.extendedProfile.availableHours || '',
lookingForTeamLabel: dto.extendedProfile.lookingForTeam ? 'Yes' : 'No',
openToRequestsLabel: dto.extendedProfile.openToRequests ? 'Yes' : 'No',
socialHandles: dto.extendedProfile.socialHandles?.map(h => ({
platformLabel: h.platform || '',
handle: h.handle || '',
url: h.url || '',
})) || [],
achievements: dto.extendedProfile.achievements?.map(a => ({
id: a.id,
title: a.title,
description: a.description,
earnedAtLabel: a.earnedAt || '',
icon: a.icon as any,
rarityLabel: a.rarity || '',
})) || [],
friends: dto.extendedProfile.friends?.map(f => ({
id: f.id,
name: f.name,
countryFlag: f.countryFlag || '',
avatarUrl: f.avatarUrl || '',
href: `/drivers/${f.id}`,
})) || [],
friendsCountLabel: dto.extendedProfile.friendsCount?.toString() || '',
} : null,
};
return new DriverProfileViewModel(viewData);
},
enabled: !!driverId,
...options,

View File

@@ -1,9 +1,9 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
export function useFindDriverById(
driverId: string,

View File

@@ -1,10 +1,10 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { DriverProfileViewModelBuilder } from '@/lib/builders/view-models/DriverProfileViewModelBuilder';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
import { DriverProfileViewModelBuilder } from '@/lib/builders/view-models/DriverProfileViewModelBuilder';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
export function useUpdateDriverProfile(
options?: Omit<UseMutationOptions<DriverProfileViewModel, ApiError, { bio?: string; country?: string }>, 'mutationFn'>

View File

@@ -1,9 +1,9 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
export interface CreateLeagueInput {
name: string;

View File

@@ -1,15 +1,10 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import type { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/LeagueWithCapacityAndScoringDTO';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
import type { AllLeaguesWithCapacityAndScoringDTO } from '@/lib/types/AllLeaguesWithCapacityAndScoringDTO';
import type { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/LeagueWithCapacityAndScoringDTO';
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
interface UseLeagueDetailOptions {
leagueId: string;
queryOptions?: UseQueryOptions<LeagueWithCapacityAndScoringDTO, ApiError>;
}
interface UseLeagueMembershipsOptions {
leagueId: string;

View File

@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
import { ApiError } from '@/lib/gateways/api/base/ApiError';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useLeagueMembershipMutation() {
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);

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