From b6cbb81388c74b62c0777f3f561aefe3cf89f92a Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 26 Dec 2025 00:20:53 +0100 Subject: [PATCH] move static data --- adapters/bootstrap/ActivityConfig.ts | 29 ++ adapters/bootstrap/IncidentConfig.ts | 19 ++ adapters/bootstrap/LeagueRoleDisplay.ts | 31 ++ adapters/bootstrap/LeagueScoringPresets.ts | 30 ++ adapters/bootstrap/LeagueTierConfig.ts | 35 +++ adapters/bootstrap/PenaltyConfig.ts | 16 + adapters/bootstrap/RaceStatusConfig.ts | 46 +++ adapters/bootstrap/RenewalConfig.ts | 29 ++ adapters/bootstrap/RoleHierarchy.ts | 28 ++ adapters/bootstrap/SeasonStatusConfig.ts | 31 ++ adapters/bootstrap/SiteConfig.ts | 162 ++++++++++ adapters/bootstrap/SkillLevelConfig.ts | 31 ++ adapters/bootstrap/SponsorshipConfig.ts | 52 ++++ adapters/bootstrap/TimingConfig.ts | 53 ++++ adapters/bootstrap/index.ts | 17 +- .../api/src/domain/league/LeagueController.ts | 5 +- .../league/dtos/LeagueScoringPresetDTO.ts | 30 +- .../league/dtos/LeagueScoringPresetsDTO.ts | 10 + .../LeagueScoringPresetsPresenter.ts | 6 +- apps/api/src/domain/race/RaceController.ts | 8 + apps/api/src/domain/race/RaceService.ts | 24 ++ .../race/dtos/ApplyPenaltyCommandDTO.ts | 27 +- .../race/dtos/PenaltyTypesReferenceDTO.ts | 40 +++ .../app/leagues/[id]/stewarding/page.tsx | 5 +- .../stewarding/protests/[protestId]/page.tsx | 278 +++++++++++++----- apps/website/app/races/[id]/page.test.tsx | 18 +- apps/website/app/races/[id]/page.tsx | 2 +- .../components/leagues/CreateLeagueWizard.tsx | 90 ++---- .../components/leagues/PenaltyHistoryList.tsx | 2 - .../components/leagues/ReviewProtestModal.tsx | 143 ++++++--- .../components/profile/DriverSummaryPill.tsx | 1 + apps/website/components/profile/UserPill.tsx | 2 +- .../__snapshots__/UserPill.test.tsx.snap | 22 ++ .../sponsors/SponsorWorkflowMockup.tsx | 36 ++- .../website/hooks/usePenaltyTypesReference.ts | 15 + .../lib/api/penalties/PenaltiesApiClient.ts | 6 + .../leagues/LeagueWizardCommandModel.ts | 3 +- .../protests/ProtestDecisionCommandModel.ts | 44 +-- .../lib/display-objects/LeagueRoleDisplay.ts | 51 ++-- .../services/penalties/PenaltyService.test.ts | 10 +- .../lib/services/penalties/PenaltyService.ts | 8 + .../services/teams/TeamJoinService.test.ts | 34 ++- .../lib/services/teams/TeamJoinService.ts | 4 +- apps/website/lib/siteConfig.ts | 128 ++++++-- .../lib/types/PenaltyTypesReferenceDTO.ts | 15 + .../types/generated/LeagueScoringPresetDTO.ts | 7 + .../lib/utilities/ScoringPresetApplier.ts | 28 +- apps/website/lib/utilities/raceStatus.ts | 12 +- .../DriverLeaderboardItemViewModel.ts | 2 +- .../LeagueScoringPresetViewModel.ts | 10 + .../RequestAvatarGenerationViewModel.ts | 8 + .../SponsorshipRequestViewModel.ts | 4 +- .../lib/view-models/TeamDetailsViewModel.ts | 8 +- .../TeamJoinRequestViewModel.test.ts | 14 +- .../view-models/TeamJoinRequestViewModel.ts | 11 + .../lib/view-models/TeamSummaryViewModel.ts | 4 +- apps/website/next.config.mjs | 5 - apps/website/tsconfig.json | 1 - .../ListLeagueScoringPresetsUseCase.ts | 1 + .../racing/domain/entities/penalty/Penalty.ts | 23 +- .../domain/entities/penalty/PenaltyType.ts | 47 ++- .../domain/types/LeagueScoringPreset.ts | 9 + ...T15:42:00Z_clean-architecture-migration.md | 30 +- 63 files changed, 1482 insertions(+), 418 deletions(-) create mode 100644 adapters/bootstrap/ActivityConfig.ts create mode 100644 adapters/bootstrap/IncidentConfig.ts create mode 100644 adapters/bootstrap/LeagueRoleDisplay.ts create mode 100644 adapters/bootstrap/LeagueTierConfig.ts create mode 100644 adapters/bootstrap/PenaltyConfig.ts create mode 100644 adapters/bootstrap/RaceStatusConfig.ts create mode 100644 adapters/bootstrap/RenewalConfig.ts create mode 100644 adapters/bootstrap/RoleHierarchy.ts create mode 100644 adapters/bootstrap/SeasonStatusConfig.ts create mode 100644 adapters/bootstrap/SiteConfig.ts create mode 100644 adapters/bootstrap/SkillLevelConfig.ts create mode 100644 adapters/bootstrap/SponsorshipConfig.ts create mode 100644 adapters/bootstrap/TimingConfig.ts create mode 100644 apps/api/src/domain/league/dtos/LeagueScoringPresetsDTO.ts create mode 100644 apps/api/src/domain/race/dtos/PenaltyTypesReferenceDTO.ts create mode 100644 apps/website/components/profile/__snapshots__/UserPill.test.tsx.snap create mode 100644 apps/website/hooks/usePenaltyTypesReference.ts create mode 100644 apps/website/lib/types/PenaltyTypesReferenceDTO.ts diff --git a/adapters/bootstrap/ActivityConfig.ts b/adapters/bootstrap/ActivityConfig.ts new file mode 100644 index 000000000..3fc92b363 --- /dev/null +++ b/adapters/bootstrap/ActivityConfig.ts @@ -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 = { + 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; \ No newline at end of file diff --git a/adapters/bootstrap/IncidentConfig.ts b/adapters/bootstrap/IncidentConfig.ts new file mode 100644 index 000000000..6f7d3a353 --- /dev/null +++ b/adapters/bootstrap/IncidentConfig.ts @@ -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; \ No newline at end of file diff --git a/adapters/bootstrap/LeagueRoleDisplay.ts b/adapters/bootstrap/LeagueRoleDisplay.ts new file mode 100644 index 000000000..7b4fe903e --- /dev/null +++ b/adapters/bootstrap/LeagueRoleDisplay.ts @@ -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 = { + 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; \ No newline at end of file diff --git a/adapters/bootstrap/LeagueScoringPresets.ts b/adapters/bootstrap/LeagueScoringPresets.ts index 9cdf3323d..99100030e 100644 --- a/adapters/bootstrap/LeagueScoringPresets.ts +++ b/adapters/bootstrap/LeagueScoringPresets.ts @@ -12,6 +12,14 @@ export type LeagueScoringPresetPrimaryChampionshipType = | 'nations' | 'trophy'; +export interface LeagueScoringPresetTimingDefaults { + practiceMinutes: number; + qualifyingMinutes: number; + sprintRaceMinutes: number; + mainRaceMinutes: number; + sessionCount: number; +} + export interface LeagueScoringPreset { id: string; name: string; @@ -20,6 +28,7 @@ export interface LeagueScoringPreset { dropPolicySummary: string; sessionSummary: string; bonusSummary: string; + defaultTimings: LeagueScoringPresetTimingDefaults; createConfig: (options: { seasonId: string }) => LeagueScoringConfig; } @@ -82,6 +91,13 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [ dropPolicySummary: 'Best 6 results of 8 count towards the championship.', sessionSummary: 'Sprint + Main', 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 }) => { const fastestLapBonus: BonusRule = { id: 'fastest-lap-main', @@ -146,6 +162,13 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [ dropPolicySummary: 'All race results count, no drop scores.', sessionSummary: 'Main race only', bonusSummary: 'No bonus points.', + defaultTimings: { + practiceMinutes: 20, + qualifyingMinutes: 20, + sprintRaceMinutes: 0, + mainRaceMinutes: 40, + sessionCount: 1, + }, createConfig: ({ seasonId }) => { const sessionTypes: SessionType[] = ['main']; @@ -190,6 +213,13 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [ dropPolicySummary: 'Best 4 results of 6 count towards the championship.', sessionSummary: 'Main race only', bonusSummary: 'No bonus points.', + defaultTimings: { + practiceMinutes: 30, + qualifyingMinutes: 20, + sprintRaceMinutes: 0, + mainRaceMinutes: 120, + sessionCount: 1, + }, createConfig: ({ seasonId }) => { const sessionTypes: SessionType[] = ['main']; diff --git a/adapters/bootstrap/LeagueTierConfig.ts b/adapters/bootstrap/LeagueTierConfig.ts new file mode 100644 index 000000000..d84a35b08 --- /dev/null +++ b/adapters/bootstrap/LeagueTierConfig.ts @@ -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 = { + 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; \ No newline at end of file diff --git a/adapters/bootstrap/PenaltyConfig.ts b/adapters/bootstrap/PenaltyConfig.ts new file mode 100644 index 000000000..f484a34e5 --- /dev/null +++ b/adapters/bootstrap/PenaltyConfig.ts @@ -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; \ No newline at end of file diff --git a/adapters/bootstrap/RaceStatusConfig.ts b/adapters/bootstrap/RaceStatusConfig.ts new file mode 100644 index 000000000..3f2590a60 --- /dev/null +++ b/adapters/bootstrap/RaceStatusConfig.ts @@ -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 = { + 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; \ No newline at end of file diff --git a/adapters/bootstrap/RenewalConfig.ts b/adapters/bootstrap/RenewalConfig.ts new file mode 100644 index 000000000..eedff4d76 --- /dev/null +++ b/adapters/bootstrap/RenewalConfig.ts @@ -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 = { + league: { + icon: 'Trophy', + }, + team: { + icon: 'Users', + }, + driver: { + icon: 'Car', + }, + race: { + icon: 'Flag', + }, + platform: { + icon: 'Megaphone', + }, +} as const; \ No newline at end of file diff --git a/adapters/bootstrap/RoleHierarchy.ts b/adapters/bootstrap/RoleHierarchy.ts new file mode 100644 index 000000000..5973cafa2 --- /dev/null +++ b/adapters/bootstrap/RoleHierarchy.ts @@ -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 = { + 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; \ No newline at end of file diff --git a/adapters/bootstrap/SeasonStatusConfig.ts b/adapters/bootstrap/SeasonStatusConfig.ts new file mode 100644 index 000000000..263472496 --- /dev/null +++ b/adapters/bootstrap/SeasonStatusConfig.ts @@ -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 = { + 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; \ No newline at end of file diff --git a/adapters/bootstrap/SiteConfig.ts b/adapters/bootstrap/SiteConfig.ts new file mode 100644 index 000000000..c5fe97884 --- /dev/null +++ b/adapters/bootstrap/SiteConfig.ts @@ -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; \ No newline at end of file diff --git a/adapters/bootstrap/SkillLevelConfig.ts b/adapters/bootstrap/SkillLevelConfig.ts new file mode 100644 index 000000000..c3954a5d5 --- /dev/null +++ b/adapters/bootstrap/SkillLevelConfig.ts @@ -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 = { + beginner: { + color: 'green', + icon: '🥉', + }, + intermediate: { + color: 'yellow', + icon: '🥈', + }, + advanced: { + color: 'orange', + icon: '🥇', + }, + expert: { + color: 'red', + icon: '👑', + }, +} as const; \ No newline at end of file diff --git a/adapters/bootstrap/SponsorshipConfig.ts b/adapters/bootstrap/SponsorshipConfig.ts new file mode 100644 index 000000000..6ff11cd18 --- /dev/null +++ b/adapters/bootstrap/SponsorshipConfig.ts @@ -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 = { + leagues: { + label: 'League', + }, + teams: { + label: 'Team', + }, + drivers: { + label: 'Driver', + }, + races: { + label: 'Race', + }, + platform: { + label: 'Platform', + }, +} as const; + +export const sponsorshipStatusConfig: Record = { + active: { + label: 'Active', + }, + pending_approval: { + label: 'Awaiting Approval', + }, + approved: { + label: 'Approved', + }, + rejected: { + label: 'Declined', + }, + expired: { + label: 'Expired', + }, +} as const; \ No newline at end of file diff --git a/adapters/bootstrap/TimingConfig.ts b/adapters/bootstrap/TimingConfig.ts new file mode 100644 index 000000000..f8130415a --- /dev/null +++ b/adapters/bootstrap/TimingConfig.ts @@ -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 = { + '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, + }; +}; \ No newline at end of file diff --git a/adapters/bootstrap/index.ts b/adapters/bootstrap/index.ts index fe8939591..5a05dab83 100644 --- a/adapters/bootstrap/index.ts +++ b/adapters/bootstrap/index.ts @@ -2,4 +2,19 @@ export * from './EnsureInitialData'; export * from './LeagueConstraints'; export * from './LeagueScoringPresets'; export * from './PointsSystems'; -export * from './ScoringDemoSetup'; \ No newline at end of file +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'; \ No newline at end of file diff --git a/apps/api/src/domain/league/LeagueController.ts b/apps/api/src/domain/league/LeagueController.ts index 9155693f4..0af8c4694 100644 --- a/apps/api/src/domain/league/LeagueController.ts +++ b/apps/api/src/domain/league/LeagueController.ts @@ -33,6 +33,7 @@ import { GetLeagueWalletOutputDTO } from './dtos/GetLeagueWalletOutputDTO'; import { TotalLeaguesDTO } from './dtos/TotalLeaguesDTO'; import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO'; import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO'; +import { LeagueScoringPresetsDTO } from './dtos/LeagueScoringPresetsDTO'; @ApiTags('leagues') @Controller('leagues') @@ -239,8 +240,8 @@ export class LeagueController { @Get('scoring-presets') @ApiOperation({ summary: 'Get league scoring presets' }) - @ApiResponse({ status: 200, description: 'List of scoring presets' }) - async getLeagueScoringPresets() { + @ApiResponse({ status: 200, description: 'List of scoring presets', type: LeagueScoringPresetsDTO }) + async getLeagueScoringPresets(): Promise { return this.leagueService.listLeagueScoringPresets(); } diff --git a/apps/api/src/domain/league/dtos/LeagueScoringPresetDTO.ts b/apps/api/src/domain/league/dtos/LeagueScoringPresetDTO.ts index c1495a149..fc8890b6c 100644 --- a/apps/api/src/domain/league/dtos/LeagueScoringPresetDTO.ts +++ b/apps/api/src/domain/league/dtos/LeagueScoringPresetDTO.ts @@ -1,5 +1,28 @@ 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 { @ApiProperty() @@ -29,4 +52,9 @@ export class LeagueScoringPresetDTO { @ApiProperty() @IsString() dropPolicySummary!: string; + + @ApiProperty({ type: LeagueScoringPresetTimingDefaultsDTO }) + @ValidateNested() + @Type(() => LeagueScoringPresetTimingDefaultsDTO) + defaultTimings!: LeagueScoringPresetTimingDefaultsDTO; } \ No newline at end of file diff --git a/apps/api/src/domain/league/dtos/LeagueScoringPresetsDTO.ts b/apps/api/src/domain/league/dtos/LeagueScoringPresetsDTO.ts new file mode 100644 index 000000000..2a0bffadf --- /dev/null +++ b/apps/api/src/domain/league/dtos/LeagueScoringPresetsDTO.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { LeagueScoringPresetDTO } from './LeagueScoringPresetDTO'; + +export class LeagueScoringPresetsDTO { + @ApiProperty({ type: [LeagueScoringPresetDTO] }) + presets!: LeagueScoringPresetDTO[]; + + @ApiProperty() + totalCount!: number; +} \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/LeagueScoringPresetsPresenter.ts b/apps/api/src/domain/league/presenters/LeagueScoringPresetsPresenter.ts index 8552826ee..aa53d0e27 100644 --- a/apps/api/src/domain/league/presenters/LeagueScoringPresetsPresenter.ts +++ b/apps/api/src/domain/league/presenters/LeagueScoringPresetsPresenter.ts @@ -1,10 +1,8 @@ import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { ListLeagueScoringPresetsResult, LeagueScoringPreset } from '@core/racing/application/use-cases/ListLeagueScoringPresetsUseCase'; +import type { LeagueScoringPresetsDTO } from '../dtos/LeagueScoringPresetsDTO'; -export interface LeagueScoringPresetsViewModel { - presets: LeagueScoringPreset[]; - totalCount: number; -} +export type LeagueScoringPresetsViewModel = LeagueScoringPresetsDTO; export class LeagueScoringPresetsPresenter implements UseCaseOutputPort { private viewModel: LeagueScoringPresetsViewModel | null = null; diff --git a/apps/api/src/domain/race/RaceController.ts b/apps/api/src/domain/race/RaceController.ts index a124529b7..daa917492 100644 --- a/apps/api/src/domain/race/RaceController.ts +++ b/apps/api/src/domain/race/RaceController.ts @@ -17,6 +17,7 @@ import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO'; import { QuickPenaltyCommandDTO } from './dtos/QuickPenaltyCommandDTO'; import { ApplyPenaltyCommandDTO } from './dtos/ApplyPenaltyCommandDTO'; import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCommandDTO'; +import { PenaltyTypesReferenceDTO } from './dtos/PenaltyTypesReferenceDTO'; @ApiTags('races') @Controller('races') @@ -96,6 +97,13 @@ export class RaceController { 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') @ApiOperation({ summary: 'Get race penalties' }) @ApiParam({ name: 'raceId', description: 'Race ID' }) diff --git a/apps/api/src/domain/race/RaceService.ts b/apps/api/src/domain/race/RaceService.ts index 7ae077ba1..8232fdc0d 100644 --- a/apps/api/src/domain/race/RaceService.ts +++ b/apps/api/src/domain/race/RaceService.ts @@ -50,6 +50,7 @@ import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO'; import { QuickPenaltyCommandDTO } from './dtos/QuickPenaltyCommandDTO'; import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCommandDTO'; import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO'; +import { PenaltyTypesReferenceDTO } from './dtos/PenaltyTypesReferenceDTO'; // Tokens import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase'; @@ -68,6 +69,13 @@ import { RACES_PAGE_DATA_PRESENTER_TOKEN } 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() export class RaceService { constructor( @@ -160,6 +168,22 @@ export class RaceService { 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 { this.logger.debug('[RaceService] Fetching race penalties:', { raceId }); await this.getRacePenaltiesUseCase.execute({ raceId }); diff --git a/apps/api/src/domain/race/dtos/ApplyPenaltyCommandDTO.ts b/apps/api/src/domain/race/dtos/ApplyPenaltyCommandDTO.ts index 0263dacad..43833356e 100644 --- a/apps/api/src/domain/race/dtos/ApplyPenaltyCommandDTO.ts +++ b/apps/api/src/domain/race/dtos/ApplyPenaltyCommandDTO.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsNotEmpty, IsOptional, IsNumber, IsEnum } from 'class-validator'; +import { PENALTY_TYPE_VALUES } from '@core/racing/domain/entities/penalty/PenaltyType'; export class ApplyPenaltyCommandDTO { @ApiProperty() @@ -17,30 +18,8 @@ export class ApplyPenaltyCommandDTO { @IsNotEmpty() stewardId!: string; - @ApiProperty({ - enum: [ - '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', - ]) + @ApiProperty({ enum: PENALTY_TYPE_VALUES }) + @IsEnum(PENALTY_TYPE_VALUES) type!: string; @ApiProperty({ required: false }) diff --git a/apps/api/src/domain/race/dtos/PenaltyTypesReferenceDTO.ts b/apps/api/src/domain/race/dtos/PenaltyTypesReferenceDTO.ts new file mode 100644 index 000000000..76dc21f64 --- /dev/null +++ b/apps/api/src/domain/race/dtos/PenaltyTypesReferenceDTO.ts @@ -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; +} \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/stewarding/page.tsx b/apps/website/app/leagues/[id]/stewarding/page.tsx index c504b3503..f1f278f5f 100644 --- a/apps/website/app/leagues/[id]/stewarding/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/page.tsx @@ -24,9 +24,6 @@ import Link from 'next/link'; import { useParams } from 'next/navigation'; 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() { const params = useParams(); const leagueId = params.id as string; @@ -81,7 +78,7 @@ export default function LeagueStewardingPage() { const handleAcceptProtest = async ( protestId: string, - penaltyType: PenaltyType, + penaltyType: string, penaltyValue: number, stewardNotes: string ) => { diff --git a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx index 85b85c219..dfce0414a 100644 --- a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx @@ -8,7 +8,8 @@ import { useServices } from '@/lib/services/ServiceProvider'; import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel'; import { RaceViewModel } from '@/lib/view-models/RaceViewModel'; 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 { AlertCircle, AlertTriangle, @@ -45,68 +46,88 @@ interface TimelineEvent { metadata?: Record; } -const PENALTY_TYPES = [ - { - type: 'time_penalty' as PenaltyType, +type PenaltyUiConfig = { + label: string; + description: string; + icon: typeof Gavel; + color: string; + defaultValue?: number; +}; + +const PENALTY_UI: Record = { + time_penalty: { label: 'Time Penalty', description: 'Add seconds to race result', icon: Clock, color: 'text-blue-400 bg-blue-500/10 border-blue-500/20', - requiresValue: true, - valueLabel: 'seconds', - defaultValue: 5 + defaultValue: 5, }, - { - type: 'grid_penalty' as PenaltyType, + grid_penalty: { label: 'Grid Penalty', description: 'Grid positions for next race', icon: Grid3x3, color: 'text-purple-400 bg-purple-500/10 border-purple-500/20', - requiresValue: true, - valueLabel: 'positions', - defaultValue: 3 + defaultValue: 3, }, - { - type: 'points_deduction' as PenaltyType, + points_deduction: { label: 'Points Deduction', description: 'Deduct championship points', icon: TrendingDown, color: 'text-red-400 bg-red-500/10 border-red-500/20', - requiresValue: true, - valueLabel: 'points', - defaultValue: 5 + defaultValue: 5, }, - { - type: 'disqualification' as PenaltyType, + disqualification: { label: 'Disqualification', description: 'Disqualify from race', icon: XCircle, color: 'text-red-500 bg-red-500/10 border-red-500/20', - requiresValue: false, - valueLabel: '', - defaultValue: 0 + defaultValue: 0, }, - { - type: 'warning' as PenaltyType, + warning: { label: 'Warning', description: 'Official warning only', icon: AlertTriangle, color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20', - requiresValue: false, - valueLabel: '', - defaultValue: 0 + defaultValue: 0, }, - { - type: 'license_points' as PenaltyType, + license_points: { label: 'License Points', description: 'Safety rating penalty', icon: ShieldAlert, color: 'text-orange-400 bg-orange-500/10 border-orange-500/20', - requiresValue: true, - valueLabel: 'points', - defaultValue: 2 + 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() { const params = useParams(); @@ -114,7 +135,7 @@ export default function ProtestReviewPage() { const leagueId = params.id as string; const protestId = params.protestId as string; const currentDriverId = useEffectiveDriverId(); - const { protestService, leagueMembershipService } = useServices(); + const { protestService, leagueMembershipService, penaltyService } = useServices(); const [protest, setProtest] = useState(null); const [race, setRace] = useState(null); @@ -122,14 +143,41 @@ export default function ProtestReviewPage() { const [accusedDriver, setAccusedDriver] = useState(null); const [loading, setLoading] = useState(true); const [isAdmin, setIsAdmin] = useState(false); - + + const [penaltyTypesReference, setPenaltyTypesReference] = useState(null); + const [penaltyTypesLoading, setPenaltyTypesLoading] = useState(false); + // Decision state const [showDecisionPanel, setShowDecisionPanel] = useState(false); const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null); - const [penaltyType, setPenaltyType] = useState('time_penalty'); + const [penaltyType, setPenaltyType] = useState('time_penalty'); const [penaltyValue, setPenaltyValue] = useState(5); const [stewardNotes, setStewardNotes] = useState(''); 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 const [newComment, setNewComment] = useState(''); @@ -168,15 +216,47 @@ export default function ProtestReviewPage() { if (isAdmin) { 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 () => { if (!decision || !stewardNotes.trim() || !protest) return; + if (penaltyTypesLoading) return; setSubmitting(true); try { + const defaultUpheldReason = penaltyTypesReference?.defaultReasons?.upheld; + const defaultDismissedReason = penaltyTypesReference?.defaultReasons?.dismissed; + if (decision === 'uphold') { + const requiresValue = selectedPenalty?.requiresValue ?? true; + const commandModel = new ProtestDecisionCommandModel({ decision, penaltyType, @@ -184,17 +264,32 @@ export default function ProtestReviewPage() { stewardNotes, }); + const options: { + requiresValue?: boolean; + defaultUpheldReason?: string; + defaultDismissedReason?: string; + } = { requiresValue }; + + if (defaultUpheldReason) { + options.defaultUpheldReason = defaultUpheldReason; + } + if (defaultDismissedReason) { + options.defaultDismissedReason = defaultDismissedReason; + } + const penaltyCommand = commandModel.toApplyPenaltyCommand( protest.raceId, protest.accusedDriverId, currentDriverId, - protest.id + protest.id, + options, ); await protestService.applyPenalty(penaltyCommand); } else { - // For dismiss, we might need a separate endpoint - // For now, just apply a warning penalty with 0 value or create a separate endpoint + const warningRef = penaltyTypesReference?.penaltyTypes.find((p) => p.type === 'warning'); + const requiresValue = warningRef?.requiresValue ?? false; + const commandModel = new ProtestDecisionCommandModel({ decision, penaltyType: 'warning', @@ -202,15 +297,27 @@ export default function ProtestReviewPage() { stewardNotes, }); + const options: { + requiresValue?: boolean; + defaultUpheldReason?: string; + defaultDismissedReason?: string; + } = { requiresValue }; + + if (defaultUpheldReason) { + options.defaultUpheldReason = defaultUpheldReason; + } + if (defaultDismissedReason) { + options.defaultDismissedReason = defaultDismissedReason; + } + const penaltyCommand = commandModel.toApplyPenaltyCommand( protest.raceId, protest.accusedDriverId, currentDriverId, - protest.id + protest.id, + options, ); - penaltyCommand.reason = stewardNotes || 'Protest dismissed'; - await protestService.applyPenalty(penaltyCommand); } @@ -601,46 +708,55 @@ export default function ProtestReviewPage() { {decision === 'uphold' && (
-
- {PENALTY_TYPES.map((penalty) => { - const Icon = penalty.icon; - const isSelected = penaltyType === penalty.type; - return ( - - ); - })} -
- {PENALTY_TYPES.find(p => p.type === penaltyType)?.requiresValue && ( -
- - 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" - /> + {penaltyTypes.length === 0 ? ( +
+ Loading penalty types...
+ ) : ( + <> +
+ {penaltyTypes.map((penalty) => { + const Icon = penalty.icon; + const isSelected = penaltyType === penalty.type; + return ( + + ); + })} +
+ + {selectedPenalty?.requiresValue && ( +
+ + 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" + /> +
+ )} + )}
)} diff --git a/apps/website/app/races/[id]/page.test.tsx b/apps/website/app/races/[id]/page.test.tsx index 38b64c1b5..691b83044 100644 --- a/apps/website/app/races/[id]/page.test.tsx +++ b/apps/website/app/races/[id]/page.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import RaceDetailPage from './page'; 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({ui}); +}; + const createViewModel = (status: string) => { return new RaceDetailViewModel({ race: { @@ -119,7 +131,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - render(); + renderWithQueryClient(); const reopenButton = await screen.findByText('Re-open Race'); expect(reopenButton).toBeInTheDocument(); @@ -145,7 +157,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => { const viewModel = createViewModel('completed'); mockGetRaceDetail.mockResolvedValue(viewModel); - render(); + renderWithQueryClient(); await waitFor(() => { expect(mockGetRaceDetail).toHaveBeenCalled(); @@ -159,7 +171,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => { const viewModel = createViewModel('scheduled'); mockGetRaceDetail.mockResolvedValue(viewModel); - render(); + renderWithQueryClient(); await waitFor(() => { expect(mockGetRaceDetail).toHaveBeenCalled(); diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index 3e6634980..9d0f5c366 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -33,7 +33,7 @@ import { } from 'lucide-react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; export default function RaceDetailPage() { const router = useRouter(); diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index 0cd369213..22475b555 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -22,9 +22,10 @@ import { Users, } from 'lucide-react'; 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 { useCreateLeagueWizard } from '@/hooks/useLeagueWizardService'; import { useLeagueScoringPresets } from '@/hooks/useLeagueScoringPresets'; 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) => { setForm((prev) => { - // Convert to LeagueWizardFormData for the command model - 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 selectedPreset = presets.find((p) => p.id === patternId); - const updated = LeagueWizardCommandModel.applyScoringPresetToConfig(formData, patternId); - - // Convert back to LeagueWizardFormModel return { - basics: updated.basics, - structure: updated.structure, - championships: updated.championships, - scoring: updated.scoring, - dropPolicy: updated.dropPolicy, - timings: updated.timings, - stewarding: updated.stewarding, - seasonName: prev.seasonName, - } as LeagueWizardFormModel; + ...prev, + scoring: { + ...prev.scoring, + patternId, + customScoringEnabled: false, + }, + timings: selectedPreset + ? { + ...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, + }; }); }; diff --git a/apps/website/components/leagues/PenaltyHistoryList.tsx b/apps/website/components/leagues/PenaltyHistoryList.tsx index 5be1b3f9a..f3dd4785b 100644 --- a/apps/website/components/leagues/PenaltyHistoryList.tsx +++ b/apps/website/components/leagues/PenaltyHistoryList.tsx @@ -8,8 +8,6 @@ import Card from "../ui/Card"; import Button from "../ui/Button"; import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react"; -type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points"; - interface PenaltyHistoryListProps { protests: ProtestViewModel[]; races: Record; diff --git a/apps/website/components/leagues/ReviewProtestModal.tsx b/apps/website/components/leagues/ReviewProtestModal.tsx index feb1123fd..1640338f5 100644 --- a/apps/website/components/leagues/ReviewProtestModal.tsx +++ b/apps/website/components/leagues/ReviewProtestModal.tsx @@ -1,6 +1,8 @@ "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 Modal from "../ui/Modal"; import Button from "../ui/Button"; @@ -21,7 +23,7 @@ import { FileWarning, } from "lucide-react"; -type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points" | "probation" | "fine" | "race_ban"; +type PenaltyType = string; interface ReviewProtestModalProps { protest: ProtestViewModel | null; @@ -94,25 +96,63 @@ export function ReviewProtestModal({ } }; - const getPenaltyLabel = (type: PenaltyType) => { + const getPenaltyName = (type: PenaltyType) => { switch (type) { case "time_penalty": - return "seconds"; + return "Time Penalty"; case "grid_penalty": - return "grid positions"; + return "Grid Penalty"; case "points_deduction": - return "points"; + return "Points Deduction"; + case "disqualification": + return "Disqualification"; + case "warning": + return "Warning"; case "license_points": - return "points"; + return "License Points"; + case "probation": + return "Probation"; case "fine": - return "points"; + return "Fine"; case "race_ban": - return "races"; + return "Race Ban"; 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 ""; } }; + 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) => { switch (type) { 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) { return ( setShowConfirmation(false)}> @@ -160,7 +219,9 @@ export function ReviewProtestModal({

Confirm Decision

{decision === "accept" - ? `Issue ${penaltyValue} ${getPenaltyLabel(penaltyType)} penalty?` + ? (selectedPenalty?.requiresValue + ? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?` + : `Issue ${selectedPenalty?.name ?? penaltyType} penalty?`) : "Reject this protest?"}

@@ -300,43 +361,39 @@ export function ReviewProtestModal({ -
- {[ - { type: "time_penalty" as PenaltyType, label: "Time Penalty" }, - { type: "grid_penalty" as PenaltyType, label: "Grid Penalty" }, - { type: "points_deduction" as PenaltyType, label: "Points Deduction" }, - { type: "disqualification" as PenaltyType, label: "Disqualification" }, - { type: "warning" as PenaltyType, label: "Warning" }, - { type: "license_points" as PenaltyType, label: "License Points" }, - { type: "probation" as PenaltyType, label: "Probation" }, - { type: "fine" as PenaltyType, label: "Fine" }, - { type: "race_ban" as PenaltyType, label: "Race Ban" }, - ].map(({ type, label }) => { - const Icon = getPenaltyIcon(type); - const colorClass = getPenaltyColor(type); - const isSelected = penaltyType === type; - return ( - - ); - })} -
+ + {penaltyTypesLoading ? ( +
Loading penalty types…
+ ) : ( +
+ {penaltyOptions.map(({ type, name, Icon, colorClass, defaultValue }) => { + const isSelected = penaltyType === type; + return ( + + ); + })} +
+ )} - {['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(penaltyType) && ( + {selectedPenalty?.requiresValue && (
renders auth links when there is no session 1`] = ` + +`; diff --git a/apps/website/components/sponsors/SponsorWorkflowMockup.tsx b/apps/website/components/sponsors/SponsorWorkflowMockup.tsx index 478f62872..2d4ec4812 100644 --- a/apps/website/components/sponsors/SponsorWorkflowMockup.tsx +++ b/apps/website/components/sponsors/SponsorWorkflowMockup.tsx @@ -172,22 +172,26 @@ export default function SponsorWorkflowMockup() { transition={{ duration: 0.3 }} className="mt-8 pt-6 border-t border-charcoal-outline" > -
-
- {(() => { - const Icon = WORKFLOW_STEPS[activeStep].icon; - return ; - })()} -
-
-

- Step {activeStep + 1} of {WORKFLOW_STEPS.length} -

-

- {WORKFLOW_STEPS[activeStep].title} -

-
-
+ {(() => { + const currentStep = WORKFLOW_STEPS[activeStep] ?? WORKFLOW_STEPS[0]; + if (!currentStep) return null; + + const Icon = currentStep.icon; + + return ( +
+
+ +
+
+

+ Step {activeStep + 1} of {WORKFLOW_STEPS.length} +

+

{currentStep.title}

+
+
+ ); + })()}
diff --git a/apps/website/hooks/usePenaltyTypesReference.ts b/apps/website/hooks/usePenaltyTypesReference.ts new file mode 100644 index 000000000..f90486526 --- /dev/null +++ b/apps/website/hooks/usePenaltyTypesReference.ts @@ -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({ + queryKey: ['penaltyTypesReference'], + queryFn: () => penaltyService.getPenaltyTypesReference(), + staleTime: 5 * 60 * 1000, + }); +} \ No newline at end of file diff --git a/apps/website/lib/api/penalties/PenaltiesApiClient.ts b/apps/website/lib/api/penalties/PenaltiesApiClient.ts index 974beaff8..51d8db4f8 100644 --- a/apps/website/lib/api/penalties/PenaltiesApiClient.ts +++ b/apps/website/lib/api/penalties/PenaltiesApiClient.ts @@ -1,6 +1,7 @@ import { BaseApiClient } from '../base/BaseApiClient'; import { RacePenaltiesDTO } from '../../types/generated/RacePenaltiesDTO'; import { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO'; +import type { PenaltyTypesReferenceDTO } from '../../types/PenaltyTypesReferenceDTO'; /** * Penalties API Client @@ -13,6 +14,11 @@ export class PenaltiesApiClient extends BaseApiClient { return this.get(`/races/${raceId}/penalties`); } + /** Get allowed penalty types and semantics */ + getPenaltyTypesReference(): Promise { + return this.get('/races/reference/penalty-types'); + } + /** Apply a penalty */ applyPenalty(input: ApplyPenaltyCommandDTO): Promise { return this.post('/races/penalties/apply', input); diff --git a/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts b/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts index 7e44d217a..62cc3ae9c 100644 --- a/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts +++ b/apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts @@ -1,6 +1,5 @@ import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO'; import { LeagueWizardValidationMessages } from '@/lib/display-objects/LeagueWizardValidationMessages'; -import { ScoringPresetApplier } from '@/lib/utilities/ScoringPresetApplier'; export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7; @@ -263,7 +262,7 @@ export class LeagueWizardCommandModel { patternId, 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 { diff --git a/apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts b/apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts index eceffd700..8f53e2a2b 100644 --- a/apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts +++ b/apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts @@ -1,19 +1,15 @@ import { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO'; -export type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points'; - export interface ProtestDecisionData { decision: 'uphold' | 'dismiss' | null; - penaltyType: PenaltyType; - penaltyValue: number; + penaltyType: string; + penaltyValue?: number; stewardNotes: string; } -const DEFAULT_PROTEST_REASON = 'Protest upheld'; - export class ProtestDecisionCommandModel { decision: 'uphold' | 'dismiss' | null = null; - penaltyType: PenaltyType = 'time_penalty'; + penaltyType: string = 'time_penalty'; penaltyValue: number = 5; stewardNotes: string = ''; @@ -39,27 +35,37 @@ export class ProtestDecisionCommandModel { this.stewardNotes = ''; } - toApplyPenaltyCommand(raceId: string, driverId: string, stewardId: string, protestId: string): ApplyPenaltyCommandDTO { - const reason = this.decision === 'uphold' - ? DEFAULT_PROTEST_REASON - : 'Protest dismissed'; + toApplyPenaltyCommand( + raceId: string, + driverId: string, + 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, driverId, stewardId, - enum: this.penaltyType, // Use penaltyType as enum + enum: this.penaltyType, type: this.penaltyType, - value: this.getPenaltyValue(), reason, protestId, notes: this.stewardNotes, }; - } - private getPenaltyValue(): number { - // Some penalties don't require a value - const penaltiesWithoutValue: PenaltyType[] = ['disqualification', 'warning']; - return penaltiesWithoutValue.includes(this.penaltyType) ? 0 : this.penaltyValue; + if (options?.requiresValue) { + return { ...base, value: this.penaltyValue }; + } + + return base; } } \ No newline at end of file diff --git a/apps/website/lib/display-objects/LeagueRoleDisplay.ts b/apps/website/lib/display-objects/LeagueRoleDisplay.ts index d5783bc3f..951394b3e 100644 --- a/apps/website/lib/display-objects/LeagueRoleDisplay.ts +++ b/apps/website/lib/display-objects/LeagueRoleDisplay.ts @@ -1,37 +1,32 @@ -type LeagueRole = 'owner' | 'admin' | 'steward' | 'member'; +export type LeagueRole = 'owner' | 'admin' | 'steward' | 'member'; export interface LeagueRoleDisplayData { text: string; badgeClasses: string; } +export const leagueRoleDisplay: Record = { + 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 { - /** - * Centralized display configuration for league membership roles. - */ - 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', - }; - } + static getLeagueRoleDisplay(role: LeagueRole) { + return leagueRoleDisplay[role]; } } \ No newline at end of file diff --git a/apps/website/lib/services/penalties/PenaltyService.test.ts b/apps/website/lib/services/penalties/PenaltyService.test.ts index c4f407810..19482894f 100644 --- a/apps/website/lib/services/penalties/PenaltyService.test.ts +++ b/apps/website/lib/services/penalties/PenaltyService.test.ts @@ -9,8 +9,9 @@ describe('PenaltyService', () => { beforeEach(() => { mockApiClient = { getRacePenalties: vi.fn(), + getPenaltyTypesReference: vi.fn(), applyPenalty: vi.fn(), - } as Mocked; + } as unknown as Mocked; service = new PenaltyService(mockApiClient); }); @@ -23,9 +24,10 @@ describe('PenaltyService', () => { { 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' }, ], + driverMap: {}, }; - mockApiClient.getRacePenalties.mockResolvedValue(mockDto); + mockApiClient.getRacePenalties.mockResolvedValue(mockDto as any); const result = await service.findByRaceId(raceId); @@ -36,9 +38,9 @@ describe('PenaltyService', () => { it('should handle empty penalties array', async () => { 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); diff --git a/apps/website/lib/services/penalties/PenaltyService.ts b/apps/website/lib/services/penalties/PenaltyService.ts index 839754ec8..f397f5c82 100644 --- a/apps/website/lib/services/penalties/PenaltyService.ts +++ b/apps/website/lib/services/penalties/PenaltyService.ts @@ -1,4 +1,5 @@ import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient'; +import type { PenaltyTypesReferenceDTO } from '../../types/PenaltyTypesReferenceDTO'; /** * Penalty Service @@ -19,6 +20,13 @@ export class PenaltyService { return dto.penalties; } + /** + * Get allowed penalty types and semantics + */ + async getPenaltyTypesReference(): Promise { + return this.apiClient.getPenaltyTypesReference(); + } + /** * Apply a penalty */ diff --git a/apps/website/lib/services/teams/TeamJoinService.test.ts b/apps/website/lib/services/teams/TeamJoinService.test.ts index fc7943bc6..6188929d3 100644 --- a/apps/website/lib/services/teams/TeamJoinService.test.ts +++ b/apps/website/lib/services/teams/TeamJoinService.test.ts @@ -19,17 +19,22 @@ describe('TeamJoinService', () => { const mockDto = { requests: [ { - id: 'request-1', + requestId: 'request-1', teamId: 'team-1', driverId: 'driver-1', + driverName: 'Driver One', + status: 'pending', 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', driverId: 'driver-2', + driverName: 'Driver Two', + status: 'pending', 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(result).toHaveLength(2); - expect(result[0].id).toBe('request-1'); - expect(result[0].canApprove).toBe(true); - expect(result[1].id).toBe('request-2'); - expect(result[1].canApprove).toBe(true); + + const first = result[0]; + const second = result[1]; + + 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 () => { const mockDto = { requests: [ { - id: 'request-1', + requestId: 'request-1', teamId: 'team-1', driverId: 'driver-1', + driverName: 'Driver One', + status: 'pending', 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); - expect(result[0].canApprove).toBe(false); + expect(result[0]).toBeDefined(); + expect(result[0]!.canApprove).toBe(false); }); }); diff --git a/apps/website/lib/services/teams/TeamJoinService.ts b/apps/website/lib/services/teams/TeamJoinService.ts index 9549dbf88..d01f63399 100644 --- a/apps/website/lib/services/teams/TeamJoinService.ts +++ b/apps/website/lib/services/teams/TeamJoinService.ts @@ -34,7 +34,7 @@ export class TeamJoinService { * a request requires a future management endpoint, so this method fails explicitly. */ async approveJoinRequest(): Promise { - 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. */ async rejectJoinRequest(): Promise { - throw new Error('Rejecting team join requests is not supported in this build'); + throw new Error('Not implemented: API endpoint for rejecting join requests'); } } \ No newline at end of file diff --git a/apps/website/lib/siteConfig.ts b/apps/website/lib/siteConfig.ts index fa99189e6..2e2bc50eb 100644 --- a/apps/website/lib/siteConfig.ts +++ b/apps/website/lib/siteConfig.ts @@ -4,45 +4,111 @@ * Values are primarily sourced from environment variables so that * deployments can provide real company details without hard-coding * production data in the repository. + * + * Website must remain an API consumer (no adapter imports). */ -const env = { - 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 = { +export interface SiteConfigData { // Platform Information - platformName: env.platformName ?? 'GridPilot', - platformUrl: env.platformUrl ?? 'https://gridpilot.com', + platformName: string; + platformUrl: string; // Contact Information - supportEmail: env.supportEmail ?? 'support@example.com', - sponsorEmail: env.sponsorEmail ?? 'sponsors@example.com', + supportEmail: string; + sponsorEmail: string; // Legal & Business Information legal: { - companyName: env.legalCompanyName ?? '', - vatId: env.legalVatId ?? '', - registeredCountry: env.legalRegisteredCountry ?? '', - registeredAddress: env.legalRegisteredAddress ?? '', - }, - + companyName: string; + vatId: string; + registeredCountry: string; + registeredAddress: string; + }; + // Platform Fees fees: { - platformFeePercent: 10, // 10% platform fee on sponsorships - description: 'Platform fee supports maintenance, analytics, and secure payment processing.', - }, - + 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, + 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, @@ -50,7 +116,7 @@ export const siteConfig = { 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: { @@ -79,10 +145,9 @@ export const siteConfig = { 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, @@ -91,8 +156,7 @@ export const siteConfig = { newsletterInclusion: true, homepageAds: true, sidebarAds: true, - // We don't control these - broadcastOverlays: false, // We don't control broadcast + broadcastOverlays: false, }, } as const; diff --git a/apps/website/lib/types/PenaltyTypesReferenceDTO.ts b/apps/website/lib/types/PenaltyTypesReferenceDTO.ts new file mode 100644 index 000000000..0788fb9ed --- /dev/null +++ b/apps/website/lib/types/PenaltyTypesReferenceDTO.ts @@ -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; + }; +} \ No newline at end of file diff --git a/apps/website/lib/types/generated/LeagueScoringPresetDTO.ts b/apps/website/lib/types/generated/LeagueScoringPresetDTO.ts index cbf4a44de..2bc8ee34b 100644 --- a/apps/website/lib/types/generated/LeagueScoringPresetDTO.ts +++ b/apps/website/lib/types/generated/LeagueScoringPresetDTO.ts @@ -12,4 +12,11 @@ export interface LeagueScoringPresetDTO { sessionSummary: string; bonusSummary: string; dropPolicySummary: string; + defaultTimings: { + practiceMinutes: number; + qualifyingMinutes: number; + sprintRaceMinutes: number; + mainRaceMinutes: number; + sessionCount: number; + }; } diff --git a/apps/website/lib/utilities/ScoringPresetApplier.ts b/apps/website/lib/utilities/ScoringPresetApplier.ts index dfbfb8f51..286ab4964 100644 --- a/apps/website/lib/utilities/ScoringPresetApplier.ts +++ b/apps/website/lib/utilities/ScoringPresetApplier.ts @@ -8,30 +8,8 @@ export type Timings = { }; export class ScoringPresetApplier { - static applyToTimings(patternId: string, currentTimings: Timings): Timings { - // Website-local fallback mapping (UI convenience only). - // Authoritative preset/timing rules should live in the API. - 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; - } + static applyToTimings(_patternId: string, currentTimings: Timings): Timings { + // Deprecated: timing defaults are provided by the API via scoring preset `defaultTimings`. + return currentTimings; } } diff --git a/apps/website/lib/utilities/raceStatus.ts b/apps/website/lib/utilities/raceStatus.ts index cd1c3ec60..b45dfe6dd 100644 --- a/apps/website/lib/utilities/raceStatus.ts +++ b/apps/website/lib/utilities/raceStatus.ts @@ -1,6 +1,14 @@ 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 = { scheduled: { icon: Clock, color: 'text-primary-blue', @@ -29,4 +37,4 @@ export const raceStatusConfig = { border: 'border-warning-amber/30', label: 'Cancelled', }, -}; \ No newline at end of file +} as const; \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts index 8fa00bf34..5a4b26aba 100644 --- a/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts +++ b/apps/website/lib/view-models/DriverLeaderboardItemViewModel.ts @@ -14,7 +14,7 @@ export class DriverLeaderboardItemViewModel { avatarUrl: string; position: number; - private previousRating?: number; + private previousRating: number | undefined; constructor(dto: DriverLeaderboardItemDTO, position: number, previousRating?: number) { this.id = dto.id; diff --git a/apps/website/lib/view-models/LeagueScoringPresetViewModel.ts b/apps/website/lib/view-models/LeagueScoringPresetViewModel.ts index c75e666bb..8e5ead2c2 100644 --- a/apps/website/lib/view-models/LeagueScoringPresetViewModel.ts +++ b/apps/website/lib/view-models/LeagueScoringPresetViewModel.ts @@ -1,5 +1,13 @@ import { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO'; +export type LeagueScoringPresetTimingDefaultsViewModel = { + practiceMinutes: number; + qualifyingMinutes: number; + sprintRaceMinutes: number; + mainRaceMinutes: number; + sessionCount: number; +}; + /** * LeagueScoringPresetViewModel * @@ -10,11 +18,13 @@ export class LeagueScoringPresetViewModel { readonly name: string; readonly sessionSummary: string; readonly bonusSummary?: string; + readonly defaultTimings: LeagueScoringPresetTimingDefaultsViewModel; constructor(dto: LeagueScoringPresetDTO) { this.id = dto.id; this.name = dto.name; this.sessionSummary = dto.sessionSummary; this.bonusSummary = dto.bonusSummary; + this.defaultTimings = dto.defaultTimings; } } \ No newline at end of file diff --git a/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts index 2d7a62f80..59e026a9e 100644 --- a/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts +++ b/apps/website/lib/view-models/RequestAvatarGenerationViewModel.ts @@ -32,4 +32,12 @@ export class RequestAvatarGenerationViewModel { get firstAvatarUrl(): string | undefined { return this.avatarUrls?.[0]; } + + get avatarUrl(): string | undefined { + return this.firstAvatarUrl; + } + + get error(): string | undefined { + return this.errorMessage; + } } \ No newline at end of file diff --git a/apps/website/lib/view-models/SponsorshipRequestViewModel.ts b/apps/website/lib/view-models/SponsorshipRequestViewModel.ts index 9f418bedb..a4ef103eb 100644 --- a/apps/website/lib/view-models/SponsorshipRequestViewModel.ts +++ b/apps/website/lib/view-models/SponsorshipRequestViewModel.ts @@ -18,13 +18,13 @@ export class SponsorshipRequestViewModel { this.id = dto.id; this.sponsorId = dto.sponsorId; 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. this.tier = dto.tier === 'main' ? 'main' : 'secondary'; this.offeredAmount = dto.offeredAmount; this.currency = dto.currency; this.formattedAmount = dto.formattedAmount; - this.message = dto.message; + if (dto.message !== undefined) this.message = dto.message; this.createdAt = new Date(dto.createdAt); this.platformFee = dto.platformFee; this.netAmount = dto.netAmount; diff --git a/apps/website/lib/view-models/TeamDetailsViewModel.ts b/apps/website/lib/view-models/TeamDetailsViewModel.ts index 5d619ecc2..7d330bb26 100644 --- a/apps/website/lib/view-models/TeamDetailsViewModel.ts +++ b/apps/website/lib/view-models/TeamDetailsViewModel.ts @@ -7,10 +7,10 @@ export class TeamDetailsViewModel { description?: string; ownerId!: string; leagues!: string[]; - createdAt?: string; - specialization?: string; - region?: string; - languages?: string[]; + createdAt: string | undefined; + specialization: string | undefined; + region: string | undefined; + languages: string[] | undefined; membership: { role: string; joinedAt: string; isActive: boolean } | null; private _canManage: boolean; private currentUserId: string; diff --git a/apps/website/lib/view-models/TeamJoinRequestViewModel.test.ts b/apps/website/lib/view-models/TeamJoinRequestViewModel.test.ts index c3cf8d91a..d2918f9be 100644 --- a/apps/website/lib/view-models/TeamJoinRequestViewModel.test.ts +++ b/apps/website/lib/view-models/TeamJoinRequestViewModel.test.ts @@ -1,18 +1,21 @@ 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 => ({ - id: 'request-1', + requestId: 'request-1', teamId: 'team-1', driverId: 'driver-1', + driverName: 'Driver One', + status: 'pending', requestedAt: '2024-01-01T12:00:00Z', - message: 'Please let me join', + avatarUrl: 'https://example.com/avatar.jpg', ...overrides, }); describe('TeamJoinRequestViewModel', () => { 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); @@ -20,7 +23,6 @@ describe('TeamJoinRequestViewModel', () => { expect(vm.teamId).toBe('team-1'); expect(vm.driverId).toBe('driver-123'); expect(vm.requestedAt).toBe('2024-01-01T12:00:00Z'); - expect(vm.message).toBe('Please let me join'); }); it('allows approval only for owners', () => { @@ -34,7 +36,7 @@ describe('TeamJoinRequestViewModel', () => { }); it('exposes a pending status with yellow color', () => { - const dto = createTeamJoinRequestDto(); + const dto = createTeamJoinRequestDto({ status: 'pending' }); const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true); expect(vm.status).toBe('Pending'); diff --git a/apps/website/lib/view-models/TeamJoinRequestViewModel.ts b/apps/website/lib/view-models/TeamJoinRequestViewModel.ts index aa2af4e7e..b0feeb2b2 100644 --- a/apps/website/lib/view-models/TeamJoinRequestViewModel.ts +++ b/apps/website/lib/view-models/TeamJoinRequestViewModel.ts @@ -24,6 +24,17 @@ export class TeamJoinRequestViewModel { 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 */ get canApprove(): boolean { return this.isOwner; diff --git a/apps/website/lib/view-models/TeamSummaryViewModel.ts b/apps/website/lib/view-models/TeamSummaryViewModel.ts index ea324f331..7f333b9ef 100644 --- a/apps/website/lib/view-models/TeamSummaryViewModel.ts +++ b/apps/website/lib/view-models/TeamSummaryViewModel.ts @@ -10,8 +10,8 @@ export class TeamSummaryViewModel { totalRaces: number = 0; performanceLevel: string = ''; isRecruiting: boolean = false; - specialization?: string; - region?: string; + specialization: string | undefined; + region: string | undefined; languages: string[] = []; leagues: string[] = []; diff --git a/apps/website/next.config.mjs b/apps/website/next.config.mjs index 7373bb26e..2f03ed077 100644 --- a/apps/website/next.config.mjs +++ b/apps/website/next.config.mjs @@ -27,11 +27,6 @@ const nextConfig = { eslint: { ignoreDuringBuilds: true, }, - transpilePackages: [ - '@core/racing', - '@core/identity', - '@core/social' - ], webpack: (config) => { config.module.rules.push({ test: /\.(mp4|webm)$/, diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 7e24120fe..e28d49219 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -84,7 +84,6 @@ "**/*.spec.tsx", "**/__tests__/**", "../../core/**", - "../../adapters/**", "../../apps/api/**", "../../scripts/**", "../../testing/**", diff --git a/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts b/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts index 658e90bd1..e98c3f8d3 100644 --- a/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts +++ b/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts @@ -37,6 +37,7 @@ export class ListLeagueScoringPresetsUseCase { sessionSummary: p.sessionSummary, bonusSummary: p.bonusSummary, dropPolicySummary: p.dropPolicySummary, + defaultTimings: p.defaultTimings, })); const result: ListLeagueScoringPresetsResult = { presets }; diff --git a/core/racing/domain/entities/penalty/Penalty.ts b/core/racing/domain/entities/penalty/Penalty.ts index d9622cf60..bcb1d9ef8 100644 --- a/core/racing/domain/entities/penalty/Penalty.ts +++ b/core/racing/domain/entities/penalty/Penalty.ts @@ -11,7 +11,7 @@ import { PenaltyId } from './PenaltyId'; import { LeagueId } from '../LeagueId'; import { RaceId } from '../RaceId'; import { DriverId } from '../DriverId'; -import { PenaltyType } from './PenaltyType'; +import { PenaltyType, type PenaltyTypeValue } from './PenaltyType'; import { PenaltyValue } from './PenaltyValue'; import { PenaltyReason } from './PenaltyReason'; import { ProtestId } from '../ProtestId'; @@ -47,6 +47,19 @@ export interface PenaltyProps { 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 { private constructor(private readonly props: PenaltyProps) {} @@ -73,10 +86,12 @@ export class Penalty implements IEntity { if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required'); if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward'); + const penaltyType = PenaltyType.create(props.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) { - 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 { leagueId: LeagueId.create(props.leagueId), raceId: RaceId.create(props.raceId), driverId: DriverId.create(props.driverId), - type: PenaltyType.create(props.type), + type: penaltyType, reason: PenaltyReason.create(props.reason), issuedBy: StewardId.create(props.issuedBy), status: PenaltyStatus.create(props.status || 'pending'), diff --git a/core/racing/domain/entities/penalty/PenaltyType.ts b/core/racing/domain/entities/penalty/PenaltyType.ts index 09b044e09..a5389a74e 100644 --- a/core/racing/domain/entities/penalty/PenaltyType.ts +++ b/core/racing/domain/entities/penalty/PenaltyType.ts @@ -11,23 +11,44 @@ export type PenaltyTypeValue = | 'fine' | '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 { private constructor(private readonly value: PenaltyTypeValue) {} static create(value: string): PenaltyType { - const validTypes: PenaltyTypeValue[] = [ - 'time_penalty', - 'grid_penalty', - 'points_deduction', - 'disqualification', - 'warning', - 'license_points', - 'probation', - 'fine', - 'race_ban', - ]; - - if (!validTypes.includes(value as PenaltyTypeValue)) { + if (!PENALTY_TYPE_VALUES.includes(value as PenaltyTypeValue)) { throw new RacingDomainValidationError(`Invalid penalty type: ${value}`); } diff --git a/core/racing/domain/types/LeagueScoringPreset.ts b/core/racing/domain/types/LeagueScoringPreset.ts index cd2119de1..9a54d4898 100644 --- a/core/racing/domain/types/LeagueScoringPreset.ts +++ b/core/racing/domain/types/LeagueScoringPreset.ts @@ -10,6 +10,14 @@ export type LeagueScoringPresetPrimaryChampionshipType = | 'nations' | 'trophy'; +export interface LeagueScoringPresetTimingDefaults { + practiceMinutes: number; + qualifyingMinutes: number; + sprintRaceMinutes: number; + mainRaceMinutes: number; + sessionCount: number; +} + export interface LeagueScoringPreset { id: string; name: string; @@ -18,4 +26,5 @@ export interface LeagueScoringPreset { dropPolicySummary: string; sessionSummary: string; bonusSummary: string; + defaultTimings: LeagueScoringPresetTimingDefaults; } \ No newline at end of file diff --git a/plans/2025-12-15T15:42:00Z_clean-architecture-migration.md b/plans/2025-12-15T15:42:00Z_clean-architecture-migration.md index ac1883d2a..68a2f733e 100644 --- a/plans/2025-12-15T15:42:00Z_clean-architecture-migration.md +++ b/plans/2025-12-15T15:42:00Z_clean-architecture-migration.md @@ -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 -| Concern | Purpose | Location | -| --------- | ------------------- | ------------------------------- | -| Migration | Schema | adapters/persistence/migrations | -| Bootstrap | Required start data | adapters/bootstrap | -| Factory | Test data | testing | +| Concern | Purpose | Location | +| -------------- | ---------------------------------------------- | ------------------------------- | +| Migration | Schema | adapters/persistence/migrations | +| Bootstrap | Required start data | adapters/bootstrap | +| Reference Data | Authoritative registries consumed via API | adapters/bootstrap + apps/api | +| Factory | Test data | testing | ---