This commit is contained in:
2025-12-11 00:57:32 +01:00
parent 1303a14493
commit 6a427eab57
112 changed files with 6148 additions and 2272 deletions

View File

@@ -1,13 +1,22 @@
'use client';
import { useState } from 'react';
import Button from '../ui/Button';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { v4 as uuidv4 } from 'uuid';
import Button from '../ui/Button';
interface ImportResultRowDTO {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
}
interface ImportResultsFormProps {
raceId: string;
onSuccess: (results: Result[]) => void;
onSuccess: (results: ImportResultRowDTO[]) => void;
onError: (error: string) => void;
}
@@ -25,36 +34,35 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
const parseCSV = (content: string): CSVRow[] => {
const lines = content.trim().split('\n');
if (lines.length < 2) {
throw new Error('CSV file is empty or invalid');
}
// Parse header
const header = lines[0].toLowerCase().split(',').map(h => h.trim());
const header = lines[0].toLowerCase().split(',').map((h) => h.trim());
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
for (const field of requiredFields) {
if (!header.includes(field)) {
throw new Error(`Missing required field: ${field}`);
}
}
// Parse rows
const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim());
const values = lines[i].split(',').map((v) => v.trim());
if (values.length !== header.length) {
throw new Error(`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`);
throw new Error(
`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`,
);
}
const row: any = {};
const row: Record<string, string> = {};
header.forEach((field, index) => {
row[field] = values[index];
row[field] = values[index] ?? '';
});
// Validate and convert types
const driverId = row.driverid;
const position = parseInt(row.position, 10);
const fastestLap = parseFloat(row.fastestlap);
@@ -65,34 +73,32 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
throw new Error(`Row ${i}: driverId is required`);
}
if (isNaN(position) || position < 1) {
if (Number.isNaN(position) || position < 1) {
throw new Error(`Row ${i}: position must be a positive integer`);
}
if (isNaN(fastestLap) || fastestLap < 0) {
if (Number.isNaN(fastestLap) || fastestLap < 0) {
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
}
if (isNaN(incidents) || incidents < 0) {
if (Number.isNaN(incidents) || incidents < 0) {
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
}
if (isNaN(startPosition) || startPosition < 1) {
if (Number.isNaN(startPosition) || startPosition < 1) {
throw new Error(`Row ${i}: startPosition must be a positive integer`);
}
rows.push({ driverId, position, fastestLap, incidents, startPosition });
}
// Validate no duplicate positions
const positions = rows.map(r => r.position);
const positions = rows.map((r) => r.position);
const uniquePositions = new Set(positions);
if (positions.length !== uniquePositions.size) {
throw new Error('Duplicate positions found in CSV');
}
// Validate no duplicate drivers
const driverIds = rows.map(r => r.driverId);
const driverIds = rows.map((r) => r.driverId);
const uniqueDrivers = new Set(driverIds);
if (driverIds.length !== uniqueDrivers.size) {
throw new Error('Duplicate driver IDs found in CSV');
@@ -109,33 +115,27 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
setError(null);
try {
// Read file
const content = await file.text();
// Parse CSV
const rows = parseCSV(content);
// Create Result entities
const results = rows.map(row =>
Result.create({
id: uuidv4(),
raceId,
driverId: row.driverId,
position: row.position,
fastestLap: row.fastestLap,
incidents: row.incidents,
startPosition: row.startPosition,
})
);
const results: ImportResultRowDTO[] = rows.map((row) => ({
id: uuidv4(),
raceId,
driverId: row.driverId,
position: row.position,
fastestLap: row.fastestLap,
incidents: row.incidents,
startPosition: row.startPosition,
}));
onSuccess(results);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to parse CSV file';
const errorMessage =
err instanceof Error ? err.message : 'Failed to parse CSV file';
setError(errorMessage);
onError(errorMessage);
} finally {
setUploading(false);
// Reset file input
event.target.value = '';
}
};

View File

@@ -1,32 +1,58 @@
'use client';
import Link from 'next/link';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import type { PenaltyType } from '@gridpilot/racing/domain/entities/Penalty';
import { AlertTriangle, ExternalLink } from 'lucide-react';
/**
* Penalty data for display (can be domain Penalty or RacePenaltyDTO)
*/
type PenaltyTypeDTO =
| 'time_penalty'
| 'grid_penalty'
| 'points_deduction'
| 'disqualification'
| 'warning'
| 'license_points'
| string;
interface ResultDTO {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
incidents: number;
startPosition: number;
getPositionChange(): number;
}
interface DriverDTO {
id: string;
name: string;
}
interface PenaltyData {
driverId: string;
type: PenaltyType;
type: PenaltyTypeDTO;
value?: number;
}
interface ResultsTableProps {
results: Result[];
drivers: Driver[];
results: ResultDTO[];
drivers: DriverDTO[];
pointsSystem: Record<number, number>;
fastestLapTime?: number;
penalties?: PenaltyData[];
currentDriverId?: string;
}
export default function ResultsTable({ results, drivers, pointsSystem, fastestLapTime, penalties = [], currentDriverId }: ResultsTableProps) {
const getDriver = (driverId: string): Driver | undefined => {
return drivers.find(d => d.id === driverId);
export default function ResultsTable({
results,
drivers,
pointsSystem,
fastestLapTime,
penalties = [],
currentDriverId,
}: ResultsTableProps) {
const getDriver = (driverId: string): DriverDTO | undefined => {
return drivers.find((d) => d.id === driverId);
};
const getDriverName = (driverId: string): string => {
@@ -35,7 +61,7 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
};
const getDriverPenalties = (driverId: string): PenaltyData[] => {
return penalties.filter(p => p.driverId === driverId);
return penalties.filter((p) => p.driverId === driverId);
};
const getPenaltyDescription = (penalty: PenaltyData): string => {
@@ -97,30 +123,39 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
<tbody>
{results.map((result) => {
const positionChange = result.getPositionChange();
const isFastestLap = fastestLapTime && result.fastestLap === fastestLapTime;
const isFastestLap =
typeof fastestLapTime === 'number' && result.fastestLap === fastestLapTime;
const driverPenalties = getDriverPenalties(result.driverId);
const driver = getDriver(result.driverId);
const isCurrentUser = currentDriverId === result.driverId;
const isPodium = result.position <= 3;
return (
<tr
key={result.id}
className={`
border-b border-charcoal-outline/50 transition-colors
${isCurrentUser
? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent hover:from-primary-blue/30'
: 'hover:bg-iron-gray/20'}
${
isCurrentUser
? 'bg-gradient-to-r from-primary-blue/20 via-primary-blue/10 to-transparent hover:from-primary-blue/30'
: 'hover:bg-iron-gray/20'
}
`}
>
<td className="py-3 px-4">
<div className={`
<div
className={`
inline-flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
${result.position === 1 ? 'bg-yellow-500/20 text-yellow-400' :
result.position === 2 ? 'bg-gray-400/20 text-gray-300' :
result.position === 3 ? 'bg-amber-600/20 text-amber-500' :
'text-white'}
`}>
${
result.position === 1
? 'bg-yellow-500/20 text-yellow-400'
: result.position === 2
? 'bg-gray-400/20 text-gray-300'
: result.position === 3
? 'bg-amber-600/20 text-amber-500'
: 'text-white'
}
`}
>
{result.position}
</div>
</td>
@@ -128,17 +163,27 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
<div className="flex items-center gap-3">
{driver ? (
<>
<div className={`
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0
${isCurrentUser ? 'bg-primary-blue/30 text-primary-blue ring-2 ring-primary-blue/50' : 'bg-iron-gray text-gray-400'}
`}>
${
isCurrentUser
? 'bg-primary-blue/30 text-primary-blue ring-2 ring-primary-blue/50'
: 'bg-iron-gray text-gray-400'
}
`}
>
{driver.name.charAt(0)}
</div>
<Link
href={`/drivers/${driver.id}`}
className={`
flex items-center gap-1.5 group
${isCurrentUser ? 'text-primary-blue font-semibold' : 'text-white hover:text-primary-blue'}
${
isCurrentUser
? 'text-primary-blue font-semibold'
: 'text-white hover:text-primary-blue'
}
transition-colors
`}
>
@@ -157,20 +202,30 @@ export default function ResultsTable({ results, drivers, pointsSystem, fastestLa
</div>
</td>
<td className="py-3 px-4">
<span className={isFastestLap ? 'text-performance-green font-medium' : 'text-white'}>
<span
className={
isFastestLap ? 'text-performance-green font-medium' : 'text-white'
}
>
{formatLapTime(result.fastestLap)}
</span>
</td>
<td className="py-3 px-4">
<span className={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}>
<span
className={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}
>
{result.incidents}×
</span>
</td>
<td className="py-3 px-4">
<span className="text-white font-medium">{getPoints(result.position)}</span>
<span className="text-white font-medium">
{getPoints(result.position)}
</span>
</td>
<td className="py-3 px-4">
<span className={`font-medium ${getPositionChangeColor(positionChange)}`}>
<span
className={`font-medium ${getPositionChangeColor(positionChange)}`}
>
{getPositionChangeText(positionChange)}
</span>
</td>