This commit is contained in:
2025-12-13 11:43:09 +01:00
parent 4b6fc668b5
commit bb0497f429
38 changed files with 3838 additions and 55 deletions

View File

@@ -20,6 +20,7 @@ import {
EntityMappers,
type DriverDTO,
type LeagueScoringConfigDTO,
Race,
} from '@gridpilot/racing';
import {
getLeagueRepository,
@@ -32,9 +33,10 @@ import {
getSeasonRepository,
getSponsorRepository,
getSeasonSponsorshipRepository,
getCompleteRaceUseCase,
} from '@/lib/di-container';
import { LeagueScoringConfigPresenter } from '@/lib/presenters/LeagueScoringConfigPresenter';
import { Trophy, Star, ExternalLink } from 'lucide-react';
import { Trophy, Star, ExternalLink, Calendar, Users } from 'lucide-react';
import { getMembership, getLeagueMembers } from '@/lib/leagueMembership';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
@@ -62,6 +64,7 @@ export default function LeagueDetailPage() {
const [averageSOF, setAverageSOF] = useState<number | null>(null);
const [completedRacesCount, setCompletedRacesCount] = useState<number>(0);
const [sponsors, setSponsors] = useState<SponsorInfo[]>([]);
const [runningRaces, setRunningRaces] = useState<Race[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
@@ -139,6 +142,11 @@ export default function LeagueDetailPage() {
setDrivers(driverDtos);
// Load all races for this league to find running ones
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
const runningRaces = leagueRaces.filter(r => r.status === 'running');
setRunningRaces(runningRaces);
// Load league stats including average SOF from application use case
await leagueStatsUseCase.execute({ leagueId });
const leagueStatsViewModel = leagueStatsUseCase.presenter.getViewModel();
@@ -147,7 +155,6 @@ export default function LeagueDetailPage() {
setCompletedRacesCount(leagueStatsViewModel.completedRaces);
} else {
// Fallback: count completed races manually
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
const completedRaces = leagueRaces.filter(r => r.status === 'completed');
setCompletedRacesCount(completedRaces.length);
}
@@ -306,6 +313,88 @@ export default function LeagueDetailPage() {
/>
)}
{/* Live Race Card - Prominently show running races */}
{runningRaces.length > 0 && (
<Card className="border-2 border-performance-green/50 bg-gradient-to-r from-performance-green/10 to-performance-green/5 mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 bg-performance-green rounded-full animate-pulse"></div>
<h2 className="text-xl font-bold text-white">🏁 Live Race in Progress</h2>
</div>
<div className="space-y-3">
{runningRaces.map((race) => (
<div
key={race.id}
className="p-4 rounded-lg bg-deep-graphite border border-performance-green/30"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-performance-green/20 border border-performance-green/40 rounded-full">
<span className="text-sm font-semibold text-performance-green">LIVE</span>
</div>
<h3 className="text-lg font-semibold text-white">
{race.track} - {race.car}
</h3>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="primary"
onClick={() => router.push(`/races/${race.id}`)}
className="bg-performance-green hover:bg-performance-green/80 text-white"
>
View Live Race
</Button>
{membership?.role === 'admin' && (
<Button
variant="secondary"
onClick={async () => {
const confirmed = window.confirm(
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
);
if (!confirmed) return;
try {
const completeRace = getCompleteRaceUseCase();
await completeRace.execute({ raceId: race.id });
// Reload league data to reflect the completed race
await loadLeagueData();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
}}
className="border-performance-green/50 text-performance-green hover:bg-performance-green/10"
>
End Race & Process Results
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-400">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>Started {new Date(race.scheduledAt).toLocaleDateString()}</span>
</div>
{race.registeredCount && (
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{race.registeredCount} drivers registered</span>
</div>
)}
{race.strengthOfField && (
<div className="flex items-center gap-2">
<Trophy className="w-4 h-4" />
<span>SOF: {race.strengthOfField}</span>
</div>
)}
</div>
</div>
))}
</div>
</Card>
)}
{/* Action Card */}
{!membership && !isSponsor && (
<Card className="mb-6">

View File

@@ -9,8 +9,9 @@ import Heading from '@/components/ui/Heading';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import FileProtestModal from '@/components/races/FileProtestModal';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase } from '@/lib/di-container';
import { getGetRaceDetailUseCase, getRegisterForRaceUseCase, getWithdrawFromRaceUseCase, getCancelRaceUseCase, getCompleteRaceUseCase } from '@/lib/di-container';
import { useEffectiveDriverId } from '@/lib/currentDriver';
import { getMembership, isOwnerOrAdmin } from '@/lib/leagueMembership';
import type {
RaceDetailViewModel,
RaceDetailEntryViewModel,
@@ -49,6 +50,7 @@ export default function RaceDetailPage() {
const [ratingChange, setRatingChange] = useState<number | null>(null);
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
const [showProtestModal, setShowProtestModal] = useState(false);
const [membership, setMembership] = useState<any>(null);
const currentDriverId = useEffectiveDriverId();
const isSponsorMode = useSponsorMode();
@@ -65,6 +67,13 @@ export default function RaceDetailPage() {
throw new Error('Race detail not available');
}
setViewModel(vm);
// Fetch league membership for admin controls
if (vm.league) {
const leagueMembership = getMembership(vm.league.id, currentDriverId);
setMembership(leagueMembership);
}
const userResultRatingChange = vm.userResult?.ratingChange ?? null;
setRatingChange(userResultRatingChange);
if (userResultRatingChange === null) {
@@ -529,7 +538,7 @@ export default function RaceDetailPage() {
{animatedRatingChange > 0 ? '+' : ''}
{animatedRatingChange}
</div>
<div className="text-xs text-gray-400 mt-0.5">iRating</div>
<div className="text-xs text-gray-400 mt-0.5">Rating</div>
</div>
)}
@@ -717,11 +726,11 @@ export default function RaceDetailPage() {
className={`
flex items-center justify-center w-8 h-8 rounded-lg font-bold text-sm
${
index === 0
race.status === 'completed' && index === 0
? 'bg-yellow-500/20 text-yellow-400'
: index === 1
: race.status === 'completed' && index === 1
? 'bg-gray-400/20 text-gray-300'
: index === 2
: race.status === 'completed' && index === 2
? 'bg-amber-600/20 text-amber-500'
: 'bg-iron-gray text-gray-500'
}
@@ -892,9 +901,55 @@ export default function RaceDetailPage() {
<Scale className="w-4 h-4" />
Stewarding
</Button>
{membership && isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<>
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={async () => {
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.'
);
if (!confirmed) return;
// TODO: Implement re-open race functionality
alert('Re-open race functionality not yet implemented');
}}
>
<PlayCircle className="w-4 h-4" />
Re-open Race
</Button>
</>
)}
</>
)}
{race.status === 'running' && membership && isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="primary"
className="w-full flex items-center justify-center gap-2"
onClick={async () => {
const confirmed = window.confirm(
'Are you sure you want to end this race and process results?\n\nThis will mark the race as completed and calculate final standings.'
);
if (!confirmed) return;
try {
const completeRace = getCompleteRaceUseCase();
await completeRace.execute({ raceId: race.id });
// Reload race data to reflect the completed race
await loadRaceData();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
}}
>
<CheckCircle2 className="w-4 h-4" />
End Race & Process Results
</Button>
)}
{race.status === 'scheduled' && (
<Button
variant="secondary"