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

@@ -6,8 +6,11 @@ import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race';
import { getGetRacesPageDataUseCase } from '@/lib/di-container';
import type {
RacesPageViewModel,
RaceListItemViewModel,
} from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
import {
Calendar,
Clock,
@@ -27,21 +30,16 @@ import {
} from 'lucide-react';
type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
type RaceStatusFilter = RaceListItemViewModel['status'];
export default function RacesPage() {
const router = useRouter();
const [pageData, setPageData] = useState<{
races: Array<{ race: Race; leagueName: string }>;
stats: { total: number; scheduled: number; running: number; completed: number };
liveRaces: Array<{ race: Race; leagueName: string }>;
upcomingRaces: Array<{ race: Race; leagueName: string }>;
recentResults: Array<{ race: Race; leagueName: string }>;
} | null>(null);
const [pageData, setPageData] = useState<RacesPageViewModel | null>(null);
const [loading, setLoading] = useState(true);
// Filters
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter | 'all'>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [timeFilter, setTimeFilter] = useState<TimeFilter>('upcoming');
@@ -50,71 +48,7 @@ export default function RacesPage() {
const useCase = getGetRacesPageDataUseCase();
await useCase.execute();
const data = useCase.presenter.getViewModel();
// Transform ViewModel back to page format
setPageData({
races: data.races.map(r => ({
race: {
id: r.id,
track: r.track,
car: r.car,
scheduledAt: new Date(r.scheduledAt),
status: r.status,
leagueId: r.leagueId,
strengthOfField: r.strengthOfField,
isUpcoming: () => r.isUpcoming,
isLive: () => r.isLive,
isPast: () => r.isPast,
} as Race,
leagueName: r.leagueName,
})),
stats: data.stats,
liveRaces: data.liveRaces.map(r => ({
race: {
id: r.id,
track: r.track,
car: r.car,
scheduledAt: new Date(r.scheduledAt),
status: r.status,
leagueId: r.leagueId,
strengthOfField: r.strengthOfField,
isUpcoming: () => r.isUpcoming,
isLive: () => r.isLive,
isPast: () => r.isPast,
} as Race,
leagueName: r.leagueName,
})),
upcomingRaces: data.upcomingThisWeek.map(r => ({
race: {
id: r.id,
track: r.track,
car: r.car,
scheduledAt: new Date(r.scheduledAt),
status: r.status,
leagueId: r.leagueId,
strengthOfField: r.strengthOfField,
isUpcoming: () => r.isUpcoming,
isLive: () => r.isLive,
isPast: () => r.isPast,
} as Race,
leagueName: r.leagueName,
})),
recentResults: data.recentResults.map(r => ({
race: {
id: r.id,
track: r.track,
car: r.car,
scheduledAt: new Date(r.scheduledAt),
status: r.status,
leagueId: r.leagueId,
strengthOfField: r.strengthOfField,
isUpcoming: () => r.isUpcoming,
isLive: () => r.isLive,
isPast: () => r.isPast,
} as Race,
leagueName: r.leagueName,
})),
});
setPageData(data);
} catch (err) {
console.error('Failed to load races:', err);
} finally {
@@ -130,7 +64,7 @@ export default function RacesPage() {
const filteredRaces = useMemo(() => {
if (!pageData) return [];
return pageData.races.filter(({ race }) => {
return pageData.races.filter((race) => {
// Status filter
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
@@ -142,13 +76,13 @@ export default function RacesPage() {
}
// Time filter
if (timeFilter === 'upcoming' && !race.isUpcoming()) {
if (timeFilter === 'upcoming' && !race.isUpcoming) {
return false;
}
if (timeFilter === 'live' && !race.isLive()) {
if (timeFilter === 'live' && !race.isLive) {
return false;
}
if (timeFilter === 'past' && !race.isPast()) {
if (timeFilter === 'past' && !race.isPast) {
return false;
}
@@ -158,18 +92,18 @@ export default function RacesPage() {
// Group races by date for calendar view
const racesByDate = useMemo(() => {
const grouped = new Map<string, Array<{ race: Race; leagueName: string }>>();
filteredRaces.forEach(item => {
const dateKey = item.race.scheduledAt.toISOString().split('T')[0];
const grouped = new Map<string, RaceListItemViewModel[]>();
filteredRaces.forEach(race => {
const dateKey = new Date(race.scheduledAt).toISOString().split('T')[0];
if (!grouped.has(dateKey)) {
grouped.set(dateKey, []);
}
grouped.get(dateKey)!.push(item);
grouped.get(dateKey)!.push(race);
});
return grouped;
}, [filteredRaces]);
const upcomingRaces = pageData?.upcomingRaces ?? [];
const upcomingRaces = pageData?.upcomingThisWeek ?? [];
const liveRaces = pageData?.liveRaces ?? [];
const recentResults = pageData?.recentResults ?? [];
const stats = pageData?.stats ?? { total: 0, scheduled: 0, running: 0, completed: 0 };
@@ -331,7 +265,7 @@ export default function RacesPage() {
</div>
<div className="space-y-3">
{liveRaces.map(({ race, leagueName }) => (
{liveRaces.map((race) => (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}`)}
@@ -343,7 +277,7 @@ export default function RacesPage() {
</div>
<div>
<h3 className="font-semibold text-white">{race.track}</h3>
<p className="text-sm text-gray-400">{leagueName}</p>
<p className="text-sm text-gray-400">{race.leagueName}</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
@@ -385,8 +319,8 @@ export default function RacesPage() {
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="all">All Leagues</option>
{pageData && [...new Set(pageData.races.map(r => r.race.leagueId))].map(leagueId => {
const item = pageData.races.find(r => r.race.leagueId === leagueId);
{pageData && [...new Set(pageData.races.map(r => r.leagueId))].map(leagueId => {
const item = pageData.races.find(r => r.leagueId === leagueId);
return item ? (
<option key={leagueId} value={leagueId}>
{item.leagueName}
@@ -415,106 +349,106 @@ export default function RacesPage() {
</div>
</Card>
) : (
<div className="space-y-4">
{Array.from(racesByDate.entries()).map(([dateKey, dayRaces]) => (
<div key={dateKey} className="space-y-3">
{/* Date Header */}
<div className="flex items-center gap-3 px-2">
<div className="p-2 bg-primary-blue/10 rounded-lg">
<Calendar className="w-4 h-4 text-primary-blue" />
</div>
<span className="text-sm font-semibold text-white">
{formatFullDate(new Date(dateKey))}
</span>
<span className="text-xs text-gray-500">
{dayRaces.length} race{dayRaces.length !== 1 ? 's' : ''}
</span>
</div>
<div className="space-y-4">
{Array.from(racesByDate.entries()).map(([dateKey, dayRaces]) => (
<div key={dateKey} className="space-y-3">
{/* Date Header */}
<div className="flex items-center gap-3 px-2">
<div className="p-2 bg-primary-blue/10 rounded-lg">
<Calendar className="w-4 h-4 text-primary-blue" />
</div>
<span className="text-sm font-semibold text-white">
{formatFullDate(new Date(dateKey))}
</span>
<span className="text-xs text-gray-500">
{dayRaces.length} race{dayRaces.length !== 1 ? 's' : ''}
</span>
</div>
{/* Races for this date */}
<div className="space-y-2">
{dayRaces.map(({ race, leagueName }) => {
const config = statusConfig[race.status];
const StatusIcon = config.icon;
{/* Races for this date */}
<div className="space-y-2">
{dayRaces.map((race) => {
const config = statusConfig[race.status];
const StatusIcon = config.icon;
return (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}`)}
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
>
{/* Live indicator */}
{race.status === 'running' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
)}
return (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}`)}
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
>
{/* Live indicator */}
{race.status === 'running' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
)}
<div className="flex items-start gap-4">
{/* Time Column */}
<div className="flex-shrink-0 text-center min-w-[60px]">
<p className="text-lg font-bold text-white">{formatTime(race.scheduledAt)}</p>
<p className={`text-xs ${config.color}`}>
{race.status === 'running' ? 'LIVE' : getRelativeTime(race.scheduledAt)}
</p>
</div>
<div className="flex items-start gap-4">
{/* Time Column */}
<div className="flex-shrink-0 text-center min-w-[60px]">
<p className="text-lg font-bold text-white">{formatTime(new Date(race.scheduledAt))}</p>
<p className={`text-xs ${config.color}`}>
{race.status === 'running' ? 'LIVE' : getRelativeTime(new Date(race.scheduledAt))}
</p>
</div>
{/* Divider */}
<div className={`w-px self-stretch ${config.bg}`} />
{/* Divider */}
<div className={`w-px self-stretch ${config.bg}`} />
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
{race.track}
</h3>
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-sm text-gray-400">
<Car className="w-3.5 h-3.5" />
{race.car}
</span>
{race.strengthOfField && (
<span className="flex items-center gap-1 text-sm text-gray-400">
<Zap className="w-3.5 h-3.5 text-warning-amber" />
SOF {race.strengthOfField}
</span>
)}
</div>
</div>
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
{race.track}
</h3>
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-sm text-gray-400">
<Car className="w-3.5 h-3.5" />
{race.car}
</span>
{race.strengthOfField && (
<span className="flex items-center gap-1 text-sm text-gray-400">
<Zap className="w-3.5 h-3.5 text-warning-amber" />
SOF {race.strengthOfField}
</span>
)}
</div>
</div>
{/* Status Badge */}
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
<span className={`text-xs font-medium ${config.color}`}>
{config.label}
</span>
</div>
</div>
{/* Status Badge */}
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border`}>
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
<span className={`text-xs font-medium ${config.color}`}>
{config.label}
</span>
</div>
</div>
{/* League Link */}
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<Link
href={`/leagues/${race.leagueId}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
>
<Trophy className="w-3.5 h-3.5" />
{leagueName}
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
{/* League Link */}
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<Link
href={`/leagues/${race.leagueId}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
>
<Trophy className="w-3.5 h-3.5" />
{race.leagueName}
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{/* View All Link */}
{filteredRaces.length > 0 && (
@@ -548,7 +482,7 @@ export default function RacesPage() {
</p>
) : (
<div className="space-y-3">
{upcomingRaces.map(({ race }) => (
{upcomingRaces.map((race) => (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}`)}
@@ -561,7 +495,7 @@ export default function RacesPage() {
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{race.track}</p>
<p className="text-xs text-gray-500">{formatTime(race.scheduledAt)}</p>
<p className="text-xs text-gray-500">{formatTime(new Date(race.scheduledAt))}</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
@@ -585,7 +519,7 @@ export default function RacesPage() {
</p>
) : (
<div className="space-y-3">
{recentResults.map(({ race }) => (
{recentResults.map((race) => (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}/results`)}
@@ -596,7 +530,7 @@ export default function RacesPage() {
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{race.track}</p>
<p className="text-xs text-gray-500">{formatDate(race.scheduledAt)}</p>
<p className="text-xs text-gray-500">{formatDate(new Date(race.scheduledAt))}</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>