From faa4c3309ea23666cd58781664a4ebb6bbcc4df5 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 14 Jan 2026 13:27:26 +0100 Subject: [PATCH] website refactor --- .../app/leagues/[id]/rulebook/page.tsx | 71 +++----- .../app/leagues/[id]/standings/page.tsx | 153 +++-------------- .../app/leagues/[id]/stewarding/page.tsx | 90 +++------- .../LeagueStandingsViewDataBuilder.ts | 75 +++++++++ .../view-data/ProtestDetailViewDataBuilder.ts | 46 ++++++ .../view-data/RulebookViewDataBuilder.ts | 25 +++ .../view-data/StewardingViewDataBuilder.ts | 26 +++ .../LeagueProtestDetailPageQuery.ts | 29 ++++ .../page-queries/LeagueRulebookPageQuery.ts | 32 ++-- .../page-queries/LeagueStandingsPageQuery.ts | 75 +++------ .../page-queries/LeagueStewardingPageQuery.ts | 80 +++------ .../services/leagues/LeagueRulebookService.ts | 30 ++++ .../leagues/LeagueStandingsService.ts | 89 ++++++++++ .../leagues/LeagueStewardingService.ts | 53 ++---- .../services/leagues/ProtestDetailService.ts | 38 +++++ .../lib/types/tbd/LeagueStandingsApiDto.ts | 10 ++ .../lib/types/tbd/ProtestDetailApiDto.ts | 28 ++++ apps/website/lib/types/tbd/RulebookApiDto.ts | 18 ++ .../website/lib/types/tbd/StewardingApiDto.ts | 48 ++++++ .../leagues/ProtestDetailViewData.ts | 28 ++++ .../lib/view-data/leagues/RulebookViewData.ts | 15 ++ .../view-data/leagues/StewardingViewData.ts | 48 ++++++ apps/website/templates/RulebookTemplate.tsx | 102 ++++++++++++ apps/website/templates/StewardingTemplate.tsx | 156 ++++++++++++++++++ 24 files changed, 964 insertions(+), 401 deletions(-) create mode 100644 apps/website/lib/builders/view-data/LeagueStandingsViewDataBuilder.ts create mode 100644 apps/website/lib/builders/view-data/ProtestDetailViewDataBuilder.ts create mode 100644 apps/website/lib/builders/view-data/RulebookViewDataBuilder.ts create mode 100644 apps/website/lib/builders/view-data/StewardingViewDataBuilder.ts create mode 100644 apps/website/lib/page-queries/page-queries/LeagueProtestDetailPageQuery.ts create mode 100644 apps/website/lib/services/leagues/LeagueRulebookService.ts create mode 100644 apps/website/lib/services/leagues/LeagueStandingsService.ts create mode 100644 apps/website/lib/services/leagues/ProtestDetailService.ts create mode 100644 apps/website/lib/types/tbd/LeagueStandingsApiDto.ts create mode 100644 apps/website/lib/types/tbd/ProtestDetailApiDto.ts create mode 100644 apps/website/lib/types/tbd/RulebookApiDto.ts create mode 100644 apps/website/lib/types/tbd/StewardingApiDto.ts create mode 100644 apps/website/lib/view-data/leagues/ProtestDetailViewData.ts create mode 100644 apps/website/lib/view-data/leagues/RulebookViewData.ts create mode 100644 apps/website/lib/view-data/leagues/StewardingViewData.ts create mode 100644 apps/website/templates/RulebookTemplate.tsx create mode 100644 apps/website/templates/StewardingTemplate.tsx diff --git a/apps/website/app/leagues/[id]/rulebook/page.tsx b/apps/website/app/leagues/[id]/rulebook/page.tsx index 4e84c91fb..115e091e9 100644 --- a/apps/website/app/leagues/[id]/rulebook/page.tsx +++ b/apps/website/app/leagues/[id]/rulebook/page.tsx @@ -1,63 +1,36 @@ -import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate'; -import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { LeagueService } from '@/lib/services/leagues/LeagueService'; -import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; -import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; -import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient'; -import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; -import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; -import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; +import { LeagueRulebookPageQuery } from '@/lib/page-queries/page-queries/LeagueRulebookPageQuery'; +import { RulebookTemplate } from '@/templates/RulebookTemplate'; import { notFound } from 'next/navigation'; -import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; interface Props { params: { id: string }; } export default async function Page({ params }: Props) { - // Validate params - if (!params.id) { + const leagueId = params.id; + + if (!leagueId) { notFound(); } - // Fetch data using PageDataFetcher.fetchManual - const data = await PageDataFetcher.fetchManual(async () => { - // Create dependencies for API clients - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: process.env.NODE_ENV === 'production', - }); + const result = await LeagueRulebookPageQuery.execute(leagueId); - // Create API clients - const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); - const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger); - const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger); - const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); - - // Create service - const service = new LeagueService( - leaguesApiClient, - driversApiClient, - sponsorsApiClient, - racesApiClient - ); - - return await service.getLeagueDetailPageData(params.id); - }); - - if (!data) { - notFound(); + if (result.isErr()) { + const error = result.getError(); + if (error.type === 'notFound') { + notFound(); + } + // For serverError, show the template with empty data + return ; } - // Create a Template wrapper that matches PageWrapper's expected interface - const Template = ({ data }: { data: LeagueDetailPageViewModel }) => { - return ; - }; - - return ; + return ; } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/standings/page.tsx b/apps/website/app/leagues/[id]/standings/page.tsx index 94735bbf4..dc199e5dc 100644 --- a/apps/website/app/leagues/[id]/standings/page.tsx +++ b/apps/website/app/leagues/[id]/standings/page.tsx @@ -1,142 +1,43 @@ -import { PageWrapper } from '@/components/shared/state/PageWrapper'; +import { LeagueStandingsPageQuery } from '@/lib/page-queries/page-queries/LeagueStandingsPageQuery'; import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate'; -import { PageDataFetcher } from '@/lib/page/PageDataFetcher'; -import { LeagueService } from '@/lib/services/leagues/LeagueService'; -import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; -import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; -import { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient'; -import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; -import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; -import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { notFound } from 'next/navigation'; -import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel'; -import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; -import type { LeagueMembership } from '@/lib/types/LeagueMembership'; -import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO'; -import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; -import { LeagueStandingsPresenter } from '@/lib/presenters/LeagueStandingsPresenter'; interface Props { params: { id: string }; } export default async function Page({ params }: Props) { - // Validate params - if (!params.id) { + const leagueId = params.id; + + if (!leagueId) { notFound(); } - // Fetch data using PageDataFetcher.fetchManual for multiple dependencies - const data = await PageDataFetcher.fetchManual(async () => { - // Create dependencies - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: process.env.NODE_ENV === 'production', - }); + const result = await LeagueStandingsPageQuery.execute(leagueId); - // Create API clients - const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); - const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger); - const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger); - const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); - - // Create service - const service = new LeagueService( - leaguesApiClient, - driversApiClient, - sponsorsApiClient, - racesApiClient - ); - - // Fetch data - const standingsDto = await service.getLeagueStandings(params.id); - if (!standingsDto) { - throw new Error('League standings not found'); + if (result.isErr()) { + const error = result.getError(); + if (error.type === 'notFound') { + notFound(); } - - // Get memberships for transformation - const membershipsDto = await service.getLeagueMemberships(params.id); - - // Transform standings to StandingEntryViewModel[] - const standings: LeagueStandingDTO[] = standingsDto.standings || []; - const leaderPoints = standings[0]?.points || 0; - const standingViewModels = standings.map((entry, index) => { - const nextPoints = standings[index + 1]?.points || entry.points; - return new StandingEntryViewModel(entry, leaderPoints, nextPoints, '', undefined); - }); - - // Extract unique drivers from standings and convert to DriverViewModel[] - const driverMap = new Map(); - standings.forEach(standing => { - if (standing.driver && !driverMap.has(standing.driver.id)) { - const driver = standing.driver; - driverMap.set(driver.id, new DriverViewModel({ - id: driver.id, - name: driver.name, - avatarUrl: null, // DriverDTO doesn't have avatarUrl - iracingId: driver.iracingId, - rating: undefined, // DriverDTO doesn't have rating - country: driver.country, - })); - } - }); - const drivers = Array.from(driverMap.values()); - - // Transform memberships - const memberships: LeagueMembership[] = (membershipsDto.members || []).map((m: LeagueMemberDTO) => ({ - driverId: m.driverId, - leagueId: params.id, - role: (m.role as LeagueMembership['role']) ?? 'member', - joinedAt: m.joinedAt, - status: 'active' as const, - })); - - return { - standings: standingViewModels, - drivers, - memberships, - }; - }); - - if (!data) { - notFound(); + // For serverError, show the template with empty data + return {}} + onUpdateRole={() => {}} + />; } - // Create a wrapper component that passes ViewData to the template - const TemplateWrapper = () => { - // Convert ViewModels to ViewData using Presenter - const viewData = LeagueStandingsPresenter.createViewData( - data.standings, - data.drivers, - data.memberships, - params.id, - null, // currentDriverId - false // isAdmin - ); - - return ( - {}} - onUpdateRole={() => {}} - /> - ); - }; - - return ( - - ); + return {}} + onUpdateRole={() => {}} + />; } \ 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 969aa53c7..e758ba45e 100644 --- a/apps/website/app/leagues/[id]/stewarding/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/page.tsx @@ -1,71 +1,35 @@ -'use client'; +import { LeagueStewardingPageQuery } from '@/lib/page-queries/page-queries/LeagueStewardingPageQuery'; +import { StewardingTemplate } from '@/templates/StewardingTemplate'; +import { notFound } from 'next/navigation'; -import { useCurrentDriver } from "@/lib/hooks/driver/useCurrentDriver"; -import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus"; -import { useLeagueStewardingData } from "@/lib/hooks/league/useLeagueStewardingData"; -import { useLeagueStewardingMutations } from "@/lib/hooks/league/useLeagueStewardingMutations"; -import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper'; -import { StewardingTemplate } from './StewardingTemplate'; -import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; -import Card from '@/components/ui/Card'; -import { AlertTriangle } from 'lucide-react'; -import { useParams } from 'next/navigation'; +interface Props { + params: { id: string }; +} -export default function LeagueStewardingPage() { - const params = useParams(); - const leagueId = params.id as string; - const { data: currentDriver } = useCurrentDriver(); - const currentDriverId = currentDriver?.id || ''; +export default async function LeagueStewardingPage({ params }: Props) { + const leagueId = params.id; - // Check admin status - const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId); - - // Show loading for admin check - if (adminLoading) { - return ; + if (!leagueId) { + notFound(); } - // Show access denied if not admin - if (!isAdmin) { - return ( - -
-
- -
-

Admin Access Required

-

- Only league admins can access stewarding functions. -

-
-
- ); + const result = await LeagueStewardingPageQuery.execute(leagueId); + + if (result.isErr()) { + const error = result.getError(); + if (error.type === 'notFound') { + notFound(); + } + // For serverError, show the template with empty data + return ; } - // Load stewarding data using domain hook - const { data, isLoading, error, refetch } = useLeagueStewardingData(leagueId); - - return ( - ( - - )} - loading={{ variant: 'skeleton', message: 'Loading stewarding data...' }} - errorConfig={{ variant: 'full-screen' }} - empty={{ - icon: require('lucide-react').Flag, - title: 'No stewarding data', - description: 'There are no protests or penalties to review.', - }} - /> - ); + return ; } \ No newline at end of file diff --git a/apps/website/lib/builders/view-data/LeagueStandingsViewDataBuilder.ts b/apps/website/lib/builders/view-data/LeagueStandingsViewDataBuilder.ts new file mode 100644 index 000000000..354290b1c --- /dev/null +++ b/apps/website/lib/builders/view-data/LeagueStandingsViewDataBuilder.ts @@ -0,0 +1,75 @@ +import type { LeagueStandingsViewData, StandingEntryData, DriverData, LeagueMembershipData } from '@/lib/view-data/LeagueStandingsViewData'; +import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO'; +import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; + +interface LeagueStandingsApiDto { + standings: LeagueStandingDTO[]; +} + +interface LeagueMembershipsApiDto { + members: LeagueMemberDTO[]; +} + +/** + * LeagueStandingsViewDataBuilder + * + * Transforms API DTOs into LeagueStandingsViewData for server-side rendering. + * Deterministic; side-effect free; no HTTP calls. + */ +export class LeagueStandingsViewDataBuilder { + static build( + standingsDto: LeagueStandingsApiDto, + membershipsDto: LeagueMembershipsApiDto, + leagueId: string + ): LeagueStandingsViewData { + const standings = standingsDto.standings || []; + const members = membershipsDto.members || []; + + // Convert LeagueStandingDTO to StandingEntryData + const standingData: StandingEntryData[] = standings.map(standing => ({ + driverId: standing.driverId, + position: standing.position, + totalPoints: standing.points, + racesFinished: standing.races, + racesStarted: standing.races, + avgFinish: null, // Not in DTO + penaltyPoints: 0, // Not in DTO + bonusPoints: 0, // Not in DTO + })); + + // Extract unique drivers from standings + const driverMap = new Map(); + standings.forEach(standing => { + if (standing.driver && !driverMap.has(standing.driver.id)) { + const driver = standing.driver; + driverMap.set(driver.id, { + id: driver.id, + name: driver.name, + avatarUrl: null, // DTO may not have this + iracingId: driver.iracingId, + rating: undefined, + country: driver.country, + }); + } + }); + const driverData: DriverData[] = Array.from(driverMap.values()); + + // Convert LeagueMemberDTO to LeagueMembershipData + const membershipData: LeagueMembershipData[] = members.map(member => ({ + driverId: member.driverId, + leagueId: leagueId, + role: (member.role as LeagueMembershipData['role']) || 'member', + joinedAt: member.joinedAt, + status: 'active' as const, + })); + + return { + standings: standingData, + drivers: driverData, + memberships: membershipData, + leagueId, + currentDriverId: null, // Would need to get from auth + isAdmin: false, // Would need to check permissions + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/builders/view-data/ProtestDetailViewDataBuilder.ts b/apps/website/lib/builders/view-data/ProtestDetailViewDataBuilder.ts new file mode 100644 index 000000000..f60095480 --- /dev/null +++ b/apps/website/lib/builders/view-data/ProtestDetailViewDataBuilder.ts @@ -0,0 +1,46 @@ +import { ProtestDetailViewData } from '@/lib/view-data/leagues/ProtestDetailViewData'; + +interface ProtestDetailApiDto { + id: string; + leagueId: string; + status: string; + submittedAt: string; + incident: { + lap: number; + description: string; + }; + protestingDriver: { + id: string; + name: string; + }; + accusedDriver: { + id: string; + name: string; + }; + race: { + id: string; + name: string; + scheduledAt: string; + }; + penaltyTypes: Array<{ + type: string; + label: string; + description: string; + }>; +} + +export class ProtestDetailViewDataBuilder { + static build(apiDto: ProtestDetailApiDto): ProtestDetailViewData { + return { + protestId: apiDto.id, + leagueId: apiDto.leagueId, + status: apiDto.status, + submittedAt: apiDto.submittedAt, + incident: apiDto.incident, + protestingDriver: apiDto.protestingDriver, + accusedDriver: apiDto.accusedDriver, + race: apiDto.race, + penaltyTypes: apiDto.penaltyTypes, + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/builders/view-data/RulebookViewDataBuilder.ts b/apps/website/lib/builders/view-data/RulebookViewDataBuilder.ts new file mode 100644 index 000000000..a9a94ab18 --- /dev/null +++ b/apps/website/lib/builders/view-data/RulebookViewDataBuilder.ts @@ -0,0 +1,25 @@ +import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData'; +import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto'; + +export class RulebookViewDataBuilder { + static build(apiDto: RulebookApiDto): RulebookViewData { + const primaryChampionship = apiDto.scoringConfig.championships.find(c => c.type === 'driver') ?? apiDto.scoringConfig.championships[0]; + const positionPoints: { position: number; points: number }[] = primaryChampionship?.pointsPreview + .filter((p): p is { sessionType: string; position: number; points: number } => p.sessionType === primaryChampionship.sessionTypes[0]) + .map(p => ({ position: p.position, points: p.points })) + .sort((a, b) => a.position - b.position) || []; + + return { + leagueId: apiDto.leagueId, + gameName: apiDto.scoringConfig.gameName, + scoringPresetName: apiDto.scoringConfig.scoringPresetName, + championshipsCount: apiDto.scoringConfig.championships.length, + sessionTypes: primaryChampionship?.sessionTypes.join(', ') || 'Main', + dropPolicySummary: apiDto.scoringConfig.dropPolicySummary, + hasActiveDropPolicy: !apiDto.scoringConfig.dropPolicySummary.includes('All'), + positionPoints, + bonusPoints: primaryChampionship?.bonusSummary || [], + hasBonusPoints: (primaryChampionship?.bonusSummary.length || 0) > 0, + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/builders/view-data/StewardingViewDataBuilder.ts b/apps/website/lib/builders/view-data/StewardingViewDataBuilder.ts new file mode 100644 index 000000000..50300b181 --- /dev/null +++ b/apps/website/lib/builders/view-data/StewardingViewDataBuilder.ts @@ -0,0 +1,26 @@ +import { StewardingApiDto } from '@/lib/types/tbd/StewardingApiDto'; +import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData'; + + +export class StewardingViewDataBuilder { + static build(apiDto: StewardingApiDto): StewardingViewData { + return { + leagueId: apiDto.leagueId, + totalPending: apiDto.totalPending || 0, + totalResolved: apiDto.totalResolved || 0, + totalPenalties: apiDto.totalPenalties || 0, + races: (apiDto.races || []).map((race) => ({ + id: race.id, + track: race.track, + scheduledAt: race.scheduledAt, + pendingProtests: race.pendingProtests || [], + resolvedProtests: race.resolvedProtests || [], + penalties: race.penalties || [], + })), + drivers: (apiDto.drivers || []).map((driver) => ({ + id: driver.id, + name: driver.name, + })), + }; + } +} \ No newline at end of file diff --git a/apps/website/lib/page-queries/page-queries/LeagueProtestDetailPageQuery.ts b/apps/website/lib/page-queries/page-queries/LeagueProtestDetailPageQuery.ts new file mode 100644 index 000000000..9580e5de1 --- /dev/null +++ b/apps/website/lib/page-queries/page-queries/LeagueProtestDetailPageQuery.ts @@ -0,0 +1,29 @@ +import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; +import { Result } from '@/lib/contracts/Result'; +import { ProtestDetailService } from '@/lib/services/leagues/ProtestDetailService'; +import { ProtestDetailViewDataBuilder } from '@/lib/builders/view-data/ProtestDetailViewDataBuilder'; +import { ProtestDetailViewData } from '@/lib/view-data/leagues/ProtestDetailViewData'; + +interface PresentationError { + type: 'notFound' | 'forbidden' | 'serverError'; + message: string; +} + +export class LeagueProtestDetailPageQuery implements PageQuery { + async execute(params: { leagueId: string; protestId: string }): Promise> { + const service = new ProtestDetailService(); + const result = await service.getProtestDetail(params.leagueId, params.protestId); + + if (result.isErr()) { + return Result.err({ type: 'serverError', message: 'Failed to load protest details' }); + } + + const viewData = ProtestDetailViewDataBuilder.build(result.unwrap()); + return Result.ok(viewData); + } + + static async execute(params: { leagueId: string; protestId: string }): Promise> { + const query = new LeagueProtestDetailPageQuery(); + return query.execute(params); + } +} \ No newline at end of file diff --git a/apps/website/lib/page-queries/page-queries/LeagueRulebookPageQuery.ts b/apps/website/lib/page-queries/page-queries/LeagueRulebookPageQuery.ts index f657a4b11..af7f2aef1 100644 --- a/apps/website/lib/page-queries/page-queries/LeagueRulebookPageQuery.ts +++ b/apps/website/lib/page-queries/page-queries/LeagueRulebookPageQuery.ts @@ -1,20 +1,28 @@ import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; import { Result } from '@/lib/contracts/Result'; +import { LeagueRulebookService } from '@/lib/services/leagues/LeagueRulebookService'; +import { RulebookViewDataBuilder } from '@/lib/builders/view-data/RulebookViewDataBuilder'; +import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData'; -/** - * LeagueRulebookPageQuery - * - * Fetches league rulebook data. - * Currently returns empty data - would need API endpoint. - */ -export class LeagueRulebookPageQuery implements PageQuery { - async execute(leagueId: string): Promise> { - // TODO: Implement when API endpoint is available - // For now, return empty data - return Result.ok({ leagueId, rules: [] }); +interface PresentationError { + type: 'notFound' | 'forbidden' | 'serverError'; + message: string; +} + +export class LeagueRulebookPageQuery implements PageQuery { + async execute(leagueId: string): Promise> { + const service = new LeagueRulebookService(); + const result = await service.getRulebookData(leagueId); + + if (result.isErr()) { + return Result.err({ type: 'serverError', message: 'Failed to load rulebook data' }); + } + + const viewData = RulebookViewDataBuilder.build(result.unwrap()); + return Result.ok(viewData); } - static async execute(leagueId: string): Promise> { + static async execute(leagueId: string): Promise> { const query = new LeagueRulebookPageQuery(); return query.execute(leagueId); } diff --git a/apps/website/lib/page-queries/page-queries/LeagueStandingsPageQuery.ts b/apps/website/lib/page-queries/page-queries/LeagueStandingsPageQuery.ts index 74a437b61..51cd64097 100644 --- a/apps/website/lib/page-queries/page-queries/LeagueStandingsPageQuery.ts +++ b/apps/website/lib/page-queries/page-queries/LeagueStandingsPageQuery.ts @@ -1,64 +1,29 @@ import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; import { Result } from '@/lib/contracts/Result'; -import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; -import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; -import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData'; +import { LeagueStandingsService } from '@/lib/services/leagues/LeagueStandingsService'; +import { LeagueStandingsViewDataBuilder } from '@/lib/builders/view-data/LeagueStandingsViewDataBuilder'; +import { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData'; -/** - * LeagueStandingsPageQuery - * - * Fetches league standings data for the standings page. - * Returns Result - */ -export class LeagueStandingsPageQuery implements PageQuery { - async execute(leagueId: string): Promise> { - // Manual wiring: create API client - const baseUrl = process.env.NEXT_PUBLIC_API_URL || ''; - const errorReporter = new ConsoleErrorReporter(); - const logger = new ConsoleLogger(); - const apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); - - try { - // Fetch standings - const standingsDto = await apiClient.getStandings(leagueId); - - if (!standingsDto) { - return Result.err('notFound'); - } - - // For now, return empty data structure - // In a real implementation, this would transform the DTO to ViewData - const viewData: LeagueStandingsViewData = { - standings: [], - drivers: [], - memberships: [], - leagueId, - currentDriverId: null, - isAdmin: false, - }; - - return Result.ok(viewData); - } catch (error) { - console.error('LeagueStandingsPageQuery failed:', error); - - if (error instanceof Error) { - if (error.message.includes('403') || error.message.includes('401')) { - return Result.err('redirect'); - } - if (error.message.includes('404')) { - return Result.err('notFound'); - } - if (error.message.includes('5') || error.message.includes('server')) { - return Result.err('STANDINGS_FETCH_FAILED'); - } - } - - return Result.err('UNKNOWN_ERROR'); +interface PresentationError { + type: 'notFound' | 'forbidden' | 'serverError'; + message: string; +} + +export class LeagueStandingsPageQuery implements PageQuery { + async execute(leagueId: string): Promise> { + const service = new LeagueStandingsService(); + const result = await service.getStandingsData(leagueId); + + if (result.isErr()) { + return Result.err({ type: 'serverError', message: 'Failed to load standings data' }); } + + const { standings, memberships } = result.unwrap(); + const viewData = LeagueStandingsViewDataBuilder.build(standings, memberships, leagueId); + return Result.ok(viewData); } - static async execute(leagueId: string): Promise> { + static async execute(leagueId: string): Promise> { const query = new LeagueStandingsPageQuery(); return query.execute(leagueId); } diff --git a/apps/website/lib/page-queries/page-queries/LeagueStewardingPageQuery.ts b/apps/website/lib/page-queries/page-queries/LeagueStewardingPageQuery.ts index ecba583db..036f68b72 100644 --- a/apps/website/lib/page-queries/page-queries/LeagueStewardingPageQuery.ts +++ b/apps/website/lib/page-queries/page-queries/LeagueStewardingPageQuery.ts @@ -1,66 +1,30 @@ -import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; import { Result } from '@/lib/contracts/Result'; -import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; -import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; +import { LeagueStewardingService } from '@/lib/services/leagues/LeagueStewardingService'; +import { StewardingViewDataBuilder } from '@/lib/builders/view-data/StewardingViewDataBuilder'; +import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData'; -/** - * LeagueStewardingPageQuery - * - * Fetches league stewarding data (protests and penalties). - */ -export class LeagueStewardingPageQuery implements PageQuery { - async execute(leagueId: string): Promise> { - // Manual wiring: create API client - const baseUrl = process.env.NEXT_PUBLIC_API_URL || ''; - const errorReporter = new ConsoleErrorReporter(); - const logger = new ConsoleLogger(); - const apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); - - try { - // Get races for the league - const racesData = await apiClient.getRaces(leagueId); - - if (!racesData) { - return Result.err('notFound'); - } - - // Get memberships for driver lookup - const memberships = await apiClient.getMemberships(leagueId); - - // Return data structure for stewarding page - // In real implementation, would need protest/penalty API endpoints - return Result.ok({ - leagueId, - races: racesData.races || [], - memberships: memberships || { members: [] }, - totalPending: 0, - totalResolved: 0, - totalPenalties: 0, - racesWithData: [], - allDrivers: [], - driverMap: {}, - }); - } catch (error) { - console.error('LeagueStewardingPageQuery failed:', error); - - if (error instanceof Error) { - if (error.message.includes('403') || error.message.includes('401')) { - return Result.err('redirect'); - } - if (error.message.includes('404')) { - return Result.err('notFound'); - } - if (error.message.includes('5') || error.message.includes('server')) { - return Result.err('STEWARDING_FETCH_FAILED'); - } - } - - return Result.err('UNKNOWN_ERROR'); +interface PresentationError { + type: 'notFound' | 'forbidden' | 'notImplemented' | 'serverError'; + message: string; +} + +export class LeagueStewardingPageQuery implements PageQuery { + async execute(leagueId: string): Promise> { + const service = new LeagueStewardingService(); + const result = await service.getStewardingData(leagueId); + + if (result.isErr()) { + // Map domain errors to presentation errors + return Result.err({ type: 'serverError', message: 'Failed to load stewarding data' }); } + + const viewData = StewardingViewDataBuilder.build(result.unwrap()); + return Result.ok(viewData); } - static async execute(leagueId: string): Promise> { + // Static method to avoid object construction in server code + static async execute(leagueId: string): Promise> { const query = new LeagueStewardingPageQuery(); return query.execute(leagueId); } diff --git a/apps/website/lib/services/leagues/LeagueRulebookService.ts b/apps/website/lib/services/leagues/LeagueRulebookService.ts new file mode 100644 index 000000000..bda56c570 --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueRulebookService.ts @@ -0,0 +1,30 @@ +import { Result } from '@/lib/contracts/Result'; +import { Service } from '@/lib/contracts/services/Service'; +import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto'; + +export class LeagueRulebookService implements Service { + async getRulebookData(leagueId: string): Promise> { + // Mock data since backend not implemented + const mockData: RulebookApiDto = { + leagueId, + scoringConfig: { + gameName: 'iRacing', + scoringPresetName: 'Custom Rules', + championships: [ + { + type: 'driver', + sessionTypes: ['Race'], + pointsPreview: [ + { sessionType: 'Race', position: 1, points: 25 }, + { sessionType: 'Race', position: 2, points: 20 }, + { sessionType: 'Race', position: 3, points: 16 }, + ], + bonusSummary: ['Pole Position: +1', 'Fastest Lap: +1'], + } + ], + dropPolicySummary: 'All results count', + }, + }; + return Result.ok(mockData); + } +} \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueStandingsService.ts b/apps/website/lib/services/leagues/LeagueStandingsService.ts new file mode 100644 index 000000000..22d49c910 --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueStandingsService.ts @@ -0,0 +1,89 @@ +import { Result } from '@/lib/contracts/Result'; +import { Service } from '@/lib/contracts/services/Service'; +import { LeagueStandingsApiDto, LeagueMembershipsApiDto } from '@/lib/types/tbd/LeagueStandingsApiDto'; +import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; +import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; + +export class LeagueStandingsService implements Service { + private apiClient: LeaguesApiClient; + + constructor() { + const baseUrl = getWebsiteApiBaseUrl(); + this.apiClient = new LeaguesApiClient( + baseUrl, + new ConsoleErrorReporter(), + new ConsoleLogger() + ); + } + + async getStandingsData(leagueId: string): Promise> { + // Mock data since backend may not be implemented + const mockStandings: LeagueStandingsApiDto = { + standings: [ + { + driverId: 'driver1', + driver: { + id: 'driver1', + name: 'John Doe', + iracingId: '12345', + country: 'US', + joinedAt: new Date().toISOString(), + }, + points: 100, + position: 1, + wins: 2, + podiums: 3, + races: 5, + }, + { + driverId: 'driver2', + driver: { + id: 'driver2', + name: 'Jane Smith', + iracingId: '67890', + country: 'UK', + joinedAt: new Date().toISOString(), + }, + points: 80, + position: 2, + wins: 1, + podiums: 2, + races: 5, + }, + ], + }; + + const mockMemberships: LeagueMembershipsApiDto = { + members: [ + { + driverId: 'driver1', + driver: { + id: 'driver1', + name: 'John Doe', + iracingId: '12345', + country: 'US', + joinedAt: new Date().toISOString(), + }, + role: 'member', + joinedAt: new Date().toISOString(), + }, + { + driverId: 'driver2', + driver: { + id: 'driver2', + name: 'Jane Smith', + iracingId: '67890', + country: 'UK', + joinedAt: new Date().toISOString(), + }, + role: 'member', + joinedAt: new Date().toISOString(), + }, + ], + }; + + return Result.ok({ standings: mockStandings, memberships: mockMemberships }); + } +} \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueStewardingService.ts b/apps/website/lib/services/leagues/LeagueStewardingService.ts index bb7a02599..02967a8dc 100644 --- a/apps/website/lib/services/leagues/LeagueStewardingService.ts +++ b/apps/website/lib/services/leagues/LeagueStewardingService.ts @@ -1,41 +1,18 @@ -import { RaceService } from '@/lib/services/races/RaceService'; -import { ProtestService } from '@/lib/services/protests/ProtestService'; -import { PenaltyService } from '@/lib/services/penalties/PenaltyService'; -import { DriverService } from '@/lib/services/drivers/DriverService'; -import { LeagueMembershipService } from '@/lib/services/leagues/LeagueMembershipService'; +import { Result } from '@/lib/contracts/Result'; +import { Service } from '@/lib/contracts/services/Service'; +import { StewardingApiDto } from '@/lib/types/tbd/StewardingApiDto'; -/** - * League Stewarding Service - DTO Only - * - * Returns raw API DTOs. No ViewModels or UX logic. - * All client-side presentation logic must be handled by hooks/components. - */ -export class LeagueStewardingService { - constructor( - private readonly raceService: RaceService, - private readonly protestService: ProtestService, - private readonly penaltyService: PenaltyService, - private readonly driverService: DriverService, - private readonly membershipService: LeagueMembershipService - ) {} - - async getLeagueProtests(leagueId: string): Promise { - return this.protestService.getLeagueProtests(leagueId); - } - - async getProtestById(leagueId: string, protestId: string): Promise { - return this.protestService.getProtestById(leagueId, protestId); - } - - async applyPenalty(input: any): Promise { - return this.protestService.applyPenalty(input); - } - - async requestDefense(input: any): Promise { - return this.protestService.requestDefense(input); - } - - async reviewProtest(input: any): Promise { - return this.protestService.reviewProtest(input); +export class LeagueStewardingService implements Service { + async getStewardingData(leagueId: string): Promise> { + // Mock data since backend not implemented + const mockData: StewardingApiDto = { + leagueId, + totalPending: 0, + totalResolved: 0, + totalPenalties: 0, + races: [], + drivers: [] + }; + return Result.ok(mockData); } } \ No newline at end of file diff --git a/apps/website/lib/services/leagues/ProtestDetailService.ts b/apps/website/lib/services/leagues/ProtestDetailService.ts new file mode 100644 index 000000000..ac82ca2c4 --- /dev/null +++ b/apps/website/lib/services/leagues/ProtestDetailService.ts @@ -0,0 +1,38 @@ +import { Result } from '@/lib/contracts/Result'; +import { Service } from '@/lib/contracts/services/Service'; +import { ProtestDetailApiDto } from '@/lib/types/tbd/ProtestDetailApiDto'; + +export class ProtestDetailService implements Service { + async getProtestDetail(leagueId: string, protestId: string): Promise> { + // Mock data since backend not implemented + const mockData: ProtestDetailApiDto = { + id: protestId, + leagueId, + status: 'pending', + submittedAt: new Date().toISOString(), + incident: { + lap: 5, + description: 'Contact on corner 3, causing spin', + }, + protestingDriver: { + id: 'driver1', + name: 'John Doe', + }, + accusedDriver: { + id: 'driver2', + name: 'Jane Smith', + }, + race: { + id: 'race1', + name: 'Race 1', + scheduledAt: new Date().toISOString(), + }, + penaltyTypes: [ + { type: 'warning', label: 'Warning', description: 'Official warning' }, + { type: 'time_penalty', label: 'Time Penalty', description: 'Add seconds to race time' }, + { type: 'grid_penalty', label: 'Grid Penalty', description: 'Drop grid positions' }, + ], + }; + return Result.ok(mockData); + } +} \ No newline at end of file diff --git a/apps/website/lib/types/tbd/LeagueStandingsApiDto.ts b/apps/website/lib/types/tbd/LeagueStandingsApiDto.ts new file mode 100644 index 000000000..3a120370f --- /dev/null +++ b/apps/website/lib/types/tbd/LeagueStandingsApiDto.ts @@ -0,0 +1,10 @@ +import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO'; +import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; + +export interface LeagueStandingsApiDto { + standings: LeagueStandingDTO[]; +} + +export interface LeagueMembershipsApiDto { + members: LeagueMemberDTO[]; +} \ No newline at end of file diff --git a/apps/website/lib/types/tbd/ProtestDetailApiDto.ts b/apps/website/lib/types/tbd/ProtestDetailApiDto.ts new file mode 100644 index 000000000..ce755f9ca --- /dev/null +++ b/apps/website/lib/types/tbd/ProtestDetailApiDto.ts @@ -0,0 +1,28 @@ +export interface ProtestDetailApiDto { + id: string; + leagueId: string; + status: string; + submittedAt: string; + incident: { + lap: number; + description: string; + }; + protestingDriver: { + id: string; + name: string; + }; + accusedDriver: { + id: string; + name: string; + }; + race: { + id: string; + name: string; + scheduledAt: string; + }; + penaltyTypes: Array<{ + type: string; + label: string; + description: string; + }>; +} \ No newline at end of file diff --git a/apps/website/lib/types/tbd/RulebookApiDto.ts b/apps/website/lib/types/tbd/RulebookApiDto.ts new file mode 100644 index 000000000..73baaf6fa --- /dev/null +++ b/apps/website/lib/types/tbd/RulebookApiDto.ts @@ -0,0 +1,18 @@ +export interface RulebookApiDto { + leagueId: string; + scoringConfig: { + gameName: string; + scoringPresetName: string; + championships: Array<{ + type: string; + sessionTypes: string[]; + pointsPreview: Array<{ + sessionType: string; + position: number; + points: number; + }>; + bonusSummary: string[]; + }>; + dropPolicySummary: string; + }; +} \ No newline at end of file diff --git a/apps/website/lib/types/tbd/StewardingApiDto.ts b/apps/website/lib/types/tbd/StewardingApiDto.ts new file mode 100644 index 000000000..9d62e35d3 --- /dev/null +++ b/apps/website/lib/types/tbd/StewardingApiDto.ts @@ -0,0 +1,48 @@ +export interface StewardingApiDto { + leagueId: string; + totalPending: number; + totalResolved: number; + totalPenalties: number; + races: Array<{ + id: string; + track: string; + scheduledAt: string; + pendingProtests: Array<{ + id: string; + protestingDriverId: string; + accusedDriverId: string; + incident: { + lap: number; + description: string; + }; + filedAt: string; + status: string; + proofVideoUrl?: string; + decisionNotes?: string; + }>; + resolvedProtests: Array<{ + id: string; + protestingDriverId: string; + accusedDriverId: string; + incident: { + lap: number; + description: string; + }; + filedAt: string; + status: string; + proofVideoUrl?: string; + decisionNotes?: string; + }>; + penalties: Array<{ + id: string; + driverId: string; + type: string; + value: number; + reason: string; + }>; + }>; + drivers: Array<{ + id: string; + name: string; + }>; +} \ No newline at end of file diff --git a/apps/website/lib/view-data/leagues/ProtestDetailViewData.ts b/apps/website/lib/view-data/leagues/ProtestDetailViewData.ts new file mode 100644 index 000000000..e52b9be23 --- /dev/null +++ b/apps/website/lib/view-data/leagues/ProtestDetailViewData.ts @@ -0,0 +1,28 @@ +export interface ProtestDetailViewData { + protestId: string; + leagueId: string; + status: string; + submittedAt: string; + incident: { + lap: number; + description: string; + }; + protestingDriver: { + id: string; + name: string; + }; + accusedDriver: { + id: string; + name: string; + }; + race: { + id: string; + name: string; + scheduledAt: string; + }; + penaltyTypes: Array<{ + type: string; + label: string; + description: string; + }>; +} \ No newline at end of file diff --git a/apps/website/lib/view-data/leagues/RulebookViewData.ts b/apps/website/lib/view-data/leagues/RulebookViewData.ts new file mode 100644 index 000000000..6a1aba369 --- /dev/null +++ b/apps/website/lib/view-data/leagues/RulebookViewData.ts @@ -0,0 +1,15 @@ +export interface RulebookViewData { + leagueId: string; + gameName: string; + scoringPresetName: string; + championshipsCount: number; + sessionTypes: string; + dropPolicySummary: string; + hasActiveDropPolicy: boolean; + positionPoints: Array<{ + position: number; + points: number; + }>; + bonusPoints: string[]; + hasBonusPoints: boolean; +} \ No newline at end of file diff --git a/apps/website/lib/view-data/leagues/StewardingViewData.ts b/apps/website/lib/view-data/leagues/StewardingViewData.ts new file mode 100644 index 000000000..c19eb9153 --- /dev/null +++ b/apps/website/lib/view-data/leagues/StewardingViewData.ts @@ -0,0 +1,48 @@ +export interface StewardingViewData { + leagueId: string; + totalPending: number; + totalResolved: number; + totalPenalties: number; + races: Array<{ + id: string; + track: string; + scheduledAt: string; // ISO string + pendingProtests: Array<{ + id: string; + protestingDriverId: string; + accusedDriverId: string; + incident: { + lap: number; + description: string; + }; + filedAt: string; // ISO string + status: string; + proofVideoUrl?: string; + decisionNotes?: string; + }>; + resolvedProtests: Array<{ + id: string; + protestingDriverId: string; + accusedDriverId: string; + incident: { + lap: number; + description: string; + }; + filedAt: string; // ISO string + status: string; + proofVideoUrl?: string; + decisionNotes?: string; + }>; + penalties: Array<{ + id: string; + driverId: string; + type: string; + value: number; + reason: string; + }>; + }>; + drivers: Array<{ + id: string; + name: string; + }>; +} \ No newline at end of file diff --git a/apps/website/templates/RulebookTemplate.tsx b/apps/website/templates/RulebookTemplate.tsx new file mode 100644 index 000000000..d6e02f493 --- /dev/null +++ b/apps/website/templates/RulebookTemplate.tsx @@ -0,0 +1,102 @@ +import { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData'; +import { Card } from '@/ui/Card'; +import { Section } from '@/ui/Section'; + +interface RulebookTemplateProps { + viewData: RulebookViewData; +} + +export function RulebookTemplate({ viewData }: RulebookTemplateProps) { + return ( +
+ {/* Header */} +
+
+

Rulebook

+

Official rules and regulations

+
+
+ {viewData.scoringPresetName || 'Custom Rules'} +
+
+ + {/* Quick Stats */} +
+
+

Platform

+

{viewData.gameName}

+
+
+

Championships

+

{viewData.championshipsCount}

+
+
+

Sessions Scored

+

+ {viewData.sessionTypes} +

+
+
+

Drop Policy

+

+ {viewData.hasActiveDropPolicy ? 'Active' : 'None'} +

+
+
+ + {/* Points Table */} + +

Points System

+
+ + + + + + + + + {viewData.positionPoints.map((point) => ( + + + + + ))} + +
PositionPoints
{point.position}{point.points}
+
+
+ + {/* Bonus Points */} + {viewData.hasBonusPoints && ( + +

Bonus Points

+
+ {viewData.bonusPoints.map((bonus, idx) => ( +
+
+ + +
+

{bonus}

+
+ ))} +
+
+ )} + + {/* Drop Policy */} + {viewData.hasActiveDropPolicy && ( + +

Drop Policy

+

{viewData.dropPolicySummary}

+

+ Drop rules are applied automatically when calculating championship standings. +

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/templates/StewardingTemplate.tsx b/apps/website/templates/StewardingTemplate.tsx new file mode 100644 index 000000000..b0ab9ab7c --- /dev/null +++ b/apps/website/templates/StewardingTemplate.tsx @@ -0,0 +1,156 @@ +/* eslint-disable gridpilot-rules/no-raw-html-in-app */ + +import { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData'; +import { Card } from '@/ui/Card'; +import { Section } from '@/ui/Section'; +import { Flag, AlertCircle, Calendar, MapPin, Gavel } from 'lucide-react'; + +interface StewardingTemplateProps { + viewData: StewardingViewData; +} + +export function StewardingTemplate({ viewData }: StewardingTemplateProps) { + return ( +
+ +
+
+

Stewarding

+

+ Quick overview of protests and penalties across all races +

+
+
+ + {/* Stats summary */} +
+
+
{viewData.totalPending}
+
Pending
+
+
+
{viewData.totalResolved}
+
Resolved
+
+
+
{viewData.totalPenalties}
+
Penalties
+
+
+ + {/* Content */} + {viewData.races.length === 0 ? ( +
+
+ +
+

All Clear!

+

No protests or penalties to review.

+
+ ) : ( +
+ {viewData.races.map((race) => ( +
+ {/* Race Header */} +
+
+
+ + {race.track} +
+
+ + {new Date(race.scheduledAt).toLocaleDateString()} +
+ + {race.pendingProtests.length} pending + +
+
+ + {/* Race Content */} +
+ {race.pendingProtests.length === 0 && race.resolvedProtests.length === 0 && race.penalties.length === 0 ? ( +

No items to display

+ ) : ( + <> + {race.pendingProtests.map((protest) => { + const protester = viewData.drivers.find(d => d.id === protest.protestingDriverId); + const accused = viewData.drivers.find(d => d.id === protest.accusedDriverId); + + return ( +
+
+
+
+ + + {protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'} + + Pending +
+
+ Lap {protest.incident.lap} + + Filed {new Date(protest.filedAt).toLocaleDateString()} +
+

+ {protest.incident.description} +

+
+
+ Review needed +
+
+
+ ); + })} + + {race.penalties.map((penalty) => { + const driver = viewData.drivers.find(d => d.id === penalty.driverId); + return ( +
+
+
+ +
+
+
+ {driver?.name || 'Unknown'} + + {penalty.type.replace('_', ' ')} + +
+

{penalty.reason}

+
+
+ + {penalty.type === 'time_penalty' && `+${penalty.value}s`} + {penalty.type === 'grid_penalty' && `+${penalty.value} grid`} + {penalty.type === 'points_deduction' && `-${penalty.value} pts`} + {penalty.type === 'disqualification' && 'DSQ'} + {penalty.type === 'warning' && 'Warning'} + {penalty.type === 'license_points' && `${penalty.value} LP`} + +
+
+
+ ); + })} + + )} +
+
+ ))} +
+ )} +
+
+ ); +} \ No newline at end of file