This commit is contained in:
2025-12-10 18:28:32 +01:00
parent 6d61be9c51
commit 1303a14493
108 changed files with 3366 additions and 1559 deletions

View File

@@ -17,11 +17,11 @@ import {
getRaceRepository,
getLeagueRepository,
getDriverRepository,
getGetRaceRegistrationsQuery,
getIsDriverRegisteredForRaceQuery,
getGetRaceRegistrationsUseCase,
getIsDriverRegisteredForRaceUseCase,
getRegisterForRaceUseCase,
getWithdrawFromRaceUseCase,
getGetRaceWithSOFQuery,
getGetRaceWithSOFUseCase,
getResultRepository,
getImageService,
} from '@/lib/di-container';
@@ -80,7 +80,7 @@ export default function RaceDetailPage() {
try {
const raceRepo = getRaceRepository();
const leagueRepo = getLeagueRepository();
const raceWithSOFQuery = getGetRaceWithSOFQuery();
const raceWithSOFUseCase = getGetRaceWithSOFUseCase();
const raceData = await raceRepo.findById(raceId);
@@ -92,10 +92,11 @@ export default function RaceDetailPage() {
setRace(raceData);
// Load race with SOF from application query
const raceWithSOF = await raceWithSOFQuery.execute({ raceId });
if (raceWithSOF) {
setRaceSOF(raceWithSOF.strengthOfField);
// Load race with SOF from application use case
await raceWithSOFUseCase.execute({ raceId });
const raceViewModel = raceWithSOFUseCase.presenter.getViewModel();
if (raceViewModel) {
setRaceSOF(raceViewModel.strengthOfField);
}
// Load league data
@@ -135,8 +136,10 @@ export default function RaceDetailPage() {
try {
const driverRepo = getDriverRepository();
const raceRegistrationsQuery = getGetRaceRegistrationsQuery();
const registeredDriverIds = await raceRegistrationsQuery.execute({ raceId });
const raceRegistrationsUseCase = getGetRaceRegistrationsUseCase();
await raceRegistrationsUseCase.execute({ raceId });
const registrationsViewModel = raceRegistrationsUseCase.presenter.getViewModel();
const registeredDriverIds = registrationsViewModel.registeredDriverIds;
const drivers = await Promise.all(
registeredDriverIds.map((id: string) => driverRepo.findById(id)),
@@ -144,12 +147,13 @@ export default function RaceDetailPage() {
const validDrivers = drivers.filter((d: Driver | null): d is Driver => d !== null);
setEntryList(validDrivers);
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
const userIsRegistered = await isRegisteredQuery.execute({
const isRegisteredUseCase = getIsDriverRegisteredForRaceUseCase();
await isRegisteredUseCase.execute({
raceId,
driverId: currentDriverId,
});
setIsUserRegistered(userIsRegistered);
const registrationViewModel = isRegisteredUseCase.presenter.getViewModel();
setIsUserRegistered(registrationViewModel.isRegistered);
const membership = getMembership(leagueId, currentDriverId);
const isUpcoming = race?.status === 'scheduled';

View File

@@ -18,8 +18,8 @@ import {
getResultRepository,
getStandingRepository,
getDriverRepository,
getGetRaceWithSOFQuery,
getGetRacePenaltiesQuery,
getGetRaceWithSOFUseCase,
getGetRacePenaltiesUseCase,
} from '@/lib/di-container';
interface PenaltyData {
@@ -52,7 +52,7 @@ export default function RaceResultsPage() {
const leagueRepo = getLeagueRepository();
const resultRepo = getResultRepository();
const driverRepo = getDriverRepository();
const raceWithSOFQuery = getGetRaceWithSOFQuery();
const raceWithSOFUseCase = getGetRaceWithSOFUseCase();
const raceData = await raceRepo.findById(raceId);
@@ -64,10 +64,11 @@ export default function RaceResultsPage() {
setRace(raceData);
// Load race with SOF from application query
const raceWithSOF = await raceWithSOFQuery.execute({ raceId });
if (raceWithSOF) {
setRaceSOF(raceWithSOF.strengthOfField);
// Load race with SOF from application use case
await raceWithSOFUseCase.execute({ raceId });
const raceViewModel = raceWithSOFUseCase.presenter.getViewModel();
if (raceViewModel) {
setRaceSOF(raceViewModel.strengthOfField);
}
// Load league data
@@ -89,10 +90,11 @@ export default function RaceResultsPage() {
// Load penalties for this race
try {
const penaltiesQuery = getGetRacePenaltiesQuery();
const penaltiesData = await penaltiesQuery.execute(raceId);
const penaltiesUseCase = getGetRacePenaltiesUseCase();
await penaltiesUseCase.execute(raceId);
const penaltiesViewModel = penaltiesUseCase.presenter.getViewModel();
// Map the DTO to the PenaltyData interface expected by ResultsTable
setPenalties(penaltiesData.map(p => ({
setPenalties(penaltiesViewModel.map(p => ({
driverId: p.driverId,
type: p.type,
value: p.value,

View File

@@ -7,8 +7,7 @@ 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 { League } from '@gridpilot/racing/domain/entities/League';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
import { getGetRacesPageDataUseCase } from '@/lib/di-container';
import {
Calendar,
Clock,
@@ -32,8 +31,13 @@ type TimeFilter = 'all' | 'upcoming' | 'live' | 'past';
export default function RacesPage() {
const router = useRouter();
const [races, setRaces] = useState<Race[]>([]);
const [leagues, setLeagues] = useState<Map<string, League>>(new Map());
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 [loading, setLoading] = useState(true);
// Filters
@@ -43,19 +47,74 @@ export default function RacesPage() {
const loadRaces = async () => {
try {
const raceRepo = getRaceRepository();
const leagueRepo = getLeagueRepository();
const [allRaces, allLeagues] = await Promise.all([
raceRepo.findAll(),
leagueRepo.findAll()
]);
setRaces(allRaces.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()));
const useCase = getGetRacesPageDataUseCase();
await useCase.execute();
const data = useCase.presenter.getViewModel();
const leagueMap = new Map<string, League>();
allLeagues.forEach(league => leagueMap.set(league.id, league));
setLeagues(leagueMap);
// 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,
})),
});
} catch (err) {
console.error('Failed to load races:', err);
} finally {
@@ -69,7 +128,9 @@ export default function RacesPage() {
// Filter races
const filteredRaces = useMemo(() => {
return races.filter(race => {
if (!pageData) return [];
return pageData.races.filter(({ race }) => {
// Status filter
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
@@ -93,53 +154,25 @@ export default function RacesPage() {
return true;
});
}, [races, statusFilter, leagueFilter, timeFilter]);
}, [pageData, statusFilter, leagueFilter, timeFilter]);
// Group races by date for calendar view
const racesByDate = useMemo(() => {
const grouped = new Map<string, Race[]>();
filteredRaces.forEach(race => {
const dateKey = race.scheduledAt.toISOString().split('T')[0];
const grouped = new Map<string, Array<{ race: Race; leagueName: string }>>();
filteredRaces.forEach(item => {
const dateKey = item.race.scheduledAt.toISOString().split('T')[0];
if (!grouped.has(dateKey)) {
grouped.set(dateKey, []);
}
grouped.get(dateKey)!.push(race);
grouped.get(dateKey)!.push(item);
});
return grouped;
}, [filteredRaces]);
// Get upcoming races (next 7 days)
const upcomingRaces = useMemo(() => {
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
return races.filter(race =>
race.isUpcoming() &&
race.scheduledAt >= now &&
race.scheduledAt <= nextWeek
).slice(0, 5);
}, [races]);
// Get live races
const liveRaces = useMemo(() => {
return races.filter(race => race.isLive());
}, [races]);
// Get recent results
const recentResults = useMemo(() => {
return races
.filter(race => race.status === 'completed')
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
.slice(0, 3);
}, [races]);
// Stats
const stats = useMemo(() => {
const total = races.length;
const scheduled = races.filter(r => r.status === 'scheduled').length;
const running = races.filter(r => r.status === 'running').length;
const completed = races.filter(r => r.status === 'completed').length;
return { total, scheduled, running, completed };
}, [races]);
const upcomingRaces = pageData?.upcomingRaces ?? [];
const liveRaces = pageData?.liveRaces ?? [];
const recentResults = pageData?.recentResults ?? [];
const stats = pageData?.stats ?? { total: 0, scheduled: 0, running: 0, completed: 0 };
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
@@ -298,8 +331,8 @@ export default function RacesPage() {
</div>
<div className="space-y-3">
{liveRaces.map(race => (
<div
{liveRaces.map(({ race, leagueName }) => (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}`)}
className="flex items-center justify-between p-4 bg-deep-graphite/80 rounded-lg border border-performance-green/20 cursor-pointer hover:border-performance-green/40 transition-all"
@@ -310,7 +343,7 @@ export default function RacesPage() {
</div>
<div>
<h3 className="font-semibold text-white">{race.track}</h3>
<p className="text-sm text-gray-400">{leagues.get(race.leagueId)?.name}</p>
<p className="text-sm text-gray-400">{leagueName}</p>
</div>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
@@ -352,11 +385,14 @@ 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>
{Array.from(leagues.values()).map(league => (
<option key={league.id} value={league.id}>
{league.name}
</option>
))}
{pageData && [...new Set(pageData.races.map(r => r.race.leagueId))].map(leagueId => {
const item = pageData.races.find(r => r.race.leagueId === leagueId);
return item ? (
<option key={leagueId} value={leagueId}>
{item.leagueName}
</option>
) : null;
})}
</select>
</div>
</Card>
@@ -371,7 +407,7 @@ export default function RacesPage() {
<div>
<p className="text-white font-medium mb-1">No races found</p>
<p className="text-sm text-gray-500">
{races.length === 0
{pageData?.races.length === 0
? 'No races have been scheduled yet'
: 'Try adjusting your filters'}
</p>
@@ -397,10 +433,9 @@ export default function RacesPage() {
{/* Races for this date */}
<div className="space-y-2">
{dayRaces.map(race => {
{dayRaces.map(({ race, leagueName }) => {
const config = statusConfig[race.status];
const StatusIcon = config.icon;
const league = leagues.get(race.leagueId);
return (
<div
@@ -456,19 +491,17 @@ export default function RacesPage() {
</div>
{/* League Link */}
{league && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<Link
href={`/leagues/${league.id}`}
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" />
{league.name}
<ArrowRight className="w-3 h-3" />
</Link>
</div>
)}
<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>
{/* Arrow */}
@@ -515,8 +548,8 @@ export default function RacesPage() {
</p>
) : (
<div className="space-y-3">
{upcomingRaces.map((race, index) => (
<div
{upcomingRaces.map(({ race }) => (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}`)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"
@@ -552,8 +585,8 @@ export default function RacesPage() {
</p>
) : (
<div className="space-y-3">
{recentResults.map(race => (
<div
{recentResults.map(({ race }) => (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}/results`)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-deep-graphite cursor-pointer transition-colors"