move static data

This commit is contained in:
2025-12-26 00:20:53 +01:00
parent c977defd6a
commit b6cbb81388
63 changed files with 1482 additions and 418 deletions

View File

@@ -0,0 +1,29 @@
/**
* Activity Configuration
*
* UI display configuration for activity feed items
*/
export type ActivityType = 'race' | 'league' | 'team' | 'driver' | 'platform';
export interface ActivityConfigData {
color: string;
}
export const activityConfig: Record<ActivityType, ActivityConfigData> = {
race: {
color: 'bg-warning-amber',
},
league: {
color: 'bg-primary-blue',
},
team: {
color: 'bg-purple-400',
},
driver: {
color: 'bg-performance-green',
},
platform: {
color: 'bg-racing-red',
},
} as const;

View File

@@ -0,0 +1,19 @@
/**
* Incident Configuration
*
* UI display configuration for incident badges
*/
export interface IncidentConfigData {
color: string;
}
export const getIncidentBadgeColor = (incidents: number): string => {
if (incidents === 0) return 'green';
if (incidents <= 2) return 'yellow';
return 'red';
};
export const incidentConfig = {
getIncidentBadgeColor,
} as const;

View File

@@ -0,0 +1,31 @@
/**
* League Role Display Configuration
*
* UI display configuration for league membership roles
*/
export type LeagueRole = 'owner' | 'admin' | 'steward' | 'member';
export interface LeagueRoleDisplayData {
text: string;
badgeClasses: string;
}
export const leagueRoleDisplay: Record<LeagueRole, LeagueRoleDisplayData> = {
owner: {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
},
admin: {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
},
steward: {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
member: {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
},
} as const;

View File

@@ -12,6 +12,14 @@ export type LeagueScoringPresetPrimaryChampionshipType =
| 'nations' | 'nations'
| 'trophy'; | 'trophy';
export interface LeagueScoringPresetTimingDefaults {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
}
export interface LeagueScoringPreset { export interface LeagueScoringPreset {
id: string; id: string;
name: string; name: string;
@@ -20,6 +28,7 @@ export interface LeagueScoringPreset {
dropPolicySummary: string; dropPolicySummary: string;
sessionSummary: string; sessionSummary: string;
bonusSummary: string; bonusSummary: string;
defaultTimings: LeagueScoringPresetTimingDefaults;
createConfig: (options: { seasonId: string }) => LeagueScoringConfig; createConfig: (options: { seasonId: string }) => LeagueScoringConfig;
} }
@@ -82,6 +91,13 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [
dropPolicySummary: 'Best 6 results of 8 count towards the championship.', dropPolicySummary: 'Best 6 results of 8 count towards the championship.',
sessionSummary: 'Sprint + Main', sessionSummary: 'Sprint + Main',
bonusSummary: 'Fastest lap +1 point in main race if finishing P10 or better.', bonusSummary: 'Fastest lap +1 point in main race if finishing P10 or better.',
defaultTimings: {
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: 20,
mainRaceMinutes: 40,
sessionCount: 2,
},
createConfig: ({ seasonId }) => { createConfig: ({ seasonId }) => {
const fastestLapBonus: BonusRule = { const fastestLapBonus: BonusRule = {
id: 'fastest-lap-main', id: 'fastest-lap-main',
@@ -146,6 +162,13 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [
dropPolicySummary: 'All race results count, no drop scores.', dropPolicySummary: 'All race results count, no drop scores.',
sessionSummary: 'Main race only', sessionSummary: 'Main race only',
bonusSummary: 'No bonus points.', bonusSummary: 'No bonus points.',
defaultTimings: {
practiceMinutes: 20,
qualifyingMinutes: 20,
sprintRaceMinutes: 0,
mainRaceMinutes: 40,
sessionCount: 1,
},
createConfig: ({ seasonId }) => { createConfig: ({ seasonId }) => {
const sessionTypes: SessionType[] = ['main']; const sessionTypes: SessionType[] = ['main'];
@@ -190,6 +213,13 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [
dropPolicySummary: 'Best 4 results of 6 count towards the championship.', dropPolicySummary: 'Best 4 results of 6 count towards the championship.',
sessionSummary: 'Main race only', sessionSummary: 'Main race only',
bonusSummary: 'No bonus points.', bonusSummary: 'No bonus points.',
defaultTimings: {
practiceMinutes: 30,
qualifyingMinutes: 20,
sprintRaceMinutes: 0,
mainRaceMinutes: 120,
sessionCount: 1,
},
createConfig: ({ seasonId }) => { createConfig: ({ seasonId }) => {
const sessionTypes: SessionType[] = ['main']; const sessionTypes: SessionType[] = ['main'];

View File

@@ -0,0 +1,35 @@
/**
* League Tier Configuration
*
* UI display configuration for league tiers
*/
export type LeagueTier = 'premium' | 'standard' | 'starter';
export interface LeagueTierConfigData {
color: string;
bgColor: string;
border: string;
icon: string;
}
export const leagueTierConfig: Record<LeagueTier, LeagueTierConfigData> = {
premium: {
color: 'text-yellow-400',
bgColor: 'bg-yellow-500/10',
border: 'border-yellow-500/30',
icon: '⭐',
},
standard: {
color: 'text-primary-blue',
bgColor: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
icon: '🏆',
},
starter: {
color: 'text-gray-400',
bgColor: 'bg-gray-500/10',
border: 'border-gray-500/30',
icon: '🚀',
},
} as const;

View File

@@ -0,0 +1,16 @@
/**
* Penalty Configuration
*
* Business logic configuration for protest decisions and penalties
*/
export type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
export const penaltiesWithoutValue: PenaltyType[] = ['disqualification', 'warning'];
export const defaultProtestReason = 'Protest upheld';
export const penaltyConfig = {
penaltiesWithoutValue,
defaultProtestReason,
} as const;

View File

@@ -0,0 +1,46 @@
/**
* Race Status Configuration
*
* UI display configuration for race status states
*/
import { Clock, PlayCircle, CheckCircle2, XCircle } from 'lucide-react';
export interface RaceStatusConfigData {
icon: any;
color: string;
bg: string;
border: string;
label: string;
}
export const raceStatusConfig: Record<string, RaceStatusConfigData> = {
scheduled: {
icon: Clock,
color: 'text-primary-blue',
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
label: 'Scheduled',
},
running: {
icon: PlayCircle,
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
label: 'LIVE',
},
completed: {
icon: CheckCircle2,
color: 'text-gray-400',
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
label: 'Completed',
},
cancelled: {
icon: XCircle,
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
label: 'Cancelled',
},
} as const;

View File

@@ -0,0 +1,29 @@
/**
* Renewal Alert Configuration
*
* UI display configuration for renewal alerts
*/
export type RenewalType = 'league' | 'team' | 'driver' | 'race' | 'platform';
export interface RenewalConfigData {
icon: string;
}
export const renewalConfig: Record<RenewalType, RenewalConfigData> = {
league: {
icon: 'Trophy',
},
team: {
icon: 'Users',
},
driver: {
icon: 'Car',
},
race: {
icon: 'Flag',
},
platform: {
icon: 'Megaphone',
},
} as const;

View File

@@ -0,0 +1,28 @@
/**
* Role Hierarchy Configuration
*
* Business logic configuration for league role ordering and permissions
*/
export type LeagueMembershipRole = 'owner' | 'admin' | 'steward' | 'member';
export const roleOrder: Record<LeagueMembershipRole, number> = {
owner: 0,
admin: 1,
steward: 2,
member: 3,
};
export const getRoleOrder = (role: LeagueMembershipRole): number => {
return roleOrder[role] ?? 99;
};
export const isLeagueAdminOrHigherRole = (role: LeagueMembershipRole): boolean => {
return role === 'owner' || role === 'admin' || role === 'steward';
};
export const roleHierarchy = {
roleOrder,
getRoleOrder,
isLeagueAdminOrHigherRole,
} as const;

View File

@@ -0,0 +1,31 @@
/**
* Season Status Configuration
*
* UI display configuration for season states
*/
export type SeasonStatus = 'active' | 'upcoming' | 'completed';
export interface SeasonStatusConfigData {
color: string;
bg: string;
label: string;
}
export const seasonStatusConfig: Record<SeasonStatus, SeasonStatusConfigData> = {
active: {
color: 'text-performance-green',
bg: 'bg-performance-green/10',
label: 'Active Season',
},
upcoming: {
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
label: 'Starting Soon',
},
completed: {
color: 'text-gray-400',
bg: 'bg-gray-400/10',
label: 'Season Ended',
},
} as const;

View File

@@ -0,0 +1,162 @@
/**
* Site Configuration
*
* Platform-wide configuration and settings
*/
export interface SiteConfigData {
// Platform Information
platformName: string;
platformUrl: string;
// Contact Information
supportEmail: string;
sponsorEmail: string;
// Legal & Business Information
legal: {
companyName: string;
vatId: string;
registeredCountry: string;
registeredAddress: string;
};
// Platform Fees
fees: {
platformFeePercent: number;
description: string;
};
// VAT Information
vat: {
euReverseChargeApplies: boolean;
nonEuVatExempt: boolean;
standardRate: number;
notice: string;
euBusinessNotice: string;
nonEuNotice: string;
};
// Sponsorship Types Available
sponsorshipTypes: {
leagues: {
enabled: boolean;
title: string;
description: string;
};
teams: {
enabled: boolean;
title: string;
description: string;
};
drivers: {
enabled: boolean;
title: string;
description: string;
};
races: {
enabled: boolean;
title: string;
description: string;
};
platform: {
enabled: boolean;
title: string;
description: string;
};
};
// Feature Flags for Sponsorship Features
features: {
liveryPlacement: boolean;
leaguePageBranding: boolean;
racePageBranding: boolean;
profileBadges: boolean;
socialMediaMentions: boolean;
newsletterInclusion: boolean;
homepageAds: boolean;
sidebarAds: boolean;
broadcastOverlays: boolean;
};
}
export const siteConfig: SiteConfigData = {
// Platform Information
platformName: process.env.NEXT_PUBLIC_SITE_NAME ?? 'GridPilot',
platformUrl: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://gridpilot.com',
// Contact Information
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL ?? 'support@example.com',
sponsorEmail: process.env.NEXT_PUBLIC_SPONSOR_EMAIL ?? 'sponsors@example.com',
// Legal & Business Information
legal: {
companyName: process.env.NEXT_PUBLIC_LEGAL_COMPANY_NAME ?? '',
vatId: process.env.NEXT_PUBLIC_LEGAL_VAT_ID ?? '',
registeredCountry: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY ?? '',
registeredAddress: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS ?? '',
},
// Platform Fees
fees: {
platformFeePercent: 10, // 10% platform fee on sponsorships
description: 'Platform fee supports maintenance, analytics, and secure payment processing.',
},
// VAT Information
vat: {
// Note: All prices displayed are exclusive of VAT
euReverseChargeApplies: true,
nonEuVatExempt: true,
standardRate: 20,
notice: 'All prices shown are exclusive of VAT. Applicable taxes will be calculated at checkout.',
euBusinessNotice: 'EU businesses with a valid VAT ID may apply reverse charge.',
nonEuNotice: 'Non-EU businesses are not charged VAT.',
},
// Sponsorship Types Available
sponsorshipTypes: {
leagues: {
enabled: true,
title: 'League Sponsorship',
description: 'Sponsor entire racing leagues and get your brand in front of all participants.',
},
teams: {
enabled: true,
title: 'Team Sponsorship',
description: 'Partner with competitive racing teams for long-term brand association.',
},
drivers: {
enabled: true,
title: 'Driver Sponsorship',
description: 'Support individual drivers and grow with rising sim racing talent.',
},
races: {
enabled: true,
title: 'Race Sponsorship',
description: 'Sponsor individual race events for targeted, high-impact exposure.',
},
platform: {
enabled: true,
title: 'Platform Advertising',
description: 'Reach the entire GridPilot audience with strategic platform placements.',
},
},
// Feature Flags for Sponsorship Features
features: {
// What sponsors can actually get (no broadcast control)
liveryPlacement: true,
leaguePageBranding: true,
racePageBranding: true,
profileBadges: true,
socialMediaMentions: true,
newsletterInclusion: true,
homepageAds: true,
sidebarAds: true,
// We don't control these
broadcastOverlays: false, // We don't control broadcast
},
} as const;
export type SiteConfig = typeof siteConfig;

View File

@@ -0,0 +1,31 @@
/**
* Skill Level Configuration
*
* UI display configuration for driver skill levels
*/
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export interface SkillLevelConfigData {
color: string;
icon: string;
}
export const skillLevelConfig: Record<SkillLevel, SkillLevelConfigData> = {
beginner: {
color: 'green',
icon: '🥉',
},
intermediate: {
color: 'yellow',
icon: '🥈',
},
advanced: {
color: 'orange',
icon: '🥇',
},
expert: {
color: 'red',
icon: '👑',
},
} as const;

View File

@@ -0,0 +1,52 @@
/**
* Sponsorship Configuration
*
* UI display configuration for sponsorship types and statuses
*/
export type SponsorshipType = 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
export type SponsorshipStatus = 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
export interface SponsorshipTypeConfigData {
label: string;
}
export interface SponsorshipStatusConfigData {
label: string;
}
export const sponsorshipTypeConfig: Record<SponsorshipType, SponsorshipTypeConfigData> = {
leagues: {
label: 'League',
},
teams: {
label: 'Team',
},
drivers: {
label: 'Driver',
},
races: {
label: 'Race',
},
platform: {
label: 'Platform',
},
} as const;
export const sponsorshipStatusConfig: Record<SponsorshipStatus, SponsorshipStatusConfigData> = {
active: {
label: 'Active',
},
pending_approval: {
label: 'Awaiting Approval',
},
approved: {
label: 'Approved',
},
rejected: {
label: 'Declined',
},
expired: {
label: 'Expired',
},
} as const;

View File

@@ -0,0 +1,53 @@
/**
* Timing Configuration
*
* Business logic configuration for scoring preset timings
*/
export interface Timings {
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
[key: string]: unknown;
}
export interface TimingConfigData {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
}
export const timingConfig: Record<string, TimingConfigData> = {
'sprint-main-driver': {
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: 20,
mainRaceMinutes: 40,
sessionCount: 2,
},
'endurance-main-driver': {
practiceMinutes: 30,
qualifyingMinutes: 20,
sprintRaceMinutes: 0,
mainRaceMinutes: 120,
sessionCount: 1,
},
} as const;
export const applyTimingConfig = (patternId: string, currentTimings: Timings): Timings => {
const config = timingConfig[patternId];
if (!config) return currentTimings;
return {
...currentTimings,
practiceMinutes: currentTimings.practiceMinutes ?? config.practiceMinutes,
qualifyingMinutes: currentTimings.qualifyingMinutes ?? config.qualifyingMinutes,
sprintRaceMinutes: currentTimings.sprintRaceMinutes ?? config.sprintRaceMinutes,
mainRaceMinutes: currentTimings.mainRaceMinutes ?? config.mainRaceMinutes,
sessionCount: config.sessionCount,
};
};

View File

@@ -2,4 +2,19 @@ export * from './EnsureInitialData';
export * from './LeagueConstraints'; export * from './LeagueConstraints';
export * from './LeagueScoringPresets'; export * from './LeagueScoringPresets';
export * from './PointsSystems'; export * from './PointsSystems';
export * from './ScoringDemoSetup'; export * from './ScoringDemoSetup';
// New configuration exports
export * from './SiteConfig';
export * from './RaceStatusConfig';
export * from './LeagueRoleDisplay';
export * from './ActivityConfig';
export * from './RenewalConfig';
export * from './LeagueTierConfig';
export * from './SeasonStatusConfig';
export * from './SponsorshipConfig';
export * from './SkillLevelConfig';
export * from './IncidentConfig';
export * from './PenaltyConfig';
export * from './RoleHierarchy';
export * from './TimingConfig';

View File

@@ -33,6 +33,7 @@ import { GetLeagueWalletOutputDTO } from './dtos/GetLeagueWalletOutputDTO';
import { TotalLeaguesDTO } from './dtos/TotalLeaguesDTO'; import { TotalLeaguesDTO } from './dtos/TotalLeaguesDTO';
import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO'; import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO';
import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO'; import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO';
import { LeagueScoringPresetsDTO } from './dtos/LeagueScoringPresetsDTO';
@ApiTags('leagues') @ApiTags('leagues')
@Controller('leagues') @Controller('leagues')
@@ -239,8 +240,8 @@ export class LeagueController {
@Get('scoring-presets') @Get('scoring-presets')
@ApiOperation({ summary: 'Get league scoring presets' }) @ApiOperation({ summary: 'Get league scoring presets' })
@ApiResponse({ status: 200, description: 'List of scoring presets' }) @ApiResponse({ status: 200, description: 'List of scoring presets', type: LeagueScoringPresetsDTO })
async getLeagueScoringPresets() { async getLeagueScoringPresets(): Promise<LeagueScoringPresetsDTO> {
return this.leagueService.listLeagueScoringPresets(); return this.leagueService.listLeagueScoringPresets();
} }

View File

@@ -1,5 +1,28 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum } from 'class-validator'; import { IsString, IsEnum, IsNumber, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class LeagueScoringPresetTimingDefaultsDTO {
@ApiProperty()
@IsNumber()
practiceMinutes!: number;
@ApiProperty()
@IsNumber()
qualifyingMinutes!: number;
@ApiProperty()
@IsNumber()
sprintRaceMinutes!: number;
@ApiProperty()
@IsNumber()
mainRaceMinutes!: number;
@ApiProperty()
@IsNumber()
sessionCount!: number;
}
export class LeagueScoringPresetDTO { export class LeagueScoringPresetDTO {
@ApiProperty() @ApiProperty()
@@ -29,4 +52,9 @@ export class LeagueScoringPresetDTO {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
dropPolicySummary!: string; dropPolicySummary!: string;
@ApiProperty({ type: LeagueScoringPresetTimingDefaultsDTO })
@ValidateNested()
@Type(() => LeagueScoringPresetTimingDefaultsDTO)
defaultTimings!: LeagueScoringPresetTimingDefaultsDTO;
} }

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { LeagueScoringPresetDTO } from './LeagueScoringPresetDTO';
export class LeagueScoringPresetsDTO {
@ApiProperty({ type: [LeagueScoringPresetDTO] })
presets!: LeagueScoringPresetDTO[];
@ApiProperty()
totalCount!: number;
}

View File

@@ -1,10 +1,8 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ListLeagueScoringPresetsResult, LeagueScoringPreset } from '@core/racing/application/use-cases/ListLeagueScoringPresetsUseCase'; import type { ListLeagueScoringPresetsResult, LeagueScoringPreset } from '@core/racing/application/use-cases/ListLeagueScoringPresetsUseCase';
import type { LeagueScoringPresetsDTO } from '../dtos/LeagueScoringPresetsDTO';
export interface LeagueScoringPresetsViewModel { export type LeagueScoringPresetsViewModel = LeagueScoringPresetsDTO;
presets: LeagueScoringPreset[];
totalCount: number;
}
export class LeagueScoringPresetsPresenter implements UseCaseOutputPort<ListLeagueScoringPresetsResult> { export class LeagueScoringPresetsPresenter implements UseCaseOutputPort<ListLeagueScoringPresetsResult> {
private viewModel: LeagueScoringPresetsViewModel | null = null; private viewModel: LeagueScoringPresetsViewModel | null = null;

View File

@@ -17,6 +17,7 @@ import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
import { QuickPenaltyCommandDTO } from './dtos/QuickPenaltyCommandDTO'; import { QuickPenaltyCommandDTO } from './dtos/QuickPenaltyCommandDTO';
import { ApplyPenaltyCommandDTO } from './dtos/ApplyPenaltyCommandDTO'; import { ApplyPenaltyCommandDTO } from './dtos/ApplyPenaltyCommandDTO';
import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCommandDTO'; import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCommandDTO';
import { PenaltyTypesReferenceDTO } from './dtos/PenaltyTypesReferenceDTO';
@ApiTags('races') @ApiTags('races')
@Controller('races') @Controller('races')
@@ -96,6 +97,13 @@ export class RaceController {
return presenter.viewModel; return presenter.viewModel;
} }
@Get('reference/penalty-types')
@ApiOperation({ summary: 'Get allowed penalty types and semantics' })
@ApiResponse({ status: 200, description: 'Penalty types reference', type: PenaltyTypesReferenceDTO })
getPenaltyTypesReference(): PenaltyTypesReferenceDTO {
return this.raceService.getPenaltyTypesReference();
}
@Get(':raceId/penalties') @Get(':raceId/penalties')
@ApiOperation({ summary: 'Get race penalties' }) @ApiOperation({ summary: 'Get race penalties' })
@ApiParam({ name: 'raceId', description: 'Race ID' }) @ApiParam({ name: 'raceId', description: 'Race ID' })

View File

@@ -50,6 +50,7 @@ import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
import { QuickPenaltyCommandDTO } from './dtos/QuickPenaltyCommandDTO'; import { QuickPenaltyCommandDTO } from './dtos/QuickPenaltyCommandDTO';
import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCommandDTO'; import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCommandDTO';
import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO'; import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO';
import { PenaltyTypesReferenceDTO } from './dtos/PenaltyTypesReferenceDTO';
// Tokens // Tokens
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase'; import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
@@ -68,6 +69,13 @@ import {
RACES_PAGE_DATA_PRESENTER_TOKEN RACES_PAGE_DATA_PRESENTER_TOKEN
} from './RaceTokens'; } from './RaceTokens';
import {
PENALTY_TYPE_VALUES,
getPenaltyValueKind,
type PenaltyTypeValue,
} from '@core/racing/domain/entities/penalty/PenaltyType';
import { penaltyTypeRequiresValue } from '@core/racing/domain/entities/penalty/Penalty';
@Injectable() @Injectable()
export class RaceService { export class RaceService {
constructor( constructor(
@@ -160,6 +168,22 @@ export class RaceService {
return this.raceProtestsPresenter; return this.raceProtestsPresenter;
} }
getPenaltyTypesReference(): PenaltyTypesReferenceDTO {
const items = PENALTY_TYPE_VALUES.map((type: PenaltyTypeValue) => ({
type,
requiresValue: penaltyTypeRequiresValue(type),
valueKind: getPenaltyValueKind(type),
}));
return {
penaltyTypes: items,
defaultReasons: {
upheld: 'Protest upheld',
dismissed: 'Protest dismissed',
},
};
}
async getRacePenalties(raceId: string): Promise<RacePenaltiesPresenter> { async getRacePenalties(raceId: string): Promise<RacePenaltiesPresenter> {
this.logger.debug('[RaceService] Fetching race penalties:', { raceId }); this.logger.debug('[RaceService] Fetching race penalties:', { raceId });
await this.getRacePenaltiesUseCase.execute({ raceId }); await this.getRacePenaltiesUseCase.execute({ raceId });

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, IsNumber, IsEnum } from 'class-validator'; import { IsString, IsNotEmpty, IsOptional, IsNumber, IsEnum } from 'class-validator';
import { PENALTY_TYPE_VALUES } from '@core/racing/domain/entities/penalty/PenaltyType';
export class ApplyPenaltyCommandDTO { export class ApplyPenaltyCommandDTO {
@ApiProperty() @ApiProperty()
@@ -17,30 +18,8 @@ export class ApplyPenaltyCommandDTO {
@IsNotEmpty() @IsNotEmpty()
stewardId!: string; stewardId!: string;
@ApiProperty({ @ApiProperty({ enum: PENALTY_TYPE_VALUES })
enum: [ @IsEnum(PENALTY_TYPE_VALUES)
'time_penalty',
'grid_penalty',
'points_deduction',
'disqualification',
'warning',
'license_points',
'probation',
'fine',
'race_ban',
],
})
@IsEnum([
'time_penalty',
'grid_penalty',
'points_deduction',
'disqualification',
'warning',
'license_points',
'probation',
'fine',
'race_ban',
])
type!: string; type!: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })

View File

@@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsString } from 'class-validator';
import {
PENALTY_TYPE_VALUES,
type PenaltyTypeValue,
} from '@core/racing/domain/entities/penalty/PenaltyType';
export type PenaltyValueKindDTO = 'seconds' | 'grid_positions' | 'points' | 'races' | 'none';
export class PenaltyTypeReferenceDTO {
@ApiProperty({ enum: PENALTY_TYPE_VALUES })
@IsEnum(PENALTY_TYPE_VALUES)
type!: PenaltyTypeValue;
@ApiProperty()
@IsBoolean()
requiresValue!: boolean;
@ApiProperty({ enum: ['seconds', 'grid_positions', 'points', 'races', 'none'] })
@IsString()
valueKind!: PenaltyValueKindDTO;
}
export class PenaltyDefaultReasonsDTO {
@ApiProperty()
@IsString()
upheld!: string;
@ApiProperty()
@IsString()
dismissed!: string;
}
export class PenaltyTypesReferenceDTO {
@ApiProperty({ type: [PenaltyTypeReferenceDTO] })
penaltyTypes!: PenaltyTypeReferenceDTO[];
@ApiProperty({ type: PenaltyDefaultReasonsDTO })
defaultReasons!: PenaltyDefaultReasonsDTO;
}

View File

@@ -24,9 +24,6 @@ import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
// Local type definitions to replace core imports
type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points' | 'probation' | 'fine' | 'race_ban';
export default function LeagueStewardingPage() { export default function LeagueStewardingPage() {
const params = useParams(); const params = useParams();
const leagueId = params.id as string; const leagueId = params.id as string;
@@ -81,7 +78,7 @@ export default function LeagueStewardingPage() {
const handleAcceptProtest = async ( const handleAcceptProtest = async (
protestId: string, protestId: string,
penaltyType: PenaltyType, penaltyType: string,
penaltyValue: number, penaltyValue: number,
stewardNotes: string stewardNotes: string
) => { ) => {

View File

@@ -8,7 +8,8 @@ import { useServices } from '@/lib/services/ServiceProvider';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel'; import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel'; import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel'; import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
import { ProtestDecisionCommandModel, type PenaltyType } from '@/lib/command-models/protests/ProtestDecisionCommandModel'; import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import type { PenaltyTypesReferenceDTO, PenaltyValueKindDTO } from '@/lib/types/PenaltyTypesReferenceDTO';
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@@ -45,68 +46,88 @@ interface TimelineEvent {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
const PENALTY_TYPES = [ type PenaltyUiConfig = {
{ label: string;
type: 'time_penalty' as PenaltyType, description: string;
icon: typeof Gavel;
color: string;
defaultValue?: number;
};
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
time_penalty: {
label: 'Time Penalty', label: 'Time Penalty',
description: 'Add seconds to race result', description: 'Add seconds to race result',
icon: Clock, icon: Clock,
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20', color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
requiresValue: true, defaultValue: 5,
valueLabel: 'seconds',
defaultValue: 5
}, },
{ grid_penalty: {
type: 'grid_penalty' as PenaltyType,
label: 'Grid Penalty', label: 'Grid Penalty',
description: 'Grid positions for next race', description: 'Grid positions for next race',
icon: Grid3x3, icon: Grid3x3,
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20', color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
requiresValue: true, defaultValue: 3,
valueLabel: 'positions',
defaultValue: 3
}, },
{ points_deduction: {
type: 'points_deduction' as PenaltyType,
label: 'Points Deduction', label: 'Points Deduction',
description: 'Deduct championship points', description: 'Deduct championship points',
icon: TrendingDown, icon: TrendingDown,
color: 'text-red-400 bg-red-500/10 border-red-500/20', color: 'text-red-400 bg-red-500/10 border-red-500/20',
requiresValue: true, defaultValue: 5,
valueLabel: 'points',
defaultValue: 5
}, },
{ disqualification: {
type: 'disqualification' as PenaltyType,
label: 'Disqualification', label: 'Disqualification',
description: 'Disqualify from race', description: 'Disqualify from race',
icon: XCircle, icon: XCircle,
color: 'text-red-500 bg-red-500/10 border-red-500/20', color: 'text-red-500 bg-red-500/10 border-red-500/20',
requiresValue: false, defaultValue: 0,
valueLabel: '',
defaultValue: 0
}, },
{ warning: {
type: 'warning' as PenaltyType,
label: 'Warning', label: 'Warning',
description: 'Official warning only', description: 'Official warning only',
icon: AlertTriangle, icon: AlertTriangle,
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20', color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
requiresValue: false, defaultValue: 0,
valueLabel: '',
defaultValue: 0
}, },
{ license_points: {
type: 'license_points' as PenaltyType,
label: 'License Points', label: 'License Points',
description: 'Safety rating penalty', description: 'Safety rating penalty',
icon: ShieldAlert, icon: ShieldAlert,
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20', color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
requiresValue: true, defaultValue: 2,
valueLabel: 'points',
defaultValue: 2
}, },
]; };
function getPenaltyValueLabel(valueKind: PenaltyValueKindDTO): string {
switch (valueKind) {
case 'seconds':
return 'seconds';
case 'grid_positions':
return 'positions';
case 'points':
return 'points';
case 'races':
return 'races';
case 'none':
return '';
}
}
function getFallbackDefaultValue(valueKind: PenaltyValueKindDTO): number {
switch (valueKind) {
case 'seconds':
return 5;
case 'grid_positions':
return 3;
case 'points':
return 5;
case 'races':
return 1;
case 'none':
return 0;
}
}
export default function ProtestReviewPage() { export default function ProtestReviewPage() {
const params = useParams(); const params = useParams();
@@ -114,7 +135,7 @@ export default function ProtestReviewPage() {
const leagueId = params.id as string; const leagueId = params.id as string;
const protestId = params.protestId as string; const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const { protestService, leagueMembershipService } = useServices(); const { protestService, leagueMembershipService, penaltyService } = useServices();
const [protest, setProtest] = useState<ProtestViewModel | null>(null); const [protest, setProtest] = useState<ProtestViewModel | null>(null);
const [race, setRace] = useState<RaceViewModel | null>(null); const [race, setRace] = useState<RaceViewModel | null>(null);
@@ -122,14 +143,41 @@ export default function ProtestReviewPage() {
const [accusedDriver, setAccusedDriver] = useState<ProtestDriverViewModel | null>(null); const [accusedDriver, setAccusedDriver] = useState<ProtestDriverViewModel | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [penaltyTypesReference, setPenaltyTypesReference] = useState<PenaltyTypesReferenceDTO | null>(null);
const [penaltyTypesLoading, setPenaltyTypesLoading] = useState(false);
// Decision state // Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false); const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null); const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
const [penaltyType, setPenaltyType] = useState<PenaltyType>('time_penalty'); const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(5); const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState(''); const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const penaltyTypes = useMemo(() => {
const referenceItems = penaltyTypesReference?.penaltyTypes ?? [];
return referenceItems.map((ref) => {
const ui = PENALTY_UI[ref.type] ?? {
label: ref.type.replaceAll('_', ' '),
description: '',
icon: Gavel,
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
defaultValue: getFallbackDefaultValue(ref.valueKind),
};
return {
...ref,
...ui,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: ui.defaultValue ?? getFallbackDefaultValue(ref.valueKind),
};
});
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
return penaltyTypes.find((p) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
// Comment state // Comment state
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
@@ -168,15 +216,47 @@ export default function ProtestReviewPage() {
if (isAdmin) { if (isAdmin) {
loadProtest(); loadProtest();
} }
}, [protestId, leagueId, isAdmin, router]); }, [protestId, leagueId, isAdmin, router, protestService]);
useEffect(() => {
async function loadPenaltyTypes() {
if (!isAdmin) return;
if (penaltyTypesReference) return;
setPenaltyTypesLoading(true);
try {
const ref = await penaltyService.getPenaltyTypesReference();
setPenaltyTypesReference(ref);
const hasSelected = ref.penaltyTypes.some((p) => p.type === penaltyType);
const [first] = ref.penaltyTypes;
if (!hasSelected && first) {
setPenaltyType(first.type);
setPenaltyValue(PENALTY_UI[first.type]?.defaultValue ?? getFallbackDefaultValue(first.valueKind));
}
} catch (err) {
console.error('Failed to load penalty types reference:', err);
} finally {
setPenaltyTypesLoading(false);
}
}
loadPenaltyTypes();
}, [isAdmin, penaltyService, penaltyTypesReference, penaltyType]);
const handleSubmitDecision = async () => { const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protest) return; if (!decision || !stewardNotes.trim() || !protest) return;
if (penaltyTypesLoading) return;
setSubmitting(true); setSubmitting(true);
try { try {
const defaultUpheldReason = penaltyTypesReference?.defaultReasons?.upheld;
const defaultDismissedReason = penaltyTypesReference?.defaultReasons?.dismissed;
if (decision === 'uphold') { if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
const commandModel = new ProtestDecisionCommandModel({ const commandModel = new ProtestDecisionCommandModel({
decision, decision,
penaltyType, penaltyType,
@@ -184,17 +264,32 @@ export default function ProtestReviewPage() {
stewardNotes, stewardNotes,
}); });
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand( const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId, protest.raceId,
protest.accusedDriverId, protest.accusedDriverId,
currentDriverId, currentDriverId,
protest.id protest.id,
options,
); );
await protestService.applyPenalty(penaltyCommand); await protestService.applyPenalty(penaltyCommand);
} else { } else {
// For dismiss, we might need a separate endpoint const warningRef = penaltyTypesReference?.penaltyTypes.find((p) => p.type === 'warning');
// For now, just apply a warning penalty with 0 value or create a separate endpoint const requiresValue = warningRef?.requiresValue ?? false;
const commandModel = new ProtestDecisionCommandModel({ const commandModel = new ProtestDecisionCommandModel({
decision, decision,
penaltyType: 'warning', penaltyType: 'warning',
@@ -202,15 +297,27 @@ export default function ProtestReviewPage() {
stewardNotes, stewardNotes,
}); });
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand( const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId, protest.raceId,
protest.accusedDriverId, protest.accusedDriverId,
currentDriverId, currentDriverId,
protest.id protest.id,
options,
); );
penaltyCommand.reason = stewardNotes || 'Protest dismissed';
await protestService.applyPenalty(penaltyCommand); await protestService.applyPenalty(penaltyCommand);
} }
@@ -601,46 +708,55 @@ export default function ProtestReviewPage() {
{decision === 'uphold' && ( {decision === 'uphold' && (
<div className="mb-4"> <div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label> <label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
<div className="grid grid-cols-2 gap-1.5">
{PENALTY_TYPES.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{PENALTY_TYPES.find(p => p.type === penaltyType)?.requiresValue && ( {penaltyTypes.length === 0 ? (
<div className="mt-3"> <div className="text-xs text-gray-500">
<label className="text-xs font-medium text-gray-400 mb-1 block"> Loading penalty types...
Value ({PENALTY_TYPES.find(p => p.type === penaltyType)?.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
</div> </div>
) : (
<>
<div className="grid grid-cols-2 gap-1.5">
{penaltyTypes.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{selectedPenalty?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
</div>
)}
</>
)} )}
</div> </div>
)} )}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import RaceDetailPage from './page'; import RaceDetailPage from './page';
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel'; import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
@@ -67,6 +68,17 @@ vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
}, },
})); }));
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const createViewModel = (status: string) => { const createViewModel = (status: string) => {
return new RaceDetailViewModel({ return new RaceDetailViewModel({
race: { race: {
@@ -119,7 +131,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RaceDetailPage />); renderWithQueryClient(<RaceDetailPage />);
const reopenButton = await screen.findByText('Re-open Race'); const reopenButton = await screen.findByText('Re-open Race');
expect(reopenButton).toBeInTheDocument(); expect(reopenButton).toBeInTheDocument();
@@ -145,7 +157,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const viewModel = createViewModel('completed'); const viewModel = createViewModel('completed');
mockGetRaceDetail.mockResolvedValue(viewModel); mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />); renderWithQueryClient(<RaceDetailPage />);
await waitFor(() => { await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled(); expect(mockGetRaceDetail).toHaveBeenCalled();
@@ -159,7 +171,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const viewModel = createViewModel('scheduled'); const viewModel = createViewModel('scheduled');
mockGetRaceDetail.mockResolvedValue(viewModel); mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />); renderWithQueryClient(<RaceDetailPage />);
await waitFor(() => { await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled(); expect(mockGetRaceDetail).toHaveBeenCalled();

View File

@@ -33,7 +33,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
export default function RaceDetailPage() { export default function RaceDetailPage() {
const router = useRouter(); const router = useRouter();

View File

@@ -22,9 +22,10 @@ import {
Users, Users,
} from 'lucide-react'; } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { FormEvent, useCallback, useEffect, useState } from 'react'; import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel'; import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { useCreateLeagueWizard } from '@/hooks/useLeagueWizardService'; import { useCreateLeagueWizard } from '@/hooks/useLeagueWizardService';
import { useLeagueScoringPresets } from '@/hooks/useLeagueScoringPresets'; import { useLeagueScoringPresets } from '@/hooks/useLeagueScoringPresets';
import { LeagueBasicsSection } from './LeagueBasicsSection'; import { LeagueBasicsSection } from './LeagueBasicsSection';
@@ -494,78 +495,29 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
} }
}; };
// Handler for scoring preset selection - delegates to application-level config helper // Handler for scoring preset selection (timings default from API)
const handleScoringPresetChange = (patternId: string) => { const handleScoringPresetChange = (patternId: string) => {
setForm((prev) => { setForm((prev) => {
// Convert to LeagueWizardFormData for the command model const selectedPreset = presets.find((p) => p.id === patternId);
const formData: any = {
leagueId: prev.leagueId || '',
basics: {
name: prev.basics?.name || '',
description: prev.basics?.description || '',
visibility: (prev.basics?.visibility as 'public' | 'private' | 'unlisted') || 'public',
gameId: prev.basics?.gameId || 'iracing',
},
structure: {
mode: (prev.structure?.mode as 'solo' | 'fixedTeams') || 'solo',
maxDrivers: prev.structure?.maxDrivers || 24,
maxTeams: prev.structure?.maxTeams || 0,
driversPerTeam: prev.structure?.driversPerTeam || 0,
},
championships: {
enableDriverChampionship: prev.championships?.enableDriverChampionship ?? true,
enableTeamChampionship: prev.championships?.enableTeamChampionship ?? false,
enableNationsChampionship: prev.championships?.enableNationsChampionship ?? false,
enableTrophyChampionship: prev.championships?.enableTrophyChampionship ?? false,
},
scoring: {
patternId: prev.scoring?.patternId,
customScoringEnabled: prev.scoring?.customScoringEnabled ?? false,
},
dropPolicy: {
strategy: (prev.dropPolicy?.strategy as 'none' | 'bestNResults' | 'dropWorstN') || 'bestNResults',
n: prev.dropPolicy?.n || 6,
},
timings: {
practiceMinutes: prev.timings?.practiceMinutes || 0,
qualifyingMinutes: prev.timings?.qualifyingMinutes || 0,
sprintRaceMinutes: prev.timings?.sprintRaceMinutes || 0,
mainRaceMinutes: prev.timings?.mainRaceMinutes || 0,
sessionCount: prev.timings?.sessionCount || 0,
roundsPlanned: prev.timings?.roundsPlanned || 0,
raceDayOfWeek: prev.timings?.raceDayOfWeek || 0,
raceTimeUtc: prev.timings?.raceTimeUtc || '',
weekdays: (prev.timings?.weekdays as Weekday[]) || [],
recurrenceStrategy: prev.timings?.recurrenceStrategy || '',
timezoneId: prev.timings?.timezoneId || '',
seasonStartDate: prev.timings?.seasonStartDate || '',
},
stewarding: {
decisionMode: (prev.stewarding?.decisionMode as 'owner_only' | 'admin_vote' | 'steward_panel') || 'admin_only',
requiredVotes: prev.stewarding?.requiredVotes || 2,
requireDefense: prev.stewarding?.requireDefense ?? false,
defenseTimeLimit: prev.stewarding?.defenseTimeLimit || 48,
voteTimeLimit: prev.stewarding?.voteTimeLimit || 72,
protestDeadlineHours: prev.stewarding?.protestDeadlineHours || 48,
stewardingClosesHours: prev.stewarding?.stewardingClosesHours || 168,
notifyAccusedOnProtest: prev.stewarding?.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: prev.stewarding?.notifyOnVoteRequired ?? true,
},
};
const updated = LeagueWizardCommandModel.applyScoringPresetToConfig(formData, patternId);
// Convert back to LeagueWizardFormModel
return { return {
basics: updated.basics, ...prev,
structure: updated.structure, scoring: {
championships: updated.championships, ...prev.scoring,
scoring: updated.scoring, patternId,
dropPolicy: updated.dropPolicy, customScoringEnabled: false,
timings: updated.timings, },
stewarding: updated.stewarding, timings: selectedPreset
seasonName: prev.seasonName, ? {
} as LeagueWizardFormModel; ...prev.timings,
practiceMinutes: prev.timings?.practiceMinutes ?? selectedPreset.defaultTimings.practiceMinutes,
qualifyingMinutes: prev.timings?.qualifyingMinutes ?? selectedPreset.defaultTimings.qualifyingMinutes,
sprintRaceMinutes: prev.timings?.sprintRaceMinutes ?? selectedPreset.defaultTimings.sprintRaceMinutes,
mainRaceMinutes: prev.timings?.mainRaceMinutes ?? selectedPreset.defaultTimings.mainRaceMinutes,
sessionCount: selectedPreset.defaultTimings.sessionCount,
}
: prev.timings,
};
}); });
}; };

View File

@@ -8,8 +8,6 @@ import Card from "../ui/Card";
import Button from "../ui/Button"; import Button from "../ui/Button";
import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react"; import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react";
type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points";
interface PenaltyHistoryListProps { interface PenaltyHistoryListProps {
protests: ProtestViewModel[]; protests: ProtestViewModel[];
races: Record<string, RaceViewModel>; races: Record<string, RaceViewModel>;

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import { usePenaltyTypesReference } from "@/hooks/usePenaltyTypesReference";
import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO";
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel"; import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
import Modal from "../ui/Modal"; import Modal from "../ui/Modal";
import Button from "../ui/Button"; import Button from "../ui/Button";
@@ -21,7 +23,7 @@ import {
FileWarning, FileWarning,
} from "lucide-react"; } from "lucide-react";
type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points" | "probation" | "fine" | "race_ban"; type PenaltyType = string;
interface ReviewProtestModalProps { interface ReviewProtestModalProps {
protest: ProtestViewModel | null; protest: ProtestViewModel | null;
@@ -94,25 +96,63 @@ export function ReviewProtestModal({
} }
}; };
const getPenaltyLabel = (type: PenaltyType) => { const getPenaltyName = (type: PenaltyType) => {
switch (type) { switch (type) {
case "time_penalty": case "time_penalty":
return "seconds"; return "Time Penalty";
case "grid_penalty": case "grid_penalty":
return "grid positions"; return "Grid Penalty";
case "points_deduction": case "points_deduction":
return "points"; return "Points Deduction";
case "disqualification":
return "Disqualification";
case "warning":
return "Warning";
case "license_points": case "license_points":
return "points"; return "License Points";
case "probation":
return "Probation";
case "fine": case "fine":
return "points"; return "Fine";
case "race_ban": case "race_ban":
return "races"; return "Race Ban";
default: default:
return type.replaceAll("_", " ");
}
};
const getPenaltyValueLabel = (valueKind: PenaltyValueKindDTO): string => {
switch (valueKind) {
case "seconds":
return "seconds";
case "grid_positions":
return "grid positions";
case "points":
return "points";
case "races":
return "races";
case "none":
return ""; return "";
} }
}; };
const getPenaltyDefaultValue = (type: PenaltyType, valueKind: PenaltyValueKindDTO): number => {
if (type === "license_points") return 2;
if (type === "race_ban") return 1;
switch (valueKind) {
case "seconds":
return 5;
case "grid_positions":
return 3;
case "points":
return 5;
case "races":
return 1;
case "none":
return 0;
}
};
const getPenaltyColor = (type: PenaltyType) => { const getPenaltyColor = (type: PenaltyType) => {
switch (type) { switch (type) {
case "time_penalty": case "time_penalty":
@@ -138,6 +178,25 @@ export function ReviewProtestModal({
} }
}; };
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const penaltyOptions = useMemo(() => {
const refs = penaltyTypesReference?.penaltyTypes ?? [];
return refs.map((ref) => ({
type: ref.type as PenaltyType,
name: getPenaltyName(ref.type),
requiresValue: ref.requiresValue,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: getPenaltyDefaultValue(ref.type, ref.valueKind),
Icon: getPenaltyIcon(ref.type),
colorClass: getPenaltyColor(ref.type),
}));
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
return penaltyOptions.find((p) => p.type === penaltyType);
}, [penaltyOptions, penaltyType]);
if (showConfirmation) { if (showConfirmation) {
return ( return (
<Modal title="Confirm Decision" isOpen={true} onOpenChange={() => setShowConfirmation(false)}> <Modal title="Confirm Decision" isOpen={true} onOpenChange={() => setShowConfirmation(false)}>
@@ -160,7 +219,9 @@ export function ReviewProtestModal({
<h3 className="text-xl font-bold text-white">Confirm Decision</h3> <h3 className="text-xl font-bold text-white">Confirm Decision</h3>
<p className="text-gray-400 mt-2"> <p className="text-gray-400 mt-2">
{decision === "accept" {decision === "accept"
? `Issue ${penaltyValue} ${getPenaltyLabel(penaltyType)} penalty?` ? (selectedPenalty?.requiresValue
? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?`
: `Issue ${selectedPenalty?.name ?? penaltyType} penalty?`)
: "Reject this protest?"} : "Reject this protest?"}
</p> </p>
</div> </div>
@@ -300,43 +361,39 @@ export function ReviewProtestModal({
<label className="text-sm font-medium text-gray-400 mb-2 block"> <label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Type Penalty Type
</label> </label>
<div className="grid grid-cols-3 gap-2">
{[ {penaltyTypesLoading ? (
{ type: "time_penalty" as PenaltyType, label: "Time Penalty" }, <div className="text-sm text-gray-500">Loading penalty types</div>
{ type: "grid_penalty" as PenaltyType, label: "Grid Penalty" }, ) : (
{ type: "points_deduction" as PenaltyType, label: "Points Deduction" }, <div className="grid grid-cols-3 gap-2">
{ type: "disqualification" as PenaltyType, label: "Disqualification" }, {penaltyOptions.map(({ type, name, Icon, colorClass, defaultValue }) => {
{ type: "warning" as PenaltyType, label: "Warning" }, const isSelected = penaltyType === type;
{ type: "license_points" as PenaltyType, label: "License Points" }, return (
{ type: "probation" as PenaltyType, label: "Probation" }, <button
{ type: "fine" as PenaltyType, label: "Fine" }, key={type}
{ type: "race_ban" as PenaltyType, label: "Race Ban" }, onClick={() => {
].map(({ type, label }) => { setPenaltyType(type);
const Icon = getPenaltyIcon(type); setPenaltyValue(defaultValue);
const colorClass = getPenaltyColor(type); }}
const isSelected = penaltyType === type; className={`p-3 rounded-lg border transition-all ${
return ( isSelected
<button ? `${colorClass} border-2`
key={type} : "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
onClick={() => setPenaltyType(type)} }`}
className={`p-3 rounded-lg border transition-all ${ >
isSelected <Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? "" : "text-gray-400"}`} />
? `${colorClass} border-2` <p className={`text-xs font-medium ${isSelected ? "" : "text-gray-400"}`}>{name}</p>
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50" </button>
}`} );
> })}
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? '' : 'text-gray-400'}`} /> </div>
<p className={`text-xs font-medium ${isSelected ? '' : 'text-gray-400'}`}>{label}</p> )}
</button>
);
})}
</div>
</div> </div>
{['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(penaltyType) && ( {selectedPenalty?.requiresValue && (
<div> <div>
<label className="text-sm font-medium text-gray-400 mb-2 block"> <label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Value ({getPenaltyLabel(penaltyType)}) Penalty Value ({selectedPenalty.valueLabel})
</label> </label>
<input <input
type="number" type="number"

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import React from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';

View File

@@ -4,7 +4,7 @@ import { useAuth } from '@/lib/auth/AuthContext';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megaphone, Paintbrush, Settings, TrendingUp, Trophy } from 'lucide-react'; import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megaphone, Paintbrush, Settings, TrendingUp, Trophy } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';

View File

@@ -0,0 +1,22 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserPill > renders auth links when there is no session 1`] = `
<div>
<div
class="flex items-center gap-2"
>
<a
class="inline-flex items-center gap-2 rounded-full bg-iron-gray border border-charcoal-outline px-4 py-1.5 text-xs font-medium text-gray-300 hover:text-white hover:border-gray-500 transition-all"
href="/auth/login"
>
Sign In
</a>
<a
class="inline-flex items-center gap-2 rounded-full bg-primary-blue px-4 py-1.5 text-xs font-semibold text-white shadow-[0_0_12px_rgba(25,140,255,0.5)] hover:bg-primary-blue/90 hover:shadow-[0_0_18px_rgba(25,140,255,0.8)] transition-all"
href="/auth/signup"
>
Get Started
</a>
</div>
</div>
`;

View File

@@ -172,22 +172,26 @@ export default function SponsorWorkflowMockup() {
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="mt-8 pt-6 border-t border-charcoal-outline" className="mt-8 pt-6 border-t border-charcoal-outline"
> >
<div className="flex items-center justify-center gap-3"> {(() => {
<div className={`w-8 h-8 rounded-lg bg-iron-gray flex items-center justify-center`}> const currentStep = WORKFLOW_STEPS[activeStep] ?? WORKFLOW_STEPS[0];
{(() => { if (!currentStep) return null;
const Icon = WORKFLOW_STEPS[activeStep].icon;
return <Icon className={`w-4 h-4 ${WORKFLOW_STEPS[activeStep].color}`} />; const Icon = currentStep.icon;
})()}
</div> return (
<div className="text-left"> <div className="flex items-center justify-center gap-3">
<p className="text-sm text-gray-400"> <div className="w-8 h-8 rounded-lg bg-iron-gray flex items-center justify-center">
Step {activeStep + 1} of {WORKFLOW_STEPS.length} <Icon className={`w-4 h-4 ${currentStep.color}`} />
</p> </div>
<p className="text-white font-medium"> <div className="text-left">
{WORKFLOW_STEPS[activeStep].title} <p className="text-sm text-gray-400">
</p> Step {activeStep + 1} of {WORKFLOW_STEPS.length}
</div> </p>
</div> <p className="text-white font-medium">{currentStep.title}</p>
</div>
</div>
);
})()}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>

View File

@@ -0,0 +1,15 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useServices } from '@/lib/services/ServiceProvider';
import type { PenaltyTypesReferenceDTO } from '@/lib/types/PenaltyTypesReferenceDTO';
export function usePenaltyTypesReference() {
const { penaltyService } = useServices();
return useQuery<PenaltyTypesReferenceDTO>({
queryKey: ['penaltyTypesReference'],
queryFn: () => penaltyService.getPenaltyTypesReference(),
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -1,6 +1,7 @@
import { BaseApiClient } from '../base/BaseApiClient'; import { BaseApiClient } from '../base/BaseApiClient';
import { RacePenaltiesDTO } from '../../types/generated/RacePenaltiesDTO'; import { RacePenaltiesDTO } from '../../types/generated/RacePenaltiesDTO';
import { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO'; import { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO';
import type { PenaltyTypesReferenceDTO } from '../../types/PenaltyTypesReferenceDTO';
/** /**
* Penalties API Client * Penalties API Client
@@ -13,6 +14,11 @@ export class PenaltiesApiClient extends BaseApiClient {
return this.get<RacePenaltiesDTO>(`/races/${raceId}/penalties`); return this.get<RacePenaltiesDTO>(`/races/${raceId}/penalties`);
} }
/** Get allowed penalty types and semantics */
getPenaltyTypesReference(): Promise<PenaltyTypesReferenceDTO> {
return this.get<PenaltyTypesReferenceDTO>('/races/reference/penalty-types');
}
/** Apply a penalty */ /** Apply a penalty */
applyPenalty(input: ApplyPenaltyCommandDTO): Promise<void> { applyPenalty(input: ApplyPenaltyCommandDTO): Promise<void> {
return this.post<void>('/races/penalties/apply', input); return this.post<void>('/races/penalties/apply', input);

View File

@@ -1,6 +1,5 @@
import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO'; import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
import { LeagueWizardValidationMessages } from '@/lib/display-objects/LeagueWizardValidationMessages'; import { LeagueWizardValidationMessages } from '@/lib/display-objects/LeagueWizardValidationMessages';
import { ScoringPresetApplier } from '@/lib/utilities/ScoringPresetApplier';
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7; export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7;
@@ -263,7 +262,7 @@ export class LeagueWizardCommandModel {
patternId, patternId,
customScoringEnabled: false, customScoringEnabled: false,
}; };
this.timings = ScoringPresetApplier.applyToTimings(patternId, this.timings); // Timing defaults are applied by the UI using the selected preset's `defaultTimings` from the API.
} }
toCreateLeagueCommand(ownerId: string): CreateLeagueInputDTO { toCreateLeagueCommand(ownerId: string): CreateLeagueInputDTO {

View File

@@ -1,19 +1,15 @@
import { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO'; import { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO';
export type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
export interface ProtestDecisionData { export interface ProtestDecisionData {
decision: 'uphold' | 'dismiss' | null; decision: 'uphold' | 'dismiss' | null;
penaltyType: PenaltyType; penaltyType: string;
penaltyValue: number; penaltyValue?: number;
stewardNotes: string; stewardNotes: string;
} }
const DEFAULT_PROTEST_REASON = 'Protest upheld';
export class ProtestDecisionCommandModel { export class ProtestDecisionCommandModel {
decision: 'uphold' | 'dismiss' | null = null; decision: 'uphold' | 'dismiss' | null = null;
penaltyType: PenaltyType = 'time_penalty'; penaltyType: string = 'time_penalty';
penaltyValue: number = 5; penaltyValue: number = 5;
stewardNotes: string = ''; stewardNotes: string = '';
@@ -39,27 +35,37 @@ export class ProtestDecisionCommandModel {
this.stewardNotes = ''; this.stewardNotes = '';
} }
toApplyPenaltyCommand(raceId: string, driverId: string, stewardId: string, protestId: string): ApplyPenaltyCommandDTO { toApplyPenaltyCommand(
const reason = this.decision === 'uphold' raceId: string,
? DEFAULT_PROTEST_REASON driverId: string,
: 'Protest dismissed'; stewardId: string,
protestId: string,
options?: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
},
): ApplyPenaltyCommandDTO {
const reason =
this.decision === 'uphold'
? (options?.defaultUpheldReason ?? 'Protest upheld')
: (options?.defaultDismissedReason ?? 'Protest dismissed');
return { const base: ApplyPenaltyCommandDTO = {
raceId, raceId,
driverId, driverId,
stewardId, stewardId,
enum: this.penaltyType, // Use penaltyType as enum enum: this.penaltyType,
type: this.penaltyType, type: this.penaltyType,
value: this.getPenaltyValue(),
reason, reason,
protestId, protestId,
notes: this.stewardNotes, notes: this.stewardNotes,
}; };
}
private getPenaltyValue(): number { if (options?.requiresValue) {
// Some penalties don't require a value return { ...base, value: this.penaltyValue };
const penaltiesWithoutValue: PenaltyType[] = ['disqualification', 'warning']; }
return penaltiesWithoutValue.includes(this.penaltyType) ? 0 : this.penaltyValue;
return base;
} }
} }

View File

@@ -1,37 +1,32 @@
type LeagueRole = 'owner' | 'admin' | 'steward' | 'member'; export type LeagueRole = 'owner' | 'admin' | 'steward' | 'member';
export interface LeagueRoleDisplayData { export interface LeagueRoleDisplayData {
text: string; text: string;
badgeClasses: string; badgeClasses: string;
} }
export const leagueRoleDisplay: Record<LeagueRole, LeagueRoleDisplayData> = {
owner: {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
},
admin: {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
},
steward: {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
member: {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
},
} as const;
// For backward compatibility, also export the class with static method
export class LeagueRoleDisplay { export class LeagueRoleDisplay {
/** static getLeagueRoleDisplay(role: LeagueRole) {
* Centralized display configuration for league membership roles. return leagueRoleDisplay[role];
*/
static getLeagueRoleDisplay(role: LeagueRole): LeagueRoleDisplayData {
switch (role) {
case 'owner':
return {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
};
case 'admin':
return {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
};
case 'steward':
return {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
};
case 'member':
default:
return {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
};
}
} }
} }

View File

@@ -9,8 +9,9 @@ describe('PenaltyService', () => {
beforeEach(() => { beforeEach(() => {
mockApiClient = { mockApiClient = {
getRacePenalties: vi.fn(), getRacePenalties: vi.fn(),
getPenaltyTypesReference: vi.fn(),
applyPenalty: vi.fn(), applyPenalty: vi.fn(),
} as Mocked<PenaltiesApiClient>; } as unknown as Mocked<PenaltiesApiClient>;
service = new PenaltyService(mockApiClient); service = new PenaltyService(mockApiClient);
}); });
@@ -23,9 +24,10 @@ describe('PenaltyService', () => {
{ id: 'penalty-1', driverId: 'driver-1', type: 'time', value: 5, reason: 'Incident' }, { id: 'penalty-1', driverId: 'driver-1', type: 'time', value: 5, reason: 'Incident' },
{ id: 'penalty-2', driverId: 'driver-2', type: 'grid', value: 3, reason: 'Qualifying incident' }, { id: 'penalty-2', driverId: 'driver-2', type: 'grid', value: 3, reason: 'Qualifying incident' },
], ],
driverMap: {},
}; };
mockApiClient.getRacePenalties.mockResolvedValue(mockDto); mockApiClient.getRacePenalties.mockResolvedValue(mockDto as any);
const result = await service.findByRaceId(raceId); const result = await service.findByRaceId(raceId);
@@ -36,9 +38,9 @@ describe('PenaltyService', () => {
it('should handle empty penalties array', async () => { it('should handle empty penalties array', async () => {
const raceId = 'race-123'; const raceId = 'race-123';
const mockDto = { penalties: [] }; const mockDto = { penalties: [], driverMap: {} };
mockApiClient.getRacePenalties.mockResolvedValue(mockDto); mockApiClient.getRacePenalties.mockResolvedValue(mockDto as any);
const result = await service.findByRaceId(raceId); const result = await service.findByRaceId(raceId);

View File

@@ -1,4 +1,5 @@
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient'; import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
import type { PenaltyTypesReferenceDTO } from '../../types/PenaltyTypesReferenceDTO';
/** /**
* Penalty Service * Penalty Service
@@ -19,6 +20,13 @@ export class PenaltyService {
return dto.penalties; return dto.penalties;
} }
/**
* Get allowed penalty types and semantics
*/
async getPenaltyTypesReference(): Promise<PenaltyTypesReferenceDTO> {
return this.apiClient.getPenaltyTypesReference();
}
/** /**
* Apply a penalty * Apply a penalty
*/ */

View File

@@ -19,17 +19,22 @@ describe('TeamJoinService', () => {
const mockDto = { const mockDto = {
requests: [ requests: [
{ {
id: 'request-1', requestId: 'request-1',
teamId: 'team-1', teamId: 'team-1',
driverId: 'driver-1', driverId: 'driver-1',
driverName: 'Driver One',
status: 'pending',
requestedAt: '2023-01-01T00:00:00Z', requestedAt: '2023-01-01T00:00:00Z',
message: 'Please accept me', avatarUrl: 'https://example.com/avatar-1.jpg',
}, },
{ {
id: 'request-2', requestId: 'request-2',
teamId: 'team-1', teamId: 'team-1',
driverId: 'driver-2', driverId: 'driver-2',
driverName: 'Driver Two',
status: 'pending',
requestedAt: '2023-01-02T00:00:00Z', requestedAt: '2023-01-02T00:00:00Z',
avatarUrl: 'https://example.com/avatar-2.jpg',
}, },
], ],
}; };
@@ -40,20 +45,30 @@ describe('TeamJoinService', () => {
expect(mockApiClient.getJoinRequests).toHaveBeenCalledWith('team-1'); expect(mockApiClient.getJoinRequests).toHaveBeenCalledWith('team-1');
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result[0].id).toBe('request-1');
expect(result[0].canApprove).toBe(true); const first = result[0];
expect(result[1].id).toBe('request-2'); const second = result[1];
expect(result[1].canApprove).toBe(true);
expect(first).toBeDefined();
expect(second).toBeDefined();
expect(first!.id).toBe('request-1');
expect(first!.canApprove).toBe(true);
expect(second!.id).toBe('request-2');
expect(second!.canApprove).toBe(true);
}); });
it('should pass correct parameters to view model constructor', async () => { it('should pass correct parameters to view model constructor', async () => {
const mockDto = { const mockDto = {
requests: [ requests: [
{ {
id: 'request-1', requestId: 'request-1',
teamId: 'team-1', teamId: 'team-1',
driverId: 'driver-1', driverId: 'driver-1',
driverName: 'Driver One',
status: 'pending',
requestedAt: '2023-01-01T00:00:00Z', requestedAt: '2023-01-01T00:00:00Z',
avatarUrl: 'https://example.com/avatar-1.jpg',
}, },
], ],
}; };
@@ -62,7 +77,8 @@ describe('TeamJoinService', () => {
const result = await service.getJoinRequests('team-1', 'user-1', false); const result = await service.getJoinRequests('team-1', 'user-1', false);
expect(result[0].canApprove).toBe(false); expect(result[0]).toBeDefined();
expect(result[0]!.canApprove).toBe(false);
}); });
}); });

View File

@@ -34,7 +34,7 @@ export class TeamJoinService {
* a request requires a future management endpoint, so this method fails explicitly. * a request requires a future management endpoint, so this method fails explicitly.
*/ */
async approveJoinRequest(): Promise<never> { async approveJoinRequest(): Promise<never> {
throw new Error('Approving team join requests is not supported in this build'); throw new Error('Not implemented: API endpoint for approving join requests');
} }
/** /**
@@ -44,6 +44,6 @@ export class TeamJoinService {
* must treat this as an unsupported operation rather than a silent no-op. * must treat this as an unsupported operation rather than a silent no-op.
*/ */
async rejectJoinRequest(): Promise<never> { async rejectJoinRequest(): Promise<never> {
throw new Error('Rejecting team join requests is not supported in this build'); throw new Error('Not implemented: API endpoint for rejecting join requests');
} }
} }

View File

@@ -4,45 +4,111 @@
* Values are primarily sourced from environment variables so that * Values are primarily sourced from environment variables so that
* deployments can provide real company details without hard-coding * deployments can provide real company details without hard-coding
* production data in the repository. * production data in the repository.
*
* Website must remain an API consumer (no adapter imports).
*/ */
const env = { export interface SiteConfigData {
platformName: process.env.NEXT_PUBLIC_SITE_NAME,
platformUrl: process.env.NEXT_PUBLIC_SITE_URL,
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL,
sponsorEmail: process.env.NEXT_PUBLIC_SPONSOR_EMAIL,
legalCompanyName: process.env.NEXT_PUBLIC_LEGAL_COMPANY_NAME,
legalVatId: process.env.NEXT_PUBLIC_LEGAL_VAT_ID,
legalRegisteredCountry: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY,
legalRegisteredAddress: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS,
} as const;
export const siteConfig = {
// Platform Information // Platform Information
platformName: env.platformName ?? 'GridPilot', platformName: string;
platformUrl: env.platformUrl ?? 'https://gridpilot.com', platformUrl: string;
// Contact Information // Contact Information
supportEmail: env.supportEmail ?? 'support@example.com', supportEmail: string;
sponsorEmail: env.sponsorEmail ?? 'sponsors@example.com', sponsorEmail: string;
// Legal & Business Information // Legal & Business Information
legal: { legal: {
companyName: env.legalCompanyName ?? '', companyName: string;
vatId: env.legalVatId ?? '', vatId: string;
registeredCountry: env.legalRegisteredCountry ?? '', registeredCountry: string;
registeredAddress: env.legalRegisteredAddress ?? '', registeredAddress: string;
}, };
// Platform Fees // Platform Fees
fees: { fees: {
platformFeePercent: 10, // 10% platform fee on sponsorships platformFeePercent: number;
description: 'Platform fee supports maintenance, analytics, and secure payment processing.', description: string;
}, };
// VAT Information
vat: {
euReverseChargeApplies: boolean;
nonEuVatExempt: boolean;
standardRate: number;
notice: string;
euBusinessNotice: string;
nonEuNotice: string;
};
// Sponsorship Types Available
sponsorshipTypes: {
leagues: {
enabled: boolean;
title: string;
description: string;
};
teams: {
enabled: boolean;
title: string;
description: string;
};
drivers: {
enabled: boolean;
title: string;
description: string;
};
races: {
enabled: boolean;
title: string;
description: string;
};
platform: {
enabled: boolean;
title: string;
description: string;
};
};
// Feature Flags for Sponsorship Features
features: {
liveryPlacement: boolean;
leaguePageBranding: boolean;
racePageBranding: boolean;
profileBadges: boolean;
socialMediaMentions: boolean;
newsletterInclusion: boolean;
homepageAds: boolean;
sidebarAds: boolean;
broadcastOverlays: boolean;
};
}
export const siteConfig: SiteConfigData = {
// Platform Information
platformName: process.env.NEXT_PUBLIC_SITE_NAME ?? 'GridPilot',
platformUrl: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://gridpilot.com',
// Contact Information
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL ?? 'support@example.com',
sponsorEmail: process.env.NEXT_PUBLIC_SPONSOR_EMAIL ?? 'sponsors@example.com',
// Legal & Business Information
legal: {
companyName: process.env.NEXT_PUBLIC_LEGAL_COMPANY_NAME ?? '',
vatId: process.env.NEXT_PUBLIC_LEGAL_VAT_ID ?? '',
registeredCountry: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY ?? '',
registeredAddress: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS ?? '',
},
// Platform Fees
fees: {
platformFeePercent: 10,
description: 'Platform fee supports maintenance, analytics, and secure payment processing.',
},
// VAT Information // VAT Information
vat: { vat: {
// Note: All prices displayed are exclusive of VAT
euReverseChargeApplies: true, euReverseChargeApplies: true,
nonEuVatExempt: true, nonEuVatExempt: true,
standardRate: 20, standardRate: 20,
@@ -50,7 +116,7 @@ export const siteConfig = {
euBusinessNotice: 'EU businesses with a valid VAT ID may apply reverse charge.', euBusinessNotice: 'EU businesses with a valid VAT ID may apply reverse charge.',
nonEuNotice: 'Non-EU businesses are not charged VAT.', nonEuNotice: 'Non-EU businesses are not charged VAT.',
}, },
// Sponsorship Types Available // Sponsorship Types Available
sponsorshipTypes: { sponsorshipTypes: {
leagues: { leagues: {
@@ -79,10 +145,9 @@ export const siteConfig = {
description: 'Reach the entire GridPilot audience with strategic platform placements.', description: 'Reach the entire GridPilot audience with strategic platform placements.',
}, },
}, },
// Feature Flags for Sponsorship Features // Feature Flags for Sponsorship Features
features: { features: {
// What sponsors can actually get (no broadcast control)
liveryPlacement: true, liveryPlacement: true,
leaguePageBranding: true, leaguePageBranding: true,
racePageBranding: true, racePageBranding: true,
@@ -91,8 +156,7 @@ export const siteConfig = {
newsletterInclusion: true, newsletterInclusion: true,
homepageAds: true, homepageAds: true,
sidebarAds: true, sidebarAds: true,
// We don't control these broadcastOverlays: false,
broadcastOverlays: false, // We don't control broadcast
}, },
} as const; } as const;

View File

@@ -0,0 +1,15 @@
export type PenaltyValueKindDTO = 'seconds' | 'grid_positions' | 'points' | 'races' | 'none';
export interface PenaltyTypeReferenceDTO {
type: string;
requiresValue: boolean;
valueKind: PenaltyValueKindDTO;
}
export interface PenaltyTypesReferenceDTO {
penaltyTypes: PenaltyTypeReferenceDTO[];
defaultReasons: {
upheld: string;
dismissed: string;
};
}

View File

@@ -12,4 +12,11 @@ export interface LeagueScoringPresetDTO {
sessionSummary: string; sessionSummary: string;
bonusSummary: string; bonusSummary: string;
dropPolicySummary: string; dropPolicySummary: string;
defaultTimings: {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
};
} }

View File

@@ -8,30 +8,8 @@ export type Timings = {
}; };
export class ScoringPresetApplier { export class ScoringPresetApplier {
static applyToTimings(patternId: string, currentTimings: Timings): Timings { static applyToTimings(_patternId: string, currentTimings: Timings): Timings {
// Website-local fallback mapping (UI convenience only). // Deprecated: timing defaults are provided by the API via scoring preset `defaultTimings`.
// Authoritative preset/timing rules should live in the API. return currentTimings;
switch (patternId) {
case 'sprint-main-driver':
return {
...currentTimings,
practiceMinutes: currentTimings.practiceMinutes ?? 20,
qualifyingMinutes: currentTimings.qualifyingMinutes ?? 30,
sprintRaceMinutes: currentTimings.sprintRaceMinutes ?? 20,
mainRaceMinutes: currentTimings.mainRaceMinutes ?? 40,
sessionCount: 2,
};
case 'endurance-main-driver':
return {
...currentTimings,
practiceMinutes: currentTimings.practiceMinutes ?? 30,
qualifyingMinutes: currentTimings.qualifyingMinutes ?? 20,
sprintRaceMinutes: 0,
mainRaceMinutes: currentTimings.mainRaceMinutes ?? 120,
sessionCount: 1,
};
default:
return currentTimings;
}
} }
} }

View File

@@ -1,6 +1,14 @@
import { Clock, PlayCircle, CheckCircle2, XCircle } from 'lucide-react'; import { Clock, PlayCircle, CheckCircle2, XCircle } from 'lucide-react';
export const raceStatusConfig = { export interface RaceStatusConfigData {
icon: any;
color: string;
bg: string;
border: string;
label: string;
}
export const raceStatusConfig: Record<string, RaceStatusConfigData> = {
scheduled: { scheduled: {
icon: Clock, icon: Clock,
color: 'text-primary-blue', color: 'text-primary-blue',
@@ -29,4 +37,4 @@ export const raceStatusConfig = {
border: 'border-warning-amber/30', border: 'border-warning-amber/30',
label: 'Cancelled', label: 'Cancelled',
}, },
}; } as const;

View File

@@ -14,7 +14,7 @@ export class DriverLeaderboardItemViewModel {
avatarUrl: string; avatarUrl: string;
position: number; position: number;
private previousRating?: number; private previousRating: number | undefined;
constructor(dto: DriverLeaderboardItemDTO, position: number, previousRating?: number) { constructor(dto: DriverLeaderboardItemDTO, position: number, previousRating?: number) {
this.id = dto.id; this.id = dto.id;

View File

@@ -1,5 +1,13 @@
import { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO'; import { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
export type LeagueScoringPresetTimingDefaultsViewModel = {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
};
/** /**
* LeagueScoringPresetViewModel * LeagueScoringPresetViewModel
* *
@@ -10,11 +18,13 @@ export class LeagueScoringPresetViewModel {
readonly name: string; readonly name: string;
readonly sessionSummary: string; readonly sessionSummary: string;
readonly bonusSummary?: string; readonly bonusSummary?: string;
readonly defaultTimings: LeagueScoringPresetTimingDefaultsViewModel;
constructor(dto: LeagueScoringPresetDTO) { constructor(dto: LeagueScoringPresetDTO) {
this.id = dto.id; this.id = dto.id;
this.name = dto.name; this.name = dto.name;
this.sessionSummary = dto.sessionSummary; this.sessionSummary = dto.sessionSummary;
this.bonusSummary = dto.bonusSummary; this.bonusSummary = dto.bonusSummary;
this.defaultTimings = dto.defaultTimings;
} }
} }

View File

@@ -32,4 +32,12 @@ export class RequestAvatarGenerationViewModel {
get firstAvatarUrl(): string | undefined { get firstAvatarUrl(): string | undefined {
return this.avatarUrls?.[0]; return this.avatarUrls?.[0];
} }
get avatarUrl(): string | undefined {
return this.firstAvatarUrl;
}
get error(): string | undefined {
return this.errorMessage;
}
} }

View File

@@ -18,13 +18,13 @@ export class SponsorshipRequestViewModel {
this.id = dto.id; this.id = dto.id;
this.sponsorId = dto.sponsorId; this.sponsorId = dto.sponsorId;
this.sponsorName = dto.sponsorName; this.sponsorName = dto.sponsorName;
this.sponsorLogo = dto.sponsorLogo; if (dto.sponsorLogo !== undefined) this.sponsorLogo = dto.sponsorLogo;
// Backend currently returns tier as string; normalize to our supported tiers. // Backend currently returns tier as string; normalize to our supported tiers.
this.tier = dto.tier === 'main' ? 'main' : 'secondary'; this.tier = dto.tier === 'main' ? 'main' : 'secondary';
this.offeredAmount = dto.offeredAmount; this.offeredAmount = dto.offeredAmount;
this.currency = dto.currency; this.currency = dto.currency;
this.formattedAmount = dto.formattedAmount; this.formattedAmount = dto.formattedAmount;
this.message = dto.message; if (dto.message !== undefined) this.message = dto.message;
this.createdAt = new Date(dto.createdAt); this.createdAt = new Date(dto.createdAt);
this.platformFee = dto.platformFee; this.platformFee = dto.platformFee;
this.netAmount = dto.netAmount; this.netAmount = dto.netAmount;

View File

@@ -7,10 +7,10 @@ export class TeamDetailsViewModel {
description?: string; description?: string;
ownerId!: string; ownerId!: string;
leagues!: string[]; leagues!: string[];
createdAt?: string; createdAt: string | undefined;
specialization?: string; specialization: string | undefined;
region?: string; region: string | undefined;
languages?: string[]; languages: string[] | undefined;
membership: { role: string; joinedAt: string; isActive: boolean } | null; membership: { role: string; joinedAt: string; isActive: boolean } | null;
private _canManage: boolean; private _canManage: boolean;
private currentUserId: string; private currentUserId: string;

View File

@@ -1,18 +1,21 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TeamJoinRequestViewModel, type TeamJoinRequestDTO } from './TeamJoinRequestViewModel'; import { TeamJoinRequestViewModel } from './TeamJoinRequestViewModel';
import type { TeamJoinRequestDTO } from '@/lib/types/generated/TeamJoinRequestDTO';
const createTeamJoinRequestDto = (overrides: Partial<TeamJoinRequestDTO> = {}): TeamJoinRequestDTO => ({ const createTeamJoinRequestDto = (overrides: Partial<TeamJoinRequestDTO> = {}): TeamJoinRequestDTO => ({
id: 'request-1', requestId: 'request-1',
teamId: 'team-1', teamId: 'team-1',
driverId: 'driver-1', driverId: 'driver-1',
driverName: 'Driver One',
status: 'pending',
requestedAt: '2024-01-01T12:00:00Z', requestedAt: '2024-01-01T12:00:00Z',
message: 'Please let me join', avatarUrl: 'https://example.com/avatar.jpg',
...overrides, ...overrides,
}); });
describe('TeamJoinRequestViewModel', () => { describe('TeamJoinRequestViewModel', () => {
it('maps fields from DTO', () => { it('maps fields from DTO', () => {
const dto = createTeamJoinRequestDto({ id: 'req-123', driverId: 'driver-123' }); const dto = createTeamJoinRequestDto({ requestId: 'req-123', driverId: 'driver-123' });
const vm = new TeamJoinRequestViewModel(dto, 'current-user', true); const vm = new TeamJoinRequestViewModel(dto, 'current-user', true);
@@ -20,7 +23,6 @@ describe('TeamJoinRequestViewModel', () => {
expect(vm.teamId).toBe('team-1'); expect(vm.teamId).toBe('team-1');
expect(vm.driverId).toBe('driver-123'); expect(vm.driverId).toBe('driver-123');
expect(vm.requestedAt).toBe('2024-01-01T12:00:00Z'); expect(vm.requestedAt).toBe('2024-01-01T12:00:00Z');
expect(vm.message).toBe('Please let me join');
}); });
it('allows approval only for owners', () => { it('allows approval only for owners', () => {
@@ -34,7 +36,7 @@ describe('TeamJoinRequestViewModel', () => {
}); });
it('exposes a pending status with yellow color', () => { it('exposes a pending status with yellow color', () => {
const dto = createTeamJoinRequestDto(); const dto = createTeamJoinRequestDto({ status: 'pending' });
const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true); const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true);
expect(vm.status).toBe('Pending'); expect(vm.status).toBe('Pending');

View File

@@ -24,6 +24,17 @@ export class TeamJoinRequestViewModel {
this.isOwner = isOwner; this.isOwner = isOwner;
} }
get id(): string {
return this.requestId;
}
get status(): string {
if (this.requestStatus === 'pending') return 'Pending';
if (this.requestStatus === 'approved') return 'Approved';
if (this.requestStatus === 'rejected') return 'Rejected';
return this.requestStatus;
}
/** UI-specific: Whether current user can approve */ /** UI-specific: Whether current user can approve */
get canApprove(): boolean { get canApprove(): boolean {
return this.isOwner; return this.isOwner;

View File

@@ -10,8 +10,8 @@ export class TeamSummaryViewModel {
totalRaces: number = 0; totalRaces: number = 0;
performanceLevel: string = ''; performanceLevel: string = '';
isRecruiting: boolean = false; isRecruiting: boolean = false;
specialization?: string; specialization: string | undefined;
region?: string; region: string | undefined;
languages: string[] = []; languages: string[] = [];
leagues: string[] = []; leagues: string[] = [];

View File

@@ -27,11 +27,6 @@ const nextConfig = {
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
transpilePackages: [
'@core/racing',
'@core/identity',
'@core/social'
],
webpack: (config) => { webpack: (config) => {
config.module.rules.push({ config.module.rules.push({
test: /\.(mp4|webm)$/, test: /\.(mp4|webm)$/,

View File

@@ -84,7 +84,6 @@
"**/*.spec.tsx", "**/*.spec.tsx",
"**/__tests__/**", "**/__tests__/**",
"../../core/**", "../../core/**",
"../../adapters/**",
"../../apps/api/**", "../../apps/api/**",
"../../scripts/**", "../../scripts/**",
"../../testing/**", "../../testing/**",

View File

@@ -37,6 +37,7 @@ export class ListLeagueScoringPresetsUseCase {
sessionSummary: p.sessionSummary, sessionSummary: p.sessionSummary,
bonusSummary: p.bonusSummary, bonusSummary: p.bonusSummary,
dropPolicySummary: p.dropPolicySummary, dropPolicySummary: p.dropPolicySummary,
defaultTimings: p.defaultTimings,
})); }));
const result: ListLeagueScoringPresetsResult = { presets }; const result: ListLeagueScoringPresetsResult = { presets };

View File

@@ -11,7 +11,7 @@ import { PenaltyId } from './PenaltyId';
import { LeagueId } from '../LeagueId'; import { LeagueId } from '../LeagueId';
import { RaceId } from '../RaceId'; import { RaceId } from '../RaceId';
import { DriverId } from '../DriverId'; import { DriverId } from '../DriverId';
import { PenaltyType } from './PenaltyType'; import { PenaltyType, type PenaltyTypeValue } from './PenaltyType';
import { PenaltyValue } from './PenaltyValue'; import { PenaltyValue } from './PenaltyValue';
import { PenaltyReason } from './PenaltyReason'; import { PenaltyReason } from './PenaltyReason';
import { ProtestId } from '../ProtestId'; import { ProtestId } from '../ProtestId';
@@ -47,6 +47,19 @@ export interface PenaltyProps {
notes?: PenaltyNotes; notes?: PenaltyNotes;
} }
export const PENALTY_TYPES_REQUIRING_VALUE: PenaltyTypeValue[] = [
'time_penalty',
'grid_penalty',
'points_deduction',
'license_points',
'fine',
'race_ban',
];
export function penaltyTypeRequiresValue(type: PenaltyTypeValue): boolean {
return PENALTY_TYPES_REQUIRING_VALUE.includes(type);
}
export class Penalty implements IEntity<string> { export class Penalty implements IEntity<string> {
private constructor(private readonly props: PenaltyProps) {} private constructor(private readonly props: PenaltyProps) {}
@@ -73,10 +86,12 @@ export class Penalty implements IEntity<string> {
if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required'); if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required');
if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward'); if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward');
const penaltyType = PenaltyType.create(props.type);
// Validate value based on type // Validate value based on type
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) { if (penaltyTypeRequiresValue(penaltyType.toString())) {
if (props.value === undefined || props.value <= 0) { if (props.value === undefined || props.value <= 0) {
throw new RacingDomainValidationError(`${props.type} requires a positive value`); throw new RacingDomainValidationError(`${penaltyType.toString()} requires a positive value`);
} }
} }
@@ -85,7 +100,7 @@ export class Penalty implements IEntity<string> {
leagueId: LeagueId.create(props.leagueId), leagueId: LeagueId.create(props.leagueId),
raceId: RaceId.create(props.raceId), raceId: RaceId.create(props.raceId),
driverId: DriverId.create(props.driverId), driverId: DriverId.create(props.driverId),
type: PenaltyType.create(props.type), type: penaltyType,
reason: PenaltyReason.create(props.reason), reason: PenaltyReason.create(props.reason),
issuedBy: StewardId.create(props.issuedBy), issuedBy: StewardId.create(props.issuedBy),
status: PenaltyStatus.create(props.status || 'pending'), status: PenaltyStatus.create(props.status || 'pending'),

View File

@@ -11,23 +11,44 @@ export type PenaltyTypeValue =
| 'fine' | 'fine'
| 'race_ban'; | 'race_ban';
export const PENALTY_TYPE_VALUES: PenaltyTypeValue[] = [
'time_penalty',
'grid_penalty',
'points_deduction',
'disqualification',
'warning',
'license_points',
'probation',
'fine',
'race_ban',
];
export type PenaltyValueKind = 'seconds' | 'grid_positions' | 'points' | 'races' | 'none';
export function getPenaltyValueKind(type: PenaltyTypeValue): PenaltyValueKind {
switch (type) {
case 'time_penalty':
return 'seconds';
case 'grid_penalty':
return 'grid_positions';
case 'points_deduction':
case 'license_points':
case 'fine':
return 'points';
case 'race_ban':
return 'races';
case 'disqualification':
case 'warning':
case 'probation':
return 'none';
}
}
export class PenaltyType { export class PenaltyType {
private constructor(private readonly value: PenaltyTypeValue) {} private constructor(private readonly value: PenaltyTypeValue) {}
static create(value: string): PenaltyType { static create(value: string): PenaltyType {
const validTypes: PenaltyTypeValue[] = [ if (!PENALTY_TYPE_VALUES.includes(value as PenaltyTypeValue)) {
'time_penalty',
'grid_penalty',
'points_deduction',
'disqualification',
'warning',
'license_points',
'probation',
'fine',
'race_ban',
];
if (!validTypes.includes(value as PenaltyTypeValue)) {
throw new RacingDomainValidationError(`Invalid penalty type: ${value}`); throw new RacingDomainValidationError(`Invalid penalty type: ${value}`);
} }

View File

@@ -10,6 +10,14 @@ export type LeagueScoringPresetPrimaryChampionshipType =
| 'nations' | 'nations'
| 'trophy'; | 'trophy';
export interface LeagueScoringPresetTimingDefaults {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
}
export interface LeagueScoringPreset { export interface LeagueScoringPreset {
id: string; id: string;
name: string; name: string;
@@ -18,4 +26,5 @@ export interface LeagueScoringPreset {
dropPolicySummary: string; dropPolicySummary: string;
sessionSummary: string; sessionSummary: string;
bonusSummary: string; bonusSummary: string;
defaultTimings: LeagueScoringPresetTimingDefaults;
} }

View File

@@ -608,13 +608,33 @@ Adapters decide **how** it runs.
--- ---
### Reference Data (registries exposed via API)
Some “data” is neither **domain behavior** nor **UI config**. It is **authoritative reference data** that outer layers need (website, API DTOs), but it is not a domain entity itself.
Examples:
* Scoring preset registries (preset identifiers, point systems, drop-week policies, default timing presets)
* Catalog-like lists needed by the UI to render forms safely without duplicating domain semantics
Rules:
* The website (presentation) must never own or hardcode domain catalogs.
* Core owns invariants, enumerations, and validation rules. If a rule is an invariant, it belongs in Core.
* Adapters may own **reference registries** when they are not domain entities, and when they represent curated “known lists” used for configuration and defaults.
* The API must expose reference registries via HTTP so the website remains a pure API consumer (no importing adapters or core).
* UI-only concerns (labels, icons, colors, layout defaults) remain in the website.
---
### Hard separation ### Hard separation
| Concern | Purpose | Location | | Concern | Purpose | Location |
| --------- | ------------------- | ------------------------------- | | -------------- | ---------------------------------------------- | ------------------------------- |
| Migration | Schema | adapters/persistence/migrations | | Migration | Schema | adapters/persistence/migrations |
| Bootstrap | Required start data | adapters/bootstrap | | Bootstrap | Required start data | adapters/bootstrap |
| Factory | Test data | testing | | Reference Data | Authoritative registries consumed via API | adapters/bootstrap + apps/api |
| Factory | Test data | testing |
--- ---