wip
This commit is contained in:
194
apps/website/components/races/ImportResultsForm.tsx
Normal file
194
apps/website/components/races/ImportResultsForm.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'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';
|
||||
|
||||
interface ImportResultsFormProps {
|
||||
raceId: string;
|
||||
onSuccess: (results: Result[]) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
interface CSVRow {
|
||||
driverId: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}
|
||||
|
||||
export default function ImportResultsForm({ raceId, onSuccess, onError }: ImportResultsFormProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 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());
|
||||
|
||||
if (values.length !== header.length) {
|
||||
throw new Error(`Invalid row ${i}: expected ${header.length} columns, got ${values.length}`);
|
||||
}
|
||||
|
||||
const row: any = {};
|
||||
header.forEach((field, index) => {
|
||||
row[field] = values[index];
|
||||
});
|
||||
|
||||
// Validate and convert types
|
||||
const driverId = row.driverid;
|
||||
const position = parseInt(row.position, 10);
|
||||
const fastestLap = parseFloat(row.fastestlap);
|
||||
const incidents = parseInt(row.incidents, 10);
|
||||
const startPosition = parseInt(row.startposition, 10);
|
||||
|
||||
if (!driverId || driverId.length === 0) {
|
||||
throw new Error(`Row ${i}: driverId is required`);
|
||||
}
|
||||
|
||||
if (isNaN(position) || position < 1) {
|
||||
throw new Error(`Row ${i}: position must be a positive integer`);
|
||||
}
|
||||
|
||||
if (isNaN(fastestLap) || fastestLap < 0) {
|
||||
throw new Error(`Row ${i}: fastestLap must be a non-negative number`);
|
||||
}
|
||||
|
||||
if (isNaN(incidents) || incidents < 0) {
|
||||
throw new Error(`Row ${i}: incidents must be a non-negative integer`);
|
||||
}
|
||||
|
||||
if (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 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 uniqueDrivers = new Set(driverIds);
|
||||
if (driverIds.length !== uniqueDrivers.size) {
|
||||
throw new Error('Duplicate driver IDs found in CSV');
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
onSuccess(results);
|
||||
} catch (err) {
|
||||
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 = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Upload Results CSV
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
CSV format: driverId, position, fastestLap, incidents, startPosition
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
className="block w-full text-sm text-gray-400
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-primary-blue file:text-white
|
||||
file:cursor-pointer file:transition-colors
|
||||
hover:file:bg-primary-blue/80
|
||||
disabled:file:opacity-50 disabled:file:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded text-warning-amber text-sm">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="text-center text-gray-400 text-sm">
|
||||
Parsing CSV and importing results...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-iron-gray/20 rounded text-xs text-gray-500">
|
||||
<p className="font-semibold mb-2">CSV Example:</p>
|
||||
<pre className="text-gray-400">
|
||||
{`driverId,position,fastestLap,incidents,startPosition
|
||||
550e8400-e29b-41d4-a716-446655440001,1,92.456,0,3
|
||||
550e8400-e29b-41d4-a716-446655440002,2,92.789,1,1
|
||||
550e8400-e29b-41d4-a716-446655440003,3,93.012,2,2`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
apps/website/components/races/LatestResultsSidebar.tsx
Normal file
36
apps/website/components/races/LatestResultsSidebar.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { RaceWithResultsDTO } from '@gridpilot/testing-support';
|
||||
|
||||
interface LatestResultsSidebarProps {
|
||||
results: RaceWithResultsDTO[];
|
||||
}
|
||||
|
||||
export default function LatestResultsSidebar({ results }: LatestResultsSidebarProps) {
|
||||
if (!results.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-iron-gray/80">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Latest results</h3>
|
||||
<ul className="space-y-3">
|
||||
{results.slice(0, 4).map(result => (
|
||||
<li key={result.raceId} className="flex items-start justify-between gap-3 text-xs">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{result.track}</p>
|
||||
<p className="text-gray-400 truncate">
|
||||
{result.winnerName} • {result.car}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-gray-500 whitespace-nowrap">
|
||||
{result.scheduledAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
87
apps/website/components/races/RaceCard.tsx
Normal file
87
apps/website/components/races/RaceCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
|
||||
interface RaceCardProps {
|
||||
race: Race;
|
||||
leagueName?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function RaceCard({ race, leagueName, onClick }: RaceCardProps) {
|
||||
const statusColors = {
|
||||
scheduled: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30',
|
||||
completed: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
cancelled: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
};
|
||||
|
||||
const getRelativeTime = (date: Date) => {
|
||||
const now = new Date();
|
||||
const targetDate = new Date(date);
|
||||
const diffMs = targetDate.getTime() - now.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return null;
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 7) return `in ${diffDays} days`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const relativeTime = race.status === 'scheduled' ? getRelativeTime(race.scheduledAt) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`
|
||||
p-6 rounded-lg bg-iron-gray border border-charcoal-outline
|
||||
transition-all duration-200
|
||||
${onClick ? 'cursor-pointer hover:scale-[1.03] hover:border-primary-blue' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-white">{race.track}</h3>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded border ${statusColors[race.status]}`}>
|
||||
{race.status.charAt(0).toUpperCase() + race.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">{race.car}</p>
|
||||
{leagueName && (
|
||||
<p className="text-gray-500 text-xs mt-1">{leagueName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white font-medium text-sm">{formatDate(race.scheduledAt)}</p>
|
||||
<p className="text-gray-400 text-xs">{formatTime(race.scheduledAt)}</p>
|
||||
{relativeTime && (
|
||||
<p className="text-primary-blue text-xs mt-1">{relativeTime}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wide">
|
||||
{race.sessionType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/website/components/races/ResultsTable.tsx
Normal file
103
apps/website/components/races/ResultsTable.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { Result } from '@gridpilot/racing/domain/entities/Result';
|
||||
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
|
||||
interface ResultsTableProps {
|
||||
results: Result[];
|
||||
drivers: Driver[];
|
||||
pointsSystem: Record<number, number>;
|
||||
fastestLapTime?: number;
|
||||
}
|
||||
|
||||
export default function ResultsTable({ results, drivers, pointsSystem, fastestLapTime }: ResultsTableProps) {
|
||||
const getDriverName = (driverId: string): string => {
|
||||
const driver = drivers.find(d => d.id === driverId);
|
||||
return driver?.name || 'Unknown Driver';
|
||||
};
|
||||
|
||||
const formatLapTime = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = (seconds % 60).toFixed(3);
|
||||
return `${minutes}:${secs.padStart(6, '0')}`;
|
||||
};
|
||||
|
||||
const getPoints = (position: number): number => {
|
||||
return pointsSystem[position] || 0;
|
||||
};
|
||||
|
||||
const getPositionChangeColor = (change: number): string => {
|
||||
if (change > 0) return 'text-performance-green';
|
||||
if (change < 0) return 'text-warning-amber';
|
||||
return 'text-gray-500';
|
||||
};
|
||||
|
||||
const getPositionChangeText = (change: number): string => {
|
||||
if (change > 0) return `+${change}`;
|
||||
if (change < 0) return `${change}`;
|
||||
return '0';
|
||||
};
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
No results available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline">
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Pos</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Driver</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Fastest Lap</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Incidents</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">Points</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-400">+/-</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result) => {
|
||||
const positionChange = result.getPositionChange();
|
||||
const isFastestLap = fastestLapTime && result.fastestLap === fastestLapTime;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={result.id}
|
||||
className="border-b border-charcoal-outline/50 hover:bg-iron-gray/20 transition-colors"
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white font-semibold">{result.position}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white">{getDriverName(result.driverId)}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<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'}>
|
||||
{result.incidents}×
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-white font-medium">{getPoints(result.position)}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`font-medium ${getPositionChangeColor(positionChange)}`}>
|
||||
{getPositionChangeText(positionChange)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
apps/website/components/races/UpcomingRacesSidebar.tsx
Normal file
45
apps/website/components/races/UpcomingRacesSidebar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
|
||||
interface UpcomingRacesSidebarProps {
|
||||
races: Race[];
|
||||
}
|
||||
|
||||
export default function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
|
||||
if (!races.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-iron-gray/80">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-white">Upcoming races</h3>
|
||||
<Button
|
||||
as="a"
|
||||
href="/races"
|
||||
variant="secondary"
|
||||
className="text-[11px] px-3 py-1.5"
|
||||
>
|
||||
View all
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
{races.slice(0, 4).map(race => (
|
||||
<li key={race.id} className="flex items-start justify-between gap-3 text-xs">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white truncate">{race.track}</p>
|
||||
<p className="text-gray-400 truncate">{race.car}</p>
|
||||
</div>
|
||||
<div className="text-right text-gray-500 whitespace-nowrap">
|
||||
{race.scheduledAt.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user